Skip to content

Views & focus ​

Who this course is for ​

Prerequisites: Layout and Reactive state. Before you begin, you should be comfortable with rows, columns, fixed and flexible sizing, signals, and view-owned cleanup.

The motivating problem is a real-world form or workspace that looks correct with a mouse but becomes unpredictable from the keyboard. By the end you will be able to build a retained view tree, explain who owns each mounted resource, diagnose missing repaint and focus behavior, and verify traversal and modal restoration headlessly.

You will learn to:

  • explain retained view identity, parent-child ownership, mounting, invalidation, and cleanup;
  • design document-order Tab and Shift+Tab traversal across nested groups;
  • choose between exact focusView() and restorative focusInto() entry;
  • make hidden and disabled eligibility changes safe; and
  • contain focus inside modal work, then restore the exact previous target.

Mental model ​

JSVision uses a retained tree. Each View is a persistent instance with identity, bounds, state, layout intent, and an optional reactive scope. A Group owns an ordered list of children. That parent-child tree simultaneously defines layout containment, paint order, lifecycle ownership, event routes, and the default keyboard document order.

text
Application root
└─ Desktop
   └─ Dialog
      └─ content Group
         ├─ field Group
         │  ├─ Label
         │  └─ Input  ← focused leaf
         └─ button Group
            ├─ OK
            └─ Cancel

Focus also lives in the tree. Along the active ancestor chain, each Group.current points toward the one focused leaf. In an inactive group, the same property remembers its last focused child instead; that restoration memory does not mean the child is currently focused. This distinction enables restorative entry without rebuilding the interface.

Your first focusable view tree ​

Use layout helpers to establish meaningful child order. Passive Text and Group objects are skipped; controls such as Input and Button are focusable.

ts
import { Button, Input, Label, col, row, signal } from '@jsvision/ui';

const name = signal('');
const field = new Input({ value: name });

const form = col(
  { gap: 1 },
  row({ gap: 1 }, new Label('~N~ame', field), field),
  row({ gap: 1 }, new Button('~S~ave'), new Button('~C~ancel')),
);

The argument order is the retained child order: Name field, Save, then Cancel. Tab moves forward in that tree order; Shift+Tab is its exact reverse. Keep order logical in the tree instead of trying to repair it visually with coordinates.

Mounting, ownership, and invalidation ​

Adding a child records its parent immediately. Mounting the root creates a reactive scope for every view under its parent's owner. Unmounting or removing a subtree disposes all descendant scopes and runs their cleanup. Acquire external work and release it together:

ts
import { View } from '@jsvision/ui';

declare const panel: View;
declare function subscribeToService(notify: () => void): () => void;

panel.onMount(() => {
  const unsubscribe = subscribeToService(() => panel.invalidate());
  panel.onCleanup(unsubscribe);
});

invalidate() requests a repaint when pixels change but geometry does not. invalidateLayout() requests measurement, layout, and repaint when size, position, or participation changes:

ts
import { View } from '@jsvision/ui';

declare const details: View;

details.state.visible = false;
details.invalidateLayout(); // Hidden views leave layout, so a repaint alone is insufficient.

details.state.disabled = true;
details.invalidate(); // Eligibility and focused styling changed; geometry did not.

Writing state.visible or state.disabled does not schedule work by itself. A visible-to-hidden change needs invalidateLayout() because layout omits hidden views. Prefer setLayout() for layout property changes because it applies the patch and schedules reflow together.

Removing a focused child also asks the event loop to heal focus toward an eligible sibling. Do not keep a stale reference and try to focus a disposed or unmounted view later; focusView() will correctly do nothing.

Traverse in document order ​

Tab calls focusNext() and Shift+Tab calls focusPrev(). Traversal is depth-first tree order:

  1. descend through nested groups to an eligible leaf;
  2. move to the next eligible sibling;
  3. climb out of an exhausted group and continue at its parent;
  4. wrap only at the active scope boundary.

Shift+Tab is the exact inverse and reverse entry lands on a group's last eligible leaf. An open modal becomes the scope boundary, so traversal cannot wrap into the application behind it.

Use Tab and Shift+Tab to inspect tree order, then hide or disable targets and use focusInto to restore a group.

Try Tab twice, then Shift+Tab. Use Alt+H and Alt+D to remove one hidden and one disabled target from eligibility. Alt+I invokes focusInto() and reports whether group memory was restored or the first eligible child was entered.

Focus entry and restoration ​

Use the event loop's public focus methods. focusView() targets one exact mounted, visible, enabled, focusable leaf. A composite Group, list, tree, data grid, or tab view is often not itself the keyboard target.

focusInto() accepts a leaf or container. For a container it restores the last eligible child remembered by Group.current; without valid memory it enters the first focusable descendant.

ts
import { Group, Input, type EventLoop } from '@jsvision/ui';

declare const loop: EventLoop;
declare const form: Group;
declare const nameInput: Input;

loop.focusView(nameInput); // Exact leaf.
loop.focusInto(form); // Restore the form's last child, or enter its first eligible leaf.

Container memory is for non-Tab entry: a click, a programmatic call, a window activation, or modal restoration. Continuous Tab traversal remains document order. This distinction prevents an old group memory from making forward traversal jump backward unexpectedly.

Labels provide a user-facing entry route. A Label linked to a control is passive in Tab order, but a click or its Alt accelerator focuses the target. Prefer that connection to custom shortcut code.

Focus eligibility ​

A view can receive focus only when all of these are true:

RequirementConsequence when false
The view is mountedDetached and not-yet-mounted references are ignored.
focusable is truePassive text, decoration, and ordinary groups are skipped.
state.visible is trueA hidden target does not participate in layout or focus.
state.disabled is falseA disabled target is inert and skipped.
Every ancestor is visible and enabledHiding or disabling a container blocks its whole subtree.

When the focused view becomes hidden, disabled, or removed, move focus to a sensible eligible neighbor as part of the same action. Removal through Group.remove() heals its focused child. Direct state changes should explicitly choose a new target when the current one becomes ineligible.

execView() saves current focus, makes the modal subtree the active dispatch and traversal scope, and calls focusInto() on it. Input outside the modal is inert: keyboard events and pointer hits behind it are ignored. End the operation through the modal host—normally a Dialog command—not by removing the window directly.

ts
import { Dialog, type EventLoop } from '@jsvision/ui';

declare const loop: EventLoop;
declare const dialog: Dialog;

const result = await loop.execView(dialog);
// Dialog OK, Cancel, Esc, or its close box calls endModal() through the attached host.
console.log(result ?? 'host disposed');

At completion, endModal() restores the previous saved focus if it is still eligible. Nested modals use LIFO (last-in, first-out) order: closing the inner modal restores focus inside the outer modal; closing the outer modal restores the application target.

Open the nested dialog, traverse its confined keyboard scope, then press Esc and observe the exact restored target.

Focus either main-window button, then use Alt+M. Tab and Shift+Tab remain contained in the nested dialog. Press Esc: the visible Restored focus: readout names the exact control saved before the modal opened.

Composition and integration ​

Layout determines child order and clipping; reactive state determines when view state changes; focus determines where keyboard input goes. Coordinate them deliberately:

  • Build groups in the same order users should traverse them.
  • When a signal changes visibility, request layout and re-home focus in the same transaction.
  • Let controls expose commands while the event loop owns focus and dispatch.
  • Let dialogs own modal completion; do not simulate modality with a disabled background.
  • For screens and windows, save stable focus intent only when the owning surface documents it.

The next course, Events, commands & keymaps, explains the dispatch phases after focus chooses a target. Dialogs & modality owns validation, cancellation, nested decisions, and result workflows beyond the focus model.

Advanced behavior ​

Observe focus without polling ​

focusSignal() ticks whenever a view gains or loses focus. Read it inside a view binding or effect when another region must repaint a caption or status line. The value is intentionally void; read state.focused or loop.getFocused() after the tick for the current fact.

Dynamic trees ​

Group.addDynamic() mounts produced children under the group scope and disposes removed children. Stable identity matters: keyed dynamic views retain local state and focus memory; replacement views start with new identity. If reconciliation removes the focused child, focus healing runs after the old scope is disposed.

Focusable custom views ​

A custom widget opts in with focusable = true, draws a visible focused state, and handles keyboard events through onEvent(). Use theme roles and a non-colour cue. Do not make a passive container focusable merely to intercept its children's keys.

Cleanup and teardown ​

Unmounting disposes view-owned reactive work, but external resources still need an onCleanup() release. Modal promises resolve to undefined if the complete event loop is disposed while they are pending. Treat that as host teardown, not as a user confirmation.

Theming and resilient focus cues ​

buttonFocused paints a focused button. For linked captions, label paints ordinary text, labelSelected signals that the target has focus, and labelShortcut highlights its accelerator. Inputs, lists, trees, and specialist controls use their documented focused roles.

Do not rely on colour alone. Keep a caret, selected frame or border, Focused: status text, or another non-colour cue. Verify the same interaction in monochrome, reduced colour depth, and ASCII fallbacks, and keep focus instructions visible in a small viewport.

Failure modes and diagnosis ​

SymptomCauseCorrectionEvidence
focusView(panel) does nothingpanel is a passive composite rather than its focusable leafTarget the child or use focusInto(panel)getFocused() names the descendant after entry
A hidden control still occupies cellsstate.visible changed without layout invalidationCall invalidateLayout() after the visibility writeThe next frame reflows siblings into the released cells
Tab skips a controlThe view or an ancestor is hidden, disabled, unmounted, or not focusableInspect the whole ancestry and restore eligibility at the correct ownerForward and reverse traversal both include the target
Focus styling looks staleState changed without repaint invalidationCall invalidate() when geometry is unchangedOld and new focus cues repaint in one frame
A removed child still receives service updatesThe subscription was not owned by its mounted scopeAcquire on mount and release with onCleanup()Removing the subtree stops later callbacks
A modal lets focus escapeWork was displayed modelessly or closed outside its modal hostUse execView() and a terminating Dialog commandTab wraps inside the modal and outside input stays inert
Focus does not restore after a modalThe saved target was removed or made ineligibleKeep it mounted and eligible, or choose an explicit fallbackClosing reports the expected Restored focus: target
Instructions clip in a small viewportFixed geometry was authored without responsive resize behaviorPreserve instruction rows and let the principal workspace growCompact, resize, maximize, and restore evidence stays unclipped

Development builds also explain common focus no-ops, including attempts to target an unmounted, hidden, disabled, or passive composite view. Use the warning as diagnosis, then verify through the public focus result rather than matching warning prose in application logic.

Best practices ​

  • Make retained child order match reading and keyboard order; visual rearrangement alone creates an inaccessible mental model.
  • Focus the actual interactive leaf. Use focusInto() when restoration is part of the requirement.
  • Treat visibility and eligibility as one state transition; otherwise focus can point at content the user cannot see.
  • Keep acquisition and cleanup together in the mounted owner; otherwise removed screens continue doing work.
  • Use real modal execution for blocking decisions; a painted overlay without a modal scope leaves the background interactive.
  • Provide visible, textual or structural focus evidence in addition to colour.
  • Test forward and reverse traversal, constrained geometry, modal containment, and restoration.

Practice and next steps ​

Exercise 1: traverse the first laboratory forward with Tab and backward with Shift+Tab; write down the tree order before looking at the status line. Exercise 2: make Beta hidden and Gamma disabled, then predict the next eligible target in both directions. Exercise 3: focus each main-window control, open the modal, traverse it, and verify that Esc restores the correct one. Finally, resize and maximize both laboratories and repeat the keyboard path.

Continue with:

Public API: