Documentation
Guides

Keyboard and accessibility

The fixed ARIA keys, a configurable key map for closing tabs and moving focus, and shortcuts of your own that run commands.

The primitives implement the WAI-ARIA tabs pattern (with manual activation) and the window splitter pattern. Those structural keys are fixed; command shortcuts are configurable through a key map.

Keyboard
Gallery

Fixed keys

onkeydoes
a tabLeft / Right (Up / Down in a vertical TabList)move focus to the previous / next tab; in RTL, Right / Left
a tabHome / Endmove focus to the first / last tab
a tabEnter or Spaceselect the tab; on the selected tab, move focus into its panel
a splitterarrows along its axismove it by 10px, the way they point

The strip has a single tab stop (roving tabIndex): the selected tab, or the first tab when none is selected. Arrow keys with a modifier held are left alone, so they stay available for your bindings. A TabList with orientation="vertical" switches the arrows to Up/Down and sets aria-orientation.

The key map

Command shortcuts come from a key map: "Ctrl+Shift+Key" strings, merged over defaultKeyMap (from @fragiola/dockable-react). Pass yours to Dockable.Root:

<Dockable.Root
    model={model}
    keyMap={{
        focusNextTabset: "Ctrl+F6",
        focusPreviousTabset: "Ctrl+Shift+F6",
        focusTabToggle: "F6",
        closeTab: "Ctrl+Delete",
    }}
>
bindingdefaultdoes
closeTabCtrl+Deletecloses the focused tab with tab.close, moving focus to a neighbor first; only when model.can("tab.close", …) accepts it
renameTabF2reserved for renaming the focused tab; the React primitives do not act on it yet, so implement rename yourself: a tab's name is its label and changes with tab.rename, which model.can answers (see Tabs)
focusTabToggleofftoggles focus between the selected tab button and its content
focusNextTabsetoffmoves focus to the selected tab of the next tabset (wrapping), from anywhere in the layout, including inside tab content; that tabset becomes active
focusPreviousTabsetoffthe same, backwards
closeOverlayBorderEscapecloses an open overlay border's panel, with focus in it or on its tab button (Borders)

Passing an explicit undefined disables a binding: keyMap={{ closeTab: undefined }}. Modifiers can be in any order; the key is compared case-insensitively with KeyboardEvent.key.

Every active binding is advertised to assistive technology through aria-keyshortcuts on the elements it applies to (tabs, tab lists, panels), so the announced shortcuts always match the configured ones.

WCAG 2.1.4 requires single printable-character shortcuts to be remappable or off by default: prefer function keys or modifier combinations.

Which tabs close

closeTab goes through the model like any other close. The tab asks model.can("tab.close", { tabId }) (a dry run: nothing changes) and, when it is accepted, runs tab.close. A pinned tab, a tab whose closable resolves to false, and a tab a middleware vetoes are all left alone, and their aria-keyshortcuts omit the binding. Your own close buttons and hints can ask the same question:

function CloseHint({ tab }: { tab: TabOf<Types> }) {
    const { model } = useDockable<Types>();
    const closeable = model.can("tab.close", { tabId: tab.id });
    return closeable ? <kbd>Ctrl+Delete</kbd> : null;
}

Your own shortcuts

Commands you add (maximize, pop out, undo) need your own key handler. matchesKey(event, spec) from the core matches a binding string, so your shortcuts can use the same format:

import { matchesKey, useDockable } from "@fragiola/dockable-react";

function useMaximizeShortcut() {
    const { model } = useDockable<Types>();
    useEffect(() => {
        const onKeyDown = (event: KeyboardEvent) => {
            if (event.defaultPrevented || !matchesKey(event, "Ctrl+Shift+M")) return;
            const tabset = model.get("active-tabset");
            if (!tabset) return;
            const maximized = model.is("tabset-maximized", { tabsetId: tabset.id });
            // refused (not allowed, or the only tabset): the key is left to others
            if (model.run("tabset.maximize", { tabsetId: tabset.id, value: !maximized }).ok) {
                event.preventDefault();
            }
        };
        document.addEventListener("keydown", onKeyDown);
        return () => document.removeEventListener("keydown", onKeyDown);
    }, [model]);
}

Check event.defaultPrevented: content that handled a key keeps it (the primitives do the same).

Focus you should keep visible

The primitives never style focus. Give tabs, splitters and your buttons a visible focus indicator (:focus-visible): a splitter especially, since it is invisible when thin.

Accessibility checklist

  • Name every tab list (aria-label), every icon button, and the splitters (aria-label in renderSplitter); see Accessible names.
  • Keep your buttons inside tabs out of the tab order if they duplicate a key binding, or make them reachable: a close button that is only visible on hover still needs a keyboard path (Ctrl+Delete).
  • Content inside panels is yours; the panel is a tabpanel labelled by its tab, and focusable (tabIndex={-1}) so Enter on a selected tab can move focus into it.

The key map and the ARIA wiring are adapted from FlexLayout's accessibility work (Caplin Systems Ltd, MIT).