fix(uix): audit Phase 2 — focus-strategy refactor (combobox-001 + select-003)

Commit both virtual-focus list components to a single keyboard model and a
shared, unit-tested index helper (the EX-1 extraction).

- keyboard/directional: new pure `nextIndex(curr, key, len, {loop,nextKey,
  prevKey})` — the next/prev/Home/End math every roving/virtual list route
  duplicated. Seeds `curr === -1` to first (next) / LAST (prev) BEFORE the
  loop/clamp, which kills the off-by where `-1 + prev + loop` wrapped to
  `len-2`. 11-case boundary unit suite.
- combobox [combobox-001]: the Input opened then `dom.focus(items[0])` and the
  Content route moved real DOM focus onto options — while the Input advertised
  `aria-activedescendant`. That's the A17 virtual+real focus mix and breaks the
  APG combobox pattern (focus must stay on the textbox). Rewrote the Input route
  to own all navigation via a `moveHighlight` helper (highlightedId only, never
  `dom.focus(item)`); deleted the real-focus Content route. Browser-verified on
  a 19-item list: focus stays on the input, ArrowDown/Up/Home/End drive
  aria-activedescendant.
- select [select-003]: the Trigger route now uses the shared `nextIndex`; the
  dead Content route (read `activeElement` in a virtual-focus component, carried
  the loop off-by) is deleted — verified that activeElement === trigger while
  open, so the route never fired. Browser-verified: Trigger route navigates
  (Down/Up/End) correctly.

Verified: combobox 3/3, select 3/3, directional 11/11, svelte-check 0 errors in
the touched files, browser-verified keyboard nav for both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 3 months ago
parent db33f66cdf
commit bac673158f

@ -12,7 +12,7 @@ import type {
Arrayable, Arrayable,
StyleProperties StyleProperties
} from '../../types'; } from '../../types';
import { KEYS } from '../../keyboard'; import { KEYS, nextIndex } from '../../keyboard';
import { Soma } from '../../core/soma.svelte'; import { Soma } from '../../core/soma.svelte';
import { Presence } from '../../layers/presence.svelte'; import { Presence } from '../../layers/presence.svelte';
@ -381,37 +381,27 @@ export class ComboboxInputProvider {
}; };
readonly onkeydown = (e: SomaKeyboardEvent<HTMLInputElement>) => { readonly onkeydown = (e: SomaKeyboardEvent<HTMLInputElement>) => {
if (e.key === KEYS.ARROW_DOWN) { if (
e.preventDefault(); e.key === KEYS.ARROW_DOWN ||
if (!this.provider.opts.open.current) { e.key === KEYS.ARROW_UP ||
this.provider.handleOpen(); e.key === KEYS.HOME ||
} e.key === KEYS.END
// Focus first item ) {
this.provider.soma.dom.requestFrame(() => {
const container = this.provider.contentRef.current;
if (!container) return;
const items = this.provider.getItems(container);
if (items.length > 0) {
this.provider.soma.dom.focus(items[0]);
this.provider.highlightedId = items[0].id;
}
}, this.provider.contentRef.current ?? this.provider.inputRef.current);
} else if (e.key === KEYS.ARROW_UP) {
e.preventDefault(); e.preventDefault();
// Virtual focus (A17): focus STAYS on the input; navigation only moves
// `highlightedId` (→ the input's aria-activedescendant) — never
// `dom.focus(item)`, which would contradict aria-activedescendant and
// break the APG combobox pattern (focus on the textbox).
if (!this.provider.opts.open.current) { if (!this.provider.opts.open.current) {
this.provider.handleOpen(); this.provider.handleOpen();
// The content mounts next frame; seed the highlight once it exists.
this.provider.soma.dom.requestFrame(
() => this.moveHighlight(e.key),
this.provider.contentRef.current ?? this.provider.inputRef.current
);
} else {
this.moveHighlight(e.key);
} }
// Focus last item
this.provider.soma.dom.requestFrame(() => {
const container = this.provider.contentRef.current;
if (!container) return;
const items = this.provider.getItems(container);
if (items.length > 0) {
const last = items[items.length - 1];
this.provider.soma.dom.focus(last);
this.provider.highlightedId = last.id;
}
}, this.provider.contentRef.current ?? this.provider.inputRef.current);
} else if (e.key === KEYS.ENTER) { } else if (e.key === KEYS.ENTER) {
if (this.provider.opts.open.current && this.provider.highlightedId) { if (this.provider.opts.open.current && this.provider.highlightedId) {
e.preventDefault(); e.preventDefault();
@ -446,6 +436,30 @@ export class ComboboxInputProvider {
} }
}; };
/**
* Advance the virtual highlight (`highlightedId` → aria-activedescendant) per
* a directional key and scroll the target into view. Focus never leaves the
* input — items are highlighted, not DOM-focused (A17).
*/
private moveHighlight(key: string) {
const container = this.provider.contentRef.current;
if (!container) return;
const items = this.provider.getItems(container);
if (items.length === 0) return;
const currentIndex = this.provider.highlightedId
? items.findIndex((el) => el.id === this.provider.highlightedId)
: -1;
const target = nextIndex(currentIndex, key, items.length, {
loop: this.provider.opts.loop.current,
nextKey: KEYS.ARROW_DOWN,
prevKey: KEYS.ARROW_UP
});
if (target >= 0 && items[target]) {
this.provider.highlightedId = items[target].id;
this.provider.soma.dom.scrollIntoView(items[target], { block: 'nearest' });
}
}
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
// Morfo-declared: role, aria-haspopup/expanded/controls/activedescendant/ // Morfo-declared: role, aria-haspopup/expanded/controls/activedescendant/
@ -636,72 +650,25 @@ export class ComboboxContentProvider {
readonly wrapperProps = $derived.by(() => buildFloatingShellWrapperProps(this.floating)); readonly wrapperProps = $derived.by(() => buildFloatingShellWrapperProps(this.floating));
readonly onkeydown = (e: KeyboardEvent) => { // No content-level `onkeydown`: Combobox uses VIRTUAL focus — focus stays on
const container = this.opts.ref.current; // the Input (aria-activedescendant), so the Content is never focused and a
if (!container) return; // keydown handler here would never fire. The Input route owns all keyboard
// navigation (next/prev/Home/End via the shared `nextIndex` helper). The old
const items = this.provider.getItems(container); // content route called `dom.focus(item)` (real focus) — an A17 mix that
const currentIndex = this.provider.highlightedId // contradicted the Input's aria-activedescendant and broke the APG combobox
? items.findIndex((el) => el.id === this.provider.highlightedId) // pattern (focus must stay on the textbox). (combobox-001)
: -1;
const loop = this.provider.opts.loop.current;
let targetIndex = -1;
if (e.key === KEYS.ARROW_DOWN) {
e.preventDefault();
targetIndex = loop
? (currentIndex + 1) % items.length
: Math.min(currentIndex + 1, items.length - 1);
} else if (e.key === KEYS.ARROW_UP) {
e.preventDefault();
if (currentIndex === -1) {
// No item highlighted — go to last
targetIndex = items.length - 1;
} else if (currentIndex === 0 && !loop) {
// First item, return focus to input
this.provider.highlightedId = '';
this.provider.soma.dom.focus(this.provider.inputRef.current);
return;
} else {
targetIndex = loop
? (currentIndex - 1 + items.length) % items.length
: Math.max(currentIndex - 1, 0);
}
} else if (e.key === KEYS.HOME) {
e.preventDefault();
targetIndex = 0;
} else if (e.key === KEYS.END) {
e.preventDefault();
targetIndex = items.length - 1;
} else if (e.key === KEYS.TAB) {
e.preventDefault();
this.provider.handleClose();
return;
} else {
return;
}
if (targetIndex >= 0 && items[targetIndex]) {
const target = items[targetIndex];
this.provider.soma.dom.focus(target);
this.provider.soma.dom.scrollIntoView(target, { block: 'nearest' });
this.provider.highlightedId = target.id;
}
};
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
// Morfo-declared: role, aria-labelledby, aria-multiselectable. // Morfo-declared: role, aria-labelledby, aria-multiselectable.
...this.runtimePart.renderProps(), ...this.runtimePart.renderProps(),
// Soma-only: data-state (morfo declares it WITHOUT a value), the // Soma-only: data-state (morfo declares it WITHOUT a value), the
// floating/presence/dismissal layer attrs, native tabindex + handler. // floating/presence/dismissal layer attrs, native tabindex.
'data-state': getDataOpenClosed(this.provider.opts.open.current), 'data-state': getDataOpenClosed(this.provider.opts.open.current),
...this.floating.props, ...this.floating.props,
...this.provider.contentPresence.transitionAttrs, ...this.provider.contentPresence.transitionAttrs,
...this.dismissal.props, ...this.dismissal.props,
tabindex: -1, tabindex: -1
onkeydown: this.onkeydown
} as const) } as const)
); );
} }

@ -17,7 +17,7 @@ import type {
Arrayable, Arrayable,
StyleProperties StyleProperties
} from '../../types'; } from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard'; import { KEYS, getDirectionalKeys, nextIndex } from '../../keyboard';
import { Soma } from '../../core/soma.svelte'; import { Soma } from '../../core/soma.svelte';
import { Typeahead } from '../../typeahead'; import { Typeahead } from '../../typeahead';
@ -327,30 +327,17 @@ export class SelectTriggerProvider {
? items.findIndex((el) => el.id === this.provider.highlightedId) ? items.findIndex((el) => el.id === this.provider.highlightedId)
: -1; : -1;
const loop = this.provider.opts.loop.current; const loop = this.provider.opts.loop.current;
let targetIndex = -1;
if (e.key === nextKey) { // Shared directional-nav math (next/prev/Home/End). Returns -1 for
e.preventDefault(); // non-nav keys, which fall through to select / close / typeahead below.
targetIndex = const targetIndex = nextIndex(currentIndex, e.key, items.length, {
currentIndex === -1 loop,
? 0 nextKey,
: loop prevKey
? (currentIndex + 1) % items.length });
: Math.min(currentIndex + 1, items.length - 1);
} else if (e.key === prevKey) { if (targetIndex >= 0) {
e.preventDefault();
targetIndex =
currentIndex === -1
? items.length - 1
: loop
? (currentIndex - 1 + items.length) % items.length
: Math.max(currentIndex - 1, 0);
} else if (e.key === KEYS.HOME) {
e.preventDefault();
targetIndex = 0;
} else if (e.key === KEYS.END) {
e.preventDefault(); e.preventDefault();
targetIndex = items.length - 1;
} else if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) { } else if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault(); e.preventDefault();
if (this.provider.highlightedId) { if (this.provider.highlightedId) {
@ -559,60 +546,14 @@ export class SelectContentProvider {
readonly wrapperProps = $derived.by(() => buildFloatingShellWrapperProps(this.floating)); readonly wrapperProps = $derived.by(() => buildFloatingShellWrapperProps(this.floating));
readonly onkeydown = (e: KeyboardEvent) => { // No content-level `onkeydown`: Select uses VIRTUAL focus — focus stays on the
const container = this.opts.ref.current; // Trigger (aria-activedescendant), so the Content is never focused and a keydown
if (!container) return; // handler here would never fire (verified: activeElement === trigger while open).
// The Trigger route owns all keyboard nav (next/prev/Home/End via the shared
const items = this.provider.getItems(container); // `nextIndex` helper, Enter/Space to select, Escape to close; Tab closes via the
const active = this.provider.soma.dom.activeElement(container) as HTMLElement | null; // dismissal focus-out). The removed content route read `activeElement` (real
const currentIndex = active ? items.indexOf(active) : -1; // focus) — an A17 mix that diverged from the Trigger route and carried a loop
const loop = this.provider.opts.loop.current; // off-by. (select-003)
const dir = this.provider.opts.dir.current;
const { nextKey, prevKey } = getDirectionalKeys(dir, 'vertical');
let targetIndex = -1;
if (e.key === nextKey) {
e.preventDefault();
targetIndex = loop
? (currentIndex + 1) % items.length
: Math.min(currentIndex + 1, items.length - 1);
} else if (e.key === prevKey) {
e.preventDefault();
targetIndex = loop
? (currentIndex - 1 + items.length) % items.length
: Math.max(currentIndex - 1, 0);
} else if (e.key === KEYS.HOME) {
e.preventDefault();
targetIndex = 0;
} else if (e.key === KEYS.END) {
e.preventDefault();
targetIndex = items.length - 1;
} else if (e.key === KEYS.TAB) {
// Tab closes select (returns focus to trigger)
e.preventDefault();
this.provider.handleClose();
return;
} else {
// Typeahead
const match = this.provider.typeahead.handle(e.key, items);
if (match) {
this.provider.soma.dom.scrollIntoView(match, {
block: this.provider.opts.scrollAlignment.current
});
this.provider.highlightedId = match.id;
}
return;
}
if (targetIndex >= 0 && items[targetIndex]) {
const target = items[targetIndex];
this.provider.soma.dom.scrollIntoView(target, {
block: this.provider.opts.scrollAlignment.current
});
this.provider.highlightedId = target.id;
}
};
readonly props = $derived.by(() => readonly props = $derived.by(() =>
this.runtimePart.assert({ this.runtimePart.assert({
@ -626,8 +567,7 @@ export class SelectContentProvider {
...this.floating.props, ...this.floating.props,
...this.provider.contentPresence.transitionAttrs, ...this.provider.contentPresence.transitionAttrs,
...this.dismissal.props, ...this.dismissal.props,
tabindex: -1, tabindex: -1
onkeydown: this.onkeydown
} as const) } as const)
); );
} }

@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest';
import { nextIndex, getDirectionalKeys } from './directional';
import { KEYS } from './keys';
// Vertical list (the select/combobox/menu case): nextKey = ArrowDown, prevKey = ArrowUp.
const V = getDirectionalKeys('ltr', 'vertical');
const down = (curr: number, len: number, loop = false) =>
nextIndex(curr, KEYS.ARROW_DOWN, len, { loop, ...V });
const up = (curr: number, len: number, loop = false) =>
nextIndex(curr, KEYS.ARROW_UP, len, { loop, ...V });
describe('nextIndex', () => {
it('returns -1 for an empty list regardless of key', () => {
expect(down(-1, 0)).toBe(-1);
expect(up(-1, 0)).toBe(-1);
expect(nextIndex(-1, KEYS.HOME, 0, { loop: false, ...V })).toBe(-1);
});
it('returns -1 for non-navigation keys', () => {
expect(nextIndex(2, KEYS.ENTER, 5, { loop: false, ...V })).toBe(-1);
expect(nextIndex(2, 'a', 5, { loop: false, ...V })).toBe(-1);
expect(nextIndex(2, KEYS.ESCAPE, 5, { loop: true, ...V })).toBe(-1);
});
describe('no current selection (curr === -1) — APG no-selection landing', () => {
it('next lands on the FIRST item', () => {
expect(down(-1, 5)).toBe(0);
expect(down(-1, 5, true)).toBe(0);
});
it('prev lands on the LAST item (not n-2 — the off-by this helper fixes)', () => {
expect(up(-1, 5)).toBe(4);
expect(up(-1, 5, true)).toBe(4); // loop path must NOT wrap to len-2
});
});
describe('clamping (loop = false)', () => {
it('next clamps at the last index', () => {
expect(down(3, 5)).toBe(4);
expect(down(4, 5)).toBe(4);
});
it('prev clamps at the first index', () => {
expect(up(1, 5)).toBe(0);
expect(up(0, 5)).toBe(0);
});
});
describe('wrapping (loop = true)', () => {
it('next wraps last → first', () => {
expect(down(4, 5, true)).toBe(0);
expect(down(2, 5, true)).toBe(3);
});
it('prev wraps first → last', () => {
expect(up(0, 5, true)).toBe(4);
expect(up(2, 5, true)).toBe(1);
});
});
describe('Home / End', () => {
it('Home → 0, End → len-1, irrespective of current/loop', () => {
expect(nextIndex(3, KEYS.HOME, 5, { loop: false, ...V })).toBe(0);
expect(nextIndex(0, KEYS.END, 5, { loop: true, ...V })).toBe(4);
});
});
describe('single-item list', () => {
it('stays on 0 in every direction', () => {
expect(down(0, 1)).toBe(0);
expect(up(0, 1)).toBe(0);
expect(down(0, 1, true)).toBe(0);
expect(up(0, 1, true)).toBe(0);
expect(up(-1, 1)).toBe(0);
});
});
describe('horizontal RTL resolves keys via getDirectionalKeys', () => {
const H = getDirectionalKeys('rtl', 'horizontal');
it('ArrowLeft advances, ArrowRight retreats in RTL', () => {
expect(nextIndex(0, KEYS.ARROW_LEFT, 3, { loop: false, ...H })).toBe(1);
expect(nextIndex(2, KEYS.ARROW_RIGHT, 3, { loop: false, ...H })).toBe(1);
});
});
});

@ -26,3 +26,41 @@ export function getDirectionalKeys(
prevKey: getPrevKey(dir, orientation) prevKey: getPrevKey(dir, orientation)
}; };
} }
export interface NextIndexOpts {
/** Wrap from last→first / first→last instead of clamping at the ends. */
loop: boolean;
/** The "advance" key for this list, resolved via `getDirectionalKeys`. */
nextKey: string;
/** The "retreat" key for this list, resolved via `getDirectionalKeys`. */
prevKey: string;
}
/**
* Pure directional-nav index math shared by every roving / virtual list route
* (select, combobox, dropdown-menu, …). Given the current index, the pressed
* key, and the item count, returns the target index — or `-1` when `key` is not
* a navigation key (the caller still owns Enter / typeahead / Escape).
*
* The no-current seed (`curr === -1`) lands on the APG no-selection target:
* FIRST for `nextKey`, **LAST** for `prevKey`. Computing the seed BEFORE the
* loop/clamp math is what avoids the off-by where `curr === -1 + prev + loop`
* wraps to `len - 2` instead of the last item.
*
* Each caller keeps its own focus/scroll application (real DOM focus for the
* menu/roving routes, virtual `highlightedId` for select/combobox) — this
* helper computes only the index, never a side effect.
*/
export function nextIndex(curr: number, key: string, len: number, opts: NextIndexOpts): number {
if (len <= 0) return -1;
const { loop, nextKey, prevKey } = opts;
if (key === nextKey) {
return curr === -1 ? 0 : loop ? (curr + 1) % len : Math.min(curr + 1, len - 1);
}
if (key === prevKey) {
return curr === -1 ? len - 1 : loop ? (curr - 1 + len) % len : Math.max(curr - 1, 0);
}
if (key === KEYS.HOME) return 0;
if (key === KEYS.END) return len - 1;
return -1;
}

@ -1,2 +1,3 @@
export { KEYS, FIRST_KEYS, LAST_KEYS, SELECTION_KEYS } from './keys'; export { KEYS, FIRST_KEYS, LAST_KEYS, SELECTION_KEYS } from './keys';
export { getNextKey, getPrevKey, getDirectionalKeys } from './directional'; export { getNextKey, getPrevKey, getDirectionalKeys, nextIndex } from './directional';
export type { NextIndexOpts } from './directional';

Loading…
Cancel
Save

Powered by TurnKey Linux.