@ -8,9 +8,9 @@ import { state } from '$libs/reactive';
import type { Morfo } from '$uix/morfo' ;
import { Soma } from '$soma/core/soma.svelte' ;
import { createSomaRuntime , type SomaRuntimeSources } from '$soma/runtime.svelte' ;
import { FloatingProvider } from '$soma/layers/floating' ;
import { FloatingProvider , FloatingAnchor } from '$soma/layers/floating' ;
import { SelectProvider } from './select-provider.svelte' ;
import { SelectProvider , SelectTriggerProvider } from './select-provider.svelte' ;
import type { ScrollAlignment , SelectType } from './types' ;
function withEffectRoot < T > ( fn : ( ) = > T ) : { result : T ; cleanup : ( ) = > void } {
@ -132,3 +132,140 @@ describe('SelectProvider', () => {
dom . dispose ( ) ;
} ) ;
} ) ;
// ── Keyboard navigation (virtual focus) — covers select-003 ──────────────────
//
// The Trigger route owns ALL keyboard navigation via the shared `nextIndex`
// helper; the old real-focus Content route was deleted. These exercise the
// integration: keys move `highlightedId` (→ aria-activedescendant) in the right
// order, the no-selection seed lands ArrowUp on the LAST item (the `n-2`
// off-by the helper fixes), and focus never leaves the trigger for an item.
function keyEvent ( key : string ) {
return { key , preventDefault : vi.fn ( ) } ;
}
function setupSelectKeyboard ( configure ? : ( opts : ReturnType < typeof createSelectOpts > ) = > void ) {
const { dom , timers } = installSomaHarness ( ) ;
const opts = createSelectOpts ( ) ; // open: true by default
configure ? . ( opts ) ;
const root = withEffectRoot ( ( ) = > SelectProvider . create ( opts ) ) ;
vi . spyOn ( SelectProvider , 'require' ) . mockReturnValue ( root . result ) ;
vi . spyOn ( FloatingAnchor , 'create' ) . mockReturnValue ( { } as never ) ;
const triggerEl = document . createElement ( 'button' ) ;
document . body . appendChild ( triggerEl ) ;
const trigger = withEffectRoot ( ( ) = >
SelectTriggerProvider . create ( { id : state ( 'select-trigger' ) , ref : state ( triggerEl ) } )
) ;
const container = document . createElement ( 'div' ) ;
// tabindex="-1" mirrors real virtual-focus items: programmatically focusable,
// so a stray `dom.focus(item)` WOULD move focus — what the virtual-focus test
// must be able to catch (an unfocusable div makes `.focus()` a no-op → vacuous).
container . innerHTML =
'<div id="a" data-select-item data-value="a" tabindex="-1">Alpha</div>' +
'<div id="b" data-select-item data-value="b" tabindex="-1">Beta</div>' +
'<div id="c" data-select-item data-value="c" tabindex="-1">Gamma</div>' ;
document . body . appendChild ( container ) ;
root . result . setContentRef ( container ) ;
const dispose = ( ) = > {
trigger . cleanup ( ) ;
root . cleanup ( ) ;
timers . dispose ( ) ;
dom . dispose ( ) ;
} ;
return { provider : root.result , trigger : trigger.result , triggerEl , dispose } ;
}
describe ( 'SelectProvider keyboard navigation (virtual focus)' , ( ) = > {
afterEach ( ( ) = > {
vi . restoreAllMocks ( ) ;
document . body . innerHTML = '' ;
} ) ;
it ( 'ArrowDown from no highlight lands on the first item, then advances' , ( ) = > {
const s = setupSelectKeyboard ( ) ;
expect ( s . provider . highlightedId ) . toBe ( '' ) ;
const down = keyEvent ( 'ArrowDown' ) ;
s . trigger . onkeydown ( down as never ) ;
expect ( down . preventDefault ) . toHaveBeenCalledOnce ( ) ;
expect ( s . provider . highlightedId ) . toBe ( 'a' ) ;
s . trigger . onkeydown ( keyEvent ( 'ArrowDown' ) as never ) ;
expect ( s . provider . highlightedId ) . toBe ( 'b' ) ;
s . dispose ( ) ;
} ) ;
it ( 'ArrowUp from no highlight lands on the LAST item (no-selection seed)' , ( ) = > {
const s = setupSelectKeyboard ( ) ;
s . trigger . onkeydown ( keyEvent ( 'ArrowUp' ) as never ) ;
expect ( s . provider . highlightedId ) . toBe ( 'c' ) ; // last, NOT n-2
s . dispose ( ) ;
} ) ;
it ( 'Home highlights the first item, End the last' , ( ) = > {
const s = setupSelectKeyboard ( ) ;
s . trigger . onkeydown ( keyEvent ( 'End' ) as never ) ;
expect ( s . provider . highlightedId ) . toBe ( 'c' ) ;
s . trigger . onkeydown ( keyEvent ( 'Home' ) as never ) ;
expect ( s . provider . highlightedId ) . toBe ( 'a' ) ;
s . dispose ( ) ;
} ) ;
it ( 'clamps at the ends when loop is off' , ( ) = > {
const s = setupSelectKeyboard ( ) ;
s . provider . highlightedId = 'c' ;
s . trigger . onkeydown ( keyEvent ( 'ArrowDown' ) as never ) ;
expect ( s . provider . highlightedId ) . toBe ( 'c' ) ;
s . provider . highlightedId = 'a' ;
s . trigger . onkeydown ( keyEvent ( 'ArrowUp' ) as never ) ;
expect ( s . provider . highlightedId ) . toBe ( 'a' ) ;
s . dispose ( ) ;
} ) ;
it ( 'wraps around when loop is on' , ( ) = > {
const s = setupSelectKeyboard ( ( o ) = > {
o . loop . current = true ;
} ) ;
s . provider . highlightedId = 'c' ;
s . trigger . onkeydown ( keyEvent ( 'ArrowDown' ) as never ) ;
expect ( s . provider . highlightedId ) . toBe ( 'a' ) ;
s . trigger . onkeydown ( keyEvent ( 'ArrowUp' ) as never ) ;
expect ( s . provider . highlightedId ) . toBe ( 'c' ) ;
s . dispose ( ) ;
} ) ;
it ( 'Enter selects the highlighted item and closes (single mode)' , ( ) = > {
const s = setupSelectKeyboard ( ) ;
s . provider . highlightedId = 'b' ;
s . trigger . onkeydown ( keyEvent ( 'Enter' ) as never ) ;
expect ( s . provider . opts . value . current ) . toEqual ( [ 'b' ] ) ;
expect ( s . provider . opts . open . current ) . toBe ( false ) ;
s . dispose ( ) ;
} ) ;
it ( 'Escape closes without selecting' , ( ) = > {
const s = setupSelectKeyboard ( ) ;
s . provider . highlightedId = 'b' ;
s . trigger . onkeydown ( keyEvent ( 'Escape' ) as never ) ;
expect ( s . provider . opts . open . current ) . toBe ( false ) ;
expect ( s . provider . opts . value . current ) . toEqual ( [ ] ) ;
s . dispose ( ) ;
} ) ;
it ( 'typeahead jumps to the first item matching the typed prefix' , ( ) = > {
const s = setupSelectKeyboard ( ) ;
s . trigger . onkeydown ( keyEvent ( 'g' ) as never ) ; // "Gamma"
expect ( s . provider . highlightedId ) . toBe ( 'c' ) ;
s . dispose ( ) ;
} ) ;
it ( 'keeps virtual focus: navigation moves highlightedId, DOM focus stays on the trigger' , ( ) = > {
const s = setupSelectKeyboard ( ) ;
s . triggerEl . focus ( ) ;
expect ( document . activeElement ) . toBe ( s . triggerEl ) ;
s . trigger . onkeydown ( keyEvent ( 'ArrowDown' ) as never ) ;
expect ( s . provider . highlightedId ) . toBe ( 'a' ) ;
// focus must NOT move to the highlighted item — the route never dom.focus()es items
expect ( document . activeElement ) . toBe ( s . triggerEl ) ;
s . dispose ( ) ;
} ) ;
} ) ;