From d0babd30d8bcc361462e248a37b59f4782a04926 Mon Sep 17 00:00:00 2001 From: jack shelton Date: Mon, 3 Jun 2024 14:09:56 -0500 Subject: [PATCH 01/15] get the first item focused --- .../src/components/select/select-context.ts | 1 + .../src/components/select/select-item.tsx | 17 +++++++++++++---- .../src/components/select/select-root.tsx | 2 ++ .../src/components/select/select-trigger.tsx | 10 +++++++++- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/kit-headless/src/components/select/select-context.ts b/packages/kit-headless/src/components/select/select-context.ts index 2e79fdd43..2a18de730 100644 --- a/packages/kit-headless/src/components/select/select-context.ts +++ b/packages/kit-headless/src/components/select/select-context.ts @@ -14,6 +14,7 @@ export type SelectContext = { listboxRef: Signal; groupRef: Signal; labelRef: Signal; + firstEnabledItemRef: Signal; // core state itemsMapSig: Readonly>; diff --git a/packages/kit-headless/src/components/select/select-item.tsx b/packages/kit-headless/src/components/select/select-item.tsx index 5889da954..d7eb9e0f2 100644 --- a/packages/kit-headless/src/components/select/select-item.tsx +++ b/packages/kit-headless/src/components/select/select-item.tsx @@ -34,8 +34,9 @@ export const HSelectItem = component$((props) => { const itemRef = useSignal(); const localIndexSig = useSignal(null); const itemId = `${context.localId}-${_index}`; + const isInitialFocusSig = useSignal(true); - const { selectionManager$ } = useSelect(); + const { selectionManager$, getNextEnabledItemIndex$ } = useSelect(); const isSelectedSig = useComputed$(() => { const index = _index ?? null; @@ -46,14 +47,20 @@ export const HSelectItem = component$((props) => { return !disabled && context.highlightedIndexSig.value === _index; }); - useTask$(function getIndexTask() { + useTask$(async function getIndexTask() { if (_index === undefined) throw Error('Qwik UI: Select component item cannot find its proper index.'); localIndexSig.value = _index; + + // update the context with the first enabled item ref + const firstEnabledIndex = await getNextEnabledItemIndex$(-1); + if (localIndexSig.value === firstEnabledIndex) { + context.firstEnabledItemRef = itemRef; + } }); - useTask$(function scrollableTask({ track, cleanup }) { + useTask$(async function scrollableTask({ track, cleanup }) { track(() => context.highlightedIndexSig.value); if (isServer) return; @@ -81,6 +88,8 @@ export const HSelectItem = component$((props) => { observer.observe(itemRef.value); } } + + if (!isInitialFocusSig.value) return; }); const handleClick$ = $(async () => { @@ -124,7 +133,7 @@ export const HSelectItem = component$((props) => { data-selected={isSelectedSig.value ? '' : undefined} data-highlighted={isHighlightedSig.value ? '' : undefined} data-disabled={disabled ? '' : undefined} - data-item + data-item={_index} role="option" > diff --git a/packages/kit-headless/src/components/select/select-root.tsx b/packages/kit-headless/src/components/select/select-root.tsx index cd8d43efb..a23ad35ea 100644 --- a/packages/kit-headless/src/components/select/select-root.tsx +++ b/packages/kit-headless/src/components/select/select-root.tsx @@ -161,6 +161,7 @@ export const HSelectImpl = component$ & InternalSelectProps const currDisplayValueSig = useSignal(); const initialLoadSig = useSignal(true); + const firstEnabledItemRef = useSignal(); const context: SelectContext = { itemsMapSig, @@ -170,6 +171,7 @@ export const HSelectImpl = component$ & InternalSelectProps listboxRef, labelRef, groupRef, + firstEnabledItemRef, localId, highlightedIndexSig, selectedIndexSetSig, diff --git a/packages/kit-headless/src/components/select/select-trigger.tsx b/packages/kit-headless/src/components/select/select-trigger.tsx index b4a870a6d..fb7db8815 100644 --- a/packages/kit-headless/src/components/select/select-trigger.tsx +++ b/packages/kit-headless/src/components/select/select-trigger.tsx @@ -134,7 +134,15 @@ export const HSelectTrigger = component$((props) => { /** When initially opening the listbox, we want to grab the first enabled option index */ if (context.highlightedIndexSig.value === null) { - context.highlightedIndexSig.value = await getNextEnabledItemIndex$(-1); + const firstItem = await getNextEnabledItemIndex$(-1); + context.highlightedIndexSig.value = firstItem; + + // Wait for the popover code to be executed + while (!(context.firstEnabledItemRef.value === document.activeElement)) { + await new Promise((resolve) => setTimeout(resolve, 5)); + context.firstEnabledItemRef.value?.focus(); + } + return; } }); From 874ad8590c503a1c3cfd6c649da13c24446deebd Mon Sep 17 00:00:00 2001 From: jack shelton Date: Mon, 3 Jun 2024 14:32:30 -0500 Subject: [PATCH 02/15] navigating --- .../src/components/select/select-item.tsx | 54 ++++++++++++++++++- .../src/components/select/select-trigger.tsx | 44 ++------------- 2 files changed, 57 insertions(+), 41 deletions(-) diff --git a/packages/kit-headless/src/components/select/select-item.tsx b/packages/kit-headless/src/components/select/select-item.tsx index d7eb9e0f2..75507645b 100644 --- a/packages/kit-headless/src/components/select/select-item.tsx +++ b/packages/kit-headless/src/components/select/select-item.tsx @@ -36,7 +36,8 @@ export const HSelectItem = component$((props) => { const itemId = `${context.localId}-${_index}`; const isInitialFocusSig = useSignal(true); - const { selectionManager$, getNextEnabledItemIndex$ } = useSelect(); + const { selectionManager$, getNextEnabledItemIndex$, getPrevEnabledItemIndex$ } = + useSelect(); const isSelectedSig = useComputed$(() => { const index = _index ?? null; @@ -44,7 +45,14 @@ export const HSelectItem = component$((props) => { }); const isHighlightedSig = useComputed$(() => { - return !disabled && context.highlightedIndexSig.value === _index; + if (disabled) return; + + if (context.highlightedIndexSig.value === _index) { + itemRef.value?.focus(); + return true; + } else { + return false; + } }); useTask$(async function getIndexTask() { @@ -118,6 +126,47 @@ export const HSelectItem = component$((props) => { isSelectedSig, }; + const handleKeyDown$ = $(async (e: KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + if (context.isListboxOpenSig.value) { + context.highlightedIndexSig.value = await getNextEnabledItemIndex$( + context.highlightedIndexSig.value!, + ); + if (context.multiple && e.shiftKey) { + await selectionManager$(context.highlightedIndexSig.value, 'toggle'); + } + } + break; + + case 'ArrowUp': + if (context.isListboxOpenSig.value) { + context.highlightedIndexSig.value = await getPrevEnabledItemIndex$( + context.highlightedIndexSig.value!, + ); + if (context.multiple && e.shiftKey) { + await selectionManager$(context.highlightedIndexSig.value, 'toggle'); + } + } + break; + + case 'Home': + if (context.isListboxOpenSig.value) { + context.highlightedIndexSig.value = await getNextEnabledItemIndex$(-1); + } + break; + + case 'End': + if (context.isListboxOpenSig.value) { + const lastEnabledOptionIndex = await getPrevEnabledItemIndex$( + context.itemsMapSig.value.size, + ); + context.highlightedIndexSig.value = lastEnabledOptionIndex; + } + break; + } + }); + useContextProvider(selectItemContextId, selectContext); return ( @@ -125,6 +174,7 @@ export const HSelectItem = component$((props) => { {...rest} id={itemId} onClick$={[handleClick$, props.onClick$]} + onKeyDown$={[handleKeyDown$, props.onKeyDown$]} onPointerOver$={[handlePointerOver$, props.onPointerOver$]} ref={itemRef} tabIndex={-1} diff --git a/packages/kit-headless/src/components/select/select-trigger.tsx b/packages/kit-headless/src/components/select/select-trigger.tsx index fb7db8815..078e56a91 100644 --- a/packages/kit-headless/src/components/select/select-trigger.tsx +++ b/packages/kit-headless/src/components/select/select-trigger.tsx @@ -56,52 +56,18 @@ export const HSelectTrigger = component$((props) => { : !context.isListboxOpenSig.value; break; - case 'ArrowDown': - if (context.isListboxOpenSig.value) { - context.highlightedIndexSig.value = await getNextEnabledItemIndex$( - context.highlightedIndexSig.value!, - ); - if (context.multiple && e.shiftKey) { - await selectionManager$(context.highlightedIndexSig.value, 'toggle'); - } - } else { - context.isListboxOpenSig.value = true; - } + case 'Tab': + case 'Escape': + context.isListboxOpenSig.value = false; break; + case 'ArrowDown': case 'ArrowUp': - if (context.isListboxOpenSig.value) { - context.highlightedIndexSig.value = await getPrevEnabledItemIndex$( - context.highlightedIndexSig.value!, - ); - if (context.multiple && e.shiftKey) { - await selectionManager$(context.highlightedIndexSig.value, 'toggle'); - } - } else { + if (!context.isListboxOpenSig.value) { context.isListboxOpenSig.value = true; } break; - case 'Home': - if (context.isListboxOpenSig.value) { - context.highlightedIndexSig.value = await getNextEnabledItemIndex$(-1); - } - break; - - case 'End': - if (context.isListboxOpenSig.value) { - const lastEnabledOptionIndex = await getPrevEnabledItemIndex$( - context.itemsMapSig.value.size, - ); - context.highlightedIndexSig.value = lastEnabledOptionIndex; - } - break; - - case 'Tab': - case 'Escape': - context.isListboxOpenSig.value = false; - break; - case 'ArrowRight': if (!context.multiple) { const currentIndex = context.highlightedIndexSig.value ?? -1; From c15490b719fd4dd968cd67004af7f1f35507f220 Mon Sep 17 00:00:00 2001 From: jack shelton Date: Mon, 3 Jun 2024 14:37:17 -0500 Subject: [PATCH 03/15] prevented keys --- .../src/components/select/select-item.tsx | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/kit-headless/src/components/select/select-item.tsx b/packages/kit-headless/src/components/select/select-item.tsx index 75507645b..d88debe9e 100644 --- a/packages/kit-headless/src/components/select/select-item.tsx +++ b/packages/kit-headless/src/components/select/select-item.tsx @@ -8,6 +8,7 @@ import { useTask$, type PropsOf, useContextProvider, + sync$, } from '@builder.io/qwik'; import { isServer, isBrowser } from '@builder.io/qwik/build'; import SelectContextId, { @@ -126,6 +127,24 @@ export const HSelectItem = component$((props) => { isSelectedSig, }; + const handleKeyDownSync$ = sync$((e: KeyboardEvent) => { + const keys = [ + 'ArrowUp', + 'ArrowDown', + 'ArrowRight', + 'ArrowLeft', + 'Home', + 'End', + 'PageDown', + 'PageUp', + 'Enter', + ' ', + ]; + if (keys.includes(e.key)) { + e.preventDefault(); + } + }); + const handleKeyDown$ = $(async (e: KeyboardEvent) => { switch (e.key) { case 'ArrowDown': @@ -164,6 +183,11 @@ export const HSelectItem = component$((props) => { context.highlightedIndexSig.value = lastEnabledOptionIndex; } break; + + case 'Tab': + case 'Escape': + context.isListboxOpenSig.value = false; + break; } }); @@ -174,7 +198,7 @@ export const HSelectItem = component$((props) => { {...rest} id={itemId} onClick$={[handleClick$, props.onClick$]} - onKeyDown$={[handleKeyDown$, props.onKeyDown$]} + onKeyDown$={[handleKeyDownSync$, handleKeyDown$, props.onKeyDown$]} onPointerOver$={[handlePointerOver$, props.onPointerOver$]} ref={itemRef} tabIndex={-1} From 713a2842ac5b3a4942e9fe756357a492ad143b2e Mon Sep 17 00:00:00 2001 From: jack shelton Date: Mon, 3 Jun 2024 15:25:46 -0500 Subject: [PATCH 04/15] focus highlighted only --- .../src/components/select/select-context.ts | 2 +- .../src/components/select/select-item.tsx | 12 ++++-------- .../src/components/select/select-root.tsx | 4 ++-- .../src/components/select/select-trigger.tsx | 15 ++++++--------- 4 files changed, 13 insertions(+), 20 deletions(-) diff --git a/packages/kit-headless/src/components/select/select-context.ts b/packages/kit-headless/src/components/select/select-context.ts index 2a18de730..3893d7f73 100644 --- a/packages/kit-headless/src/components/select/select-context.ts +++ b/packages/kit-headless/src/components/select/select-context.ts @@ -14,7 +14,7 @@ export type SelectContext = { listboxRef: Signal; groupRef: Signal; labelRef: Signal; - firstEnabledItemRef: Signal; + highlightedItemRef: Signal; // core state itemsMapSig: Readonly>; diff --git a/packages/kit-headless/src/components/select/select-item.tsx b/packages/kit-headless/src/components/select/select-item.tsx index d88debe9e..9292291c7 100644 --- a/packages/kit-headless/src/components/select/select-item.tsx +++ b/packages/kit-headless/src/components/select/select-item.tsx @@ -35,7 +35,6 @@ export const HSelectItem = component$((props) => { const itemRef = useSignal(); const localIndexSig = useSignal(null); const itemId = `${context.localId}-${_index}`; - const isInitialFocusSig = useSignal(true); const { selectionManager$, getNextEnabledItemIndex$, getPrevEnabledItemIndex$ } = useSelect(); @@ -61,12 +60,6 @@ export const HSelectItem = component$((props) => { throw Error('Qwik UI: Select component item cannot find its proper index.'); localIndexSig.value = _index; - - // update the context with the first enabled item ref - const firstEnabledIndex = await getNextEnabledItemIndex$(-1); - if (localIndexSig.value === firstEnabledIndex) { - context.firstEnabledItemRef = itemRef; - } }); useTask$(async function scrollableTask({ track, cleanup }) { @@ -98,7 +91,10 @@ export const HSelectItem = component$((props) => { } } - if (!isInitialFocusSig.value) return; + // update the context with the highlighted item ref + if (localIndexSig.value === context.highlightedIndexSig.value) { + context.highlightedItemRef = itemRef; + } }); const handleClick$ = $(async () => { diff --git a/packages/kit-headless/src/components/select/select-root.tsx b/packages/kit-headless/src/components/select/select-root.tsx index a23ad35ea..ffadae429 100644 --- a/packages/kit-headless/src/components/select/select-root.tsx +++ b/packages/kit-headless/src/components/select/select-root.tsx @@ -161,7 +161,7 @@ export const HSelectImpl = component$ & InternalSelectProps const currDisplayValueSig = useSignal(); const initialLoadSig = useSignal(true); - const firstEnabledItemRef = useSignal(); + const highlightedItemRef = useSignal(); const context: SelectContext = { itemsMapSig, @@ -171,7 +171,7 @@ export const HSelectImpl = component$ & InternalSelectProps listboxRef, labelRef, groupRef, - firstEnabledItemRef, + highlightedItemRef, localId, highlightedIndexSig, selectedIndexSetSig, diff --git a/packages/kit-headless/src/components/select/select-trigger.tsx b/packages/kit-headless/src/components/select/select-trigger.tsx index 078e56a91..767751fe8 100644 --- a/packages/kit-headless/src/components/select/select-trigger.tsx +++ b/packages/kit-headless/src/components/select/select-trigger.tsx @@ -100,16 +100,13 @@ export const HSelectTrigger = component$((props) => { /** When initially opening the listbox, we want to grab the first enabled option index */ if (context.highlightedIndexSig.value === null) { - const firstItem = await getNextEnabledItemIndex$(-1); - context.highlightedIndexSig.value = firstItem; - - // Wait for the popover code to be executed - while (!(context.firstEnabledItemRef.value === document.activeElement)) { - await new Promise((resolve) => setTimeout(resolve, 5)); - context.firstEnabledItemRef.value?.focus(); - } + context.highlightedIndexSig.value = await getNextEnabledItemIndex$(-1); + } - return; + // Wait for the popover code to be executed + while (context.highlightedItemRef.value !== document.activeElement) { + await new Promise((resolve) => setTimeout(resolve, 5)); + context.highlightedItemRef.value?.focus(); } }); From 40f4427f89ce06888b57af5e8180daa7ac20c342 Mon Sep 17 00:00:00 2001 From: jack shelton Date: Mon, 3 Jun 2024 15:41:32 -0500 Subject: [PATCH 05/15] selecting options --- .../src/components/select/select-item.tsx | 21 ++++++++++++++++++- .../src/components/select/select-trigger.tsx | 18 +++++++--------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/kit-headless/src/components/select/select-item.tsx b/packages/kit-headless/src/components/select/select-item.tsx index 9292291c7..e563a3bd1 100644 --- a/packages/kit-headless/src/components/select/select-item.tsx +++ b/packages/kit-headless/src/components/select/select-item.tsx @@ -180,10 +180,29 @@ export const HSelectItem = component$((props) => { } break; - case 'Tab': case 'Escape': + context.triggerRef.value?.focus(); + context.isListboxOpenSig.value = false; + break; + + case 'Tab': context.isListboxOpenSig.value = false; break; + + case 'Enter': + case ' ': + if (context.isListboxOpenSig.value) { + const action = context.multiple ? 'toggle' : 'add'; + await selectionManager$(context.highlightedIndexSig.value, action); + + if (!context.multiple) { + context.triggerRef.value?.focus(); + } + } + context.isListboxOpenSig.value = context.multiple + ? true + : !context.isListboxOpenSig.value; + break; } }); diff --git a/packages/kit-headless/src/components/select/select-trigger.tsx b/packages/kit-headless/src/components/select/select-trigger.tsx index 767751fe8..775df15fd 100644 --- a/packages/kit-headless/src/components/select/select-trigger.tsx +++ b/packages/kit-headless/src/components/select/select-trigger.tsx @@ -45,17 +45,6 @@ export const HSelectTrigger = component$((props) => { typeahead$(e.key); switch (e.key) { - case 'Enter': - case ' ': - if (context.isListboxOpenSig.value) { - const action = context.multiple ? 'toggle' : 'add'; - await selectionManager$(context.highlightedIndexSig.value, action); - } - context.isListboxOpenSig.value = context.multiple - ? true - : !context.isListboxOpenSig.value; - break; - case 'Tab': case 'Escape': context.isListboxOpenSig.value = false; @@ -87,6 +76,13 @@ export const HSelectTrigger = component$((props) => { } break; + case 'Enter': + case ' ': + context.isListboxOpenSig.value = context.multiple + ? true + : !context.isListboxOpenSig.value; + break; + case 'a': if (e.ctrlKey && context.multiple) { for (const [index, item] of context.itemsMapSig.value) { From e8c2832fc554f7af9abdb1824adf0b8bf316b94d Mon Sep 17 00:00:00 2001 From: jack shelton Date: Mon, 3 Jun 2024 16:47:18 -0500 Subject: [PATCH 06/15] home and end keys --- .../src/components/select/select-item.tsx | 6 +++- .../src/components/select/select.test.ts | 34 ++++++++----------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/packages/kit-headless/src/components/select/select-item.tsx b/packages/kit-headless/src/components/select/select-item.tsx index 0086fbe29..a9fee6889 100644 --- a/packages/kit-headless/src/components/select/select-item.tsx +++ b/packages/kit-headless/src/components/select/select-item.tsx @@ -15,7 +15,7 @@ import SelectContextId, { SelectItemContext, selectItemContextId, } from './select-context'; -import { useSelect } from './use-select'; +import { useSelect, useTypeahead } from './use-select'; export type SelectItemProps = PropsOf<'li'> & { /** Internal index we get from the inline component. Please see select-inline.tsx */ @@ -39,6 +39,8 @@ export const HSelectItem = component$((props) => { const { selectionManager$, getNextEnabledItemIndex$, getPrevEnabledItemIndex$ } = useSelect(); + const { typeahead$ } = useTypeahead(); + const isSelectedSig = useComputed$(() => { const index = _index ?? null; return !disabled && context.selectedIndexSetSig.value.has(index!); @@ -142,6 +144,8 @@ export const HSelectItem = component$((props) => { }); const handleKeyDown$ = $(async (e: KeyboardEvent) => { + typeahead$(e.key); + switch (e.key) { case 'ArrowDown': if (context.isListboxOpenSig.value) { diff --git a/packages/kit-headless/src/components/select/select.test.ts b/packages/kit-headless/src/components/select/select.test.ts index aea49f463..0540be1bd 100644 --- a/packages/kit-headless/src/components/select/select.test.ts +++ b/packages/kit-headless/src/components/select/select.test.ts @@ -288,40 +288,34 @@ test.describe('Keyboard Behavior', () => { test(`GIVEN an open hero select WHEN pressing the end key THEN the last option should have data-highlighted`, async ({ page }) => { - const { - getTrigger, - getItemAt: getOptionAt, - openListbox, - } = await setup(page, 'hero'); + const { driver: d } = await setup(page, 'hero'); - await openListbox('click'); + await d.openListbox('click'); - await getTrigger().focus(); - await getTrigger().press('End'); + await d.getItemAt(0).focus(); + await d.getItemAt(0).press('End'); - await expect(getOptionAt('last')).toHaveAttribute('data-highlighted'); + await expect(d.getItemAt('last')).toHaveAttribute('data-highlighted'); }); test(`GIVEN an open hero select WHEN pressing the home key after the end key THEN the first option should have data-highlighted`, async ({ page }) => { - const { - getTrigger, - getItemAt: getOptionAt, - openListbox, - } = await setup(page, 'hero'); + const { driver: d } = await setup(page, 'hero'); - await openListbox('click'); + await d.openListbox('click'); // to last index - await getTrigger().focus(); - await getTrigger().press('End'); + await d.getItemAt(0).focus(); + await d.getItemAt(0).press('End'); - await expect(getOptionAt('last')).toHaveAttribute('data-highlighted'); + await expect(d.getItemAt('last')).toHaveAttribute('data-highlighted'); // to first index - await getTrigger().press('Home'); - await expect(getOptionAt(0)).toHaveAttribute('data-highlighted'); + const itemsLength = await d.getItemsLength(); + const lastItem = d.getItemAt(itemsLength - 1); + await lastItem.press('Home'); + await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); }); test(`GIVEN an open hero select From 83b4141e5718bcb9b0e7aedef2cedf01eec2b0cc Mon Sep 17 00:00:00 2001 From: jack shelton Date: Mon, 3 Jun 2024 17:24:18 -0500 Subject: [PATCH 07/15] improving data highlighted tests --- .../src/components/select/select.test.ts | 57 +++++++------------ 1 file changed, 22 insertions(+), 35 deletions(-) diff --git a/packages/kit-headless/src/components/select/select.test.ts b/packages/kit-headless/src/components/select/select.test.ts index 0540be1bd..6a6a36633 100644 --- a/packages/kit-headless/src/components/select/select.test.ts +++ b/packages/kit-headless/src/components/select/select.test.ts @@ -321,40 +321,33 @@ test.describe('Keyboard Behavior', () => { test(`GIVEN an open hero select WHEN the first option is highlighted and the down arrow key is pressed THEN the second option should have data-highlighted`, async ({ page }) => { - const { - getTrigger, - getItemAt: getOptionAt, - openListbox, - } = await setup(page, 'hero'); + const { driver: d } = await setup(page, 'hero'); - await openListbox('Enter'); + await d.openListbox('Enter'); // first index highlighted - await expect(getOptionAt(0)).toHaveAttribute('data-highlighted'); + await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); - await getTrigger().focus(); - await getTrigger().press('ArrowDown'); - await expect(getOptionAt(1)).toHaveAttribute('data-highlighted'); + await d.getItemAt(0).focus(); + await d.getItemAt(0).press('ArrowDown'); + await expect(d.getItemAt(1)).toHaveAttribute('data-highlighted'); }); test(`GIVEN an open hero select WHEN the third option is highlighted and the up arrow key is pressed THEN the second option should have data-highlighted`, async ({ page }) => { - const { - getTrigger, - getItemAt: getOptionAt, - openListbox, - } = await setup(page, 'hero'); + const { driver: d } = await setup(page, 'hero'); - await openListbox('Enter'); + await d.openListbox('Enter'); - await expect(getOptionAt(0)).toHaveAttribute('data-highlighted'); - await getTrigger().press('ArrowDown'); - await getTrigger().press('ArrowDown'); + await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); + await d.getItemAt(0).press('ArrowDown'); + await d.getItemAt(1).press('ArrowDown'); + 1; - await getTrigger().press('ArrowUp'); - await expect(getOptionAt(1)).toHaveAttribute('data-highlighted'); + await d.getItemAt(2).press('ArrowUp'); + await expect(d.getItemAt(1)).toHaveAttribute('data-highlighted'); }); test(`GIVEN a hero select with a chosen option @@ -362,24 +355,18 @@ test.describe('Keyboard Behavior', () => { THEN the data-highlighted option should not change on re-open`, async ({ page, }) => { - const { - getTrigger, - getListbox, - getItemAt: getOptionAt, - openListbox, - } = await setup(page, 'hero'); + const { driver: d } = await setup(page, 'hero'); - await openListbox('Enter'); + await d.openListbox('Enter'); // second option highlighted + await d.getItemAt(0).press('ArrowDown'); + await expect(d.getItemAt(1)).toHaveAttribute('data-highlighted'); + await d.getItemAt(1).press('Enter'); + await expect(d.getListbox()).toBeHidden(); - await getTrigger().press('ArrowDown'); - await expect(getOptionAt(1)).toHaveAttribute('data-highlighted'); - await getTrigger().press('Enter'); - await expect(getListbox()).toBeHidden(); - - await getTrigger().press('ArrowDown'); - await expect(getOptionAt(1)).toHaveAttribute('data-highlighted'); + await d.getTrigger().press('ArrowDown'); + await expect(d.getItemAt(1)).toHaveAttribute('data-highlighted'); }); }); From 4408ba23a4969e5c1f9b5deb4bac4b5dbc427b72 Mon Sep 17 00:00:00 2001 From: jack shelton Date: Mon, 3 Jun 2024 18:19:45 -0500 Subject: [PATCH 08/15] fix more tests --- .../src/components/select/select-trigger.tsx | 4 +- .../src/components/select/select.test.ts | 43 ++++++++----------- 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/packages/kit-headless/src/components/select/select-trigger.tsx b/packages/kit-headless/src/components/select/select-trigger.tsx index 775df15fd..ea23315fa 100644 --- a/packages/kit-headless/src/components/select/select-trigger.tsx +++ b/packages/kit-headless/src/components/select/select-trigger.tsx @@ -42,7 +42,9 @@ export const HSelectTrigger = component$((props) => { const handleKeyDown$ = $(async (e: KeyboardEvent) => { if (!context.itemsMapSig.value) return; - typeahead$(e.key); + if (!context.isListboxOpenSig.value) { + typeahead$(e.key); + } switch (e.key) { case 'Tab': diff --git a/packages/kit-headless/src/components/select/select.test.ts b/packages/kit-headless/src/components/select/select.test.ts index 6a6a36633..996d4a527 100644 --- a/packages/kit-headless/src/components/select/select.test.ts +++ b/packages/kit-headless/src/components/select/select.test.ts @@ -396,19 +396,14 @@ test.describe('Keyboard Behavior', () => { AND the Enter key is pressed THEN option value should be the selected value AND should have an aria-selected of true`, async ({ page }) => { - const { - getTrigger, - getItemAt: getOptionAt, - getValueElement, - openListbox, - } = await setup(page, 'hero'); + const { driver: d } = await setup(page, 'hero'); - await openListbox('Enter'); + await d.openListbox('Enter'); - await expect(getOptionAt(0)).toHaveAttribute('data-highlighted'); - const expectedValue = await getOptionAt(0).textContent(); - await getTrigger().press('Enter'); - await expect(getValueElement()).toHaveText(expectedValue!); + await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); + const expectedValue = await d.getItemAt(0).textContent(); + await d.getItemAt(0).press('Enter'); + await expect(d.getValueElement()).toHaveText(expectedValue!); }); test(`GIVEN an open hero select @@ -438,19 +433,14 @@ test.describe('Keyboard Behavior', () => { AND the Space key is pressed THEN option value should be the selected value AND should have an aria-selected of true`, async ({ page }) => { - const { - getTrigger, - getItemAt: getOptionAt, - getValueElement, - openListbox, - } = await setup(page, 'hero'); + const { driver: d } = await setup(page, 'hero'); - await openListbox('Space'); + await d.openListbox('Space'); - await expect(getOptionAt(0)).toHaveAttribute('data-highlighted'); - const expectedValue = await getOptionAt(0).textContent(); - await getTrigger().press('Space'); - await expect(getValueElement()).toHaveText(expectedValue!); + await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); + const expectedValue = await d.getItemAt(0).textContent(); + await d.getItemAt(0).press('Space'); + await expect(d.getValueElement()).toHaveText(expectedValue!); }); test(`GIVEN no selected item and a placeholder @@ -535,11 +525,12 @@ test.describe('Keyboard Behavior', () => { THEN the second option starting with the letter "j" should have data-highlighted`, async ({ page, }) => { - const { getRoot, getTrigger, openListbox } = await setup(page, 'typeahead'); + const { getRoot, openListbox } = await setup(page, 'typeahead'); await openListbox('ArrowDown'); - await getTrigger().pressSequentially('jj', { delay: 1250 }); - const highlightedOpt = getRoot().locator('[data-highlighted]'); - await expect(highlightedOpt).toContainText('jessie', { ignoreCase: true }); + const highlightedItem = getRoot().locator('[data-highlighted]'); + + await highlightedItem.pressSequentially('jj', { delay: 1250 }); + await expect(highlightedItem).toContainText('jessie', { ignoreCase: true }); }); test(`GIVEN an open select with a typeahead support From c612623493e0f4747c5118b1ba3dcbfc76b8b5ac Mon Sep 17 00:00:00 2001 From: jack shelton Date: Mon, 3 Jun 2024 18:24:53 -0500 Subject: [PATCH 09/15] improve first test --- .../kit-headless/src/components/select/select.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/kit-headless/src/components/select/select.test.ts b/packages/kit-headless/src/components/select/select.test.ts index 996d4a527..4f4b6ecb2 100644 --- a/packages/kit-headless/src/components/select/select.test.ts +++ b/packages/kit-headless/src/components/select/select.test.ts @@ -513,10 +513,10 @@ test.describe('Keyboard Behavior', () => { THEN the first option starting with the letter "j" should have data-highlighted`, async ({ page, }) => { - const { getRoot, getTrigger, openListbox } = await setup(page, 'typeahead'); - await openListbox('ArrowDown'); - await getTrigger().pressSequentially('j'); - const highlightedOpt = getRoot().locator('[data-highlighted]'); + const { driver: d } = await setup(page, 'typeahead'); + await d.openListbox('ArrowDown'); + await d.getItemAt(0).press('j'); + const highlightedOpt = d.getRoot().locator('[data-highlighted]'); await expect(highlightedOpt).toContainText('j', { ignoreCase: true }); }); From f571e9ce66ef52ecb2544b2fa53c663423dc5df5 Mon Sep 17 00:00:00 2001 From: jack shelton Date: Mon, 3 Jun 2024 19:02:24 -0500 Subject: [PATCH 10/15] more tests passing --- .../src/components/select/select.test.ts | 92 +++++++------------ 1 file changed, 33 insertions(+), 59 deletions(-) diff --git a/packages/kit-headless/src/components/select/select.test.ts b/packages/kit-headless/src/components/select/select.test.ts index 4f4b6ecb2..faa79b1ab 100644 --- a/packages/kit-headless/src/components/select/select.test.ts +++ b/packages/kit-headless/src/components/select/select.test.ts @@ -623,95 +623,69 @@ test.describe('Keyboard Behavior', () => { AND the last option is data-highlighted WHEN the down key is pressed THEN data-highlighted should stay on the last option`, async ({ page }) => { - const { - getTrigger, - getItemAt: getOptionAt, - openListbox, - } = await setup(page, 'hero'); + const { driver: d } = await setup(page, 'hero'); // initially last option is highlighted - await openListbox('Enter'); - const trigger = getTrigger(); - await trigger.focus(); - await trigger.press('End'); - - await expect(getOptionAt('last')).toHaveAttribute('data-highlighted'); + await d.openListbox('Enter'); + await d.getItemAt(0).press('End'); - await trigger.focus(); - await trigger.press('ArrowDown'); - await expect(getOptionAt('last')).toHaveAttribute('data-highlighted'); + await d.getItemAt('last').press('ArrowDown'); + await expect(d.getItemAt('last')).toHaveAttribute('data-highlighted'); }); test(`GIVEN an open basic select AND the first option is data-highlighted WHEN the up arrow key is pressed THEN data-highlighted should stay on the first option`, async ({ page }) => { - const { - getTrigger, - getItemAt: getOptionAt, - openListbox, - } = await setup(page, 'hero'); + const { driver: d } = await setup(page, 'hero'); - await openListbox('Enter'); - const option = getOptionAt(0); - await expect(option).toHaveAttribute('data-highlighted'); - await getTrigger().focus(); - await getTrigger().press('ArrowUp'); - await expect(option).toHaveAttribute('data-highlighted'); + await d.openListbox('Enter'); + const firstItem = d.getItemAt(0); + await expect(firstItem).toHaveAttribute('data-highlighted'); + await firstItem.press('ArrowUp'); + await expect(firstItem).toHaveAttribute('data-highlighted'); }); test(`GIVEN a closed basic select AND the last option is selected WHEN the right arrow key is pressed THEN it should stay on the last option`, async ({ page }) => { - const { - getTrigger, - getItemAt: getOptionAt, - getListbox, - openListbox, - } = await setup(page, 'hero'); + const { driver: d } = await setup(page, 'hero'); // initially last option is highlighted & listbox closed - await openListbox('Enter'); - await getTrigger().focus(); - await getTrigger().press('End'); + await d.openListbox('Enter'); + await d.getItemAt(0).press('End'); - await expect(getOptionAt('last')).toHaveAttribute('data-highlighted'); - await getTrigger().press('Enter'); - await expect(getOptionAt('last')).toHaveAttribute('aria-selected', 'true'); - await expect(getListbox()).toBeHidden(); + const lastItem = d.getItemAt('last'); + await expect(lastItem).toHaveAttribute('data-highlighted'); + await lastItem.press('Enter'); + await expect(lastItem).toHaveAttribute('aria-selected', 'true'); + await expect(d.getListbox()).toBeHidden(); - await getTrigger().focus(); - await getTrigger().press('ArrowRight'); - await expect(getOptionAt('last')).toHaveAttribute('data-highlighted'); - await expect(getOptionAt('last')).toHaveAttribute('aria-selected', 'true'); + await d.getTrigger().press('ArrowRight'); + await expect(lastItem).toHaveAttribute('data-highlighted'); + await expect(lastItem).toHaveAttribute('aria-selected', 'true'); }); test(`GIVEN a closed basic select AND the first option is selected WHEN the left arrow key is pressed THEN it should stay on the first option`, async ({ page }) => { - const { - getTrigger, - getItemAt: getOptionAt, - getListbox, - openListbox, - } = await setup(page, 'hero'); + const { driver: d } = await setup(page, 'hero'); // initially first option is highlighted & listbox closed - await openListbox('Enter'); - await expect(getListbox()).toBeVisible(); - await getTrigger().focus(); - await getTrigger().press('Enter'); + await d.openListbox('Enter'); + await expect(d.getListbox()).toBeVisible(); + await d.getItemAt(0).press('Enter'); - await expect(getOptionAt(0)).toHaveAttribute('data-highlighted'); - await expect(getOptionAt(0)).toHaveAttribute('aria-selected', 'true'); - await expect(getListbox()).toBeHidden(); + await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); + await expect(d.getItemAt(0)).toHaveAttribute('aria-selected', 'true'); + await expect(d.getListbox()).toBeHidden(); - await getTrigger().focus(); - await getTrigger().press('ArrowLeft'); - await expect(getOptionAt(0)).toHaveAttribute('data-highlighted'); - await expect(getOptionAt(0)).toHaveAttribute('aria-selected', 'true'); + await d.getTrigger().focus(); + await d.getTrigger().press('ArrowLeft'); + await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); + await expect(d.getItemAt(0)).toHaveAttribute('aria-selected', 'true'); }); }); From aef3982dbfbb4d8c8c29a96490cbd775c365e0d7 Mon Sep 17 00:00:00 2001 From: jack shelton Date: Mon, 3 Jun 2024 20:50:03 -0500 Subject: [PATCH 11/15] fix: typeahead logic passed to the select items --- .../src/components/select/select-item.tsx | 15 ++++++++++++--- .../src/components/select/select-trigger.tsx | 15 +++++++++++++-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/kit-headless/src/components/select/select-item.tsx b/packages/kit-headless/src/components/select/select-item.tsx index a9fee6889..a5378ea15 100644 --- a/packages/kit-headless/src/components/select/select-item.tsx +++ b/packages/kit-headless/src/components/select/select-item.tsx @@ -9,13 +9,15 @@ import { type PropsOf, useContextProvider, sync$, + useOnWindow, + QRL, } from '@builder.io/qwik'; import { isServer, isBrowser } from '@builder.io/qwik/build'; import SelectContextId, { SelectItemContext, selectItemContextId, } from './select-context'; -import { useSelect, useTypeahead } from './use-select'; +import { useSelect } from './use-select'; export type SelectItemProps = PropsOf<'li'> & { /** Internal index we get from the inline component. Please see select-inline.tsx */ @@ -35,11 +37,18 @@ export const HSelectItem = component$((props) => { const itemRef = useSignal(); const localIndexSig = useSignal(null); const itemId = `${context.localId}-${_index}`; + const typeaheadFnSig = useSignal Promise>>(); const { selectionManager$, getNextEnabledItemIndex$, getPrevEnabledItemIndex$ } = useSelect(); - const { typeahead$ } = useTypeahead(); + // we're getting the same function instance from the trigger, without needing to restructure context + useOnWindow( + 'typeaheadFn', + $((e: CustomEvent) => { + typeaheadFnSig.value = e.detail; + }), + ); const isSelectedSig = useComputed$(() => { const index = _index ?? null; @@ -144,7 +153,7 @@ export const HSelectItem = component$((props) => { }); const handleKeyDown$ = $(async (e: KeyboardEvent) => { - typeahead$(e.key); + typeaheadFnSig.value?.(e.key); switch (e.key) { case 'ArrowDown': diff --git a/packages/kit-headless/src/components/select/select-trigger.tsx b/packages/kit-headless/src/components/select/select-trigger.tsx index ea23315fa..6ab1f0741 100644 --- a/packages/kit-headless/src/components/select/select-trigger.tsx +++ b/packages/kit-headless/src/components/select/select-trigger.tsx @@ -1,4 +1,12 @@ -import { $, Slot, component$, sync$, useContext, type PropsOf } from '@builder.io/qwik'; +import { + $, + Slot, + component$, + sync$, + useContext, + useSignal, + type PropsOf, +} from '@builder.io/qwik'; import SelectContextId from './select-context'; import { useSelect, useTypeahead } from './use-select'; @@ -9,7 +17,7 @@ export const HSelectTrigger = component$((props) => { useSelect(); const labelId = `${context.localId}-label`; const descriptionId = `${context.localId}-description`; - + const initialKeyDownSig = useSignal(true); const { typeahead$ } = useTypeahead(); const handleClickSync$ = sync$((e: MouseEvent) => { @@ -106,6 +114,9 @@ export const HSelectTrigger = component$((props) => { await new Promise((resolve) => setTimeout(resolve, 5)); context.highlightedItemRef.value?.focus(); } + + if (!initialKeyDownSig.value) return; + document.dispatchEvent(new CustomEvent('typeaheadFn', { detail: typeahead$ })); }); return ( From f24570cf72ae256ea65e23e2974279f17e0a2bf1 Mon Sep 17 00:00:00 2001 From: jack shelton Date: Mon, 3 Jun 2024 23:58:14 -0500 Subject: [PATCH 12/15] all tests passing --- .../src/components/select/select-item.tsx | 10 + .../src/components/select/select-trigger.tsx | 10 - .../src/components/select/select.driver.ts | 5 + .../src/components/select/select.test.ts | 581 +++++++----------- 4 files changed, 230 insertions(+), 376 deletions(-) diff --git a/packages/kit-headless/src/components/select/select-item.tsx b/packages/kit-headless/src/components/select/select-item.tsx index a5378ea15..f61449337 100644 --- a/packages/kit-headless/src/components/select/select-item.tsx +++ b/packages/kit-headless/src/components/select/select-item.tsx @@ -216,6 +216,16 @@ export const HSelectItem = component$((props) => { ? true : !context.isListboxOpenSig.value; break; + + case 'a': + if (e.ctrlKey && context.multiple) { + for (const [index, item] of context.itemsMapSig.value) { + if (!item.disabled) { + await selectionManager$(index, 'add'); + } + } + } + break; } }); diff --git a/packages/kit-headless/src/components/select/select-trigger.tsx b/packages/kit-headless/src/components/select/select-trigger.tsx index 6ab1f0741..294dce161 100644 --- a/packages/kit-headless/src/components/select/select-trigger.tsx +++ b/packages/kit-headless/src/components/select/select-trigger.tsx @@ -92,16 +92,6 @@ export const HSelectTrigger = component$((props) => { ? true : !context.isListboxOpenSig.value; break; - - case 'a': - if (e.ctrlKey && context.multiple) { - for (const [index, item] of context.itemsMapSig.value) { - if (!item.disabled) { - await selectionManager$(index, 'add'); - } - } - } - break; } /** When initially opening the listbox, we want to grab the first enabled option index */ diff --git a/packages/kit-headless/src/components/select/select.driver.ts b/packages/kit-headless/src/components/select/select.driver.ts index ac5a3f912..99cb1e152 100644 --- a/packages/kit-headless/src/components/select/select.driver.ts +++ b/packages/kit-headless/src/components/select/select.driver.ts @@ -35,6 +35,10 @@ export function createTestDriver(rootLocator: T) { return getTrigger().locator('[data-value]'); }; + const getHighlightedItem = () => { + return getRoot().locator('[data-highlighted]'); + }; + const openListbox = async (key: OpenKeys | 'click') => { await getTrigger().focus(); @@ -59,5 +63,6 @@ export function createTestDriver(rootLocator: T) { getItemAt, getValueElement, openListbox, + getHighlightedItem, }; } diff --git a/packages/kit-headless/src/components/select/select.test.ts b/packages/kit-headless/src/components/select/select.test.ts index faa79b1ab..97d881901 100644 --- a/packages/kit-headless/src/components/select/select.test.ts +++ b/packages/kit-headless/src/components/select/select.test.ts @@ -7,17 +7,8 @@ async function setup(page: Page, exampleName: string) { const driver = createTestDriver(page.getByRole('combobox')); - const { getRoot, getListbox, getTrigger, getItemAt, getValueElement, openListbox } = - driver; - return { driver, - getRoot, - getListbox, - getTrigger, - getItemAt, - getValueElement, - openListbox, }; } @@ -101,17 +92,6 @@ test.describe('Mouse Behavior', () => { await expect(d.getValueElement()).toHaveText(expectedValue); }); - // if we want to add focusing the trigger on blur - // test(`GIVEN a basic select - // WHEN the listbox is open and the blur event is triggered - // THEN focus should go back to the trigger`, async ({ page }) => { - // const { getTrigger, openListbox } = await setup(page, 'hero'); - - // await openListbox('click'); - // await getTrigger().blur(); - // await expect(getTrigger()).toBeFocused(); - // }); - test(`GIVEN an open hero select WHEN clicking on the group label THEN the listbox should remain open`, async ({ page }) => { @@ -221,68 +201,52 @@ test.describe('Keyboard Behavior', () => { WHEN pressing the down arrow key THEN the listbox should be opened AND the first option should have data-highlighted`, async ({ page }) => { - const { - getTrigger, - getListbox, - getItemAt: getOptionAt, - } = await setup(page, 'hero'); + const { driver: d } = await setup(page, 'hero'); - await getTrigger().focus(); - await getTrigger().press('ArrowDown'); - await expect(getListbox()).toBeVisible(); + await d.getTrigger().focus(); + await d.getTrigger().press('ArrowDown'); + await expect(d.getListbox()).toBeVisible(); - await expect(getOptionAt(0)).toHaveAttribute('data-highlighted'); + await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); }); test(`GIVEN a hero select WHEN pressing the enter key THEN open up the listbox AND the first option should have data-highlighted`, async ({ page }) => { - const { - getTrigger, - getListbox, - getItemAt: getOptionAt, - } = await setup(page, 'hero'); + const { driver: d } = await setup(page, 'hero'); - await getTrigger().focus(); - await getTrigger().press('Enter'); - await expect(getListbox()).toBeVisible(); + await d.getTrigger().focus(); + await d.getTrigger().press('Enter'); + await expect(d.getListbox()).toBeVisible(); - await expect(getOptionAt(0)).toHaveAttribute('data-highlighted'); + await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); }); test(`GIVEN a hero select WHEN pressing the space key THEN open up the listbox AND the first option should have data-highlighted`, async ({ page }) => { - const { - getTrigger, - getListbox, - getItemAt: getOptionAt, - } = await setup(page, 'hero'); + const { driver: d } = await setup(page, 'hero'); - await getTrigger().focus(); - await getTrigger().press('Space'); - await expect(getListbox()).toBeVisible(); + await d.getTrigger().focus(); + await d.getTrigger().press('Space'); + await expect(d.getListbox()).toBeVisible(); - await expect(getOptionAt(0)).toHaveAttribute('data-highlighted'); + await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); }); test(`GIVEN a hero select WHEN pressing the up arrow THEN open up the listbox AND the first option should have data-highlighted`, async ({ page }) => { - const { - getTrigger, - getListbox, - getItemAt: getOptionAt, - } = await setup(page, 'hero'); + const { driver: d } = await setup(page, 'hero'); - await getTrigger().focus(); - await getTrigger().press('ArrowUp'); - await expect(getListbox()).toBeVisible(); + await d.getTrigger().focus(); + await d.getTrigger().press('ArrowUp'); + await expect(d.getListbox()).toBeVisible(); - await expect(getOptionAt(0)).toHaveAttribute('data-highlighted'); + await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); }); test(`GIVEN an open hero select @@ -290,10 +254,10 @@ test.describe('Keyboard Behavior', () => { THEN the last option should have data-highlighted`, async ({ page }) => { const { driver: d } = await setup(page, 'hero'); - await d.openListbox('click'); + await d.openListbox('Enter'); - await d.getItemAt(0).focus(); - await d.getItemAt(0).press('End'); + await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); + await d.getHighlightedItem().press('End'); await expect(d.getItemAt('last')).toHaveAttribute('data-highlighted'); }); @@ -303,18 +267,14 @@ test.describe('Keyboard Behavior', () => { THEN the first option should have data-highlighted`, async ({ page }) => { const { driver: d } = await setup(page, 'hero'); - await d.openListbox('click'); + await d.openListbox('Enter'); // to last index - await d.getItemAt(0).focus(); - await d.getItemAt(0).press('End'); - + await d.getHighlightedItem().press('End'); await expect(d.getItemAt('last')).toHaveAttribute('data-highlighted'); // to first index - const itemsLength = await d.getItemsLength(); - const lastItem = d.getItemAt(itemsLength - 1); - await lastItem.press('Home'); + await d.getHighlightedItem().press('Home'); await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); }); @@ -327,10 +287,10 @@ test.describe('Keyboard Behavior', () => { // first index highlighted - await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); + await expect(d.getHighlightedItem()).toHaveAttribute('data-highlighted'); - await d.getItemAt(0).focus(); - await d.getItemAt(0).press('ArrowDown'); + await d.getHighlightedItem().focus(); + await d.getHighlightedItem().press('ArrowDown'); await expect(d.getItemAt(1)).toHaveAttribute('data-highlighted'); }); @@ -342,11 +302,11 @@ test.describe('Keyboard Behavior', () => { await d.openListbox('Enter'); await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); - await d.getItemAt(0).press('ArrowDown'); - await d.getItemAt(1).press('ArrowDown'); + await d.getHighlightedItem().press('ArrowDown'); + await d.getHighlightedItem().press('ArrowDown'); 1; - await d.getItemAt(2).press('ArrowUp'); + await d.getHighlightedItem().press('ArrowUp'); await expect(d.getItemAt(1)).toHaveAttribute('data-highlighted'); }); @@ -360,12 +320,12 @@ test.describe('Keyboard Behavior', () => { await d.openListbox('Enter'); // second option highlighted - await d.getItemAt(0).press('ArrowDown'); + await d.getHighlightedItem().press('ArrowDown'); await expect(d.getItemAt(1)).toHaveAttribute('data-highlighted'); - await d.getItemAt(1).press('Enter'); + await d.getHighlightedItem().press('Enter'); await expect(d.getListbox()).toBeHidden(); - await d.getTrigger().press('ArrowDown'); + await d.getHighlightedItem().press('ArrowDown'); await expect(d.getItemAt(1)).toHaveAttribute('data-highlighted'); }); }); @@ -376,19 +336,14 @@ test.describe('Keyboard Behavior', () => { THEN the listbox should be closed and aria-expanded should be false`, async ({ page, }) => { - const { - getTrigger, - getListbox, - getItemAt: getOptionAt, - openListbox, - } = await setup(page, 'hero'); - - await openListbox('Enter'); - - await expect(getOptionAt(0)).toHaveAttribute('data-highlighted'); - await getTrigger().press('Enter'); - await expect(getListbox()).toBeHidden(); - await expect(getTrigger()).toHaveAttribute('aria-expanded', 'false'); + const { driver: d } = await setup(page, 'hero'); + + await d.openListbox('Enter'); + + await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); + await d.getHighlightedItem().press('Enter'); + await expect(d.getListbox()).toBeHidden(); + await expect(d.getTrigger()).toHaveAttribute('aria-expanded', 'false'); }); test(`GIVEN an open hero select @@ -402,7 +357,7 @@ test.describe('Keyboard Behavior', () => { await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); const expectedValue = await d.getItemAt(0).textContent(); - await d.getItemAt(0).press('Enter'); + await d.getHighlightedItem().press('Enter'); await expect(d.getValueElement()).toHaveText(expectedValue!); }); @@ -412,20 +367,15 @@ test.describe('Keyboard Behavior', () => { THEN the listbox should be closed and aria-expanded false`, async ({ page, }) => { - const { - getTrigger, - getListbox, - getItemAt: getOptionAt, - openListbox, - } = await setup(page, 'hero'); + const { driver: d } = await setup(page, 'hero'); - await openListbox('Space'); + await d.openListbox('Space'); // second option highlighted - await expect(getOptionAt(0)).toHaveAttribute('data-highlighted'); - await getTrigger().press('Space'); - await expect(getListbox()).toBeHidden(); - await expect(getTrigger()).toHaveAttribute('aria-expanded', 'false'); + await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); + await d.getHighlightedItem().press('Space'); + await expect(d.getListbox()).toBeHidden(); + await expect(d.getTrigger()).toHaveAttribute('aria-expanded', 'false'); }); test(`GIVEN an open hero select @@ -448,19 +398,15 @@ test.describe('Keyboard Behavior', () => { THEN the first enabled option should be selected and have aria-selected`, async ({ page, }) => { - const { - getTrigger, - getItemAt: getOptionAt, - getValueElement, - } = await setup(page, 'hero'); - - const firstItemValue = await getOptionAt(0).textContent(); - await getTrigger().focus(); - await getTrigger().press('ArrowRight'); - - expect(getValueElement()).toHaveText(firstItemValue!); - await expect(getOptionAt(0)).toHaveAttribute('aria-selected', 'true'); - await expect(getOptionAt(0)).toHaveAttribute('data-highlighted'); + const { driver: d } = await setup(page, 'hero'); + + const firstItemValue = await d.getItemAt(0).textContent(); + await d.getTrigger().focus(); + await d.getTrigger().press('ArrowRight'); + + expect(d.getValueElement()).toHaveText(firstItemValue!); + await expect(d.getItemAt(0)).toHaveAttribute('aria-selected', 'true'); + await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); }); test(`GIVEN no selected item and a placeholder @@ -487,23 +433,19 @@ test.describe('Keyboard Behavior', () => { THEN the first item should be selected and have aria-selected & data-highlighted`, async ({ page, }) => { - const { - getTrigger, - getItemAt: getOptionAt, - getValueElement, - } = await setup(page, 'hero'); + const { driver: d } = await setup(page, 'hero'); // get initial selected value // const firstItemValue = await getOptionAt(0).textContent(); - await getTrigger().focus(); - await getTrigger().press('ArrowRight'); - await expect(getValueElement()).toHaveText('Tim'); - await getTrigger().press('ArrowRight'); - - await getTrigger().press('ArrowLeft'); - await expect(getValueElement()).toHaveText('Tim'); - await expect(getOptionAt(0)).toHaveAttribute('aria-selected', 'true'); - await expect(getOptionAt(0)).toHaveAttribute('data-highlighted'); + await d.getTrigger().focus(); + await d.getTrigger().press('ArrowRight'); + await expect(d.getValueElement()).toHaveText('Tim'); + await d.getTrigger().press('ArrowRight'); + + await d.getTrigger().press('ArrowLeft'); + await expect(d.getValueElement()).toHaveText('Tim'); + await expect(d.getItemAt(0)).toHaveAttribute('aria-selected', 'true'); + await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); }); }); @@ -525,12 +467,11 @@ test.describe('Keyboard Behavior', () => { THEN the second option starting with the letter "j" should have data-highlighted`, async ({ page, }) => { - const { getRoot, openListbox } = await setup(page, 'typeahead'); - await openListbox('ArrowDown'); - const highlightedItem = getRoot().locator('[data-highlighted]'); + const { driver: d } = await setup(page, 'typeahead'); + await d.openListbox('ArrowDown'); - await highlightedItem.pressSequentially('jj', { delay: 1250 }); - await expect(highlightedItem).toContainText('jessie', { ignoreCase: true }); + await d.getHighlightedItem().pressSequentially('jj', { delay: 1250 }); + await expect(d.getHighlightedItem()).toContainText('jessie', { ignoreCase: true }); }); test(`GIVEN an open select with a typeahead support @@ -538,11 +479,10 @@ test.describe('Keyboard Behavior', () => { THEN the first option starting with the letter "t" should have data-highlighted`, async ({ page, }) => { - const { getRoot, getTrigger, openListbox } = await setup(page, 'typeahead'); - await openListbox('ArrowDown'); - await getTrigger().pressSequentially('jjt', { delay: 1250 }); - const highlightedOpt = getRoot().locator('[data-highlighted]'); - await expect(highlightedOpt).toContainText('tim', { ignoreCase: true }); + const { driver: d } = await setup(page, 'typeahead'); + await d.openListbox('ArrowDown'); + await d.getHighlightedItem().pressSequentially('jjt', { delay: 1250 }); + await expect(d.getHighlightedItem()).toContainText('tim', { ignoreCase: true }); }); test(`GIVEN an open select with typeahead support and multiple characters @@ -551,22 +491,20 @@ test.describe('Keyboard Behavior', () => { THEN the first option starting with "je" should have data-highlighted`, async ({ page, }) => { - const { getRoot, getTrigger, openListbox } = await setup(page, 'typeahead'); - await openListbox('ArrowDown'); - await getTrigger().pressSequentially('a', { delay: 1500 }); - await getTrigger().pressSequentially('je', { delay: 100 }); - const highlightedOpt = getRoot().locator('[data-highlighted]'); - await expect(highlightedOpt).toContainText('jessie', { ignoreCase: true }); + const { driver: d } = await setup(page, 'typeahead'); + await d.openListbox('ArrowDown'); + await d.getHighlightedItem().pressSequentially('a', { delay: 1500 }); + await d.getHighlightedItem().pressSequentially('je', { delay: 100 }); + await expect(d.getHighlightedItem()).toContainText('jessie', { ignoreCase: true }); }); test(`GIVEN an open select with typeahead support and multiple characters WHEN the user types in a letter that does not match any option THEN the data-highlighted value should not change.`, async ({ page }) => { - const { getRoot, getTrigger, openListbox } = await setup(page, 'typeahead'); - await openListbox('ArrowDown'); - await getTrigger().pressSequentially('am', { delay: 1250 }); - const highlightedOpt = getRoot().locator('[data-highlighted]'); - await expect(highlightedOpt).toContainText('abby', { ignoreCase: true }); + const { driver: d } = await setup(page, 'typeahead'); + await d.openListbox('ArrowDown'); + await d.getHighlightedItem().pressSequentially('am', { delay: 1250 }); + await expect(d.getHighlightedItem()).toContainText('abby', { ignoreCase: true }); }); test(`GIVEN an open select with typeahead support and repeated characters @@ -574,44 +512,41 @@ test.describe('Keyboard Behavior', () => { THEN the data-highlighted value should cycle through the options`, async ({ page, }) => { - const { getRoot, getTrigger, openListbox } = await setup(page, 'typeahead'); - await openListbox('ArrowDown'); - await getTrigger().pressSequentially('jjj', { delay: 1250 }); - const highlightedOpt = getRoot().locator('[data-highlighted]'); - await expect(highlightedOpt).toContainText('jim', { ignoreCase: true }); + const { driver: d } = await setup(page, 'typeahead'); + await d.openListbox('ArrowDown'); + await d.getHighlightedItem().pressSequentially('jjj', { delay: 1250 }); + await expect(d.getHighlightedItem()).toContainText('jim', { ignoreCase: true }); }); test(`GIVEN an open select with typeahead support and grouped options WHEN the user types a letter matching an option in one group AND the user types a letter matching an option in another group THEN the data-highlighted value should switch groups`, async ({ page }) => { - const { getRoot, getTrigger, openListbox } = await setup(page, 'group'); - await openListbox('ArrowDown'); - await getTrigger().press('j'); - const highlightedOpt = getRoot().locator('[data-highlighted]'); - await expect(highlightedOpt).toContainText('Jim', { ignoreCase: true }); - await getTrigger().press('d'); - await expect(highlightedOpt).toContainText('dog', { ignoreCase: true }); + const { driver: d } = await setup(page, 'group'); + await d.openListbox('ArrowDown'); + await d.getHighlightedItem().press('j'); + await expect(d.getHighlightedItem()).toContainText('Jim', { ignoreCase: true }); + await d.getHighlightedItem().press('d'); + await expect(d.getHighlightedItem()).toContainText('dog', { ignoreCase: true }); }); test(`GIVEN a closed select with typeahead support WHEN the user types a letter matching an option THEN the display value should first matching option`, async ({ page }) => { - const { getTrigger } = await setup(page, 'hero'); - await getTrigger().focus(); - await getTrigger().press('j'); - await expect(getTrigger()).toHaveText('Jim'); + const { driver: d } = await setup(page, 'hero'); + await d.getTrigger().press('j'); + await expect(d.getTrigger()).toHaveText('Jim'); }); test(`GIVEN a closed select with typeahead support WHEN the user types a letter matching an option THEN the first matching option should be selected`, async ({ page }) => { // ideally want to refactor this so that even if the test example is changed, the test will still pass, getting it more programmatically. - const { getRoot, getTrigger } = await setup(page, 'hero'); - await getTrigger().focus(); + const { driver: d } = await setup(page, 'hero'); + await d.getTrigger().focus(); const char = 'j'; - await getTrigger().press(char); - const firstJOption = getRoot().locator('li', { hasText: char }).nth(0); + await d.getTrigger().press(char); + const firstJOption = d.getRoot().locator('li', { hasText: char }).nth(0); await expect(firstJOption).toHaveAttribute('aria-selected', 'true'); await expect(firstJOption).toHaveAttribute('data-highlighted'); }); @@ -694,94 +629,71 @@ test.describe('Keyboard Behavior', () => { AND the last option is data-highlighted WHEN the down arrow key is pressed THEN the first option should have data-highlighted`, async ({ page }) => { - const { - getTrigger, - getItemAt: getOptionAt, - openListbox, - } = await setup(page, 'loop'); + const { driver: d } = await setup(page, 'loop'); // initially last option is highlighted - await openListbox('Enter'); - await getTrigger().focus(); - await getTrigger().press('End'); + await d.openListbox('Enter'); + await d.getHighlightedItem().press('End'); - await expect(getOptionAt('last')).toHaveAttribute('data-highlighted'); + await expect(d.getItemAt('last')).toHaveAttribute('data-highlighted'); - await getTrigger().focus(); - await getTrigger().press('ArrowDown'); - await expect(getOptionAt(0)).toHaveAttribute('data-highlighted'); + await d.getHighlightedItem().press('ArrowDown'); + await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); }); test(`GIVEN an open select with loop enabled AND the first option is data-highlighted WHEN the up arrow key is pressed THEN the last option should have data-highlighted`, async ({ page }) => { - const { - getTrigger, - getItemAt: getOptionAt, - openListbox, - } = await setup(page, 'loop'); + const { driver: d } = await setup(page, 'loop'); // initially last option is highlighted - await openListbox('Enter'); - await expect(getOptionAt(0)).toHaveAttribute('data-highlighted'); + await d.openListbox('Enter'); + await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); - await getTrigger().focus(); - await getTrigger().press('ArrowUp'); - await expect(getOptionAt('last')).toHaveAttribute('data-highlighted'); + await d.getHighlightedItem().press('ArrowUp'); + await expect(d.getItemAt('last')).toHaveAttribute('data-highlighted'); }); test(`GIVEN a closed select with loop enabled AND the last option is selected WHEN the right arrow key is pressed THEN it should loop to the first option`, async ({ page }) => { - const { - getTrigger, - getItemAt: getOptionAt, - openListbox, - getListbox, - } = await setup(page, 'loop'); + const { driver: d } = await setup(page, 'loop'); // initially last option is highlighted - await openListbox('Enter'); - await getTrigger().focus(); - await getTrigger().press('End'); - await getTrigger().press('Enter'); + await d.openListbox('Enter'); + await d.getHighlightedItem().press('End'); + await d.getHighlightedItem().press('Enter'); - await expect(getListbox()).toBeHidden(); + await expect(d.getListbox()).toBeHidden(); - await expect(getOptionAt('last')).toHaveAttribute('aria-selected', 'true'); + await expect(d.getItemAt('last')).toHaveAttribute('aria-selected', 'true'); - await getTrigger().focus(); - await getTrigger().press('ArrowRight'); - await expect(getOptionAt(0)).toHaveAttribute('data-highlighted'); - await expect(getOptionAt(0)).toHaveAttribute('aria-selected', 'true'); + await d.getTrigger().focus(); + await d.getTrigger().press('ArrowRight'); + await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); + await expect(d.getItemAt(0)).toHaveAttribute('aria-selected', 'true'); }); test(`GIVEN a closed select with loop enabled AND the first option is selected WHEN the right arrow key is pressed THEN it should loop to the first option`, async ({ page }) => { - const { - getTrigger, - getItemAt: getOptionAt, - openListbox, - getListbox, - } = await setup(page, 'loop'); + const { driver: d } = await setup(page, 'loop'); // initially select first option - await openListbox('Enter'); - await getTrigger().focus(); - await getTrigger().press('Enter'); + await d.openListbox('Enter'); + await d.getHighlightedItem().press('Enter'); - await expect(getListbox()).toBeHidden(); + await expect(d.getListbox()).toBeHidden(); - await expect(getOptionAt(0)).toHaveAttribute('aria-selected', 'true'); + await expect(d.getItemAt(0)).toHaveAttribute('aria-selected', 'true'); - await getTrigger().focus(); - await getTrigger().press('ArrowLeft'); - await expect(getOptionAt('last')).toHaveAttribute('data-highlighted'); - await expect(getOptionAt('last')).toHaveAttribute('aria-selected', 'true'); + await d.getTrigger().focus(); + await d.getTrigger().press('ArrowLeft'); + await expect(d.getItemAt('last')).toHaveAttribute('data-highlighted'); + await expect(d.getItemAt('last')).toHaveAttribute('aria-selected', 'true'); }); }); }); @@ -790,18 +702,13 @@ test.describe('Keyboard Behavior', () => { AND the last option is not visible WHEN the end key is pressed THEN the last option should be visible`, async ({ page }) => { - const { - getTrigger, - getItemAt: getOptionAt, - openListbox, - } = await setup(page, 'scrollable'); + const { driver: d } = await setup(page, 'scrollable'); - await openListbox('Enter'); + await d.openListbox('Enter'); - await getTrigger().focus(); - await getTrigger().press('End'); + await d.getHighlightedItem().press('End'); - await expect(getOptionAt('last')).toBeInViewport(); + await expect(d.getItemAt('last')).toBeInViewport(); }); }); @@ -809,60 +716,32 @@ test.describe('Disabled', () => { test(`GIVEN an open disabled select with the first option disabled WHEN clicking the disabled option It should have aria-disabled`, async ({ page }) => { - const { - getTrigger, - getItemAt: getOptionAt, - openListbox, - } = await setup(page, 'disabled'); - - await openListbox('Enter'); + const { driver: d } = await setup(page, 'disabled'); - await getTrigger().focus(); - await getTrigger().press('ArrowDown'); - await expect(getOptionAt(0)).toBeDisabled(); + await d.openListbox('Enter'); + await d.getHighlightedItem().press('ArrowDown'); + await expect(d.getItemAt(0)).toBeDisabled(); }); - // causing false positives? - // test(`GIVEN an open disabled select with the first option disabled - // WHEN clicking the disabled option - // THEN the listbox should stay open`, async ({ page }) => { - // const { getListbox, getOptionAt, openListbox } = await setup( - // page, - // 'disabled', - // ); - - // await openListbox('Enter'); - - // const options = await getOptionAt(); - // // eslint-disable-next-line playwright/no-force-option - // await options[0].click({ force: true }); - // await expect(getListbox()).toBeVisible(); - // }); - test(`GIVEN an open disabled select WHEN first option is disabled THEN the second option should have data-highlighted`, async ({ page }) => { - const { getItemAt: getOptionAt, openListbox } = await setup(page, 'disabled'); + const { driver: d } = await setup(page, 'disabled'); - await openListbox('ArrowDown'); + await d.openListbox('ArrowDown'); - await expect(getOptionAt(1)).toHaveAttribute('data-highlighted'); + await expect(d.getItemAt(1)).toHaveAttribute('data-highlighted'); }); test(`GIVEN an open disabled select WHEN the last option is disabled and the end key is pressed THEN the second last index should have data-highlighted`, async ({ page }) => { - const { - driver, - getTrigger, - getItemAt: getOptionAt, - openListbox, - } = await setup(page, 'disabled'); - - await openListbox('ArrowDown'); - await getTrigger().press('End'); - const length = await driver.getItemsLength(); - const lastEnabledOption = await getOptionAt(length - 2); + const { driver: d } = await setup(page, 'disabled'); + + await d.openListbox('ArrowDown'); + await d.getHighlightedItem().press('End'); + const length = await d.getItemsLength(); + const lastEnabledOption = d.getItemAt(length - 2); await expect(lastEnabledOption).toHaveAttribute('data-highlighted'); }); @@ -870,36 +749,28 @@ test.describe('Disabled', () => { WHEN the second option is highlighted and the down arrow key is pressed AND the first and third options are disabled THEN the fourth option should be highlighted`, async ({ page }) => { - const { - getTrigger, - getItemAt: getOptionAt, - openListbox, - } = await setup(page, 'disabled'); - - await openListbox('ArrowDown'); - await expect(getOptionAt(1)).toHaveAttribute('data-highlighted'); - await getTrigger().press('ArrowDown'); - await expect(getOptionAt(3)).toHaveAttribute('data-highlighted'); + const { driver: d } = await setup(page, 'disabled'); + + await d.openListbox('ArrowDown'); + await expect(d.getItemAt(1)).toHaveAttribute('data-highlighted'); + await d.getHighlightedItem().press('ArrowDown'); + await expect(d.getItemAt(3)).toHaveAttribute('data-highlighted'); }); test(`GIVEN an open disabled select WHEN the fourth is highlighted and the up key is pressed AND the first and third options are disabled THEN the second option should be highlighted`, async ({ page }) => { - const { - getTrigger, - getItemAt: getOptionAt, - openListbox, - } = await setup(page, 'disabled'); + const { driver: d } = await setup(page, 'disabled'); // initially the fourh option is highlighted - await openListbox('ArrowDown'); - const secondOption = await getOptionAt(1); + await d.openListbox('ArrowDown'); + const secondOption = await d.getItemAt(1); await expect(secondOption).toHaveAttribute('data-highlighted'); - await getTrigger().press('ArrowDown'); - await expect(getOptionAt(3)).toHaveAttribute('data-highlighted'); + await d.getHighlightedItem().press('ArrowDown'); + await expect(d.getItemAt(3)).toHaveAttribute('data-highlighted'); - await getTrigger().press('ArrowUp'); + await d.getHighlightedItem().press('ArrowUp'); await expect(secondOption).toHaveAttribute('data-highlighted'); }); }); @@ -910,9 +781,9 @@ test.describe('Props', () => { THEN the placeholder should be presented instead of a selected value`, async ({ page, }) => { - const { getValueElement } = await setup(page, 'hero'); + const { driver: d } = await setup(page, 'hero'); - await expect(getValueElement()).toHaveText('Select an option'); + await expect(d.getValueElement()).toHaveText('Select an option'); }); test(`GIVEN a select with an onChange$ prop @@ -920,17 +791,13 @@ test.describe('Props', () => { THEN the placeholder should be presented instead of a selected value`, async ({ page, }) => { - const { - openListbox, - getItemAt: getOptionAt, - getRoot, - } = await setup(page, 'change-value'); + const { driver: d } = await setup(page, 'change-value'); - await openListbox('click'); + await d.openListbox('click'); - await getOptionAt(3).click(); + await d.getItemAt(3).click(); - const sibling = getRoot().locator('+ p'); + const sibling = d.getRoot().locator('+ p'); await expect(sibling).toHaveText('You have changed 1 times'); }); @@ -939,11 +806,11 @@ test.describe('Props', () => { THEN the placeholder should be presented instead of a selected value`, async ({ page, }) => { - const { getRoot, openListbox } = await setup(page, 'open-change'); + const { driver: d } = await setup(page, 'open-change'); - await openListbox('click'); + await d.openListbox('click'); - const sibling = getRoot().locator('+ p'); + const sibling = d.getRoot().locator('+ p'); await expect(sibling).toHaveText('The listbox opened and closed 1 time(s)'); }); @@ -969,13 +836,13 @@ test.describe('Props', () => { THEN the selected value should be the data passed to the bind:value prop AND should should have data-highlighted AND aria-selected set to true`, async ({ page }) => { - const { getValueElement, getItemAt: getOptionAt } = await setup(page, 'controlled'); + const { driver: d } = await setup(page, 'controlled'); - const expectedValue = await getOptionAt(1).textContent(); + const expectedValue = await d.getItemAt(1).textContent(); - await expect(getValueElement()).toContainText(expectedValue!); - await expect(getOptionAt(1)).toHaveAttribute('data-highlighted'); - await expect(getOptionAt(1)).toHaveAttribute('aria-selected', 'true'); + await expect(d.getValueElement()).toContainText(expectedValue!); + await expect(d.getItemAt(1)).toHaveAttribute('data-highlighted'); + await expect(d.getItemAt(1)).toHaveAttribute('aria-selected', 'true'); }); test(`GIVEN a controlled closed select with a bind:open prop on the root component @@ -995,14 +862,13 @@ test.describe('Props', () => { test(`GIVEN a select with distinct display and option values WHEN the 2nd option is selected THEN the selected value matches the 2nd option's value`, async ({ page }) => { - const { openListbox, getTrigger } = await setup(page, 'item-value'); + const { driver: d } = await setup(page, 'item-value'); - await openListbox('Enter'); + await d.openListbox('Enter'); await expect(page.locator('p')).toContainText('The selected value is: null'); - await getTrigger().focus(); - await getTrigger().press('ArrowDown'); - await getTrigger().press('Enter'); + await d.getHighlightedItem().press('ArrowDown'); + await d.getHighlightedItem().press('Enter'); await expect(page.locator('p')).toContainText('The selected value is: 1'); }); @@ -1010,12 +876,12 @@ test.describe('Props', () => { test(`GIVEN a select with distinct display and option values WHEN a controlled value is set to the 5th option THEN the selected value matches the 5th option's value`, async ({ page }) => { - const { getTrigger } = await setup(page, 'controlled-value'); + const { driver: d } = await setup(page, 'controlled-value'); - await expect(getTrigger()).toHaveText('Ryan'); + await expect(d.getTrigger()).toHaveText('Ryan'); await page.getByRole('button', { name: 'Change to Abby' }).click(); - await expect(getTrigger()).toHaveText(`Abby`); + await expect(d.getTrigger()).toHaveText(`Abby`); }); test(`GIVEN a select with distinct display and option values @@ -1044,7 +910,7 @@ test.describe('Props', () => { /** TODO: add docs telling people how to add an aria-label to the root component. (accessible name) */ test.describe('A11y', () => { test('Axe Validation Test', async ({ page }) => { - const { openListbox } = await setup(page, 'hero'); + const { driver: d } = await setup(page, 'hero'); const initialResults = await new AxeBuilder({ page }) .include('[role="combobox"]') @@ -1052,7 +918,7 @@ test.describe('A11y', () => { expect(initialResults.violations).toEqual([]); - await openListbox('click'); + await d.openListbox('click'); const afterClickResults = await new AxeBuilder({ page }) .include('[role="combobox"]') @@ -1065,11 +931,11 @@ test.describe('A11y', () => { WHEN the user adds a new group THEN the group should have an aria-labelledby attribute AND its associated label`, async ({ page }) => { - const { getRoot, openListbox } = await setup(page, 'group'); - await openListbox('ArrowDown'); - const labelId = await getRoot().getByRole('listitem').first().getAttribute('id'); + const { driver: d } = await setup(page, 'group'); + await d.openListbox('ArrowDown'); + const labelId = await d.getRoot().getByRole('listitem').first().getAttribute('id'); - await expect(getRoot().getByRole('group').first()).toHaveAttribute( + await expect(d.getRoot().getByRole('group').first()).toHaveAttribute( 'aria-labelledby', labelId!, ); @@ -1080,31 +946,27 @@ test.describe('A11y', () => { THEN aria-activedescendent should be the id of the second option`, async ({ page, }) => { - const { - getTrigger, - getRoot, - openListbox, - getItemAt: getOptionAt, - } = await setup(page, 'hero'); - await openListbox('ArrowDown'); - await getTrigger().focus(); - await getTrigger().press('ArrowDown'); - - const secondOptionId = await getOptionAt(1).getAttribute('id'); - - await expect(getRoot()).toHaveAttribute('aria-activedescendant', `${secondOptionId}`); + const { driver: d } = await setup(page, 'hero'); + await d.openListbox('ArrowDown'); + await d.getHighlightedItem().press('ArrowDown'); + + const secondOptionId = await d.getItemAt(1).getAttribute('id'); + + await expect(d.getRoot()).toHaveAttribute( + 'aria-activedescendant', + `${secondOptionId}`, + ); }); test(`GIVEN an open hero select with aria-activedescendent WHEN the listbox is closed THEN aria-activedescendent should be an empty string`, async ({ page }) => { - const { getTrigger, getRoot, openListbox, getListbox } = await setup(page, 'hero'); - await openListbox('ArrowDown'); - await getTrigger().focus(); - await getTrigger().press('Enter'); - await expect(getListbox()).toBeHidden(); + const { driver: d } = await setup(page, 'hero'); + await d.openListbox('ArrowDown'); + await d.getHighlightedItem().press('Enter'); + await expect(d.getListbox()).toBeHidden(); - await expect(getRoot()).toHaveAttribute('aria-activedescendant', ''); + await expect(d.getRoot()).toHaveAttribute('aria-activedescendant', ''); }); test(`GIVEN a hero select with aria-controls @@ -1112,11 +974,11 @@ test.describe('A11y', () => { THEN the root's aria-controls should be equal to the ID of the listbox`, async ({ page, }) => { - const { getRoot, getListbox, openListbox } = await setup(page, 'hero'); - await openListbox('Enter'); - const listboxId = await getListbox().getAttribute('id'); + const { driver: d } = await setup(page, 'hero'); + await d.openListbox('Enter'); + const listboxId = await d.getListbox().getAttribute('id'); - await expect(getRoot()).toHaveAttribute('aria-controls', `${listboxId}`); + await expect(d.getRoot()).toHaveAttribute('aria-controls', `${listboxId}`); }); }); @@ -1182,9 +1044,9 @@ test.describe('Multiple Selection', () => { }) => { const { driver: d } = await setup(page, 'multiple'); await d.openListbox('Enter'); - await d.getTrigger().press(key); + await d.getHighlightedItem().press(key); await expect(d.getItemAt(0)).toHaveAttribute('aria-selected', 'true'); - await d.getTrigger().press(key); + await d.getHighlightedItem().press(key); await expect(d.getItemAt(0)).toHaveAttribute('aria-selected', 'false'); }); } @@ -1197,7 +1059,7 @@ test.describe('Multiple Selection', () => { await d.openListbox('click'); await d.getItemAt(0).click(); await expect(d.getItemAt(0)).toHaveAttribute('aria-selected', 'true'); - await d.getTrigger().press('Escape'); + await d.getHighlightedItem().press('Escape'); await expect(d.getListbox()).toBeHidden(); }); @@ -1207,8 +1069,8 @@ test.describe('Multiple Selection', () => { page, }) => { const { driver: d } = await setup(page, 'multiple'); - await d.openListbox('click'); - await d.getTrigger().press('Shift+ArrowDown'); + await d.openListbox('Enter'); + await d.getHighlightedItem().press('Shift+ArrowDown'); await expect(d.getItemAt(1)).toHaveAttribute('data-highlighted'); await expect(d.getItemAt(1)).toHaveAttribute('aria-selected', 'true'); }); @@ -1221,25 +1083,12 @@ test.describe('Multiple Selection', () => { const { driver: d } = await setup(page, 'multiple'); // initial setup await d.openListbox('Enter'); - await d.getTrigger().press('ArrowDown'); + await d.getHighlightedItem().press('ArrowDown'); await expect(d.getItemAt(1)).toHaveAttribute('data-highlighted'); - await d.getTrigger().press('Shift+ArrowUp'); + await d.getHighlightedItem().press('Shift+ArrowUp'); await expect(d.getItemAt(0)).toHaveAttribute('data-highlighted'); await expect(d.getItemAt(0)).toHaveAttribute('aria-selected', 'true'); }); - - // test(`GIVEN a multi select - // WHEN hitting the ctrl + a key - // THEN select all options`, async ({ page }) => { - // const { driver: d } = await setup(page, 'multiple'); - // // initial setup - // await d.openListbox('Enter'); - // await d.getTrigger().press('Control+A'); - - // for (let i = 0; i < (await d.getOptionsLength()); i++) { - // await expect(d.getOptionAt(i)).toHaveAttribute('aria-selected', 'true'); - // } - // }); }); }); From b1aa122871be607bddbc8a4ae7f96f362ff43f64 Mon Sep 17 00:00:00 2001 From: jack shelton Date: Tue, 4 Jun 2024 00:01:07 -0500 Subject: [PATCH 13/15] new focus test --- .../src/components/select/select.test.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/kit-headless/src/components/select/select.test.ts b/packages/kit-headless/src/components/select/select.test.ts index 97d881901..5c7850682 100644 --- a/packages/kit-headless/src/components/select/select.test.ts +++ b/packages/kit-headless/src/components/select/select.test.ts @@ -331,7 +331,7 @@ test.describe('Keyboard Behavior', () => { }); test.describe('selecting options', () => { - test(`GIVEN an opened hero select with the first option highlighted + test(`GIVEN an opened select with the first option highlighted WHEN the Enter key is pressed THEN the listbox should be closed and aria-expanded should be false`, async ({ page, @@ -346,7 +346,7 @@ test.describe('Keyboard Behavior', () => { await expect(d.getTrigger()).toHaveAttribute('aria-expanded', 'false'); }); - test(`GIVEN an open hero select + test(`GIVEN an open select WHEN an option has data-highlighted AND the Enter key is pressed THEN option value should be the selected value @@ -361,7 +361,7 @@ test.describe('Keyboard Behavior', () => { await expect(d.getValueElement()).toHaveText(expectedValue!); }); - test(`GIVEN an open hero select + test(`GIVEN an open select WHEN an option has data-highlighted AND the Space key is pressed THEN the listbox should be closed and aria-expanded false`, async ({ @@ -393,6 +393,17 @@ test.describe('Keyboard Behavior', () => { await expect(d.getValueElement()).toHaveText(expectedValue!); }); + test(`GIVEN an open select + WHEN an option is selected + THEN focus should go back to the trigger`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + await d.openListbox('Space'); + + await d.getHighlightedItem().press('Enter'); + await expect(d.getTrigger()).toBeFocused(); + }); + test(`GIVEN no selected item and a placeholder WHEN pressing the right arrow key once THEN the first enabled option should be selected and have aria-selected`, async ({ From 0b667638fa8e63b5f8655181f18eb291a5dfaa9a Mon Sep 17 00:00:00 2001 From: jack shelton Date: Tue, 4 Jun 2024 00:08:45 -0500 Subject: [PATCH 14/15] improved styles --- .../src/routes/docs/headless/select/snippets/select.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/website/src/routes/docs/headless/select/snippets/select.css b/apps/website/src/routes/docs/headless/select/snippets/select.css index 582740d8a..cadcd66d9 100644 --- a/apps/website/src/routes/docs/headless/select/snippets/select.css +++ b/apps/website/src/routes/docs/headless/select/snippets/select.css @@ -17,12 +17,18 @@ display: flex; justify-content: center; align-items: center; + margin-top: 0.25rem; } .select-trigger:hover { background-color: hsla(var(--primary) / 0.08); } +.select-trigger:focus-visible { + outline: 2px solid hsla(var(--primary) / 1); + outline-offset: 2px; +} + .select-popover { width: 100%; max-width: var(--select-width); From 2aa748fee1768194ce1da379ee23a9e46e81027c Mon Sep 17 00:00:00 2001 From: jack shelton Date: Tue, 4 Jun 2024 00:14:58 -0500 Subject: [PATCH 15/15] add changeset --- .changeset/olive-sheep-run.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/olive-sheep-run.md diff --git a/.changeset/olive-sheep-run.md b/.changeset/olive-sheep-run.md new file mode 100644 index 000000000..b36d18b88 --- /dev/null +++ b/.changeset/olive-sheep-run.md @@ -0,0 +1,5 @@ +--- +'@qwik-ui/headless': patch +--- + +refactor: improved select focus navigation