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.
Fixed keys
| on | key | does |
|---|---|---|
| a tab | Left / Right (Up / Down in a vertical TabList) | move focus to the previous / next tab; in RTL, Right / Left |
| a tab | Home / End | move focus to the first / last tab |
| a tab | Enter or Space | select the tab; on the selected tab, move focus into its panel |
| a splitter | arrows along its axis | move 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",
}}
>| binding | default | does |
|---|---|---|
closeTab | Ctrl+Delete | closes the focused tab with tab.close, moving focus to a neighbor first; only when model.can("tab.close", …) accepts it |
renameTab | F2 | reserved 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) |
focusTabToggle | off | toggles focus between the selected tab button and its content |
focusNextTabset | off | moves focus to the selected tab of the next tabset (wrapping), from anywhere in the layout, including inside tab content; that tabset becomes active |
focusPreviousTabset | off | the same, backwards |
closeOverlayBorder | Escape | closes 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-labelinrenderSplitter); 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
tabpanellabelled 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).