Overlays and modals¶
A scene is two things: the screen tree and a z-ordered overlay layer. Dialogs, bottom sheets, action sheets, menus, popovers and toasts live in that layer — the client renders them into their own host above the tree, and the reconciler diffs both together.
Opening and closing¶
The API is imperative, because opening a dialog is an event and not a state derived from the screen:
from tempest_core import App, Button, Text, Widget
from tempest_core import Dialog
def view(app: App[State]) -> Widget:
"""Render a button that opens a dismissable dialog."""
def close() -> None:
if app.state.dialog_id is not None:
app.dismiss(app.state.dialog_id)
app.set_state(lambda state: setattr(state, "dialog_id", None))
def open_dialog() -> None:
dialog = Dialog(
title="Delete item?",
children=[
Text(content="This cannot be undone.", key="body"),
Button(label="Cancel", on_click=close, key="cancel"),
],
on_dismiss=lambda _event: close(),
)
overlay_id = app.show_dialog(dialog, barrier=True)
app.set_state(lambda state: setattr(state, "dialog_id", overlay_id))
return Button(label="Delete", on_click=open_dialog, key="delete")
app.show_dialog/app.show_sheet/app.show_menupush an overlay and return a stable id; keep it so you can callapp.dismiss(id)later.barrier=Trueputs a scrim behind the overlay: it blocks the pointer, and it is what makes the overlay modal.on_dismissfires when the user clicks the scrim or pressesEscape. The overlay does not close itself — the app decides, by callingdismiss.
With no on_dismiss, the scrim is decoration
A modal overlay with neither on_dismiss nor a close button traps the user:
the scrim takes the click, the client reports the event, and nobody answers.
When the widget declares no on_dismiss (ActionSheet is one such case),
leave an action that closes it — selecting an item, for instance.
The keyboard contract¶
While a modal overlay is open, the keyboard belongs to it:
- focus moves to the first control inside the overlay when it opens (or to the overlay itself, when it has no controls);
TabandShift+Tabcycle inside it and wrap at both ends, instead of walking the page behind the scrim;- on close, focus returns to the element that opened the overlay.
You write none of this: the client does it in all three modes. A non-modal overlay
— Menu, Popover, Toast — does not steal focus, because stealing it would
break the widget that opened it.
Menus with icons¶
MenuItem declares label, value and icon, and the icon resolves through the
same two registries the Icon widget uses: a bare name is Lucide, a material:
prefix is Material. You can mix them per item.
from tempest_core import MenuSelectEvent
from tempest_core import ActionSheet, MenuItem
def open_actions() -> None:
"""Push an action sheet whose items carry icons."""
def chose(event: MenuSelectEvent) -> None:
app.set_state(lambda state: setattr(state, "chosen", event.label))
close_sheet()
sheet = ActionSheet(
title="Row actions",
items=[
MenuItem(label="Edit", value="edit", icon="material:edit"),
MenuItem(label="Duplicate", value="duplicate", icon="plus"),
MenuItem(label="Delete", value="delete", icon="trash"),
],
on_select=chose,
)
app.set_state(lambda state: setattr(state, "sheet_id", app.show_sheet(sheet)))
The handler receives a MenuSelectEvent carrying the clicked item's value and
label.
An icon name that does not exist will not break the screen
An unknown name clears the glyph and keeps the box, so the layout does not
jump — but it does not warn either. Check the name against the registries
(client/icons/) when an item shows up without its icon.
Anchored overlays¶
Menu and Popover carry the key of the widget they anchor to, and the client
places them just below it after layout, clamped into the viewport so a menu opened
near an edge stays reachable:
from tempest_core import Menu
Menu(
key="row-menu",
anchor="row-42-more",
items=[MenuItem(label="Rename", value="rename", icon="pencil")],
on_select=chose,
)
Toast¶
Toast is the overlay that asks nothing of the user: it carries a message,
announces itself with role=status and aria-live=polite, and the app removes it
on a timer.
Recap¶
- Overlays live in their own layer;
show_*returns an id,dismiss(id)closes. barrier=Truedraws the scrim, which is what makes an overlay modal — and a modal with neitheron_dismissnor a close button traps the user.- The keyboard contract (focus in,
Tabtrapped, focus back on close) belongs to the client and holds in all three modes; non-modal overlays leave focus alone. MenuItem.icontakes Lucide (bare name) and Material (material:).
The complete example — a dismissable dialog, an action sheet with icons, and focus
walking through all of it — lives in
examples/overlay_demo/app.py: