Interaction Modality
Headless Interaction Modality; covers React Aria useFocusVisible, WICG focus-visible.
| Package | @vizejs/ui/interaction-modality |
| Maturity | stable |
| Own the source | vize lib pull interaction-modality |
| Requires | — |
| Aliases | focus modality, input modality, focus visible modality |
| Covers | React Aria useFocusVisible, WICG focus-visible |
API
createInteractionModalityTracker
Create an SSR-safe, document-scoped interaction-modality observer. Trackers in one document share native capture listeners and state. Separate documents, including iframes, remain isolated. Call InteractionModalityTracker.dispose when using this factory outside a Vue effect scope.
function createInteractionModalityTracker( options: InteractionModalityOptions = {}, ): InteractionModalityTracker
useInteractionModality
Create a tracker owned by the current Vue effect scope. The tracker is disposed automatically when its component or effect scope is destroyed, preventing document-listener leaks.
function useInteractionModality( options: InteractionModalityOptions = {}, ): InteractionModalityTracker
isElementFocusVisible
Determine whether a focused element should expose its focus indicator. Native :focus-visible semantics take precedence. The modality fallback is used only when the selector is unsupported, preserving text-input and platform heuristics implemented by the browser.
function isElementFocusVisible( element: Element | null | undefined, modality: InteractionModality | null, ): boolean
classifyKeyboardEvent
Classify keyboard intent while excluding composition and modified shortcuts.
function classifyKeyboardEvent(event: KeyboardEvent): InteractionModalityEvent | null
classifyPointerEvent
Map pointer hardware into the stable public modality vocabulary.
function classifyPointerEvent(event: PointerEvent): InteractionModalityEvent
classifyVirtualClick
Classify coordinate-free clicks used by keyboards and assistive technology.
function classifyVirtualClick( event: MouseEvent, current: InteractionModality | null, ): InteractionModalityEvent | null
subscribeToDocumentModality
Subscribe to shared document state and return an idempotent release callback.
function subscribeToDocumentModality( document: Document, initialModality: InteractionModality | null, subscriber: (update: DocumentModalityUpdate) => void, ): { readonly current: InteractionModality | null; readonly release: () => void }
publishDocumentModality
Synchronize an explicit consumer update with every peer in a document.
function publishDocumentModality( document: Document, update: DocumentModalityUpdate, ): boolean
Behavior
Normative state × input → outcome table for @vizejs/ui/interaction-modality.
Every row is exercised by src/families/accessibility/interaction-modality/interaction-modality*.test.ts;
compile-only API assertions live in
src/families/accessibility/interaction-modality/interaction-modality.types.test-d.ts.
| # | State | Input | Outcome | Proven by |
|---|---|---|---|---|
| IM1 | no DOM / SSR | create with document: null |
no native access; reactive state remains usable | is inert without a document and remains manually controllable… |
| IM2 | any | unmodified keyboard intent | modality becomes keyboard; focus is globally visible |
classifies keyboard, pointer, touch, and virtual intent |
| IM3 | any | mouse, pen, or unknown pointer | modality becomes pointer |
classifies keyboard, pointer, touch, and virtual intent |
| IM4 | any | touch pointer | modality becomes touch |
classifies keyboard, pointer, touch, and virtual intent |
| IM5 | any except keyboard | coordinate-free click | modality becomes virtual |
keeps keyboard-generated coordinate-free clicks… |
| IM6 | keyboard | synthesized click with detail: 0 |
keyboard modality is retained | keeps keyboard-generated coordinate-free clicks… |
| IM7 | any | IME or modified shortcut | event does not alter modality | ignores composition, modifier-only keys, and modified shortcuts |
| IM8 | two trackers in one document | input or manual update | one listener set; both trackers receive identical state | shares exactly one native listener set and state per document |
| IM9 | trackers in different documents | input in one document | other document remains unchanged | isolates separate documents and adopts existing state when moving |
| IM10 | tracker moves to populated document | reactive document change | existing document state is adopted with document reason |
isolates separate documents and adopts existing state when moving |
| IM11 | attached | detach | listeners release; last modality remains | supports explicit attach and detach without losing the last value |
| IM12 | disposed | attach, detach, or state mutation | stable disposed diagnostic is thrown | disposal is idempotent and rejects later mutation |
| IM13 | Vue effect scope | scope stops | tracker disposes and releases its shared subscription | the composable requires and follows a Vue effect scope |
| IM14 | focused element, supported browser | visibility query | native :focus-visible result wins |
defers to native focus-visible semantics… |
| IM15 | focused element, old browser | selector throws | keyboard/virtual modality supplies the fallback | falls back to modality only when focus-visible is unsupported |
| IM16 | unfocused element | visibility query | result is always false | defers to native focus-visible semantics… |
| IM17 | public TypeScript API | invalid modality or mutable ref use | compilation rejects the misuse | src/families/accessibility/interaction-modality/interaction-modality.types.test-d.ts |
| IM18 | legacy touch browser | touch then compatibility mousedown | touch modality is retained | shares exactly one native listener set and state per document |
| IM19 | subscriber callback | reentrant modality change | queued delivery leaves every live peer in the same state | serializes reentrant updates so every peer reaches… |
| IM20 | subscriber callback | callback throws | all peers update before the exception is surfaced | updates every peer before surfacing a subscriber exception |
| IM21 | document adoption | synchronization callback throws | new subscription rolls back without a listener leak | rolls back a failed document adoption without leaking… |
| IM22 | concurrent SSR requests | identical component trees | byte-identical, detached, neutral output is rendered | renders byte-identical output without touching a server document |
| IM23 | SSR followed by hydration | document becomes available on mount | listener attaches after warning-free hydration | attaches after hydration without mismatch diagnostics |
Accessibility decisions
- Native
:focus-visibleis authoritative when available. This preserves browser heuristics for editable controls, platform conventions, and assistive technology instead of replacing them with a narrower JavaScript guess. keyboardandvirtualare the only globally focus-visible modalities. Components should useisElementFocusVisiblefor the final per-element decision and must still provide an actual visible style.- Pointer Events reserve
pointerId: -1for input not produced by pointing hardware. An empty-type pointer with that ID is classified asvirtual. - Modified shortcuts and composition do not change visual modality. Shift+Tab remains keyboard intent because it directly changes focus.
SSR, hydration, and document ownership
- Importing this entry performs no DOM read, listener installation, style injection, timer scheduling, or mutation of request-scoped state.
- With no global document, the default tracker is inert and deterministic.
- Trackers share listeners only when keyed by the same live
Document. Weak ownership and reference counting release the hub when its final subscriber disposes; iframes and independently hydrated documents cannot leak state. - Pass a reactive document getter when an island, iframe, or portal owner is not
known until mount. Passing
nullexplicitly is the SSR/deferred escape hatch.
Styling contract
This primitive emits no CSS and sets no attributes. Consumers may map
isFocusVisible or isElementFocusVisible to classes, data attributes, CSS
variables, utility frameworks, or fully custom styles. Never remove the browser
outline unless an equivalent visible indicator is supplied.