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
parent
db33f66cdf
commit
bac673158f
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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…
Reference in new issue