Focus Navigation
Like any desktop UI, Wijjit tracks which element currently has focus so keyboard users can interact without touching the mouse. Focus management lives in wijjit.core.focus.FocusManager, which the app updates after every render.
How focus is determined
During rendering the layout engine collects all
wijjit.elements.base.Elementinstances.FocusManager.set_elementsfilters the list to those withfocusable=True(buttons, inputs, menus, etc.).The manager tries to keep focus on the same
idas the previous frame. If an element disappeared, it falls back to the same index.If nothing could be restored, an element marked
autofocus=Truetakes focus (see Initial focus: the autofocus attribute). Otherwise focus is left unset until the first Tab.Focus state is synced with each element via
element.on_focus()/element.on_blur()(implemented by the base class). These hooks toggle visual cues (highlighted borders, caret visibility).
Initial focus: the autofocus attribute
A newly started app has nothing focused. This is deliberate - focusing the
first element unconditionally makes the first Tab appear to skip an element -
but it means keystrokes go nowhere until the user presses Tab. For a form, that
is rarely what you want.
Mark the element the app should start in:
{% textinput id="entry" placeholder="New task" width="fill" autofocus=True %}
{% endtextinput %}
The rule is precise:
It applies whenever focus would otherwise be unset - on the first render, and again if the focused element disappears from the tree.
It never steals focus from an element that already has it, so a re-render cannot yank the cursor out from under the user.
It changes only where focus starts, never the Tab order.
tabindex="-1"excludes an element fromautofocusexactly as it excludes it from Tab.Use it once per view. More than one is a template mistake: the first in tab order wins, and the framework logs a warning rather than picking silently.
autofocus is accepted by every element, not just inputs, and needs no
support from the element class - it lives on
wijjit.elements.base.Element.
Inside a modal it selects which element the dialog opens on. A trapping
overlay always takes focus somewhere, so there autofocus overrides the
default “first focusable element” choice rather than adding focus where there
was none.
The imperative equivalent is app.focus_element_by_id("entry"), but note it
only works after the first render has built the elements - which is why it
cannot be called from an on_enter hook. Prefer autofocus for initial
focus, and the imperative helpers for focus that moves in response to events.
Built-in navigation
Wijjit registers global Tab/Shift+Tab handlers so keyboard traversal works out of the box:
Tab→FocusManager.focus_next()Shift+Tab→focus_previous()
When focus moves, the manager marks both the old and new element bounds as dirty so they re-render with appropriate styling.
Override strategies:
Disable automatic Tab handling by setting
app.focus_navigation_enabled = Falseand register your own key handlers (useful for custom grids).For modal dialogs, focus is automatically trapped if you set
trap_focus=Truewhen pushing the overlay. Closing the modal restores the previous focus state.
When Tab belongs to the element instead
Sometimes Tab is an editing key, not a navigation key: a code editor should indent, and an open autocomplete popup should accept the highlighted suggestion. Because the built-in handler above runs early and cancels the event, a focused element does not see Tab at all unless it asks for it.
An element asks by overriding the captures_tab property
(wijjit.elements.base.Element.captures_tab). When it returns True the
element gets first refusal on plain Tab: its handle_key is called, and
focus moves on anyway if it returns False.
Two rules keep this from stranding the keyboard user:
Shift+Tab is never captured. It always moves focus backward, so there is always a way out - and because backward movement wraps, every element stays reachable even though Tab cannot move forward past a capturing element. (Ctrl+Tab is not an alternative: terminals encode Ctrl+I as Tab.)
Declining is normal. An element that returns True unconditionally turns Tab into a dead key; return False whenever there is nothing useful to do.
In templates this is the capture_tab attribute, with tab_width
controlling how many spaces one Tab inserts:
{# A code editor indents by default - no attribute needed #}
{% codeeditor id="src" language="python" width="fill" height=20 %}{% endcodeeditor %}
{# A textarea moves focus by default; opt in where an indent is wanted #}
{% textarea id="notes" width=40 height=8 capture_tab=True tab_width=2 %}{% endtextarea %}
TextInput claims Tab conditionally: only while its autocomplete popup is
open, so a plain form field keeps Tab-to-next-field.
Focus cycling in modals
When a modal is displayed with trap_focus=True, Tab and Shift+Tab navigation continues to work within the modal’s focusable elements. This allows users to navigate between buttons, inputs, and other interactive elements inside the dialog without affecting the underlying view.
The focus manager automatically:
Cycles through focusable elements - Tab moves forward, Shift+Tab moves backward through the modal’s interactive elements
Wraps at boundaries - From the last element, Tab wraps to the first element (and vice versa for Shift+Tab)
Restores focus on close - When the modal closes, focus returns to the element that was focused before the modal opened
This behaviour is automatic and requires no configuration. Bind the modal’s
visibility to a state key with visible= (the modal only renders while that
key is truthy) and include focusable elements (buttons, inputs, checkboxes,
etc.) in its body:
{% modal id="confirm_dialog" visible="show_confirm" title="Confirm" %}
Are you sure you want to delete this item?
{% hstack spacing=2 %}
{% button action="confirm" %}Yes, Delete{% endbutton %}
{% button action="cancel" %}Cancel{% endbutton %}
{% endhstack %}
{% endmodal %}
Focus trapping is applied automatically for modal-layer overlays - there is no
trap_focus tag attribute to set. With this template, once you set
state["show_confirm"] = True the modal opens and users can Tab between the
“Yes, Delete” and “Cancel” buttons while it is open. See Modal Dialogs & Overlays
for more details on building dialogs.
Making elements focusable
When building custom elements:
class TagPicker(Element):
def __init__(self, id=None):
super().__init__(id)
self.focusable = True
def on_focus(self):
self.focused = True
def on_blur(self):
self.focused = False
def handle_key(self, key: Key) -> bool:
if key == Keys.LEFT:
self.move_left()
return True
return False
If handle_key returns True the event stops propagating. Combine this with handle_mouse to support clicks and focus on pointer interaction.
Working inside scrollable containers
Scrollable frames keep child bounds relative to the frame viewport. Focused elements inside a scrollable region are automatically scrolled into view by the frame’s ScrollManager. For best results:
Ensure each input inside a scrollable frame has a unique
id.Avoid placing focusable children outside the frame’s content area (no negative margins).
Managing focus manually
You can direct focus programmatically. The app offers a one-call helper that looks the element up by id:
app.focus_element_by_id("search_box") # returns True if the element was focused
# equivalent lower-level form:
app.focus_manager.focus_element(app.get_element_by_id("search_box"))
To wire a hotkey that jumps focus to a named element, use
app.bind_focus_key(key, element_id, priority=0) (e.g. / to focus a search
box):
app.bind_focus_key("/", "search_box")
The FocusManager also exposes helper methods:
focus_first()– highlight the first focusable element.focus_last()– highlight the last element (useful when opening popovers).get_focused_element()– inspect the current element (handy in debugging).
To disable Tab/Shift+Tab navigation entirely (e.g. for a display-only app), call
app.configure_focus(enabled=False).
For multi-pane apps where each pane should maintain its own focus, save the focus state (focus_manager.save_state()) before switching panes and restore with focus_manager.restore_state(saved) later. Overlays already do this automatically.
Troubleshooting tips
Nothing is focused when the app starts – that is the default. Mark the field you want the user to start in with
autofocus=True(see Initial focus: the autofocus attribute).Typing does nothing until I press Tab twice – a container is taking the first Tab stop. A frame whose content does not fit becomes scrollable, and scrollable containers are focusable, so they sort ahead of their own children. Give the frame enough height for its content.
Element skipped – ensure
focusable=Trueand the element is part of the layout tree (checkelement.boundsis notNone).Wrong order – focus order follows the layout tree order. Rearrange template tags or assign an explicit
tab_indexattribute to fine-tune.tab_indexis a constructor param onwijjit.elements.base.Elementand is wired through the element tags, so you can set it directly in templates (e.g.{% textinput id="name" tab_index=1 %}).Focus lost after rerender – assign stable
idvalues. Auto-generated ids may change between renders when conditionals add/remove elements.Scroll jumps – if a focused element is inside a scrollable frame with dynamic height, keep the frame height fixed so scroll offsets remain valid.
When building keyboard-first workflows, pair this guide with Event Handling and Components to ensure every interaction can be reached without a mouse.