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

  1. During rendering the layout engine collects all wijjit.elements.base.Element instances.

  2. FocusManager.set_elements filters the list to those with focusable=True (buttons, inputs, menus, etc.).

  3. The manager tries to keep focus on the same id as the previous frame. If an element disappeared, it falls back to the same index.

  4. If nothing could be restored, an element marked autofocus=True takes focus (see Initial focus: the autofocus attribute). Otherwise focus is left unset until the first Tab.

  5. 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 from autofocus exactly 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:

  • TabFocusManager.focus_next()

  • Shift+Tabfocus_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 = False and register your own key handlers (useful for custom grids).

  • For modal dialogs, focus is automatically trapped if you set trap_focus=True when 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=True and the element is part of the layout tree (check element.bounds is not None).

  • Wrong order – focus order follows the layout tree order. Rearrange template tags or assign an explicit tab_index attribute to fine-tune. tab_index is a constructor param on wijjit.elements.base.Element and 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 id values. 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.