diff --git a/.changeset/pink-worms-kneel.md b/.changeset/pink-worms-kneel.md new file mode 100644 index 00000000000..faaf98eba27 --- /dev/null +++ b/.changeset/pink-worms-kneel.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +feat(SelectPanel): remove aria activedescendant and add a roving tab index diff --git a/packages/react/src/ActionList/ActionListContainerContext.tsx b/packages/react/src/ActionList/ActionListContainerContext.tsx index 8af5b184402..9a2aca01b38 100644 --- a/packages/react/src/ActionList/ActionListContainerContext.tsx +++ b/packages/react/src/ActionList/ActionListContainerContext.tsx @@ -6,7 +6,7 @@ import type {AriaRole} from '../utils/types' type ContextProps = { container?: string listRole?: AriaRole - selectionVariant?: 'single' | 'multiple' + selectionVariant?: 'single' | 'multiple' | 'radio' selectionAttribute?: 'aria-selected' | 'aria-checked' listLabelledBy?: string // This can be any function, we don't know anything about the arguments diff --git a/packages/react/src/ActionList/Item.tsx b/packages/react/src/ActionList/Item.tsx index 40d90d50cfb..49944d74fe2 100644 --- a/packages/react/src/ActionList/Item.tsx +++ b/packages/react/src/ActionList/Item.tsx @@ -92,7 +92,7 @@ export const Item = React.forwardRef( const inactive = Boolean(inactiveText) // TODO change `menuContext` check to ```listRole !== undefined && ['menu', 'listbox'].includes(listRole)``` // once we have a better way to handle existing usage in dotcom that incorrectly use ActionList.TrailingAction - const menuContext = container === 'ActionMenu' || container === 'SelectPanel' + const menuContext = container === 'ActionMenu' || container === 'SelectPanel' || container === 'FilteredActionList' // TODO: when we change `menuContext` to check `listRole` instead of `container` const showInactiveIndicator = inactive && !(listRole !== undefined && ['menu', 'listbox'].includes(listRole)) diff --git a/packages/react/src/ActionList/List.tsx b/packages/react/src/ActionList/List.tsx index 43cad2dace0..f8448ba0055 100644 --- a/packages/react/src/ActionList/List.tsx +++ b/packages/react/src/ActionList/List.tsx @@ -28,6 +28,7 @@ export const List = React.forwardRef( listLabelledBy, selectionVariant: containerSelectionVariant, // TODO: Remove after DropdownMenu2 deprecation enableFocusZone: enableFocusZoneFromContainer, + container, } = React.useContext(ActionListContainerContext) const ariaLabelledBy = slots.heading ? (slots.heading.props.id ?? headingId) : listLabelledBy @@ -42,7 +43,8 @@ export const List = React.forwardRef( disabled: !enableFocusZone, containerRef: listRef, bindKeys: FocusKeys.ArrowVertical | FocusKeys.HomeAndEnd | FocusKeys.PageUpDown, - focusOutBehavior: listRole === 'menu' ? 'wrap' : undefined, + focusOutBehavior: + listRole === 'menu' || container === 'SelectPanel' || container === 'FilteredActionList' ? 'wrap' : undefined, }) return ( diff --git a/packages/react/src/ActionList/Selection.tsx b/packages/react/src/ActionList/Selection.tsx index 6fbab8c20ee..e2f35d99af5 100644 --- a/packages/react/src/ActionList/Selection.tsx +++ b/packages/react/src/ActionList/Selection.tsx @@ -32,7 +32,7 @@ export const Selection: React.FC> = ({se return ( {/* This is just a way to get the visuals from Radio, but it should be ignored in terms of accessibility */} - + ) } diff --git a/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts index 70e12c3cff3..9a387d232bb 100644 --- a/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts +++ b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts @@ -6,4 +6,5 @@ export const DefaultFeatureFlags = FeatureFlagScope.create({ primer_react_segmented_control_tooltip: false, primer_react_select_panel_fullscreen_on_narrow: false, primer_react_select_panel_order_selected_at_top: false, + primer_react_select_panel_remove_active_descendant: false, }) diff --git a/packages/react/src/FilteredActionList/FilteredActionList.module.css b/packages/react/src/FilteredActionList/FilteredActionList.module.css index 6a1914c59a8..549fe470b35 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.module.css +++ b/packages/react/src/FilteredActionList/FilteredActionList.module.css @@ -5,6 +5,22 @@ flex-grow: 1; } +.ActionListItem:focus { + background: var(--control-transparent-bgColor-selected); + + &::after { + @mixin activeIndicatorLine; + } +} + +.ActionListItem:where([data-input-focused]):where([data-first-child]) { + background: var(--control-transparent-bgColor-selected); + + &::after { + @mixin activeIndicatorLine; + } +} + .FullScreenTextInput { @media screen and (--viewportRange-narrow) { /* Ensures inputs don't zoom on mobile iPhone but are body-font size on iPad */ diff --git a/packages/react/src/FilteredActionList/FilteredActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionList.tsx index 6df8d14491f..66005a4dfe6 100644 --- a/packages/react/src/FilteredActionList/FilteredActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionList.tsx @@ -22,9 +22,11 @@ import {FilteredActionListLoadingTypes, FilteredActionListBodyLoader} from './Fi import classes from './FilteredActionList.module.css' import Checkbox from '../Checkbox' +import {ActionListContainerContext} from '../ActionList/ActionListContainerContext' import {isValidElementType} from 'react-is' import {useAnnouncements} from './useAnnouncements' import {clsx} from 'clsx' +import {useFeatureFlag} from '../FeatureFlags' const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8} @@ -74,6 +76,7 @@ export function FilteredActionList({ message, messageText, className, + selectionVariant, announcementsEnabled = true, fullScreenOnNarrow, onSelectAllChange, @@ -89,19 +92,79 @@ export function FilteredActionList({ [onFilterChange, setInternalFilterValue], ) + const inputAndListContainerRef = useRef(null) + const listRef = useRef(null) + const scrollContainerRef = useRef(null) const inputRef = useProvidedRefOrCreate(providedInputRef) + + const usingRemoveActiveDescendant = useFeatureFlag('primer_react_select_panel_remove_active_descendant') const [listContainerElement, setListContainerElement] = useState(null) const activeDescendantRef = useRef() + const listId = useId() const inputDescriptionTextId = useId() + const [isInputFocused, setIsInputFocused] = useState(false) const selectAllChecked = items.length > 0 && items.every(item => item.selected) const selectAllIndeterminate = !selectAllChecked && items.some(item => item.selected) const selectAllLabelText = selectAllChecked ? 'Deselect all' : 'Select all' + + const getItemListForEachGroup = useCallback( + (groupId: string) => { + const itemsInGroup = [] + for (const item of items) { + // Look up the group associated with the current item. + if (item.groupId === groupId) { + itemsInGroup.push(item) + } + } + return itemsInGroup + }, + [items], + ) + + const onInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'ArrowDown') { + if (listRef.current) { + const firstSelectedItem = listRef.current.querySelector('[role="option"]') as HTMLElement | undefined + firstSelectedItem?.focus() + + event.preventDefault() + } + } else if (event.key === 'Enter') { + let firstItem + // If there are groups, it's not guaranteed that the first item is the actual first item in the first - + // as groups are rendered in the order of the groupId provided + if (groupMetadata) { + let firstGroupIndex = 0 + + for (let i = 0; i < groupMetadata.length; i++) { + if (getItemListForEachGroup(groupMetadata[i].groupId).length > 0) { + break + } else { + firstGroupIndex++ + } + } + + const firstGroup = groupMetadata[firstGroupIndex].groupId + firstItem = items.filter(item => item.groupId === firstGroup)[0] + } else { + firstItem = items[0] + } + if (firstItem.onAction) { + firstItem.onAction(firstItem, event) + event.preventDefault() + } + } + }, + [items, groupMetadata, getItemListForEachGroup], + ) + const onInputKeyPress: KeyboardEventHandler = useCallback( - event => { + (event: React.KeyboardEvent) => { if (event.key === 'Enter' && activeDescendantRef.current) { event.preventDefault() event.nativeEvent.stopImmediatePropagation() @@ -114,6 +177,7 @@ export function FilteredActionList({ [activeDescendantRef], ) + // BEGIN: Todo remove when we remove usingRemoveActiveDescendant const listContainerRefCallback = useCallback( (node: HTMLUListElement | null) => { setListContainerElement(node) @@ -121,45 +185,78 @@ export function FilteredActionList({ }, [onListContainerRefChanged], ) - useEffect(() => { onInputRefChanged?.(inputRef) }, [inputRef, onInputRefChanged]) + //END: Todo remove when we remove usingRemoveActiveDescendant useFocusZone( - { - containerRef: {current: listContainerElement}, - bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown, - focusOutBehavior: 'wrap', - focusableElementFilter: element => { - return !(element instanceof HTMLInputElement) - }, - activeDescendantFocus: inputRef, - onActiveDescendantChanged: (current, previous, directlyActivated) => { - activeDescendantRef.current = current - - if (current && scrollContainerRef.current && directlyActivated) { - scrollIntoView(current, scrollContainerRef.current, menuScrollMargins) + !usingRemoveActiveDescendant + ? { + containerRef: {current: listContainerElement}, + bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown, + focusOutBehavior: 'wrap', + focusableElementFilter: element => { + return !(element instanceof HTMLInputElement) + }, + activeDescendantFocus: inputRef, + onActiveDescendantChanged: (current, previous, directlyActivated) => { + activeDescendantRef.current = current + + if (current && scrollContainerRef.current && directlyActivated) { + scrollIntoView(current, scrollContainerRef.current, menuScrollMargins) + } + }, } - }, - }, - [ - // List container isn't in the DOM while loading. Need to re-bind focus zone when it changes. - listContainerElement, - ], + : undefined, + [listContainerElement, usingRemoveActiveDescendant], ) useEffect(() => { - // if items changed, we want to instantly move active descendant into view if (activeDescendantRef.current && scrollContainerRef.current) { scrollIntoView(activeDescendantRef.current, scrollContainerRef.current, { ...menuScrollMargins, behavior: 'auto', }) } - }, [items]) + }, [items, inputRef]) - useAnnouncements(items, {current: listContainerElement}, inputRef, announcementsEnabled, loading, messageText) + useEffect(() => { + if (usingRemoveActiveDescendant) { + const inputAndListContainerElement = inputAndListContainerRef.current + if (!inputAndListContainerElement) return + const list = listRef.current + if (!list) return + + // Listen for focus changes within the container + const handleFocusIn = (event: FocusEvent) => { + if (event.target === inputRef.current || list.contains(event.target as Node)) { + setIsInputFocused(inputRef.current && inputRef.current === document.activeElement ? true : false) + } + } + + inputAndListContainerElement.addEventListener('focusin', handleFocusIn) + + return () => { + inputAndListContainerElement.removeEventListener('focusin', handleFocusIn) + } + } + }, [items, inputRef, listContainerElement, usingRemoveActiveDescendant]) // Re-run when items change to update active indicators + + useEffect(() => { + if (usingRemoveActiveDescendant && !loading) { + setIsInputFocused(inputRef.current && inputRef.current === document.activeElement ? true : false) + } + }, [loading, inputRef, usingRemoveActiveDescendant]) + + useAnnouncements( + items, + usingRemoveActiveDescendant ? listRef : {current: listContainerElement}, + inputRef, + announcementsEnabled, + loading, + messageText, + ) useScrollFlash(scrollContainerRef) const handleSelectAllChange = useCallback( @@ -171,17 +268,6 @@ export function FilteredActionList({ [onSelectAllChange], ) - function getItemListForEachGroup(groupId: string) { - const itemsInGroup = [] - for (const item of items) { - // Look up the group associated with the current item. - if (item.groupId === groupId) { - itemsInGroup.push(item) - } - } - return itemsInGroup - } - function getBodyContent() { if (loading && scrollContainerRef.current && loadingType.appearsInBody) { return @@ -189,11 +275,12 @@ export function FilteredActionList({ if (message) { return message } - - return ( + let firstGroupIndex = 0 + const actionListContent = ( {groupMetadata?.length ? groupMetadata.map((group, index) => { + if (index === firstGroupIndex && getItemListForEachGroup(group.groupId).length === 0) { + firstGroupIndex++ // Increment firstGroupIndex if the first group has no items + } return ( {group.header?.title ? group.header.title : `Group ${group.groupId}`} - {getItemListForEachGroup(group.groupId).map(({key: itemKey, ...item}, index) => { - const key = itemKey ?? item.id?.toString() ?? index.toString() - return + {getItemListForEachGroup(group.groupId).map(({key: itemKey, ...item}, itemIndex) => { + const key = itemKey ?? item.id?.toString() ?? itemIndex.toString() + return ( + + ) })} ) }) : items.map(({key: itemKey, ...item}, index) => { const key = itemKey ?? item.id?.toString() ?? index.toString() - return + return ( + + ) })} ) + + // Use ActionListContainerContext.Provider only for the old behavior (when feature flag is disabled) + if (usingRemoveActiveDescendant) { + return ( + + {actionListContent} + + ) + } else { + return actionListContent + } } return ( {}} placeholder={placeholderText} role="combobox" aria-expanded="true" diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx index cccaac7f9b6..80c6b8551b8 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -5,6 +5,7 @@ import {announce as liveRegionAnnounce} from '@primer/live-region-element' import {useCallback, useEffect, useRef} from 'react' import type {FilteredActionListProps} from './index' import type {ItemInput} from '../deprecated/ActionList/List' +import {useFeatureFlag} from '../FeatureFlags' // we add a delay so that it does not interrupt default screen reader announcement and queues after it const delayMs = 500 @@ -17,6 +18,7 @@ const useFirstRender = () => { return firstRender.current } +//TODO remove this when we remove usingRemoveActiveDescendant const getItemWithActiveDescendant = ( listRef: React.RefObject, items: FilteredActionListProps['items'], @@ -36,6 +38,7 @@ const getItemWithActiveDescendant = ( return {index, text, selected} } +//TODO remove this when we remove usingRemoveActiveDescendant export const useAnnouncements = ( items: FilteredActionListProps['items'], @@ -45,8 +48,12 @@ export const useAnnouncements = ( loading: boolean = false, message?: {title: string; description: string}, ) => { + const usingRemoveActiveDescendant = useFeatureFlag('primer_react_select_panel_remove_active_descendant') const liveRegion = document.querySelector('live-region') + // Notify user of the number of items available + const selectedItems = items.filter(item => item.selected).length + const announce = useCallback( (...args: Parameters): ReturnType | undefined => { if (enabled) { @@ -59,30 +66,38 @@ export const useAnnouncements = ( useEffect( function announceInitialFocus() { const focusHandler = () => { - // give @primer/behaviors a moment to apply active-descendant - window.requestAnimationFrame(() => { - const activeItem = getItemWithActiveDescendant(listContainerRef, items) - if (!activeItem) return - const {index, text, selected} = activeItem - - const announcementText = [ - `Focus on filter text box and list of items`, - `Focused item: ${text}`, - `${selected ? 'selected' : 'not selected'}`, - `${index + 1} of ${items.length}`, - ].join(', ') + if (usingRemoveActiveDescendant) { + const announcementText = `${items.length} item${items.length > 1 ? 's' : ''} available, ${selectedItems} selected.` announce(announcementText, { delayMs, from: liveRegion ? liveRegion : undefined, // announce will create a liveRegion if it doesn't find one }) - }) + } else { + // give @primer/behaviors a moment to apply active-descendant + window.requestAnimationFrame(() => { + const activeItem = getItemWithActiveDescendant(listContainerRef, items) + if (!activeItem) return + const {index, text, selected} = activeItem + + const announcementText = [ + `Focus on filter text box and list of items`, + `Focused item: ${text}`, + `${selected ? 'selected' : 'not selected'}`, + `${index + 1} of ${items.length}`, + ].join(', ') + announce(announcementText, { + delayMs, + from: liveRegion ? liveRegion : undefined, // announce will create a liveRegion if it doesn't find one + }) + }) + } } const inputElement = inputRef.current inputElement?.addEventListener('focus', focusHandler) return () => inputElement?.removeEventListener('focus', focusHandler) }, - [listContainerRef, inputRef, items, liveRegion, announce], + [listContainerRef, inputRef, items, liveRegion, announce, usingRemoveActiveDescendant, selectedItems], ) const isFirstRender = useFirstRender() @@ -97,25 +112,45 @@ export const useAnnouncements = ( return } - // give @primer/behaviors a moment to update active-descendant - window.requestAnimationFrame(() => { - const activeItem = getItemWithActiveDescendant(listContainerRef, items) - if (!activeItem) return - const {index, text, selected} = activeItem - - const announcementText = [ - `List updated`, - `Focused item: ${text}`, - `${selected ? 'selected' : 'not selected'}`, - `${index + 1} of ${items.length}`, - ].join(', ') + if (usingRemoveActiveDescendant) { + const announcementText = `${items.length} item${items.length > 1 ? 's' : ''} available, ${selectedItems} selected.` announce(announcementText, { delayMs, - from: liveRegion ? liveRegion : undefined, // announce will create a liveRegion if it doesn't find one + from: liveRegion ? liveRegion : undefined, }) - }) + } else { + // give @primer/behaviors a moment to update active-descendant + window.requestAnimationFrame(() => { + const activeItem = getItemWithActiveDescendant(listContainerRef, items) + if (!activeItem) return + const {index, text, selected} = activeItem + + const announcementText = [ + `List updated`, + `Focused item: ${text}`, + `${selected ? 'selected' : 'not selected'}`, + `${index + 1} of ${items.length}`, + ].join(', ') + + announce(announcementText, { + delayMs, + from: liveRegion ? liveRegion : undefined, // announce will create a liveRegion if it doesn't find one + }) + }) + } }, - [announce, isFirstRender, items, listContainerRef, liveRegion, loading, message], + [ + announce, + isFirstRender, + items, + listContainerRef, + liveRegion, + usingRemoveActiveDescendant, + message?.title, + message?.description, + loading, + selectedItems, + ], ) } diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index 01c3cd9e63a..e371b67ad1c 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -14,6 +14,18 @@ import {setupMatchMedia} from '../utils/test-helpers' setupMatchMedia() +const renderWithFlag = (children: React.ReactNode, flag: boolean) => { + return render( + + {children} + , + ) +} + const items: SelectPanelProps['items'] = [ { text: 'item one', @@ -61,864 +73,500 @@ function BasicSelectPanel(passthroughProps: Record) { global.Element.prototype.scrollTo = jest.fn() -describe('SelectPanel', () => { - it('should render an anchor to open the select panel using `placeholder`', () => { - render() - - expect(screen.getByText('Select items')).toBeInTheDocument() - - const trigger = screen.getByRole('button', { - name: 'Select items', - }) - expect(trigger).toHaveAttribute('aria-haspopup', 'true') - expect(trigger).toHaveAttribute('aria-expanded', 'false') - }) - - it('should open the select panel when activating the trigger', async () => { - const user = userEvent.setup() +for (const usingRemoveActiveDescendant of [false, true]) { + describe('SelectPanel', () => { + it('should render an anchor to open the select panel using `placeholder`', () => { + renderWithFlag(, usingRemoveActiveDescendant) - render() + expect(screen.getByText('Select items')).toBeInTheDocument() - await user.click(screen.getByText('Select items')) - - // Verify that the button has `aria-expanded="true"` after opening - const trigger = screen.getByRole('button', { - name: 'Select items', + const trigger = screen.getByRole('button', { + name: 'Select items', + }) + expect(trigger).toHaveAttribute('aria-haspopup', 'true') + expect(trigger).toHaveAttribute('aria-expanded', 'false') }) - expect(trigger).toHaveAttribute('aria-expanded', 'true') - - // Verify that the input and listbox are visible - expect(screen.getByLabelText('Filter items')).toBeVisible() - expect(screen.getByRole('listbox')).toBeVisible() - - expect(screen.getByLabelText('Filter items')).toHaveFocus() - }) - - it('should close the select panel when pressing Escape', async () => { - const user = userEvent.setup() - - render() - - await user.click(screen.getByText('Select items')) - await user.keyboard('{Escape}') - - expect(screen.getByRole('button', {name: 'Select items'})).toHaveFocus() - expect(screen.getByRole('button', {name: 'Select items'})).toHaveAttribute('aria-expanded', 'false') - }) - - it('should close the select panel when clicking outside of the select panel', async () => { - const user = userEvent.setup() - - render( - <> - - - , - ) - - await user.click(screen.getByText('Select items')) - await user.click(screen.getByText('outer button')) - - expect(screen.getByRole('button', {name: 'Select items'})).toHaveAttribute('aria-expanded', 'false') - }) - - it('should open a dialog that is labelled by `title` and described by `subtitle`', async () => { - const user = userEvent.setup() - - render() - - await user.click(screen.getByText('Select items')) - - expect( - screen.getByRole('dialog', { - name: 'test title', - description: 'test subtitle', - }), - ).toBeInTheDocument() - }) - - it('should call `onOpenChange` when opening and closing the dialog', async () => { - const onOpenChange = jest.fn() - - function SelectPanelOpenChange() { - const [selected, setSelected] = React.useState([]) - const [filter, setFilter] = React.useState('') - const [open, setOpen] = React.useState(false) - - const onSelectedChange = (selected: SelectPanelProps['items']) => { - setSelected(selected) - } - - return ( - - - { - setFilter(value) - }} - open={open} - onOpenChange={(...args) => { - onOpenChange(...args) - setOpen(args[0]) - }} - /> - - ) - } - - const user = userEvent.setup() - - render() - - // Open by click - await user.click(screen.getByText('Select items')) - expect(onOpenChange).toHaveBeenLastCalledWith(true, 'anchor-click') - - // Close by click on anchor - await user.click(screen.getByText('Select items')) - expect(onOpenChange).toHaveBeenLastCalledWith(false, 'anchor-click') - - // Open by button activation - await user.type(screen.getByText('Select items'), '{Space}') - expect(onOpenChange).toHaveBeenLastCalledWith(true, 'anchor-click') - - // Close by Escape key - await user.keyboard('{Escape}') - expect(onOpenChange).toHaveBeenLastCalledWith(false, 'escape') - // Close by click outside - await user.click(screen.getByText('Select items')) - await user.click(screen.getByText('Outside of select panel')) - expect(onOpenChange).toHaveBeenLastCalledWith(false, 'click-outside') - }) - - it('should label the list by title unless a aria-label is explicitly passed', async () => { - const user = userEvent.setup() - - render() - await user.click(screen.getByText('Select items')) - expect(screen.getByRole('listbox', {name: 'test title'})).toBeInTheDocument() - }) - - it('should label the list by aria-label when explicitly passed', async () => { - const user = userEvent.setup() - - render() - await user.click(screen.getByText('Select items')) - expect(screen.getByRole('listbox', {name: 'Custom label'})).toBeInTheDocument() - }) - - it('should focus the filter input on open', async () => { - const user = userEvent.setup() - - // This panel contains another focusable thing (the IconButton) that should not receive focus - // when the panel opens. - render( - - {}} - onFilterChange={() => {}} - onSelectedChange={() => {}} - open={true} - items={items} - selected={[]} - placeholder="Select items" - placeholderText="Filter items" - title={ - - - Title - - } - /> - , - ) - - await user.click(screen.getByText('Select items')) - expect(screen.getByLabelText('Filter items')).toHaveFocus() - }) - - describe('selection', () => { - it('should select an active option when activated', async () => { + it('should open the select panel when activating the trigger', async () => { const user = userEvent.setup() - render() + renderWithFlag(, usingRemoveActiveDescendant) await user.click(screen.getByText('Select items')) - await user.type(document.activeElement!, '{Enter}') - expect( - screen.getByRole('option', { - name: 'item one', - }), - ).toHaveAttribute('aria-selected', 'true') - - await user.type(document.activeElement!, '{Enter}') - expect( - screen.getByRole('option', { - name: 'item one', - }), - ).toHaveAttribute('aria-selected', 'false') + // Verify that the button has `aria-expanded="true"` after opening + const trigger = screen.getByRole('button', { + name: 'Select items', + }) + expect(trigger).toHaveAttribute('aria-expanded', 'true') - await user.click(screen.getByText('item one')) - expect( - screen.getByRole('option', { - name: 'item one', - }), - ).toHaveAttribute('aria-selected', 'true') + // Verify that the input and listbox are visible + expect(screen.getByLabelText('Filter items')).toBeVisible() + expect(screen.getByRole('listbox')).toBeVisible() - await user.click(screen.getByRole('option', {name: 'item one'})) - expect( - screen.getByRole('option', { - name: 'item one', - }), - ).toHaveAttribute('aria-selected', 'false') + expect(screen.getByLabelText('Filter items')).toHaveFocus() }) - it('should support navigating through items with ArrowUp and ArrowDown', async () => { + it('should close the select panel when pressing Escape', async () => { const user = userEvent.setup() - render() + renderWithFlag(, usingRemoveActiveDescendant) await user.click(screen.getByText('Select items')) + await user.keyboard('{Escape}') - // First item by default should be the active element - expect(document.activeElement!).toHaveAttribute( - 'aria-activedescendant', - screen.getByRole('option', {name: 'item one'}).id, - ) - - await user.type(document.activeElement!, '{ArrowDown}') - expect(document.activeElement!).toHaveAttribute( - 'aria-activedescendant', - screen.getByRole('option', {name: 'item two'}).id, - ) - - await user.type(document.activeElement!, '{ArrowDown}') - expect(document.activeElement!).toHaveAttribute( - 'aria-activedescendant', - screen.getByRole('option', {name: 'item three'}).id, - ) + expect(screen.getByRole('button', {name: 'Select items'})).toHaveFocus() + expect(screen.getByRole('button', {name: 'Select items'})).toHaveAttribute('aria-expanded', 'false') + }) - // At end of list, should wrap to the beginning - await user.type(document.activeElement!, '{ArrowDown}') - expect(document.activeElement!).toHaveAttribute( - 'aria-activedescendant', - screen.getByRole('option', {name: 'item one'}).id, - ) + it('should close the select panel when clicking outside of the select panel', async () => { + const user = userEvent.setup() - // At beginning of list, ArrowUp should wrap to the end - await user.type(document.activeElement!, '{ArrowUp}') - expect(document.activeElement!).toHaveAttribute( - 'aria-activedescendant', - screen.getByRole('option', {name: 'item three'}).id, + renderWithFlag( + <> + + + , + usingRemoveActiveDescendant, ) - await user.type(document.activeElement!, '{ArrowUp}') - expect(document.activeElement!).toHaveAttribute( - 'aria-activedescendant', - screen.getByRole('option', {name: 'item two'}).id, - ) + await user.click(screen.getByText('Select items')) + await user.click(screen.getByText('outer button')) - await user.type(document.activeElement!, '{ArrowUp}') - expect(document.activeElement!).toHaveAttribute( - 'aria-activedescendant', - screen.getByRole('option', {name: 'item one'}).id, - ) + expect(screen.getByRole('button', {name: 'Select items'})).toHaveAttribute('aria-expanded', 'false') }) - it('should support navigating through items with PageDown and PageUp', async () => { + it('should open a dialog that is labelled by `title` and described by `subtitle`', async () => { const user = userEvent.setup() - render() + renderWithFlag(, usingRemoveActiveDescendant) await user.click(screen.getByText('Select items')) - // First item by default should be the active element - expect(document.activeElement!).toHaveAttribute( - 'aria-activedescendant', - screen.getByRole('option', {name: 'item one'}).id, - ) - - await user.type(document.activeElement!, '{PageDown}') - - expect(document.activeElement!).toHaveAttribute( - 'aria-activedescendant', - screen.getByRole('option', {name: 'item three'}).id, - ) - - await user.type(document.activeElement!, '{PageUp}') - expect(document.activeElement!).toHaveAttribute( - 'aria-activedescendant', - screen.getByRole('option', {name: 'item one'}).id, - ) + expect( + screen.getByRole('dialog', { + name: 'test title', + description: 'test subtitle', + }), + ).toBeInTheDocument() }) - it('should select an item (by item.id) even when items are defined in the component', async () => { - const user = userEvent.setup() - - function Fixture() { - // items are defined in the same scope as selection, so they could rerender and create new object references - // We use item.id to track selection - const items: SelectPanelProps['items'] = [ - {id: 'one', text: 'item one'}, - {id: 'two', text: 'item two'}, - {id: 'three', text: 'item three'}, - ] + it('should call `onOpenChange` when opening and closing the dialog', async () => { + const onOpenChange = jest.fn() - const [open, setOpen] = React.useState(false) + function SelectPanelOpenChange() { const [selected, setSelected] = React.useState([]) const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) + + const onSelectedChange = (selected: SelectPanelProps['items']) => { + setSelected(selected) + } return ( + { + setFilter(value) + }} open={open} - onOpenChange={setOpen} + onOpenChange={(...args) => { + onOpenChange(...args) + setOpen(args[0]) + }} /> ) } - render() - - await user.click(screen.getByText('Select items')) - - await user.click(screen.getByText('item one')) - expect(screen.getByRole('option', {name: 'item one'})).toHaveAttribute('aria-selected', 'true') - - await user.click(screen.getByText('item two')) - expect(screen.getByRole('option', {name: 'item two'})).toHaveAttribute('aria-selected', 'true') - - await user.click(screen.getByRole('option', {name: 'item one'})) - expect(screen.getByRole('option', {name: 'item one'})).toHaveAttribute('aria-selected', 'false') - }) - }) + const user = userEvent.setup() - function FilterableSelectPanel() { - const [selected, setSelected] = React.useState([]) - const [filter, setFilter] = React.useState('') - const [open, setOpen] = React.useState(false) + renderWithFlag(, usingRemoveActiveDescendant) - const onSelectedChange = (selected: SelectPanelProps['items']) => { - setSelected(selected) - } + // Open by click + await user.click(screen.getByText('Select items')) + expect(onOpenChange).toHaveBeenLastCalledWith(true, 'anchor-click') - return ( - - item.text?.includes(filter))} - placeholder="Select items" - placeholderText="Filter items" - selected={selected} - onSelectedChange={onSelectedChange} - filterValue={filter} - onFilterChange={value => { - setFilter(value) - }} - open={open} - onOpenChange={isOpen => { - setOpen(isOpen) - }} - /> - - ) - } + // Close by click on anchor + await user.click(screen.getByText('Select items')) + expect(onOpenChange).toHaveBeenLastCalledWith(false, 'anchor-click') - const SelectPanelWithCustomMessages: React.FC<{ - items: SelectPanelProps['items'] - withAction?: boolean - onAction?: () => void - }> = ({items, withAction = false, onAction}) => { - const [selected, setSelected] = React.useState([]) - const [filter, setFilter] = React.useState('') - const [open, setOpen] = React.useState(false) - - const onSelectedChange = (selected: SelectPanelProps['items']) => { - setSelected(selected) - } + // Open by button activation + await user.type(screen.getByText('Select items'), '{Space}') + expect(onOpenChange).toHaveBeenLastCalledWith(true, 'anchor-click') - const emptyMessage = { - variant: 'empty' as const, - title: "You haven't created any projects yet", - body: 'Start your first project to organise your issues', - ...(withAction && { - action: ( - - ), - }), - } + // Close by Escape key + await user.keyboard('{Escape}') + expect(onOpenChange).toHaveBeenLastCalledWith(false, 'escape') - const noResultsMessage = (filter: string) => ({ - variant: 'empty' as const, - title: `No language found for ${filter}`, - body: 'Adjust your search term to find other languages', + // Close by click outside + await user.click(screen.getByText('Select items')) + await user.click(screen.getByText('Outside of select panel')) + expect(onOpenChange).toHaveBeenLastCalledWith(false, 'click-outside') }) - const filteredItems = items.filter(item => item.text?.includes(filter)) - - function getMessage() { - if (filteredItems.length === 0 && !filter) { - return emptyMessage - } - if (filteredItems.length === 0 && filter) { - return noResultsMessage(filter) - } - return undefined - } - - return ( - - { - setFilter(value) - }} - open={open} - onOpenChange={isOpen => { - setOpen(isOpen) - }} - message={getMessage()} - /> - - ) - } - - function NoItemAvailableSelectPanel() { - const [selected, setSelected] = React.useState([]) - const [filter, setFilter] = React.useState('') - const [open, setOpen] = React.useState(false) - - const onSelectedChange = (selected: SelectPanelProps['items']) => { - setSelected(selected) - } - - const items: SelectPanelProps['items'] = [] - - return ( - - item.text?.includes(filter))} - placeholder="Select items" - placeholderText="Filter items" - selected={selected} - onSelectedChange={onSelectedChange} - filterValue={filter} - onFilterChange={value => { - setFilter(value) - }} - open={open} - onOpenChange={isOpen => { - setOpen(isOpen) - }} - /> - - ) - } - - describe('filtering', () => { - it('should filter the list of items when the user types into the input', async () => { + it('should label the list by title unless a aria-label is explicitly passed', async () => { const user = userEvent.setup() - render() - + renderWithFlag(, usingRemoveActiveDescendant) await user.click(screen.getByText('Select items')) - - expect(screen.getAllByRole('option')).toHaveLength(3) - - await user.type(document.activeElement!, 'two') - expect(screen.getAllByRole('option')).toHaveLength(1) + expect(screen.getByRole('listbox', {name: 'test title'})).toBeInTheDocument() }) - }) - describe('screen reader announcements', () => { - beforeEach(() => { - const liveRegion = document.createElement('live-region') - document.body.appendChild(liveRegion) + it('should label the list by aria-label when explicitly passed', async () => { + const user = userEvent.setup() + + renderWithFlag(, usingRemoveActiveDescendant) + await user.click(screen.getByText('Select items')) + expect(screen.getByRole('listbox', {name: 'Custom label'})).toBeInTheDocument() }) - function LoadingSelectPanel({ - initialLoadingType = 'spinner', - items = [], - }: { - initialLoadingType?: InitialLoadingType - items?: SelectPanelProps['items'] - }) { - const [open, setOpen] = React.useState(false) + it('should focus the filter input on open', async () => { + const user = userEvent.setup() - return ( + // This panel contains another focusable thing (the IconButton) that should not receive focus + // when the panel opens. + renderWithFlag( {}} onFilterChange={() => {}} - selected={[]} onSelectedChange={() => {}} - onOpenChange={isOpen => { - setOpen(isOpen) - }} - initialLoadingType={initialLoadingType} + open={true} + items={items} + selected={[]} + placeholder="Select items" + placeholderText="Filter items" + title={ + + + Title + + } /> - + , + usingRemoveActiveDescendant, ) - } - - it('displays a loading spinner on first open', async () => { - const user = userEvent.setup() - - render() await user.click(screen.getByText('Select items')) - - expect(screen.getByTestId('filtered-action-list-spinner')).toBeTruthy() + expect(screen.getByLabelText('Filter items')).toHaveFocus() }) - it('displays a loading skeleton on first open', async () => { - const user = userEvent.setup() + describe('selection', () => { + it('should select an active option when activated', async () => { + const user = userEvent.setup() - render() + renderWithFlag(, usingRemoveActiveDescendant) - await user.click(screen.getByText('Select items')) + await user.click(screen.getByText('Select items')) - expect(screen.getByTestId('filtered-action-list-skeleton')).toBeTruthy() - }) + await user.type(document.activeElement!, '{Enter}') + expect( + screen.getByRole('option', { + name: 'item one', + }), + ).toHaveAttribute('aria-selected', 'true') + + await user.type(document.activeElement!, '{Enter}') + expect( + screen.getByRole('option', { + name: 'item one', + }), + ).toHaveAttribute('aria-selected', 'false') + + await user.click(screen.getByText('item one')) + expect( + screen.getByRole('option', { + name: 'item one', + }), + ).toHaveAttribute('aria-selected', 'true') + + await user.click(screen.getByRole('option', {name: 'item one'})) + expect( + screen.getByRole('option', { + name: 'item one', + }), + ).toHaveAttribute('aria-selected', 'false') + }) - it('displays a loading spinner in the text input if items are already loaded', async () => { - const user = userEvent.setup() + it('should support navigating through items with ArrowUp and ArrowDown', async () => { + const user = userEvent.setup() - render() + renderWithFlag(, usingRemoveActiveDescendant) - await user.click(screen.getByText('Select items')) + await user.click(screen.getByText('Select items')) - expect(screen.getAllByRole('option')).toHaveLength(3) + if (usingRemoveActiveDescendant) { + expect(document.activeElement!).toHaveAttribute('role', 'combobox') - // since the test component never repopulates the panel's list of items, the panel will - // enter the loading state after the following line executes and stay there indefinitely - await user.type(document.activeElement!, 'two') + await user.keyboard('{ArrowDown}') + expect(document.activeElement!).toHaveAccessibleName('item one') - // The aria-describedby attribute is only available if the icon is present. The input - // field has a role of combobox. - expect(screen.getByRole('combobox').hasAttribute('aria-describedby')).toBeTruthy() - }) + await user.keyboard('{ArrowDown}') + expect(document.activeElement!).toHaveAccessibleName('item two') - it('should announce initially focused item', async () => { - jest.useFakeTimers() - const user = userEvent.setup({ - advanceTimers: jest.advanceTimersByTime, - }) - render() + await user.keyboard('{ArrowDown}') + expect(document.activeElement!).toHaveAccessibleName('item three') - await user.click(screen.getByText('Select items')) - expect(screen.getByLabelText('Filter items')).toHaveFocus() + // At end of list, should wrap to the beginning + await user.keyboard('{ArrowDown}') + expect(document.activeElement!).toHaveAccessibleName('item one') - jest.runAllTimers() - // we wait because announcement is intentionally updated after a timeout to not interrupt user input - await waitFor(async () => { - expect(getLiveRegion().getMessage('polite')?.trim()).toEqual( - 'List updated, Focused item: item one, not selected, 1 of 3', - ) - }) - jest.useRealTimers() - }) + // At beginning of list, ArrowUp should wrap to the end + await user.keyboard('{ArrowUp}') + expect(document.activeElement!).toHaveAccessibleName('item three') - it('should announce notice text', async () => { - jest.useFakeTimers() - const user = userEvent.setup({ - advanceTimers: jest.advanceTimersByTime, - }) + await user.keyboard('{ArrowUp}') + expect(document.activeElement!).toHaveAccessibleName('item two') - function SelectPanelWithNotice() { - const [selected, setSelected] = React.useState([]) - const [filter, setFilter] = React.useState('') - const [open, setOpen] = React.useState(false) + await user.keyboard('{ArrowUp}') + expect(document.activeElement!).toHaveAccessibleName('item one') + } else { + // First item by default should be the active element + expect(document.activeElement!).toHaveAttribute( + 'aria-activedescendant', + screen.getByRole('option', {name: 'item one'}).id, + ) - const onSelectedChange = (selected: SelectPanelProps['items']) => { - setSelected(selected) - } + await user.type(document.activeElement!, '{ArrowDown}') + expect(document.activeElement!).toHaveAttribute( + 'aria-activedescendant', + screen.getByRole('option', {name: 'item two'}).id, + ) - return ( - - { - setFilter(value) - }} - open={open} - onOpenChange={isOpen => { - setOpen(isOpen) - }} - notice={{ - text: 'This is a notice', - variant: 'warning', - }} - /> - - ) - } + await user.type(document.activeElement!, '{ArrowDown}') + expect(document.activeElement!).toHaveAttribute( + 'aria-activedescendant', + screen.getByRole('option', {name: 'item three'}).id, + ) - render() + // At end of list, should wrap to the beginning + await user.type(document.activeElement!, '{ArrowDown}') + expect(document.activeElement!).toHaveAttribute( + 'aria-activedescendant', + screen.getByRole('option', {name: 'item one'}).id, + ) - await user.click(screen.getByText('Select items')) - expect(screen.getByLabelText('Filter items')).toHaveFocus() + // At beginning of list, ArrowUp should wrap to the end + await user.type(document.activeElement!, '{ArrowUp}') + expect(document.activeElement!).toHaveAttribute( + 'aria-activedescendant', + screen.getByRole('option', {name: 'item three'}).id, + ) - expect(getLiveRegion().getMessage('polite')?.trim()).toContain('This is a notice') - }) + await user.type(document.activeElement!, '{ArrowUp}') + expect(document.activeElement!).toHaveAttribute( + 'aria-activedescendant', + screen.getByRole('option', {name: 'item two'}).id, + ) - it('should announce filtered results', async () => { - jest.useFakeTimers() - const user = userEvent.setup({ - advanceTimers: jest.advanceTimersByTime, + await user.type(document.activeElement!, '{ArrowUp}') + expect(document.activeElement!).toHaveAttribute( + 'aria-activedescendant', + screen.getByRole('option', {name: 'item one'}).id, + ) + } }) - render() - await user.click(screen.getByText('Select items')) - expect(screen.getByLabelText('Filter items')).toHaveFocus() + it('should support navigating through items with PageDown and PageUp', async () => { + const user = userEvent.setup() - jest.runAllTimers() - await waitFor( - async () => { - expect(getLiveRegion().getMessage('polite')?.trim()).toEqual( - 'List updated, Focused item: item one, not selected, 1 of 3', - ) - }, - {timeout: 3000}, // increased timeout because we don't want the test to compare with previous announcement - ) + renderWithFlag(, usingRemoveActiveDescendant) + + await user.click(screen.getByText('Select items')) - await user.type(document.activeElement!, 'o') - expect(screen.getAllByRole('option')).toHaveLength(2) + if (usingRemoveActiveDescendant) { + await user.type(document.activeElement!, '{ArrowDown}') - jest.runAllTimers() - await waitFor( - async () => { - expect(getLiveRegion().getMessage('polite')).toBe( - 'List updated, Focused item: item one, not selected, 1 of 2', - ) - }, - {timeout: 3000}, // increased timeout because we don't want the test to compare with previous announcement - ) + expect(document.activeElement!).toHaveAccessibleName('item one') - await user.type(document.activeElement!, 'ne') // now: one - expect(screen.getAllByRole('option')).toHaveLength(1) + await user.type(document.activeElement!, '{PageDown}') - jest.runAllTimers() - await waitFor(async () => { - expect(getLiveRegion().getMessage('polite')?.trim()).toBe( - 'List updated, Focused item: item one, not selected, 1 of 1', - ) - }) - jest.useRealTimers() - }) + expect(document.activeElement!).toHaveAccessibleName('item three') - it('should announce default empty message when no results are available (no custom message is provided)', async () => { - jest.useFakeTimers() - const user = userEvent.setup({ - advanceTimers: jest.advanceTimersByTime, - }) - render() + await user.type(document.activeElement!, '{PageUp}') - await user.click(screen.getByText('Select items')) + expect(document.activeElement!).toHaveAccessibleName('item one') + } else { + // First item by default should be the active element + expect(document.activeElement!).toHaveAttribute( + 'aria-activedescendant', + screen.getByRole('option', {name: 'item one'}).id, + ) - await user.type(document.activeElement!, 'zero') - expect(screen.queryByRole('option')).toBeNull() + await user.type(document.activeElement!, '{PageDown}') - jest.runAllTimers() - await waitFor(async () => { - expect(getLiveRegion().getMessage('polite')).toBe('No items available. ') - }) - jest.useRealTimers() - }) + expect(document.activeElement!).toHaveAttribute( + 'aria-activedescendant', + screen.getByRole('option', {name: 'item three'}).id, + ) - it('should announce custom empty message when no results are available', async () => { - jest.useFakeTimers() - const user = userEvent.setup({ - advanceTimers: jest.advanceTimersByTime, + await user.type(document.activeElement!, '{PageUp}') + expect(document.activeElement!).toHaveAttribute( + 'aria-activedescendant', + screen.getByRole('option', {name: 'item one'}).id, + ) + } }) - function SelectPanelWithCustomEmptyMessage() { - const [filter, setFilter] = React.useState('') - const [open, setOpen] = React.useState(false) + it('should select an item (by item.id) even when items are defined in the component', async () => { + const user = userEvent.setup() + + function Fixture() { + // items are defined in the same scope as selection, so they could rerender and create new object references + // We use item.id to track selection + const items: SelectPanelProps['items'] = [ + {id: 'one', text: 'item one'}, + {id: 'two', text: 'item two'}, + {id: 'three', text: 'item three'}, + ] + + const [open, setOpen] = React.useState(false) + const [selected, setSelected] = React.useState([]) + const [filter, setFilter] = React.useState('') + + return ( + + + + ) + } - return ( - - { - setFilter(value) - }} - filterValue={filter} - selected={[]} - onSelectedChange={() => {}} - onOpenChange={isOpen => { - setOpen(isOpen) - }} - message={{ - title: 'Nothing found', - body: `There's nothing here.`, - variant: 'empty', - }} - /> - - ) - } + renderWithFlag(, usingRemoveActiveDescendant) - render() + await user.click(screen.getByText('Select items')) - await user.click(screen.getByText('Select items')) + await user.click(screen.getByText('item one')) + expect(screen.getByRole('option', {name: 'item one'})).toHaveAttribute('aria-selected', 'true') - await user.type(document.activeElement!, 'zero') - expect(screen.queryByRole('option')).toBeNull() + await user.click(screen.getByText('item two')) + expect(screen.getByRole('option', {name: 'item two'})).toHaveAttribute('aria-selected', 'true') - jest.runAllTimers() - await waitFor(async () => { - expect(getLiveRegion().getMessage('polite')).toBe(`Nothing found. There's nothing here.`) + await user.click(screen.getByRole('option', {name: 'item one'})) + expect(screen.getByRole('option', {name: 'item one'})).toHaveAttribute('aria-selected', 'false') }) - jest.useRealTimers() - }) - - it('should accept a className to style the component', async () => { - const user = userEvent.setup() - - render() - - await user.click(screen.getByText('Select items')) - - expect(screen.getByTestId('filtered-action-list')).toHaveClass('test-class') - }) - }) - - describe('Empty state', () => { - it('should display the default empty state message when there is no matching item after filtering (No custom message is provided)', async () => { - const user = userEvent.setup() - - render() - - await user.click(screen.getByText('Select items')) - - expect(screen.getAllByRole('option')).toHaveLength(3) - - await user.type(document.activeElement!, 'something') - expect(screen.getByText('No items available')).toBeVisible() }) - it('should display the default empty state message when there is no item after the initial load (No custom message is provided)', async () => { - const user = userEvent.setup() - - render() + function FilterableSelectPanel() { + const [selected, setSelected] = React.useState([]) + const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) - await waitFor(async () => { - await user.click(screen.getByText('Select items')) - expect(screen.getByText('No items available')).toBeVisible() - }) - }) - it('should display the custom empty state message when there is no matching item after filtering', async () => { - const user = userEvent.setup() + const onSelectedChange = (selected: SelectPanelProps['items']) => { + setSelected(selected) + } - render( - , + return ( + + item.text?.includes(filter))} + placeholder="Select items" + placeholderText="Filter items" + selected={selected} + onSelectedChange={onSelectedChange} + filterValue={filter} + onFilterChange={value => { + setFilter(value) + }} + open={open} + onOpenChange={isOpen => { + setOpen(isOpen) + }} + /> + ) + } - await user.click(screen.getByText('Select items')) - - expect(screen.getAllByRole('option')).toHaveLength(3) - - await user.type(document.activeElement!, 'something') - expect(screen.getByText('No language found for something')).toBeVisible() - expect(screen.getByText('Adjust your search term to find other languages')).toBeVisible() - }) + const SelectPanelWithCustomMessages: React.FC<{ + items: SelectPanelProps['items'] + withAction?: boolean + onAction?: () => void + }> = ({items, withAction = false, onAction}) => { + const [selected, setSelected] = React.useState([]) + const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) - it('should display the custom empty state message when there is no item after the initial load', async () => { - const user = userEvent.setup() + const onSelectedChange = (selected: SelectPanelProps['items']) => { + setSelected(selected) + } - render() + const emptyMessage = { + variant: 'empty' as const, + title: "You haven't created any projects yet", + body: 'Start your first project to organise your issues', + ...(withAction && { + action: ( + + ), + }), + } - await waitFor(async () => { - await user.click(screen.getByText('Select items')) - expect(screen.getByText("You haven't created any projects yet")).toBeVisible() - expect(screen.getByText('Start your first project to organise your issues')).toBeVisible() + const noResultsMessage = (filter: string) => ({ + variant: 'empty' as const, + title: `No language found for ${filter}`, + body: 'Adjust your search term to find other languages', }) - }) - - it('should display action button in custom empty state message', async () => { - const handleAction = jest.fn() - const user = userEvent.setup() - render() + const filteredItems = items.filter(item => item.text?.includes(filter)) - await waitFor(async () => { - await user.click(screen.getByText('Select items')) - expect(screen.getByText("You haven't created any projects yet")).toBeVisible() - expect(screen.getByText('Start your first project to organise your issues')).toBeVisible() - - // Check that action button is visible - const actionButton = screen.getByTestId('create-project-action') - expect(actionButton).toBeVisible() - expect(actionButton).toHaveTextContent('Create new project') - }) + function getMessage() { + if (filteredItems.length === 0 && !filter) { + return emptyMessage + } + if (filteredItems.length === 0 && filter) { + return noResultsMessage(filter) + } + return undefined + } - // Test that action button is clickable - const actionButton = screen.getByTestId('create-project-action') - await user.click(actionButton) - expect(handleAction).toHaveBeenCalledTimes(1) - }) - }) + return ( + + { + setFilter(value) + }} + open={open} + onOpenChange={isOpen => { + setOpen(isOpen) + }} + message={getMessage()} + /> + + ) + } - describe('with footer', () => { - function SelectPanelWithFooter() { + function NoItemAvailableSelectPanel() { const [selected, setSelected] = React.useState([]) const [filter, setFilter] = React.useState('') const [open, setOpen] = React.useState(false) @@ -927,13 +575,14 @@ describe('SelectPanel', () => { setSelected(selected) } + const items: SelectPanelProps['items'] = [] + return ( test footer} - items={items} + items={items.filter(item => item.text?.includes(filter))} placeholder="Select items" placeholderText="Filter items" selected={selected} @@ -951,320 +600,486 @@ describe('SelectPanel', () => { ) } - it('should render the provided `footer` at the bottom of the dialog', async () => { - const user = userEvent.setup() + describe('filtering', () => { + it('should filter the list of items when the user types into the input', async () => { + const user = userEvent.setup() - render() + renderWithFlag(, usingRemoveActiveDescendant) - await user.click(screen.getByText('Select items')) - expect(screen.getByText('test footer')).toBeVisible() + await user.click(screen.getByText('Select items')) + + expect(screen.getAllByRole('option')).toHaveLength(3) + + await user.type(document.activeElement!, 'two') + expect(screen.getAllByRole('option')).toHaveLength(1) + }) }) - }) - const listOfItems: Array = [ - { - id: '1', - key: 1, - text: 'Item 1', - groupId: '1', - }, - { - id: '2', - key: 2, - text: 'Item 2', - groupId: '1', - }, - { - id: '3', - key: 3, - text: 'Item 3', - groupId: '2', - }, - { - id: '4', - key: 4, - text: 'Item 4', - groupId: '3', - }, - ] - - const groupMetadata: GroupedListProps['groupMetadata'] = [ - {groupId: '1', header: {title: 'Group title 1'}}, - {groupId: '2', header: {title: 'Group title 2'}}, - {groupId: '3', header: {title: 'Group title 3'}}, - ] - - function SelectPanelWithGroups() { - const [selectedItems, setSelectedItems] = React.useState([]) - const [open, setOpen] = React.useState(false) - const [filter, setFilter] = React.useState('') - - const onSelectedChange = (selections: ItemInput[]) => { - setSelectedItems(selections) - } + describe('screen reader announcements', () => { + beforeEach(() => { + const liveRegion = document.createElement('live-region') + document.body.appendChild(liveRegion) + }) - return ( - - { - setOpen(isOpen) - }} - filterValue={filter} - onFilterChange={value => { - setFilter(value) - }} - /> - - ) - } + function LoadingSelectPanel({ + initialLoadingType = 'spinner', + items = [], + }: { + initialLoadingType?: InitialLoadingType + items?: SelectPanelProps['items'] + }) { + const [open, setOpen] = React.useState(false) - describe('with groups', () => { - it('should render groups with items', async () => { - const user = userEvent.setup() + return ( + + {}} + selected={[]} + onSelectedChange={() => {}} + onOpenChange={isOpen => { + setOpen(isOpen) + }} + initialLoadingType={initialLoadingType} + /> + + ) + } - render() + it('displays a loading spinner on first open', async () => { + const user = userEvent.setup() - await user.click(screen.getByText('Select items')) - const listbox = screen.getByRole('listbox') - expect(listbox).toBeVisible() - expect(listbox).toHaveAttribute('aria-multiselectable', 'true') - - // listbox should has 3 groups and each have heading - const groups = screen.getAllByRole('group') - expect(groups).toHaveLength(3) - expect(groups[0]).toHaveAttribute('aria-label', 'Group title 1') - expect(groups[1]).toHaveAttribute('aria-label', 'Group title 2') - expect(groups[2]).toHaveAttribute('aria-label', 'Group title 3') - - expect(screen.getAllByRole('option')).toHaveLength(4) - }) - it('should select items within groups', async () => { - const user = userEvent.setup() + renderWithFlag(, usingRemoveActiveDescendant) - render() + await user.click(screen.getByText('Select items')) - await user.click(screen.getByText('Select items')) + expect(screen.getByTestId('filtered-action-list-spinner')).toBeTruthy() + }) - // Select the first item - await user.click(screen.getByRole('option', {name: 'Item 1'})) - expect( - screen.getByRole('option', { - name: 'Item 1', - }), - ).toHaveAttribute('aria-selected', 'true') + it('displays a loading skeleton on first open', async () => { + const user = userEvent.setup() - await user.click(screen.getByRole('option', {name: 'Item 3'})) - expect( - screen.getByRole('option', { - name: 'Item 3', - }), - ).toHaveAttribute('aria-selected', 'true') + renderWithFlag(, usingRemoveActiveDescendant) - await user.click(screen.getByRole('option', {name: 'Item 4'})) - expect( - screen.getByRole('option', { - name: 'Item 4', - }), - ).toHaveAttribute('aria-selected', 'true') - }) - }) + await user.click(screen.getByText('Select items')) - describe('As Modal', () => { - it('selections render as radios when variant modal and single select', async () => { - const user = userEvent.setup() + expect(screen.getByTestId('filtered-action-list-skeleton')).toBeTruthy() + }) - render( {}} selected={undefined} />) + it('displays a loading spinner in the text input if items are already loaded', async () => { + const user = userEvent.setup() - await user.click(screen.getByText('Select items')) + renderWithFlag(, usingRemoveActiveDescendant) - expect(screen.getAllByRole('radio').length).toBe(items.length) + await user.click(screen.getByText('Select items')) - expect(screen.getByRole('button', {name: 'Save'})).toBeVisible() - expect(screen.getByRole('button', {name: 'Cancel'})).toBeVisible() - }) - it('save and oncancel buttons are present when variant modal', async () => { - const user = userEvent.setup() + expect(screen.getAllByRole('option')).toHaveLength(3) - render( {}} />) + // since the test component never repopulates the panel's list of items, the panel will + // enter the loading state after the following line executes and stay there indefinitely + await user.type(document.activeElement!, 'two') - await user.click(screen.getByText('Select items')) + // The aria-describedby attribute is only available if the icon is present. The input + // field has a role of combobox. + expect(screen.getByRole('combobox').hasAttribute('aria-describedby')).toBeTruthy() + }) - expect(screen.getByRole('button', {name: 'Save'})).toBeVisible() - expect(screen.getByRole('button', {name: 'Cancel'})).toBeVisible() - }) - }) + it('should announce initially focused item', async () => { + jest.useFakeTimers() + const user = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + }) + renderWithFlag(, usingRemoveActiveDescendant) - describe('sorting', () => { - const items = [ - { - text: 'item one', - id: '3', - }, - { - text: 'item two', - id: '1', - selected: true, - }, - { - text: 'item three', - id: '2', - }, - ] + await user.click(screen.getByText('Select items')) + expect(screen.getByLabelText('Filter items')).toHaveFocus() + + jest.runAllTimers() + // we wait because announcement is intentionally updated after a timeout to not interrupt user input + await waitFor( + async () => { + if (usingRemoveActiveDescendant) { + expect(getLiveRegion().getMessage('polite')!.trim()).toEqual('3 items available, 0 selected.') + } else { + expect(getLiveRegion().getMessage('polite')!.trim()).toEqual( + 'List updated, Focused item: item one, not selected, 1 of 3', + ) + } + }, + {timeout: 3000}, + ) + jest.useRealTimers() + }) - it('should render selected items at the top by default when FF on', async () => { - const user = userEvent.setup() + it('should announce notice text', async () => { + jest.useFakeTimers() + const user = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + }) - render( - - - , - ) + function SelectPanelWithNotice() { + const [selected, setSelected] = React.useState([]) + const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) - await user.click(screen.getByText('item two')) // item two is selected so that's what the anchor text is + const onSelectedChange = (selected: SelectPanelProps['items']) => { + setSelected(selected) + } - const options = screen.getAllByRole('option') - expect(options[0]).toHaveTextContent('item two') // item two is selected - expect(options[1]).toHaveTextContent('item one') - expect(options[2]).toHaveTextContent('item three') - }) - it('should not render selected items at the top by default when FF off', async () => { - const user = userEvent.setup() + return ( + + { + setFilter(value) + }} + open={open} + onOpenChange={isOpen => { + setOpen(isOpen) + }} + notice={{ + text: 'This is a notice', + variant: 'warning', + }} + /> + + ) + } - render( - - - , - ) + renderWithFlag(, usingRemoveActiveDescendant) - await user.click(screen.getByText('item two')) // item two is selected so that's what the anchor text is + await user.click(screen.getByText('Select items')) + expect(screen.getByLabelText('Filter items')).toHaveFocus() - const options = screen.getAllByRole('option') - expect(options[0]).toHaveTextContent('item one') - expect(options[1]).toHaveTextContent('item two') // item two is selected - expect(options[2]).toHaveTextContent('item three') - }) - it('should not render selected items at the top when showSelectedOptionsFirst set to false', async () => { - const user = userEvent.setup() + expect(getLiveRegion().getMessage('polite')?.trim()).toContain('This is a notice') + }) - render() + it('should announce filtered results', async () => { + jest.useFakeTimers() + const user = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + }) + renderWithFlag(, usingRemoveActiveDescendant) - await user.click(screen.getByText('item two')) // item two is selected so that's what the anchor text is + await user.click(screen.getByText('Select items')) + expect(screen.getByLabelText('Filter items')).toHaveFocus() + + jest.runAllTimers() + await waitFor( + async () => { + if (usingRemoveActiveDescendant) { + expect(getLiveRegion().getMessage('polite')!.trim()).toEqual('3 items available, 0 selected.') + } else { + expect(getLiveRegion().getMessage('polite')!.trim()).toEqual( + 'List updated, Focused item: item one, not selected, 1 of 3', + ) + } + }, + {timeout: 3000}, // increased timeout because we don't want the test to compare with previous announcement + ) - const options = screen.getAllByRole('option') - expect(options[0]).toHaveTextContent('item one') - expect(options[1]).toHaveTextContent('item two') // item two is selected - expect(options[2]).toHaveTextContent('item three') - }) - }) + await user.type(document.activeElement!, 'o') + expect(screen.getAllByRole('option')).toHaveLength(2) + + jest.runAllTimers() + await waitFor( + async () => { + if (usingRemoveActiveDescendant) { + expect(getLiveRegion().getMessage('polite')).toBe('2 items available, 0 selected.') + } else { + expect(getLiveRegion().getMessage('polite')).toBe( + 'List updated, Focused item: item one, not selected, 1 of 2', + ) + } + }, + {timeout: 3000}, // increased timeout because we don't want the test to compare with previous announcement + ) - describe('disableFullscreenOnNarrow prop', () => { - const renderSelectPanelWithFlags = (flags: Record, props: Record = {}) => { - return render( - - - - - , - ) - } + await user.type(document.activeElement!, 'ne') // now: one + expect(screen.getAllByRole('option')).toHaveLength(1) + + jest.runAllTimers() + await waitFor(async () => { + if (usingRemoveActiveDescendant) { + expect(getLiveRegion().getMessage('polite')!.trim()).toBe('1 item available, 0 selected.') + } else { + expect(getLiveRegion().getMessage('polite')?.trim()).toBe( + 'List updated, Focused item: item one, not selected, 1 of 1', + ) + } + }) + jest.useRealTimers() + }) - // Create a single-select version to test ResponsiveCloseButton behavior - function SingleSelectPanel(passthroughProps: Record) { - const [filter, setFilter] = React.useState('') - const [open, setOpen] = React.useState(false) + it('should announce default empty message when no results are available (no custom message is provided)', async () => { + jest.useFakeTimers() + const user = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + }) + renderWithFlag(, usingRemoveActiveDescendant) - return ( - - {}} - filterValue={filter} - onFilterChange={value => { - setFilter(value) - }} - open={open} - onOpenChange={open => setOpen(open)} - {...passthroughProps} - /> - - ) - } + await user.click(screen.getByText('Select items')) - it('should opt out of fullscreen when disableFullscreenOnNarrow=true even when feature flag is enabled', async () => { - const user = userEvent.setup() + await user.type(document.activeElement!, 'zero') + expect(screen.queryByRole('option')).toBeNull() - renderSelectPanelWithFlags( - { - primer_react_select_panel_fullscreen_on_narrow: true, - }, - {disableFullscreenOnNarrow: true}, - ) + jest.runAllTimers() + await waitFor(async () => { + expect(getLiveRegion().getMessage('polite')).toBe('No items available. ') + }) + jest.useRealTimers() + }) - await user.click(screen.getByText('Select an item')) + it('should announce custom empty message when no results are available', async () => { + jest.useFakeTimers() + const user = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + }) + + function SelectPanelWithCustomEmptyMessage() { + const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) + + return ( + + { + setFilter(value) + }} + filterValue={filter} + selected={[]} + onSelectedChange={() => {}} + onOpenChange={isOpen => { + setOpen(isOpen) + }} + message={{ + title: 'Nothing found', + body: `There's nothing here.`, + variant: 'empty', + }} + /> + + ) + } - // When disableFullscreenOnNarrow=true, the ResponsiveCloseButton should not be present - // even when the feature flag is enabled, indicating no fullscreen behavior - const responsiveCloseButton = screen.queryByRole('button', {name: 'Cancel and close'}) - expect(responsiveCloseButton).not.toBeInTheDocument() - }) + renderWithFlag(, usingRemoveActiveDescendant) - it('should use fullscreen behavior when disableFullscreenOnNarrow=false and feature flag is enabled', async () => { - const user = userEvent.setup() + await user.click(screen.getByText('Select items')) - renderSelectPanelWithFlags( - { - primer_react_select_panel_fullscreen_on_narrow: true, - }, - {disableFullscreenOnNarrow: false}, - ) + await user.type(document.activeElement!, 'zero') + expect(screen.queryByRole('option')).toBeNull() + + jest.runAllTimers() + await waitFor(async () => { + expect(getLiveRegion().getMessage('polite')).toBe(`Nothing found. There's nothing here.`) + }) + jest.useRealTimers() + }) + + it('should accept a className to style the component', async () => { + const user = userEvent.setup() - await user.click(screen.getByText('Select an item')) + renderWithFlag(, usingRemoveActiveDescendant) - // When feature flag is true and disableFullscreenOnNarrow is false, the ResponsiveCloseButton should be present - // indicating fullscreen behavior is active - const responsiveCloseButton = screen.getByRole('button', {name: 'Cancel and close'}) - expect(responsiveCloseButton).toBeInTheDocument() + await user.click(screen.getByText('Select items')) + + expect(screen.getByTestId('filtered-action-list')).toHaveClass('test-class') + }) }) - it('should default to feature flag value when disableFullscreenOnNarrow is undefined', async () => { - const user = userEvent.setup() + describe('Empty state', () => { + it('should display the default empty state message when there is no matching item after filtering (No custom message is provided)', async () => { + const user = userEvent.setup() - // Test with feature flag disabled - renderSelectPanelWithFlags({ - primer_react_select_panel_fullscreen_on_narrow: false, + renderWithFlag(, usingRemoveActiveDescendant) + + await user.click(screen.getByText('Select items')) + + expect(screen.getAllByRole('option')).toHaveLength(3) + + await user.type(document.activeElement!, 'something') + expect(screen.getByText('No items available')).toBeVisible() }) - await user.click(screen.getByText('Select an item')) + it('should display the default empty state message when there is no item after the initial load (No custom message is provided)', async () => { + const user = userEvent.setup() + + renderWithFlag(, usingRemoveActiveDescendant) + + await waitFor(async () => { + await user.click(screen.getByText('Select items')) + expect(screen.getByText('No items available')).toBeVisible() + }) + }) + it('should display the custom empty state message when there is no matching item after filtering', async () => { + const user = userEvent.setup() + + renderWithFlag( + , + usingRemoveActiveDescendant, + ) + + await user.click(screen.getByText('Select items')) + + expect(screen.getAllByRole('option')).toHaveLength(3) + + await user.type(document.activeElement!, 'something') + expect(screen.getByText('No language found for something')).toBeVisible() + expect(screen.getByText('Adjust your search term to find other languages')).toBeVisible() + }) + + it('should display the custom empty state message when there is no item after the initial load', async () => { + const user = userEvent.setup() + + renderWithFlag(, usingRemoveActiveDescendant) + + await waitFor(async () => { + await user.click(screen.getByText('Select items')) + expect(screen.getByText("You haven't created any projects yet")).toBeVisible() + expect(screen.getByText('Start your first project to organise your issues')).toBeVisible() + }) + }) + + it('should display action button in custom empty state message', async () => { + const handleAction = jest.fn() + const user = userEvent.setup() + + renderWithFlag( + , + usingRemoveActiveDescendant, + ) + + await waitFor(async () => { + await user.click(screen.getByText('Select items')) + expect(screen.getByText("You haven't created any projects yet")).toBeVisible() + expect(screen.getByText('Start your first project to organise your issues')).toBeVisible() + + // Check that action button is visible + const actionButton = screen.getByTestId('create-project-action') + expect(actionButton).toBeVisible() + expect(actionButton).toHaveTextContent('Create new project') + }) - // When feature flag is false and disableFullscreenOnNarrow is undefined, - // the ResponsiveCloseButton should not be present - const responsiveCloseButton = screen.queryByRole('button', {name: 'Cancel and close'}) - expect(responsiveCloseButton).not.toBeInTheDocument() + // Test that action button is clickable + const actionButton = screen.getByTestId('create-project-action') + await user.click(actionButton) + expect(handleAction).toHaveBeenCalledTimes(1) + }) }) - }) - describe('Select all', () => { - function SelectAllSelectPanel({showSelectAll = true}: {showSelectAll?: boolean} = {}) { - const [selected, setSelected] = React.useState([]) - const [filter, setFilter] = React.useState('') + describe('with footer', () => { + function SelectPanelWithFooter() { + const [selected, setSelected] = React.useState([]) + const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) + + const onSelectedChange = (selected: SelectPanelProps['items']) => { + setSelected(selected) + } + + return ( + + test footer} + items={items} + placeholder="Select items" + placeholderText="Filter items" + selected={selected} + onSelectedChange={onSelectedChange} + filterValue={filter} + onFilterChange={value => { + setFilter(value) + }} + open={open} + onOpenChange={isOpen => { + setOpen(isOpen) + }} + /> + + ) + } + + it('should render the provided `footer` at the bottom of the dialog', async () => { + const user = userEvent.setup() + + renderWithFlag(, usingRemoveActiveDescendant) + + await user.click(screen.getByText('Select items')) + expect(screen.getByText('test footer')).toBeVisible() + }) + }) + + const listOfItems: Array = [ + { + id: '1', + key: 1, + text: 'Item 1', + groupId: '1', + }, + { + id: '2', + key: 2, + text: 'Item 2', + groupId: '1', + }, + { + id: '3', + key: 3, + text: 'Item 3', + groupId: '2', + }, + { + id: '4', + key: 4, + text: 'Item 4', + groupId: '3', + }, + ] + + const groupMetadata: GroupedListProps['groupMetadata'] = [ + {groupId: '1', header: {title: 'Group title 1'}}, + {groupId: '2', header: {title: 'Group title 2'}}, + {groupId: '3', header: {title: 'Group title 3'}}, + ] + + function SelectPanelWithGroups() { + const [selectedItems, setSelectedItems] = React.useState([]) const [open, setOpen] = React.useState(false) + const [filter, setFilter] = React.useState('') - const onSelectedChange = (selected: SelectPanelProps['items']) => { - setSelected(selected) + const onSelectedChange = (selections: ItemInput[]) => { + setSelectedItems(selections) } return ( @@ -1272,137 +1087,266 @@ describe('SelectPanel', () => { { - setFilter(value) - }} open={open} onOpenChange={isOpen => { setOpen(isOpen) }} - showSelectAll={showSelectAll} + filterValue={filter} + onFilterChange={value => { + setFilter(value) + }} /> ) } - it('should render a Select All checkbox when showSelectAll is true', async () => { - const user = userEvent.setup() + describe('with groups', () => { + it('should render groups with items', async () => { + const user = userEvent.setup() - render() + renderWithFlag(, usingRemoveActiveDescendant) - await user.click(screen.getByText('Select items')) + await user.click(screen.getByText('Select items')) + const listbox = screen.getByRole('listbox') + expect(listbox).toBeVisible() + expect(listbox).toHaveAttribute('aria-multiselectable', 'true') + + // listbox should has 3 groups and each have heading + const groups = screen.getAllByRole('group') + expect(groups).toHaveLength(3) + expect(groups[0]).toHaveAttribute('aria-label', 'Group title 1') + expect(groups[1]).toHaveAttribute('aria-label', 'Group title 2') + expect(groups[2]).toHaveAttribute('aria-label', 'Group title 3') + + expect(screen.getAllByRole('option')).toHaveLength(4) + }) + it('should select items within groups', async () => { + const user = userEvent.setup() - expect(screen.getByText('Select all')).toBeInTheDocument() - expect(screen.getByRole('checkbox', {name: 'Select all'})).toBeInTheDocument() - expect(screen.getByRole('checkbox', {name: 'Select all'})).not.toBeChecked() - }) + renderWithFlag(, usingRemoveActiveDescendant) - it('should not render a Select All checkbox when showSelectAll is false', async () => { - const user = userEvent.setup() + await user.click(screen.getByText('Select items')) - render() + // Select the first item + await user.click(screen.getByRole('option', {name: 'Item 1'})) + expect( + screen.getByRole('option', { + name: 'Item 1', + }), + ).toHaveAttribute('aria-selected', 'true') + + await user.click(screen.getByRole('option', {name: 'Item 3'})) + expect( + screen.getByRole('option', { + name: 'Item 3', + }), + ).toHaveAttribute('aria-selected', 'true') + + await user.click(screen.getByRole('option', {name: 'Item 4'})) + expect( + screen.getByRole('option', { + name: 'Item 4', + }), + ).toHaveAttribute('aria-selected', 'true') + }) + }) - await user.click(screen.getByText('Select items')) + describe('As Modal', () => { + it('selections render as radios when variant modal and single select', async () => { + const user = userEvent.setup() - expect(screen.queryByText('Select all')).not.toBeInTheDocument() - expect(screen.queryByRole('checkbox', {name: 'Select all'})).not.toBeInTheDocument() - }) + renderWithFlag( + {}} selected={undefined} />, + usingRemoveActiveDescendant, + ) - it('should select all items when the Select All checkbox is clicked', async () => { - const user = userEvent.setup() + await user.click(screen.getByText('Select items')) - render() + expect(screen.getAllByRole('radio', {hidden: true}).length).toBe(items.length) - await user.click(screen.getByText('Select items')) + expect(screen.getByRole('button', {name: 'Save'})).toBeVisible() + expect(screen.getByRole('button', {name: 'Cancel'})).toBeVisible() + }) + it('save and oncancel buttons are present when variant modal', async () => { + const user = userEvent.setup() - await user.click(screen.getByRole('checkbox', {name: 'Select all'})) + renderWithFlag( {}} />, usingRemoveActiveDescendant) - // All options should now be selected - for (const item of items) { - expect(screen.getByRole('option', {name: item.text})).toHaveAttribute('aria-selected', 'true') - } + await user.click(screen.getByText('Select items')) + + expect(screen.getByRole('button', {name: 'Save'})).toBeVisible() + expect(screen.getByRole('button', {name: 'Cancel'})).toBeVisible() + }) }) - it('should deselect all items when the Deselect All checkbox is clicked', async () => { - const user = userEvent.setup() + describe('sorting', () => { + const items = [ + { + text: 'item one', + id: '3', + }, + { + text: 'item two', + id: '1', + selected: true, + }, + { + text: 'item three', + id: '2', + }, + ] - render() + it('should render selected items at the top by default when FF on', async () => { + const user = userEvent.setup() - await user.click(screen.getByText('Select items')) + renderWithFlag( + + + , + usingRemoveActiveDescendant, + ) - // First select all - await user.click(screen.getByRole('checkbox', {name: 'Select all'})) + await user.click(screen.getByText('item two')) // item two is selected so that's what the anchor text is - // Then deselect all - await user.click(screen.getByRole('checkbox', {name: 'Deselect all'})) + const options = screen.getAllByRole('option') + expect(options[0]).toHaveTextContent('item two') // item two is selected + expect(options[1]).toHaveTextContent('item one') + expect(options[2]).toHaveTextContent('item three') + }) + it('should not render selected items at the top by default when FF off', async () => { + const user = userEvent.setup() + + renderWithFlag( + + + , + usingRemoveActiveDescendant, + ) - // All options should now be deselected - for (const item of items) { - if (item.text) { - expect(screen.getByRole('option', {name: item.text})).toHaveAttribute('aria-selected', 'false') - } - } + await user.click(screen.getByText('item two')) // item two is selected so that's what the anchor text is + + const options = screen.getAllByRole('option') + expect(options[0]).toHaveTextContent('item one') + expect(options[1]).toHaveTextContent('item two') // item two is selected + expect(options[2]).toHaveTextContent('item three') + }) + it('should not render selected items at the top when showSelectedOptionsFirst set to false', async () => { + const user = userEvent.setup() + + renderWithFlag( + , + usingRemoveActiveDescendant, + ) + + await user.click(screen.getByText('item two')) // item two is selected so that's what the anchor text is + + const options = screen.getAllByRole('option') + expect(options[0]).toHaveTextContent('item one') + expect(options[1]).toHaveTextContent('item two') // item two is selected + expect(options[2]).toHaveTextContent('item three') + }) }) - it('should update Select All checkbox to indeterminate state when some items (but not all) are selected', async () => { - const user = userEvent.setup() + describe('disableFullscreenOnNarrow prop', () => { + const renderSelectPanelWithFlags = (flags: Record, props: Record = {}) => { + return renderWithFlag( + + + + + , + usingRemoveActiveDescendant, + ) + } - render() + // Create a single-select version to test ResponsiveCloseButton behavior + function SingleSelectPanel(passthroughProps: Record) { + const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) - await user.click(screen.getByText('Select items')) + return ( + + {}} + filterValue={filter} + onFilterChange={value => { + setFilter(value) + }} + open={open} + onOpenChange={open => setOpen(open)} + {...passthroughProps} + /> + + ) + } - // Select only one item - await user.click(screen.getByText('item one')) + it('should opt out of fullscreen when disableFullscreenOnNarrow=true even when feature flag is enabled', async () => { + const user = userEvent.setup() - // Check that Select All is in indeterminate state - const selectAllCheckbox = screen.getByRole('checkbox', {name: 'Select all'}) - expect(selectAllCheckbox).not.toBeChecked() - expect(selectAllCheckbox).toHaveProperty('indeterminate', true) - }) + renderSelectPanelWithFlags( + { + primer_react_select_panel_fullscreen_on_narrow: true, + }, + {disableFullscreenOnNarrow: true}, + ) - it('should update Select All checkbox to checked when all items are selected manually', async () => { - const user = userEvent.setup() + await user.click(screen.getByText('Select an item')) - render() + // When disableFullscreenOnNarrow=true, the ResponsiveCloseButton should not be present + // even when the feature flag is enabled, indicating no fullscreen behavior + const responsiveCloseButton = screen.queryByRole('button', {name: 'Cancel and close'}) + expect(responsiveCloseButton).not.toBeInTheDocument() + }) - await user.click(screen.getByText('Select items')) + it('should use fullscreen behavior when disableFullscreenOnNarrow=false and feature flag is enabled', async () => { + const user = userEvent.setup() - // Select all items individually - for (const item of items) { - if (item.text) { - await user.click(screen.getByText(item.text)) - } - } + renderSelectPanelWithFlags( + { + primer_react_select_panel_fullscreen_on_narrow: true, + }, + {disableFullscreenOnNarrow: false}, + ) - // Check that Deselect All is checked - expect(screen.getByRole('checkbox', {name: 'Deselect all'})).toBeChecked() - }) + await user.click(screen.getByText('Select an item')) - it('should update Select All checkbox label to "Deselect all" when all items are selected', async () => { - const user = userEvent.setup() + // When feature flag is true and disableFullscreenOnNarrow is false, the ResponsiveCloseButton should be present + // indicating fullscreen behavior is active + const responsiveCloseButton = screen.getByRole('button', {name: 'Cancel and close'}) + expect(responsiveCloseButton).toBeInTheDocument() + }) - render() + it('should default to feature flag value when disableFullscreenOnNarrow is undefined', async () => { + const user = userEvent.setup() - await user.click(screen.getByText('Select items')) + // Test with feature flag disabled + renderSelectPanelWithFlags({ + primer_react_select_panel_fullscreen_on_narrow: false, + }) - // Select all items - await user.click(screen.getByRole('checkbox', {name: 'Select all'})) + await user.click(screen.getByText('Select an item')) - // Check that the label has changed to "Deselect all" - expect(screen.getByText('Deselect all')).toBeInTheDocument() - expect(screen.getByRole('checkbox', {name: 'Deselect all'})).toBeInTheDocument() + // When feature flag is false and disableFullscreenOnNarrow is undefined, + // the ResponsiveCloseButton should not be present + const responsiveCloseButton = screen.queryByRole('button', {name: 'Cancel and close'}) + expect(responsiveCloseButton).not.toBeInTheDocument() + }) }) - it('should apply Select All only to filtered items and maintain selection state when filters are cleared', async () => { - const user = userEvent.setup() - - function FilterableSelectAllPanel() { + describe('Select all', () => { + function SelectAllSelectPanel({showSelectAll = true}: {showSelectAll?: boolean} = {}) { const [selected, setSelected] = React.useState([]) const [filter, setFilter] = React.useState('') const [open, setOpen] = React.useState(false) @@ -1416,7 +1360,7 @@ describe('SelectPanel', () => { item.text?.includes(filter))} + items={items} placeholder="Select items" placeholderText="Filter items" selected={selected} @@ -1429,42 +1373,187 @@ describe('SelectPanel', () => { onOpenChange={isOpen => { setOpen(isOpen) }} - showSelectAll={true} + showSelectAll={showSelectAll} /> ) } - render() + it('should render a Select All checkbox when showSelectAll is true', async () => { + const user = userEvent.setup() - await user.click(screen.getByText('Select items')) + renderWithFlag(, usingRemoveActiveDescendant) - // Filter to only show "item one" - await user.type(screen.getByLabelText('Filter items'), 'one') + await user.click(screen.getByText('Select items')) - // Only "item one" should be visible - expect(screen.getAllByRole('option')).toHaveLength(1) - expect(screen.getByText('item one')).toBeInTheDocument() + expect(screen.getByText('Select all')).toBeInTheDocument() + expect(screen.getByRole('checkbox', {name: 'Select all'})).toBeInTheDocument() + expect(screen.getByRole('checkbox', {name: 'Select all'})).not.toBeChecked() + }) - // Select all (which is just the one visible item) - await user.click(screen.getByRole('checkbox', {name: 'Select all'})) + it('should not render a Select All checkbox when showSelectAll is false', async () => { + const user = userEvent.setup() - // The visible item should be selected - expect(screen.getByRole('option', {name: 'item one'})).toHaveAttribute('aria-selected', 'true') + renderWithFlag(, usingRemoveActiveDescendant) - // Clear the filter - await user.clear(screen.getByLabelText('Filter items')) + await user.click(screen.getByText('Select items')) + + expect(screen.queryByText('Select all')).not.toBeInTheDocument() + expect(screen.queryByRole('checkbox', {name: 'Select all'})).not.toBeInTheDocument() + }) + + it('should select all items when the Select All checkbox is clicked', async () => { + const user = userEvent.setup() + + renderWithFlag(, usingRemoveActiveDescendant) + + await user.click(screen.getByText('Select items')) + + await user.click(screen.getByRole('checkbox', {name: 'Select all'})) + + // All options should now be selected + for (const item of items) { + expect(screen.getByRole('option', {name: item.text})).toHaveAttribute('aria-selected', 'true') + } + }) + + it('should deselect all items when the Deselect All checkbox is clicked', async () => { + const user = userEvent.setup() + + renderWithFlag(, usingRemoveActiveDescendant) + + await user.click(screen.getByText('Select items')) + + // First select all + await user.click(screen.getByRole('checkbox', {name: 'Select all'})) - // Now all items should be visible, but only "item one" should be selected - expect(screen.getAllByRole('option')).toHaveLength(3) - expect(screen.getByRole('option', {name: 'item one'})).toHaveAttribute('aria-selected', 'true') - expect(screen.getByRole('option', {name: 'item two'})).toHaveAttribute('aria-selected', 'false') - expect(screen.getByRole('option', {name: 'item three'})).toHaveAttribute('aria-selected', 'false') + // Then deselect all + await user.click(screen.getByRole('checkbox', {name: 'Deselect all'})) + + // All options should now be deselected + for (const item of items) { + if (item.text) { + expect(screen.getByRole('option', {name: item.text})).toHaveAttribute('aria-selected', 'false') + } + } + }) + + it('should update Select All checkbox to indeterminate state when some items (but not all) are selected', async () => { + const user = userEvent.setup() + + renderWithFlag(, usingRemoveActiveDescendant) + + await user.click(screen.getByText('Select items')) + + // Select only one item + await user.click(screen.getByText('item one')) + + // Check that Select All is in indeterminate state + const selectAllCheckbox = screen.getByRole('checkbox', {name: 'Select all'}) + expect(selectAllCheckbox).not.toBeChecked() + expect(selectAllCheckbox).toHaveProperty('indeterminate', true) + }) + + it('should update Select All checkbox to checked when all items are selected manually', async () => { + const user = userEvent.setup() + + renderWithFlag(, usingRemoveActiveDescendant) + + await user.click(screen.getByText('Select items')) - // Select All checkbox should be in indeterminate state - const selectAllCheckbox = screen.getByRole('checkbox', {name: 'Select all'}) - expect(selectAllCheckbox).not.toBeChecked() - expect(selectAllCheckbox).toHaveProperty('indeterminate', true) + // Select all items individually + for (const item of items) { + if (item.text) { + await user.click(screen.getByText(item.text)) + } + } + + // Check that Deselect All is checked + expect(screen.getByRole('checkbox', {name: 'Deselect all'})).toBeChecked() + }) + + it('should update Select All checkbox label to "Deselect all" when all items are selected', async () => { + const user = userEvent.setup() + + renderWithFlag(, usingRemoveActiveDescendant) + + await user.click(screen.getByText('Select items')) + + // Select all items + await user.click(screen.getByRole('checkbox', {name: 'Select all'})) + + // Check that the label has changed to "Deselect all" + expect(screen.getByText('Deselect all')).toBeInTheDocument() + expect(screen.getByRole('checkbox', {name: 'Deselect all'})).toBeInTheDocument() + }) + + it('should apply Select All only to filtered items and maintain selection state when filters are cleared', async () => { + const user = userEvent.setup() + + function FilterableSelectAllPanel() { + const [selected, setSelected] = React.useState([]) + const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) + + const onSelectedChange = (selected: SelectPanelProps['items']) => { + setSelected(selected) + } + + return ( + + item.text?.includes(filter))} + placeholder="Select items" + placeholderText="Filter items" + selected={selected} + onSelectedChange={onSelectedChange} + filterValue={filter} + onFilterChange={value => { + setFilter(value) + }} + open={open} + onOpenChange={isOpen => { + setOpen(isOpen) + }} + showSelectAll={true} + /> + + ) + } + + renderWithFlag(, usingRemoveActiveDescendant) + + await user.click(screen.getByText('Select items')) + + // Filter to only show "item one" + await user.type(screen.getByLabelText('Filter items'), 'one') + + // Only "item one" should be visible + expect(screen.getAllByRole('option')).toHaveLength(1) + expect(screen.getByText('item one')).toBeInTheDocument() + + // Select all (which is just the one visible item) + await user.click(screen.getByRole('checkbox', {name: 'Select all'})) + + // The visible item should be selected + expect(screen.getByRole('option', {name: 'item one'})).toHaveAttribute('aria-selected', 'true') + + // Clear the filter + await user.clear(screen.getByLabelText('Filter items')) + + // Now all items should be visible, but only "item one" should be selected + expect(screen.getAllByRole('option')).toHaveLength(3) + expect(screen.getByRole('option', {name: 'item one'})).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('option', {name: 'item two'})).toHaveAttribute('aria-selected', 'false') + expect(screen.getByRole('option', {name: 'item three'})).toHaveAttribute('aria-selected', 'false') + + // Select All checkbox should be in indeterminate state + const selectAllCheckbox = screen.getByRole('checkbox', {name: 'Select all'}) + expect(selectAllCheckbox).not.toBeChecked() + expect(selectAllCheckbox).toHaveProperty('indeterminate', true) + }) }) }) -}) +} diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index ce298ea11a8..0e17c5c5958 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -558,6 +558,7 @@ function Panel({ return { ...item, role: 'option', + id: item.id, selected: 'selected' in item && item.selected === undefined ? undefined : isItemCurrentlySelected(item), onAction: (itemFromAction, event) => { item.onAction?.(itemFromAction, event) @@ -864,8 +865,6 @@ function Panel({ // than the Overlay (which would break scrolling the items) sx={sx} className={clsx(className, classes.FilteredActionList)} - // needed to explicitly enable announcements for deprecated FilteredActionList, we can remove when we fully remove the deprecated version - announcementsEnabled /> {footer ? (
{footer}