From bc49f6eefbef86093d68b49f6537eb5d6f48dadb Mon Sep 17 00:00:00 2001 From: hectahertz Date: Tue, 7 Apr 2026 17:01:47 +0200 Subject: [PATCH 1/4] Refactor SelectPanel behind feature flag --- .../src/FeatureFlags/DefaultFeatureFlags.ts | 1 + .../SelectPanel.features.stories.tsx | 37 + .../src/SelectPanel/SelectPanel.shared.tsx | 191 ++ .../src/SelectPanel/SelectPanel.test.tsx | 2687 +++++++++-------- .../react/src/SelectPanel/SelectPanel.tsx | 1066 +------ .../src/SelectPanel/SelectPanelLegacy.tsx | 820 +++++ .../src/SelectPanel/SelectPanelNext.hooks.ts | 229 ++ .../SelectPanelNext.multi.utils.ts | 105 + .../SelectPanelNext.shared.utils.ts | 96 + .../SelectPanelNext.single.state.ts | 29 + .../SelectPanelNext.single.utils.ts | 65 + .../src/SelectPanel/SelectPanelNext.state.ts | 68 + .../src/SelectPanel/SelectPanelNext.test.tsx | 175 ++ .../react/src/SelectPanel/SelectPanelNext.tsx | 523 ++++ .../src/SelectPanel/SelectPanelNext.utils.ts | 10 + .../src/SelectPanel/SelectPanelNextFooter.tsx | 77 + .../src/SelectPanel/SelectPanelNextFrame.tsx | 230 ++ .../src/SelectPanel/SelectPanelNextMulti.ts | 68 + .../src/SelectPanel/SelectPanelNextSingle.ts | 70 + 19 files changed, 4180 insertions(+), 2367 deletions(-) create mode 100644 packages/react/src/SelectPanel/SelectPanel.shared.tsx create mode 100644 packages/react/src/SelectPanel/SelectPanelLegacy.tsx create mode 100644 packages/react/src/SelectPanel/SelectPanelNext.hooks.ts create mode 100644 packages/react/src/SelectPanel/SelectPanelNext.multi.utils.ts create mode 100644 packages/react/src/SelectPanel/SelectPanelNext.shared.utils.ts create mode 100644 packages/react/src/SelectPanel/SelectPanelNext.single.state.ts create mode 100644 packages/react/src/SelectPanel/SelectPanelNext.single.utils.ts create mode 100644 packages/react/src/SelectPanel/SelectPanelNext.state.ts create mode 100644 packages/react/src/SelectPanel/SelectPanelNext.test.tsx create mode 100644 packages/react/src/SelectPanel/SelectPanelNext.tsx create mode 100644 packages/react/src/SelectPanel/SelectPanelNext.utils.ts create mode 100644 packages/react/src/SelectPanel/SelectPanelNextFooter.tsx create mode 100644 packages/react/src/SelectPanel/SelectPanelNextFrame.tsx create mode 100644 packages/react/src/SelectPanel/SelectPanelNextMulti.ts create mode 100644 packages/react/src/SelectPanel/SelectPanelNextSingle.ts diff --git a/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts index a52924d7dc3..ea47302a4ce 100644 --- a/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts +++ b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts @@ -3,6 +3,7 @@ import {FeatureFlagScope} from './FeatureFlagScope' export const DefaultFeatureFlags = FeatureFlagScope.create({ primer_react_breadcrumbs_overflow_menu: false, primer_react_css_anchor_positioning: false, + primer_react_select_panel_next: 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/SelectPanel/SelectPanel.features.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx index 0fa22f1d435..5bdcc4cea97 100644 --- a/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.features.stories.tsx @@ -1,6 +1,7 @@ import React, {useState, useRef, useEffect} from 'react' import type {Meta, StoryObj} from '@storybook/react-vite' import {Button} from '../Button' +import {FeatureFlags} from '../FeatureFlags' import type {ItemInput, GroupedListProps} from '.' import Link from '../Link' import {SelectPanel, type SelectPanelProps} from './SelectPanel' @@ -333,6 +334,42 @@ export const WithSecondaryActionLink = () => { ) } +export const NextArchitecturePreview = () => { + const [selected, setSelected] = useState(items.slice(1, 3)) + const [filter, setFilter] = useState('') + const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) + const [open, setOpen] = useState(false) + + return ( + + + Labels + ( + + )} + open={open} + onOpenChange={setOpen} + items={filteredItems} + selected={selected} + onSelectedChange={setSelected} + onFilterChange={setFilter} + width="medium" + onCancel={() => setOpen(false)} + secondaryAction={Edit labels} + notice={{text: 'This story is rendered with primer_react_select_panel_next enabled.', variant: 'info'}} + message={filteredItems.length === 0 ? NoResultsMessage(filter) : undefined} + /> + + + ) +} + export const WithNotice = () => { const [selected, setSelected] = useState(items.slice(1, 3)) const [filter, setFilter] = useState('') diff --git a/packages/react/src/SelectPanel/SelectPanel.shared.tsx b/packages/react/src/SelectPanel/SelectPanel.shared.tsx new file mode 100644 index 00000000000..da1b3fedefe --- /dev/null +++ b/packages/react/src/SelectPanel/SelectPanel.shared.tsx @@ -0,0 +1,191 @@ +import {TriangleDownIcon, type IconProps} from '@primer/octicons-react' +import {announce} from '@primer/live-region-element' +import type React from 'react' + +import type {AnchoredOverlayProps} from '../AnchoredOverlay' +import type {AnchoredOverlayWrapperAnchorProps} from '../AnchoredOverlay/AnchoredOverlay' +import {Button, LinkButton} from '../Button' +import type {ButtonProps, LinkButtonProps} from '../Button/types' +import type {FilteredActionListProps, ItemInput} from '../FilteredActionList' +import type {FocusZoneHookSettings} from '../hooks/useFocusZone' +import type {OverlayProps} from '../Overlay' +import {SelectPanelMessage} from './SelectPanelMessage' + +export const SHORT_DELAY_MS = 500 +export const LONG_DELAY_MS = 1000 + +const EMPTY_MESSAGE = { + title: 'No items available', + description: '', +} + +export const DefaultEmptyMessage = ( + + {EMPTY_MESSAGE.description} + +) + +async function announceText(text: string, delayMs = SHORT_DELAY_MS) { + const liveRegion = document.querySelector('live-region') + + liveRegion?.clear() + + await announce(text, { + delayMs, + from: liveRegion || undefined, + }) +} + +export async function announceLoading() { + await announceText('Loading.') +} + +export interface SelectPanelSingleSelection { + selected: ItemInput | undefined + onSelectedChange: (selected: ItemInput | undefined) => void +} + +export interface SelectPanelMultiSelection { + selected: ItemInput[] + onSelectedChange: (selected: ItemInput[]) => void +} + +export type InitialLoadingType = 'spinner' | 'skeleton' + +export function SecondaryActionButton(props: ButtonProps) { + return + ) +} + +export const SELECT_PANEL_SLOT = Symbol('SelectPanel') diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index de52939b1ac..79d12884f70 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -1,4 +1,4 @@ -import {render, screen, waitFor} from '@testing-library/react' +import {render as baseRender, screen, waitFor} from '@testing-library/react' import {describe, expect, it, beforeEach, vi} from 'vitest' import React from 'react' import {SelectPanel, type SelectPanelProps, type ItemInput, type GroupedListProps} from '../SelectPanel' @@ -43,11 +43,10 @@ export function getLiveRegion(): LiveRegionElement { throw new Error('No live-region found') } -const renderWithProp = (element: React.ReactElement, flag?: boolean) => { - // true = 'use roving tabindex' - // false = 'use aria-activedescendant' - const focusManagement = flag ? 'roving-tabindex' : 'active-descendant' - return render(React.cloneElement(element, {_PrivateFocusManagement: focusManagement})) +const renderWithSelectPanelFlag = (element: React.ReactElement, selectPanelNextEnabled: boolean) => { + return baseRender( + {element}, + ) } const items: SelectPanelProps['items'] = [ @@ -95,1406 +94,1279 @@ function BasicSelectPanel(passthroughProps: Record) { globalThis.Element.prototype.scrollTo = vi.fn() -for (const usingRemoveActiveDescendant of [false, true]) { - describe('SelectPanel', () => { - implementsClassName(props => , classes.FilteredActionList) - it('should render an anchor to open the select panel using `placeholder`', () => { - renderWithProp() - - 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 call onActiveDescendantChanged when using keyboard while focusing on an item', async () => { - const user = userEvent.setup() - // jest function - const onActiveDescendantChanged = vi.fn() - - render() - - await user.click(screen.getByText('Select items')) - - await user.type(document.activeElement!, '{ArrowDown}') - expect(onActiveDescendantChanged).toHaveBeenCalled() - }) - - it('should open the select panel when activating the trigger', async () => { - const user = userEvent.setup() +for (const selectPanelNextEnabled of [false, true]) { + const render = (element: React.ReactElement) => renderWithSelectPanelFlag(element, selectPanelNextEnabled) + const renderWithProp = (element: React.ReactElement, flag?: boolean) => { + const focusManagement = flag ? 'roving-tabindex' : 'active-descendant' + return render(React.cloneElement(element, {_PrivateFocusManagement: focusManagement})) + } - renderWithProp(, usingRemoveActiveDescendant) + for (const usingRemoveActiveDescendant of [false, true]) { + describe(`SelectPanel (primer_react_select_panel_next=${selectPanelNextEnabled})`, () => { + implementsClassName( + props => ( + + + + ), + classes.FilteredActionList, + ) + it('should render an anchor to open the select panel using `placeholder`', () => { + renderWithProp() - await user.click(screen.getByText('Select items')) + expect(screen.getByText('Select items')).toBeInTheDocument() - // 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 in the document - expect(screen.getByPlaceholderText('Filter items')).toBeInTheDocument() - expect(screen.getByRole('listbox')).toBeInTheDocument() - - // The input box must have focus - expect(document.activeElement?.tagName.toLowerCase()).toBe('input') - }) - - it('should close the select panel when pressing Escape', async () => { - const user = userEvent.setup() + it('should call onActiveDescendantChanged when using keyboard while focusing on an item', async () => { + const user = userEvent.setup() + // jest function + const onActiveDescendantChanged = vi.fn() - renderWithProp(, usingRemoveActiveDescendant) + render() - await user.click(screen.getByText('Select items')) - await user.keyboard('{Escape}') + await user.click(screen.getByText('Select items')) - expect(screen.getByRole('button', {name: 'Select items'})).toHaveFocus() - expect(screen.getByRole('button', {name: 'Select items'})).toHaveAttribute('aria-expanded', 'false') - }) + await user.type(document.activeElement!, '{ArrowDown}') + expect(onActiveDescendantChanged).toHaveBeenCalled() + }) - it('should close the select panel when clicking outside of the select panel', async () => { - const user = userEvent.setup() + it('should open the select panel when activating the trigger', async () => { + const user = userEvent.setup() - renderWithProp( - <> - - - , - usingRemoveActiveDescendant, - ) + renderWithProp(, usingRemoveActiveDescendant) - await user.click(screen.getByText('Select items')) - await user.click(screen.getByText('outer button')) + await user.click(screen.getByText('Select items')) - expect(screen.getByRole('button', {name: 'Select items'})).toHaveAttribute('aria-expanded', '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') - it('should open a dialog that is labelled by `title` and described by `subtitle`', async () => { - const user = userEvent.setup() + // Verify that the input and listbox are in the document + expect(screen.getByPlaceholderText('Filter items')).toBeInTheDocument() + expect(screen.getByRole('listbox')).toBeInTheDocument() - renderWithProp(, usingRemoveActiveDescendant) + // The input box must have focus + expect(document.activeElement?.tagName.toLowerCase()).toBe('input') + }) - await user.click(screen.getByText('Select items')) + it('should close the select panel when pressing Escape', async () => { + const user = userEvent.setup() - expect( - screen.getByRole('dialog', { - name: 'test title', - description: 'test subtitle', - }), - ).toBeInTheDocument() - }) + renderWithProp(, usingRemoveActiveDescendant) - it('should call `onOpenChange` when opening and closing the dialog', async () => { - const onOpenChange = vi.fn() + await user.click(screen.getByText('Select items')) + await user.keyboard('{Escape}') - function SelectPanelOpenChange() { - const [selected, setSelected] = React.useState([]) - const [filter, setFilter] = React.useState('') - const [open, setOpen] = React.useState(false) + expect(screen.getByRole('button', {name: 'Select items'})).toHaveFocus() + expect(screen.getByRole('button', {name: 'Select items'})).toHaveAttribute('aria-expanded', 'false') + }) - const onSelectedChange = (selected: SelectPanelProps['items']) => { - setSelected(selected) - } + it('should close the select panel when clicking outside of the select panel', async () => { + const user = userEvent.setup() - return ( + renderWithProp( <> - - { - setFilter(value) - }} - open={open} - onOpenChange={(...args) => { - onOpenChange(...args) - setOpen(args[0]) - }} - /> - + + + , + usingRemoveActiveDescendant, ) - } - const user = userEvent.setup() - - renderWithProp(, usingRemoveActiveDescendant) - - // 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') + await user.click(screen.getByText('Select items')) + await user.click(screen.getByText('outer button')) - // Close by Escape key - await user.keyboard('{Escape}') - expect(onOpenChange).toHaveBeenLastCalledWith(false, 'escape') + expect(screen.getByRole('button', {name: 'Select items'})).toHaveAttribute('aria-expanded', 'false') + }) - // 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 open a dialog that is labelled by `title` and described by `subtitle`', async () => { + const user = userEvent.setup() - it('should label the list by title unless a aria-label is explicitly passed', async () => { - const user = userEvent.setup() + renderWithProp(, usingRemoveActiveDescendant) - renderWithProp(, usingRemoveActiveDescendant) - await user.click(screen.getByText('Select items')) - expect(screen.getByRole('listbox', {name: 'test title'})).toBeInTheDocument() - }) + await user.click(screen.getByText('Select items')) - it('should label the list by aria-label when explicitly passed', async () => { - const user = userEvent.setup() + expect( + screen.getByRole('dialog', { + name: 'test title', + description: 'test subtitle', + }), + ).toBeInTheDocument() + }) - renderWithProp(, usingRemoveActiveDescendant) - await user.click(screen.getByText('Select items')) - expect(screen.getByRole('listbox', {name: 'Custom label'})).toBeInTheDocument() - }) + it('should call `onOpenChange` when opening and closing the dialog', async () => { + const onOpenChange = vi.fn() - it('should focus the filter input on open', async () => { - const user = userEvent.setup() + function SelectPanelOpenChange() { + const [selected, setSelected] = React.useState([]) + const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) - // This panel contains another focusable thing (the IconButton) that should not receive focus - // when the panel opens. - renderWithProp( - {}} - onFilterChange={() => {}} - onSelectedChange={() => {}} - open={true} - items={items} - selected={[]} - placeholder="Select items" - placeholderText="Filter items" - title={ -
- - Title -
+ const onSelectedChange = (selected: SelectPanelProps['items']) => { + setSelected(selected) } - />, - usingRemoveActiveDescendant, - ) - await user.click(screen.getByText('Select items')) - expect(screen.getByLabelText('Filter items')).toHaveFocus() - }) + return ( + <> + + { + setFilter(value) + }} + open={open} + onOpenChange={(...args) => { + onOpenChange(...args) + setOpen(args[0]) + }} + /> + + ) + } - describe('selection', () => { - it('should select an active option when activated', async () => { const user = userEvent.setup() - renderWithProp(, usingRemoveActiveDescendant) + renderWithProp(, usingRemoveActiveDescendant) + // Open by click await user.click(screen.getByText('Select items')) + expect(onOpenChange).toHaveBeenLastCalledWith(true, 'anchor-click') - await user.type(document.activeElement!, '{Enter}') - expect( - screen.getByRole('option', { - name: 'item one', - }), - ).toHaveAttribute('aria-selected', 'true') + // Close by click on anchor + await user.click(screen.getByText('Select items')) + expect(onOpenChange).toHaveBeenLastCalledWith(false, 'anchor-click') - await user.type(document.activeElement!, '{Enter}') - expect( - screen.getByRole('option', { - name: 'item one', - }), - ).toHaveAttribute('aria-selected', 'false') + // Open by button activation + await user.type(screen.getByText('Select items'), '{Space}') + expect(onOpenChange).toHaveBeenLastCalledWith(true, 'anchor-click') - await user.click(screen.getByText('item one')) - expect( - screen.getByRole('option', { - name: 'item one', - }), - ).toHaveAttribute('aria-selected', 'true') + // Close by Escape key + await user.keyboard('{Escape}') + expect(onOpenChange).toHaveBeenLastCalledWith(false, 'escape') - await user.click(screen.getByRole('option', {name: 'item one'})) - expect( - screen.getByRole('option', { - name: 'item one', - }), - ).toHaveAttribute('aria-selected', 'false') + // 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 support navigating through items with ArrowUp and ArrowDown', async () => { + it('should label the list by title unless a aria-label is explicitly passed', async () => { const user = userEvent.setup() renderWithProp(, usingRemoveActiveDescendant) - await user.click(screen.getByText('Select items')) + expect(screen.getByRole('listbox', {name: 'test title'})).toBeInTheDocument() + }) - if (usingRemoveActiveDescendant) { - expect(document.activeElement!).toHaveAttribute('role', 'combobox') - - await user.keyboard('{ArrowDown}') - expect(document.activeElement!).toHaveAccessibleName('item one') - - await user.keyboard('{ArrowDown}') - expect(document.activeElement!).toHaveAccessibleName('item two') - - await user.keyboard('{ArrowDown}') - expect(document.activeElement!).toHaveAccessibleName('item three') - - // At end of list, should wrap to the beginning - await user.keyboard('{ArrowDown}') - expect(document.activeElement!).toHaveAccessibleName('item one') - - // At beginning of list, ArrowUp should wrap to the end - await user.keyboard('{ArrowUp}') - expect(document.activeElement!).toHaveAccessibleName('item three') - - await user.keyboard('{ArrowUp}') - expect(document.activeElement!).toHaveAccessibleName('item two') - - 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, - ) - - 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, - ) + it('should label the list by aria-label when explicitly passed', async () => { + const user = userEvent.setup() - // 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, - ) + renderWithProp(, usingRemoveActiveDescendant) + await user.click(screen.getByText('Select items')) + expect(screen.getByRole('listbox', {name: 'Custom label'})).toBeInTheDocument() + }) - // 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, - ) + it('should focus the filter input on open', async () => { + const user = userEvent.setup() - await user.type(document.activeElement!, '{ArrowUp}') - expect(document.activeElement!).toHaveAttribute( - 'aria-activedescendant', - screen.getByRole('option', {name: 'item two'}).id, - ) + // This panel contains another focusable thing (the IconButton) that should not receive focus + // when the panel opens. + renderWithProp( + {}} + onFilterChange={() => {}} + onSelectedChange={() => {}} + open={true} + items={items} + selected={[]} + placeholder="Select items" + placeholderText="Filter items" + title={ +
+ + Title +
+ } + />, + usingRemoveActiveDescendant, + ) - await user.type(document.activeElement!, '{ArrowUp}') - 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() }) - it('should support navigating through items with PageDown and PageUp', async () => { - const user = userEvent.setup() + describe('selection', () => { + it('should select an active option when activated', async () => { + const user = userEvent.setup() - renderWithProp(, usingRemoveActiveDescendant) + renderWithProp(, usingRemoveActiveDescendant) - await user.click(screen.getByText('Select items')) + await user.click(screen.getByText('Select items')) - if (usingRemoveActiveDescendant) { - await user.type(document.activeElement!, '{ArrowDown}') + 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') + }) - expect(document.activeElement!).toHaveAccessibleName('item one') + it('should support navigating through items with ArrowUp and ArrowDown', async () => { + const user = userEvent.setup() - await user.type(document.activeElement!, '{PageDown}') + renderWithProp(, usingRemoveActiveDescendant) - expect(document.activeElement!).toHaveAccessibleName('item three') + await user.click(screen.getByText('Select items')) - await user.type(document.activeElement!, '{PageUp}') + if (usingRemoveActiveDescendant) { + expect(document.activeElement!).toHaveAttribute('role', 'combobox') + + await user.keyboard('{ArrowDown}') + expect(document.activeElement!).toHaveAccessibleName('item one') + + await user.keyboard('{ArrowDown}') + expect(document.activeElement!).toHaveAccessibleName('item two') + + await user.keyboard('{ArrowDown}') + expect(document.activeElement!).toHaveAccessibleName('item three') + + // At end of list, should wrap to the beginning + await user.keyboard('{ArrowDown}') + expect(document.activeElement!).toHaveAccessibleName('item one') + + // At beginning of list, ArrowUp should wrap to the end + await user.keyboard('{ArrowUp}') + expect(document.activeElement!).toHaveAccessibleName('item three') + + await user.keyboard('{ArrowUp}') + expect(document.activeElement!).toHaveAccessibleName('item two') + + 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, + ) + + 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, + ) + + // 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, + ) + + // 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, + ) + + await user.type(document.activeElement!, '{ArrowUp}') + expect(document.activeElement!).toHaveAttribute( + 'aria-activedescendant', + screen.getByRole('option', {name: 'item two'}).id, + ) + + await user.type(document.activeElement!, '{ArrowUp}') + expect(document.activeElement!).toHaveAttribute( + 'aria-activedescendant', + screen.getByRole('option', {name: 'item one'}).id, + ) + } + }) - 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, - ) + it('should support navigating through items with PageDown and PageUp', async () => { + const user = userEvent.setup() - await user.type(document.activeElement!, '{PageDown}') + renderWithProp(, usingRemoveActiveDescendant) - expect(document.activeElement!).toHaveAttribute( - 'aria-activedescendant', - screen.getByRole('option', {name: 'item three'}).id, - ) + await user.click(screen.getByText('Select items')) - await user.type(document.activeElement!, '{PageUp}') - expect(document.activeElement!).toHaveAttribute( - 'aria-activedescendant', - screen.getByRole('option', {name: 'item one'}).id, - ) - } - }) + if (usingRemoveActiveDescendant) { + await user.type(document.activeElement!, '{ArrowDown}') - it('should select an item (by item.id) even when items are defined in the component', async () => { - const user = userEvent.setup() + expect(document.activeElement!).toHaveAccessibleName('item one') - 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'}, - ] + await user.type(document.activeElement!, '{PageDown}') - const [open, setOpen] = React.useState(false) - const [selected, setSelected] = React.useState([]) - const [filter, setFilter] = React.useState('') + expect(document.activeElement!).toHaveAccessibleName('item three') - return ( - - ) - } + await user.type(document.activeElement!, '{PageUp}') - renderWithProp(, usingRemoveActiveDescendant) + 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.click(screen.getByText('Select items')) + await user.type(document.activeElement!, '{PageDown}') - await user.click(screen.getByText('item one')) - expect(screen.getByRole('option', {name: 'item one'})).toHaveAttribute('aria-selected', 'true') + expect(document.activeElement!).toHaveAttribute( + 'aria-activedescendant', + screen.getByRole('option', {name: 'item three'}).id, + ) - await user.click(screen.getByText('item two')) - expect(screen.getByRole('option', {name: 'item two'})).toHaveAttribute('aria-selected', 'true') + await user.type(document.activeElement!, '{PageUp}') + expect(document.activeElement!).toHaveAttribute( + 'aria-activedescendant', + screen.getByRole('option', {name: 'item one'}).id, + ) + } + }) - await user.click(screen.getByRole('option', {name: 'item one'})) - expect(screen.getByRole('option', {name: 'item one'})).toHaveAttribute('aria-selected', '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 ( + + ) + } - function FilterableSelectPanel() { - const [selected, setSelected] = React.useState([]) - const [filter, setFilter] = React.useState('') - const [open, setOpen] = React.useState(false) + renderWithProp(, usingRemoveActiveDescendant) - const onSelectedChange = (selected: SelectPanelProps['items']) => { - setSelected(selected) - } + await user.click(screen.getByText('Select 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) - }} - _PrivateFocusManagement={usingRemoveActiveDescendant ? 'roving-tabindex' : 'active-descendant'} - /> - ) - } - - 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) - } + await user.click(screen.getByText('item one')) + expect(screen.getByRole('option', {name: 'item one'})).toHaveAttribute('aria-selected', 'true') - 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 user.click(screen.getByText('item two')) + expect(screen.getByRole('option', {name: 'item two'})).toHaveAttribute('aria-selected', 'true') - const noResultsMessage = (filter: string) => ({ - variant: 'empty' as const, - title: `No language found for ${filter}`, - body: 'Adjust your search term to find other languages', + await user.click(screen.getByRole('option', {name: 'item one'})) + expect(screen.getByRole('option', {name: 'item one'})).toHaveAttribute('aria-selected', 'false') + }) }) - const filteredItems = items.filter(item => item.text?.includes(filter)) + function FilterableSelectPanel() { + const [selected, setSelected] = React.useState([]) + const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) - function getMessage() { - if (filteredItems.length === 0 && !filter) { - return emptyMessage - } - if (filteredItems.length === 0 && filter) { - return noResultsMessage(filter) + const onSelectedChange = (selected: SelectPanelProps['items']) => { + setSelected(selected) } - 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) + 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) + }} + _PrivateFocusManagement={usingRemoveActiveDescendant ? 'roving-tabindex' : 'active-descendant'} + /> + ) } - 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 () => { - const user = userEvent.setup() - - renderWithProp(, usingRemoveActiveDescendant) + 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) - await user.click(screen.getByText('Select items')) + const onSelectedChange = (selected: SelectPanelProps['items']) => { + setSelected(selected) + } - expect(screen.getAllByRole('option')).toHaveLength(3) + 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 user.type(document.activeElement!, 'two') - expect(screen.getAllByRole('option')).toHaveLength(1) - }) - }) + const noResultsMessage = (filter: string) => ({ + variant: 'empty' as const, + title: `No language found for ${filter}`, + body: 'Adjust your search term to find other languages', + }) - describe('screen reader announcements', () => { - beforeEach(() => { - const liveRegion = document.createElement('live-region') - document.body.appendChild(liveRegion) - }) + const filteredItems = items.filter(item => item.text?.includes(filter)) - function LoadingSelectPanel({ - initialLoadingType = 'spinner', - items = [], - }: { - initialLoadingType?: InitialLoadingType - items?: SelectPanelProps['items'] - }) { - const [open, setOpen] = React.useState(false) + function getMessage() { + if (filteredItems.length === 0 && !filter) { + return emptyMessage + } + if (filteredItems.length === 0 && filter) { + return noResultsMessage(filter) + } + return undefined + } return ( { + setFilter(value) + }} open={open} - items={items} - onFilterChange={() => {}} - selected={[]} - onSelectedChange={() => {}} onOpenChange={isOpen => { setOpen(isOpen) }} - initialLoadingType={initialLoadingType} + message={getMessage()} /> ) } - it('displays a loading spinner on first open', async () => { - const user = userEvent.setup() - - renderWithProp(, usingRemoveActiveDescendant) - - await user.click(screen.getByText('Select items')) + function NoItemAvailableSelectPanel() { + const [selected, setSelected] = React.useState([]) + const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) - expect(screen.getByTestId('filtered-action-list-spinner')).toBeTruthy() - }) + const onSelectedChange = (selected: SelectPanelProps['items']) => { + setSelected(selected) + } - it('displays a loading skeleton on first open', async () => { - const user = userEvent.setup() + const items: SelectPanelProps['items'] = [] - renderWithProp(, usingRemoveActiveDescendant) - - await user.click(screen.getByText('Select items')) - - expect(screen.getByTestId('filtered-action-list-skeleton')).toBeTruthy() - }) - - it('displays a loading spinner in the text input if items are already loaded', async () => { - const user = userEvent.setup() - - renderWithProp(, usingRemoveActiveDescendant) - - await user.click(screen.getByText('Select items')) - - expect(screen.getAllByRole('option')).toHaveLength(3) + 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) + }} + /> + ) + } - // 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') + describe('filtering', () => { + it('should filter the list of items when the user types into the input', async () => { + const user = userEvent.setup() - // 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() - }) + renderWithProp(, usingRemoveActiveDescendant) - it('should announce initially focused item', async () => { - const user = userEvent.setup() - renderWithProp(, usingRemoveActiveDescendant) + await user.click(screen.getByText('Select items')) - await user.click(screen.getByText('Select items')) - expect(screen.getByLabelText('Filter items')).toHaveFocus() + expect(screen.getAllByRole('option')).toHaveLength(3) - // 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}, - ) + await user.type(document.activeElement!, 'two') + expect(screen.getAllByRole('option')).toHaveLength(1) + }) }) - it('should announce notice text', async () => { - const user = userEvent.setup() + describe('screen reader announcements', () => { + beforeEach(() => { + const liveRegion = document.createElement('live-region') + document.body.appendChild(liveRegion) + }) - function SelectPanelWithNotice() { - const [selected, setSelected] = React.useState([]) - const [filter, setFilter] = React.useState('') + function LoadingSelectPanel({ + initialLoadingType = 'spinner', + items = [], + }: { + initialLoadingType?: InitialLoadingType + items?: SelectPanelProps['items'] + }) { const [open, setOpen] = React.useState(false) - const onSelectedChange = (selected: SelectPanelProps['items']) => { - setSelected(selected) - } - return ( { - setFilter(value) - }} open={open} + items={items} + onFilterChange={() => {}} + selected={[]} + onSelectedChange={() => {}} onOpenChange={isOpen => { setOpen(isOpen) }} - notice={{ - text: 'This is a notice', - variant: 'warning', - }} + initialLoadingType={initialLoadingType} /> ) } - renderWithProp(, usingRemoveActiveDescendant) + it('displays a loading spinner on first open', async () => { + const user = userEvent.setup() - await user.click(screen.getByText('Select items')) - expect(screen.getByLabelText('Filter items')).toHaveFocus() + renderWithProp(, usingRemoveActiveDescendant) - expect(getLiveRegion().getMessage('polite')?.trim()).toContain('This is a notice') - }) + await user.click(screen.getByText('Select items')) - it('should announce filtered results', async () => { - const user = userEvent.setup() - renderWithProp(, usingRemoveActiveDescendant) + expect(screen.getByTestId('filtered-action-list-spinner')).toBeTruthy() + }) - await user.click(screen.getByText('Select items')) - expect(screen.getByLabelText('Filter items')).toHaveFocus() + it('displays a loading skeleton on first open', async () => { + const user = userEvent.setup() - 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 - ) + renderWithProp(, usingRemoveActiveDescendant) - await user.type(document.activeElement!, 'o') - expect(screen.getAllByRole('option')).toHaveLength(2) - - 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 - ) + await user.click(screen.getByText('Select items')) - await user.type(document.activeElement!, 'ne') // now: one - expect(screen.getAllByRole('option')).toHaveLength(1) - - 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', - ) - } - }, - {timeout: 3000}, - ) - }) + expect(screen.getByTestId('filtered-action-list-skeleton')).toBeTruthy() + }) - it('should announce default empty message when no results are available (no custom message is provided)', async () => { - const user = userEvent.setup() - renderWithProp(, usingRemoveActiveDescendant) + 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')) + renderWithProp(, usingRemoveActiveDescendant) - await user.type(document.activeElement!, 'zero') - expect(screen.queryByRole('option')).toBeNull() + await user.click(screen.getByText('Select items')) - await waitFor( - async () => { - if (usingRemoveActiveDescendant) { - expect(getLiveRegion().getMessage('polite')!.trim()).toBe('No items available.') - } else { - expect(getLiveRegion().getMessage('polite')?.trim()).toBe('No items available.') - } - }, - {timeout: 3000}, - ) - }) + expect(screen.getAllByRole('option')).toHaveLength(3) - it('should announce custom empty message when no results are available', async () => { - const user = userEvent.setup() + // 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') - function SelectPanelWithCustomEmptyMessage() { - const [filter, setFilter] = React.useState('') - const [open, setOpen] = React.useState(false) + // 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() + }) - return ( - { - setFilter(value) - }} - filterValue={filter} - selected={[]} - onSelectedChange={() => {}} - onOpenChange={isOpen => { - setOpen(isOpen) - }} - message={{ - title: 'Nothing found', - body: `There's nothing here.`, - variant: 'empty', - }} - /> - ) - } + it('should announce initially focused item', async () => { + const user = userEvent.setup() + renderWithProp(, usingRemoveActiveDescendant) - renderWithProp(, usingRemoveActiveDescendant) + await user.click(screen.getByText('Select items')) + expect(screen.getByLabelText('Filter items')).toHaveFocus() + + // 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}, + ) + }) - await user.click(screen.getByText('Select items')) + it('should announce notice text', async () => { + const user = userEvent.setup() - await user.type(document.activeElement!, 'zero') - expect(screen.queryByRole('option')).toBeNull() + function SelectPanelWithNotice() { + const [selected, setSelected] = React.useState([]) + const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) - await waitFor( - async () => { - if (usingRemoveActiveDescendant) { - expect(getLiveRegion().getMessage('polite')!.trim()).toBe("Nothing found. There's nothing here.") - } else { - expect(getLiveRegion().getMessage('polite')?.trim()).toBe("Nothing found. There's nothing here.") + const onSelectedChange = (selected: SelectPanelProps['items']) => { + setSelected(selected) } - }, - {timeout: 3000}, - ) - }) - - it('should accept a className to style the component', async () => { - const user = userEvent.setup() - - renderWithProp(, usingRemoveActiveDescendant) - - 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() + return ( + { + setFilter(value) + }} + open={open} + onOpenChange={isOpen => { + setOpen(isOpen) + }} + notice={{ + text: 'This is a notice', + variant: 'warning', + }} + /> + ) + } - renderWithProp(, usingRemoveActiveDescendant) + renderWithProp(, usingRemoveActiveDescendant) - await user.click(screen.getByText('Select items')) + await user.click(screen.getByText('Select items')) + expect(screen.getByLabelText('Filter items')).toHaveFocus() - expect(screen.getAllByRole('option')).toHaveLength(3) + expect(getLiveRegion().getMessage('polite')?.trim()).toContain('This is a notice') + }) - await user.type(document.activeElement!, 'something') - expect(screen.getByText('No items available')).toBeInTheDocument() - }) + it('should announce filtered results', async () => { + const user = userEvent.setup() + renderWithProp(, usingRemoveActiveDescendant) - 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() + await user.click(screen.getByText('Select items')) + expect(screen.getByLabelText('Filter items')).toHaveFocus() + + 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 + ) - renderWithProp(, usingRemoveActiveDescendant) + await user.type(document.activeElement!, 'o') + expect(screen.getAllByRole('option')).toHaveLength(2) + + 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 + ) - await waitFor(async () => { - await user.click(screen.getByText('Select items')) - expect(screen.getByText('No items available')).toBeInTheDocument() + await user.type(document.activeElement!, 'ne') // now: one + expect(screen.getAllByRole('option')).toHaveLength(1) + + 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', + ) + } + }, + {timeout: 3000}, + ) }) - }) - it('should display the custom empty state message when there is no matching item after filtering', async () => { - const user = userEvent.setup() - - renderWithProp( - , - usingRemoveActiveDescendant, - ) - await user.click(screen.getByText('Select items')) + it('should announce default empty message when no results are available (no custom message is provided)', async () => { + const user = userEvent.setup() + renderWithProp(, usingRemoveActiveDescendant) - expect(screen.getAllByRole('option')).toHaveLength(3) + await user.click(screen.getByText('Select items')) - await user.type(document.activeElement!, 'something') - expect(screen.getByText('No language found for something')).toBeInTheDocument() - expect(screen.getByText('Adjust your search term to find other languages')).toBeInTheDocument() - }) + await user.type(document.activeElement!, 'zero') + expect(screen.queryByRole('option')).toBeNull() + + await waitFor( + async () => { + if (usingRemoveActiveDescendant) { + expect(getLiveRegion().getMessage('polite')!.trim()).toBe('No items available.') + } else { + expect(getLiveRegion().getMessage('polite')?.trim()).toBe('No items available.') + } + }, + {timeout: 3000}, + ) + }) - it('should display the custom empty state message when there is no item after the initial load', async () => { - const user = userEvent.setup() + it('should announce custom empty message when no results are available', async () => { + const user = userEvent.setup() + + 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', + }} + /> + ) + } - renderWithProp(, usingRemoveActiveDescendant) + renderWithProp(, usingRemoveActiveDescendant) - await waitFor(async () => { await user.click(screen.getByText('Select items')) - expect(screen.getByText("You haven't created any projects yet")).toBeInTheDocument() - expect(screen.getByText('Start your first project to organise your issues')).toBeInTheDocument() + + await user.type(document.activeElement!, 'zero') + expect(screen.queryByRole('option')).toBeNull() + + await waitFor( + async () => { + if (usingRemoveActiveDescendant) { + expect(getLiveRegion().getMessage('polite')!.trim()).toBe("Nothing found. There's nothing here.") + } else { + expect(getLiveRegion().getMessage('polite')?.trim()).toBe("Nothing found. There's nothing here.") + } + }, + {timeout: 3000}, + ) }) - }) - it('should display action button in custom empty state message', async () => { - const handleAction = vi.fn() - const user = userEvent.setup() + it('should accept a className to style the component', async () => { + const user = userEvent.setup() - renderWithProp( - , - usingRemoveActiveDescendant, - ) + renderWithProp(, usingRemoveActiveDescendant) - await waitFor(async () => { await user.click(screen.getByText('Select items')) - expect(screen.getByText("You haven't created any projects yet")).toBeInTheDocument() - expect(screen.getByText('Start your first project to organise your issues')).toBeInTheDocument() - // Check that action button is in the document - const actionButton = screen.getByTestId('create-project-action') - expect(actionButton).toBeInTheDocument() - expect(actionButton).toHaveTextContent('Create new project') + expect(screen.getByTestId('filtered-action-list')).toHaveClass('test-class') }) - - // Test that action button is clickable - const actionButton = screen.getByTestId('create-project-action') - await user.click(actionButton) - expect(handleAction).toHaveBeenCalledTimes(1) }) - }) - describe('with footer', () => { - function SelectPanelWithFooter() { - const [selected, setSelected] = React.useState([]) - const [filter, setFilter] = React.useState('') - const [open, setOpen] = React.useState(false) + 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() - const onSelectedChange = (selected: SelectPanelProps['items']) => { - setSelected(selected) - } + renderWithProp(, usingRemoveActiveDescendant) - 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) - }} - /> - ) - } + await user.click(screen.getByText('Select items')) - it('should render the provided `footer` at the bottom of the dialog', async () => { - const user = userEvent.setup() + expect(screen.getAllByRole('option')).toHaveLength(3) - renderWithProp(, usingRemoveActiveDescendant) + await user.type(document.activeElement!, 'something') + expect(screen.getByText('No items available')).toBeInTheDocument() + }) - await user.click(screen.getByText('Select items')) - expect(screen.getByText('test footer')).toBeInTheDocument() - }) - }) + 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() - 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) - } + renderWithProp(, usingRemoveActiveDescendant) - return ( - { - setOpen(isOpen) - }} - filterValue={filter} - onFilterChange={value => { - setFilter(value) - }} - /> - ) - } + await user.click(screen.getByText('Select items')) - describe('with groups', () => { - it('should render groups with items', async () => { - const user = userEvent.setup() + await waitFor(() => { + expect(screen.getByText('No items available')).toBeInTheDocument() + }) + }) + it('should display the custom empty state message when there is no matching item after filtering', async () => { + const user = userEvent.setup() + + renderWithProp( + , + usingRemoveActiveDescendant, + ) - renderWithProp(, usingRemoveActiveDescendant) + await user.click(screen.getByText('Select items')) - await user.click(screen.getByText('Select items')) - const listbox = screen.getByRole('listbox') - expect(listbox).toBeInTheDocument() - expect(listbox).toHaveAttribute('aria-multiselectable', 'true') + expect(screen.getAllByRole('option')).toHaveLength(3) - // 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') + await user.type(document.activeElement!, 'something') + expect(screen.getByText('No language found for something')).toBeInTheDocument() + expect(screen.getByText('Adjust your search term to find other languages')).toBeInTheDocument() + }) - expect(screen.getAllByRole('option')).toHaveLength(4) - }) - it('should select items within groups', async () => { - const user = userEvent.setup() + it('should display the custom empty state message when there is no item after the initial load', async () => { + const user = userEvent.setup() - renderWithProp(, usingRemoveActiveDescendant) + renderWithProp(, usingRemoveActiveDescendant) - await user.click(screen.getByText('Select items')) + await waitFor(async () => { + await user.click(screen.getByText('Select items')) + expect(screen.getByText("You haven't created any projects yet")).toBeInTheDocument() + expect(screen.getByText('Start your first project to organise your issues')).toBeInTheDocument() + }) + }) - // 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('should display action button in custom empty state message', async () => { + const handleAction = vi.fn() + const user = userEvent.setup() - await user.click(screen.getByRole('option', {name: 'Item 3'})) - expect( - screen.getByRole('option', { - name: 'Item 3', - }), - ).toHaveAttribute('aria-selected', 'true') + renderWithProp( + , + usingRemoveActiveDescendant, + ) - await user.click(screen.getByRole('option', {name: 'Item 4'})) - expect( - screen.getByRole('option', { - name: 'Item 4', - }), - ).toHaveAttribute('aria-selected', 'true') - }) - }) + await waitFor(async () => { + await user.click(screen.getByText('Select items')) + expect(screen.getByText("You haven't created any projects yet")).toBeInTheDocument() + expect(screen.getByText('Start your first project to organise your issues')).toBeInTheDocument() - describe('As Modal', () => { - it('selections render as radios when variant modal and single select', async () => { - const user = userEvent.setup() + // Check that action button is in the document + const actionButton = screen.getByTestId('create-project-action') + expect(actionButton).toBeInTheDocument() + expect(actionButton).toHaveTextContent('Create new project') + }) - renderWithProp( - {}} selected={undefined} />, - usingRemoveActiveDescendant, - ) + // Test that action button is clickable + const actionButton = screen.getByTestId('create-project-action') + await user.click(actionButton) + expect(handleAction).toHaveBeenCalledTimes(1) + }) + }) - await user.click(screen.getByText('Select items')) + describe('with footer', () => { + function SelectPanelWithFooter() { + const [selected, setSelected] = React.useState([]) + const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) - expect(screen.getAllByRole('radio', {hidden: true}).length).toBe(items.length) + const onSelectedChange = (selected: SelectPanelProps['items']) => { + setSelected(selected) + } - expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument() - expect(screen.getByRole('button', {name: 'Cancel'})).toBeInTheDocument() - }) - it('save and oncancel buttons are present when variant modal', async () => { - const user = userEvent.setup() + 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) + }} + /> + ) + } - renderWithProp( {}} />, usingRemoveActiveDescendant) + it('should render the provided `footer` at the bottom of the dialog', async () => { + const user = userEvent.setup() - await user.click(screen.getByText('Select items')) + renderWithProp(, usingRemoveActiveDescendant) - expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument() - expect(screen.getByRole('button', {name: 'Cancel'})).toBeInTheDocument() + await user.click(screen.getByText('Select items')) + expect(screen.getByText('test footer')).toBeInTheDocument() + }) }) - }) - describe('sorting', () => { - const items = [ + const listOfItems: Array = [ { - text: 'item one', - id: '3', - }, - { - text: 'item two', id: '1', - selected: true, + key: 1, + text: 'Item 1', + groupId: '1', }, { - text: 'item three', 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', }, ] - it('should render selected items at the top by default when FF on', async () => { - const user = userEvent.setup() - - renderWithProp( - - - , - 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 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() - - renderWithProp( - - - , - 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 not render selected items at the top when showSelectedOptionsFirst set to false', async () => { - const user = userEvent.setup() - - renderWithProp( - , - 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') - }) - }) - - describe('disableFullscreenOnNarrow prop', () => { - const renderSelectPanelWithFlags = (flags: Record, props: Record = {}) => { - return renderWithProp( - - - , - usingRemoveActiveDescendant, - ) - } + 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'}}, + ] - // Create a single-select version to test ResponsiveCloseButton behavior - function SingleSelectPanel(passthroughProps: Record) { - const [filter, setFilter] = React.useState('') + function SelectPanelWithGroups() { + const [selectedItems, setSelectedItems] = React.useState([]) const [open, setOpen] = React.useState(false) - - return ( - {}} - filterValue={filter} - onFilterChange={value => { - setFilter(value) - }} - open={open} - onOpenChange={open => setOpen(open)} - {...passthroughProps} - /> - ) - } - - it('should opt out of fullscreen when disableFullscreenOnNarrow=true even when feature flag is enabled', async () => { - const user = userEvent.setup() - - renderSelectPanelWithFlags( - { - primer_react_select_panel_fullscreen_on_narrow: true, - }, - {disableFullscreenOnNarrow: true}, - ) - - await user.click(screen.getByText('Select an item')) - - // 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() - }) - - it('should use fullscreen behavior when disableFullscreenOnNarrow=false and feature flag is enabled', async () => { - const user = userEvent.setup() - - renderSelectPanelWithFlags( - { - primer_react_select_panel_fullscreen_on_narrow: true, - }, - {disableFullscreenOnNarrow: false}, - ) - - await user.click(screen.getByText('Select an item')) - - // 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() - }) - - it('should default to feature flag value when disableFullscreenOnNarrow is undefined', async () => { - const user = userEvent.setup() - - // Test with feature flag disabled - renderSelectPanelWithFlags({ - primer_react_select_panel_fullscreen_on_narrow: false, - }) - - await user.click(screen.getByText('Select an item')) - - // 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() - }) - }) - - 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) - const onSelectedChange = (selected: SelectPanelProps['items']) => { - setSelected(selected) + const onSelectedChange = (selections: ItemInput[]) => { + setSelectedItems(selections) } return ( { - 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() - renderWithProp(, usingRemoveActiveDescendant) + renderWithProp(, usingRemoveActiveDescendant) - await user.click(screen.getByText('Select items')) + await user.click(screen.getByText('Select items')) + const listbox = screen.getByRole('listbox') + expect(listbox).toBeInTheDocument() + 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() - }) + renderWithProp(, 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')) - renderWithProp(, usingRemoveActiveDescendant) + // 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() - }) + renderWithProp( + {}} 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')) - renderWithProp(, usingRemoveActiveDescendant) + expect(screen.getAllByRole('radio', {hidden: true}).length).toBe(items.length) - await user.click(screen.getByText('Select items')) + expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument() + expect(screen.getByRole('button', {name: 'Cancel'})).toBeInTheDocument() + }) + it('save and oncancel buttons are present when variant modal', async () => { + const user = userEvent.setup() - await user.click(screen.getByRole('checkbox', {name: 'Select all'})) + renderWithProp( {}} />, 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'})).toBeInTheDocument() + expect(screen.getByRole('button', {name: 'Cancel'})).toBeInTheDocument() + }) }) - 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', + }, + ] - renderWithProp(, usingRemoveActiveDescendant) + 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')) + renderWithProp( + + + , + 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() + + renderWithProp( + + + , + 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() + + renderWithProp( + , + 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 renderWithProp( + + + , + usingRemoveActiveDescendant, + ) + } - renderWithProp(, usingRemoveActiveDescendant) + // 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')) - renderWithProp(, usingRemoveActiveDescendant) + // 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() + }) - renderWithProp(, usingRemoveActiveDescendant) + 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) @@ -1507,7 +1379,7 @@ for (const usingRemoveActiveDescendant of [false, true]) { item.text?.includes(filter))} + items={items} placeholder="Select items" placeholderText="Filter items" selected={selected} @@ -1520,286 +1392,429 @@ for (const usingRemoveActiveDescendant of [false, true]) { onOpenChange={isOpen => { setOpen(isOpen) }} - showSelectAll={true} + showSelectAll={showSelectAll} /> ) } - renderWithProp(, usingRemoveActiveDescendant) + it('should render a Select All checkbox when showSelectAll is true', async () => { + const user = userEvent.setup() - await user.click(screen.getByText('Select items')) + renderWithProp(, 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') + renderWithProp(, usingRemoveActiveDescendant) - // Clear the filter - await user.clear(screen.getByLabelText('Filter items')) + await user.click(screen.getByText('Select 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') + expect(screen.queryByText('Select all')).not.toBeInTheDocument() + expect(screen.queryByRole('checkbox', {name: 'Select all'})).not.toBeInTheDocument() + }) - // 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) - }) - }) + it('should select all items when the Select All checkbox is clicked', async () => { + const user = userEvent.setup() - describe('disableSelectOnHover', () => { - it('should not update aria-activedescendant when hovering over items when disableSelectOnHover is true', async () => { - const user = userEvent.setup() + renderWithProp(, usingRemoveActiveDescendant) - render() + await user.click(screen.getByText('Select items')) - await user.click(screen.getByText('Select items')) + await user.click(screen.getByRole('checkbox', {name: 'Select all'})) - const input = screen.getByPlaceholderText('Filter items') - const options = screen.getAllByRole('option') + // All options should now be selected + for (const item of items) { + expect(screen.getByRole('option', {name: item.text})).toHaveAttribute('aria-selected', 'true') + } + }) - // Initially, aria-activedescendant should not be set if setInitialFocus is false (default) - const initialActiveDescendant = input.getAttribute('aria-activedescendant') + it('should deselect all items when the Deselect All checkbox is clicked', async () => { + const user = userEvent.setup() - // Hover over the first item - await user.hover(options[0]) + renderWithProp(, usingRemoveActiveDescendant) - // aria-activedescendant should not change when disableSelectOnHover is true - expect(input.getAttribute('aria-activedescendant')).toBe(initialActiveDescendant) + await user.click(screen.getByText('Select items')) - // Hover over the second item - await user.hover(options[1]) + // First select all + await user.click(screen.getByRole('checkbox', {name: 'Select all'})) - // aria-activedescendant should still not change - expect(input.getAttribute('aria-activedescendant')).toBe(initialActiveDescendant) - }) + // Then deselect all + await user.click(screen.getByRole('checkbox', {name: 'Deselect all'})) - it('should update aria-activedescendant when hovering over items when disableSelectOnHover is false (default)', async () => { - const user = userEvent.setup() + // 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') + } + } + }) - render() + it('should update Select All checkbox to indeterminate state when some items (but not all) are selected', async () => { + const user = userEvent.setup() - await user.click(screen.getByText('Select items')) + renderWithProp(, usingRemoveActiveDescendant) - const input = screen.getByPlaceholderText('Filter items') - const options = screen.getAllByRole('option') + await user.click(screen.getByText('Select items')) - // Hover over the first item - await user.hover(options[0]) + // Select only one item + await user.click(screen.getByText('item one')) - // aria-activedescendant should be set to the first item - expect(input.getAttribute('aria-activedescendant')).toBe(options[0].id) + // 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) + }) - // Hover over the second item - await user.hover(options[1]) + it('should update Select All checkbox to checked when all items are selected manually', async () => { + const user = userEvent.setup() - // aria-activedescendant should be updated to the second item - expect(input.getAttribute('aria-activedescendant')).toBe(options[1].id) - }) - }) + renderWithProp(, usingRemoveActiveDescendant) - describe('setInitialFocus', () => { - it('should not set aria-activedescendant until user interaction when setInitialFocus is true', async () => { - const user = userEvent.setup() + await user.click(screen.getByText('Select items')) - render() + // Select all items individually + for (const item of items) { + if (item.text) { + await user.click(screen.getByText(item.text)) + } + } - await user.click(screen.getByText('Select items')) + // Check that Deselect All is checked + expect(screen.getByRole('checkbox', {name: 'Deselect all'})).toBeChecked() + }) - const input = screen.getByPlaceholderText('Filter items') - const options = screen.getAllByRole('option') + it('should update Select All checkbox label to "Deselect all" when all items are selected', async () => { + const user = userEvent.setup() - // Initially, aria-activedescendant should not be set - expect(input.getAttribute('aria-activedescendant')).toBeFalsy() + renderWithProp(, usingRemoveActiveDescendant) - // User interacts with keyboard - await user.keyboard('{ArrowDown}') + 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} + /> + ) + } + + renderWithProp(, 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() - // Now aria-activedescendant should be set to the first item - expect(input.getAttribute('aria-activedescendant')).toBe(options[0].id) + // 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) + }) }) - it('should set aria-activedescendant to the first item on mount when setInitialFocus is false (default)', async () => { - const user = userEvent.setup() + describe('disableSelectOnHover', () => { + it('should not update aria-activedescendant when hovering over items when disableSelectOnHover is true', async () => { + const user = userEvent.setup() - render() + render() - await user.click(screen.getByText('Select items')) + await user.click(screen.getByText('Select items')) - const input = screen.getByPlaceholderText('Filter items') - const options = screen.getAllByRole('option') + const input = screen.getByPlaceholderText('Filter items') + const options = screen.getAllByRole('option') + + // Initially, aria-activedescendant should not be set if setInitialFocus is false (default) + const initialActiveDescendant = input.getAttribute('aria-activedescendant') + + // Hover over the first item + await user.hover(options[0]) + + // aria-activedescendant should not change when disableSelectOnHover is true + expect(input.getAttribute('aria-activedescendant')).toBe(initialActiveDescendant) + + // Hover over the second item + await user.hover(options[1]) + + // aria-activedescendant should still not change + expect(input.getAttribute('aria-activedescendant')).toBe(initialActiveDescendant) + }) + + it('should update aria-activedescendant when hovering over items when disableSelectOnHover is false (default)', async () => { + const user = userEvent.setup() - // Wait a tick for the effect to run - await new Promise(resolve => setTimeout(resolve, 0)) + render() - // aria-activedescendant should be set to the first item - expect(input.getAttribute('aria-activedescendant')).toBe(options[0].id) + await user.click(screen.getByText('Select items')) + + const input = screen.getByPlaceholderText('Filter items') + const options = screen.getAllByRole('option') + + // Hover over the first item + await user.hover(options[0]) + + // aria-activedescendant should be set to the first item + expect(input.getAttribute('aria-activedescendant')).toBe(options[0].id) + + // Hover over the second item + await user.hover(options[1]) + + // aria-activedescendant should be updated to the second item + expect(input.getAttribute('aria-activedescendant')).toBe(options[1].id) + }) }) - it('should not set aria-activedescendant on mouse hover until after first interaction when setInitialFocus is true', async () => { - const user = userEvent.setup() + describe('setInitialFocus', () => { + it('should not set aria-activedescendant until user interaction when setInitialFocus is true', async () => { + const user = userEvent.setup() - render() + render() - await user.click(screen.getByText('Select items')) + await user.click(screen.getByText('Select items')) - const input = screen.getByPlaceholderText('Filter items') - const options = screen.getAllByRole('option') + const input = screen.getByPlaceholderText('Filter items') + const options = screen.getAllByRole('option') + + // Initially, aria-activedescendant should not be set + expect(input.getAttribute('aria-activedescendant')).toBeFalsy() + + // User interacts with keyboard + await user.keyboard('{ArrowDown}') + + // Now aria-activedescendant should be set to the first item + expect(input.getAttribute('aria-activedescendant')).toBe(options[0].id) + }) + + it('should set aria-activedescendant to the first item on mount when setInitialFocus is false (default)', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByText('Select items')) + + const input = screen.getByPlaceholderText('Filter items') + const options = screen.getAllByRole('option') + + // Wait a tick for the effect to run + await new Promise(resolve => setTimeout(resolve, 0)) + + // aria-activedescendant should be set to the first item + expect(input.getAttribute('aria-activedescendant')).toBe(options[0].id) + }) - // Initially, aria-activedescendant should not be set - expect(input.getAttribute('aria-activedescendant')).toBeFalsy() + it('should not set aria-activedescendant on mouse hover until after first interaction when setInitialFocus is true', async () => { + const user = userEvent.setup() - // Hover over the first item (this is the first interaction) - await user.hover(options[0]) + render() - // Now aria-activedescendant should be set to the first item - expect(input.getAttribute('aria-activedescendant')).toBe(options[0].id) + await user.click(screen.getByText('Select items')) + + const input = screen.getByPlaceholderText('Filter items') + const options = screen.getAllByRole('option') + + // Initially, aria-activedescendant should not be set + expect(input.getAttribute('aria-activedescendant')).toBeFalsy() + + // Hover over the first item (this is the first interaction) + await user.hover(options[0]) - // Hover over the second item - await user.hover(options[1]) + // Now aria-activedescendant should be set to the first item + expect(input.getAttribute('aria-activedescendant')).toBe(options[0].id) - // aria-activedescendant should update to the second item - expect(input.getAttribute('aria-activedescendant')).toBe(options[1].id) + // Hover over the second item + await user.hover(options[1]) + + // aria-activedescendant should update to the second item + expect(input.getAttribute('aria-activedescendant')).toBe(options[1].id) + }) }) }) - }) - describe('Event propagation', () => { - function EventSelectPanel() { - const [selected, setSelected] = React.useState([]) - const [filter, setFilter] = React.useState('') - const [open, setOpen] = React.useState(false) + describe('Event propagation', () => { + function EventSelectPanel() { + const [selected, setSelected] = React.useState([]) + const [filter, setFilter] = React.useState('') + const [open, setOpen] = React.useState(false) - const onSelectedChange = (selected: SelectPanelProps['items']) => { - setSelected(selected) - } + const onSelectedChange = (selected: SelectPanelProps['items']) => { + setSelected(selected) + } - return ( -
) => { - const isAlphabetKey = e.key.length === 1 && /[a-z\d]/i.test(e.key) - const container = e.currentTarget - - if (!isAlphabetKey) return - container.setAttribute('data-keydown-called', 'true') - }} - data-keydown-called="false" - > - - { - setFilter(value) - }} - open={open} - onOpenChange={isOpen => { - setOpen(isOpen) + return ( +
) => { + const isAlphabetKey = e.key.length === 1 && /[a-z\d]/i.test(e.key) + const container = e.currentTarget + + if (!isAlphabetKey) return + container.setAttribute('data-keydown-called', 'true') }} - _PrivateFocusManagement="roving-tabindex" - /> -
- ) - } + data-keydown-called="false" + > + + { + setFilter(value) + }} + open={open} + onOpenChange={isOpen => { + setOpen(isOpen) + }} + _PrivateFocusManagement="roving-tabindex" + /> +
+ ) + } - it('should prevent event propagation when using keyboard while focusing on an item', async () => { - const user = userEvent.setup() + it('should prevent event propagation when using keyboard while focusing on an item', async () => { + const user = userEvent.setup() - render() + render() - const toggleButton = screen.getByRole('button', {name: 'Toggle SelectPanel'}) - const container = toggleButton.parentElement as HTMLDivElement + const toggleButton = screen.getByRole('button', {name: 'Toggle SelectPanel'}) + const container = toggleButton.parentElement as HTMLDivElement - await user.click(toggleButton) + await user.click(toggleButton) - expect(screen.getByText('Select items')).toBeInTheDocument() + expect(screen.getByText('Select items')).toBeInTheDocument() - const listbox = screen.getByRole('listbox') - expect(listbox).toBeInTheDocument() - expect(listbox).toHaveAttribute('aria-multiselectable', 'true') + const listbox = screen.getByRole('listbox') + expect(listbox).toBeInTheDocument() + expect(listbox).toHaveAttribute('aria-multiselectable', 'true') - const options = screen.getAllByRole('option') - expect(options).toHaveLength(3) + const options = screen.getAllByRole('option') + expect(options).toHaveLength(3) - const firstOption = options[0] - expect(firstOption).toHaveTextContent('item one') + const firstOption = options[0] + expect(firstOption).toHaveTextContent('item one') - await user.keyboard('{ArrowDown}') + await user.keyboard('{ArrowDown}') - expect(firstOption).toHaveFocus() + expect(firstOption).toHaveFocus() - await user.keyboard('{ArrowDown}') + await user.keyboard('{ArrowDown}') - // Trigger alphabet key that should not propagate - await user.keyboard('A') + // Trigger alphabet key that should not propagate + await user.keyboard('A') - expect(options[1]).toHaveFocus() - expect(container.getAttribute('data-keydown-called')).toBe('false') + expect(options[1]).toHaveFocus() + expect(container.getAttribute('data-keydown-called')).toBe('false') + }) }) - }) -} + } -describe('SelectPanel displayInViewport prop', () => { - const mockGetAnchoredPosition = vi.mocked(getAnchoredPosition) + describe('SelectPanel displayInViewport prop', () => { + const mockGetAnchoredPosition = vi.mocked(getAnchoredPosition) - beforeEach(() => { - mockGetAnchoredPosition.mockClear() - }) + beforeEach(() => { + mockGetAnchoredPosition.mockClear() + }) + + it('should forward displayInViewport={true} to getAnchoredPosition', async () => { + const user = userEvent.setup() + render() - it('should forward displayInViewport={true} to getAnchoredPosition', async () => { - const user = userEvent.setup() - render() + await user.click(screen.getByRole('button', {name: 'Select items'})) - await user.click(screen.getByRole('button', {name: 'Select items'})) + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument() + }) - await waitFor(() => { - expect(screen.getByRole('listbox')).toBeInTheDocument() - }) + await waitFor(() => { + expect(mockGetAnchoredPosition).toHaveBeenCalled() + }) - await waitFor(() => { - expect(mockGetAnchoredPosition).toHaveBeenCalled() + const calls = mockGetAnchoredPosition.mock.calls + const lastCall = calls[calls.length - 1] + expect(lastCall[2]?.displayInViewport).toBe(true) }) - const calls = mockGetAnchoredPosition.mock.calls - const lastCall = calls[calls.length - 1] - expect(lastCall[2]?.displayInViewport).toBe(true) - }) + it('should not set displayInViewport when prop is not provided', async () => { + const user = userEvent.setup() + render() - it('should not set displayInViewport when prop is not provided', async () => { - const user = userEvent.setup() - render() + await user.click(screen.getByRole('button', {name: 'Select items'})) - await user.click(screen.getByRole('button', {name: 'Select items'})) + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument() + }) - await waitFor(() => { - expect(screen.getByRole('listbox')).toBeInTheDocument() - }) + await waitFor(() => { + expect(mockGetAnchoredPosition).toHaveBeenCalled() + }) - await waitFor(() => { - expect(mockGetAnchoredPosition).toHaveBeenCalled() + const calls = mockGetAnchoredPosition.mock.calls + const lastCall = calls[calls.length - 1] + expect(lastCall[2]?.displayInViewport).not.toBe(true) }) - - const calls = mockGetAnchoredPosition.mock.calls - const lastCall = calls[calls.length - 1] - expect(lastCall[2]?.displayInViewport).not.toBe(true) }) -}) +} diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index 04667503618..06098365057 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -1,1042 +1,46 @@ -import {SearchIcon, TriangleDownIcon, XIcon, type IconProps} from '@primer/octicons-react' -import React, {useCallback, useEffect, useMemo, useRef, useState, type JSX} from 'react' -import type {AnchoredOverlayProps} from '../AnchoredOverlay' -import {AnchoredOverlay} from '../AnchoredOverlay' -import type {AnchoredOverlayWrapperAnchorProps} from '../AnchoredOverlay/AnchoredOverlay' -import type {FilteredActionListProps} from '../FilteredActionList' -import {FilteredActionList} from '../FilteredActionList' -import Heading from '../Heading' -import type {OverlayProps} from '../Overlay' -import type {TextInputProps} from '../TextInput' -import type {ItemProps, ItemInput} from './' -import {SelectPanelMessage} from './SelectPanelMessage' - -import {Button, IconButton, LinkButton} from '../Button' -import {useProvidedRefOrCreate} from '../hooks' -import type {FocusZoneHookSettings} from '../hooks/useFocusZone' -import {useId} from '../hooks/useId' -import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate' -import useSafeTimeout from '../hooks/useSafeTimeout' -import type {FilteredActionListLoadingType} from '../FilteredActionList/FilteredActionListLoaders' -import {FilteredActionListLoadingTypes} from '../FilteredActionList/FilteredActionListLoaders' import {useFeatureFlag} from '../FeatureFlags' -import {announce, announceFromElement} from '@primer/live-region-element' -import classes from './SelectPanel.module.css' -import {clsx} from 'clsx' -import {debounce} from '@github/mini-throttle' -import {useResponsiveValue} from '../hooks/useResponsiveValue' -import type {ButtonProps, LinkButtonProps} from '../Button/types' -import {Banner} from '../Banner' -import {isAlphabetKey} from '../hooks/useMnemonics' -import {useFormControlContext} from '../FormControl/_FormControlContext' - -// we add a delay so that it does not interrupt default screen reader announcement and queues after it -const SHORT_DELAY_MS = 500 -const LONG_DELAY_MS = 1000 -const EMPTY_MESSAGE = { - title: 'No items available', - description: '', -} - -const DefaultEmptyMessage = ( - - {EMPTY_MESSAGE.description} - -) - -async function announceText(text: string, delayMs = SHORT_DELAY_MS) { - const liveRegion = document.querySelector('live-region') - - liveRegion?.clear() // clear previous announcements - - await announce(text, { - delayMs, - from: liveRegion ? liveRegion : undefined, // announce will create a liveRegion if it doesn't find one - }) -} - -async function announceLoading() { - await announceText('Loading.') -} - -interface SelectPanelSingleSelection { - selected: ItemInput | undefined - onSelectedChange: (selected: ItemInput | undefined) => void -} - -interface SelectPanelMultiSelection { - selected: ItemInput[] - onSelectedChange: (selected: ItemInput[]) => void -} - -export type InitialLoadingType = 'spinner' | 'skeleton' -export type SelectPanelSecondaryAction = - | React.ReactElement - | React.ReactElement - -interface SelectPanelBaseProps { - // TODO: Make `title` required in the next major version - // eslint-disable-next-line @typescript-eslint/no-explicit-any - title?: string | React.ReactElement - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subtitle?: string | React.ReactElement - onOpenChange: ( - open: boolean, - gesture: 'anchor-click' | 'anchor-key-press' | 'click-outside' | 'escape' | 'selection' | 'cancel', - ) => void - secondaryAction?: SelectPanelSecondaryAction - placeholder?: string - // TODO: Make `inputLabel` required in next major version - inputLabel?: string - overlayProps?: Partial - initialLoadingType?: InitialLoadingType - className?: string - notice?: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - text: string | React.ReactElement - variant: 'info' | 'warning' | 'error' - } - message?: { - title: string - // eslint-disable-next-line @typescript-eslint/no-explicit-any - body: string | React.ReactElement - variant: 'empty' | 'error' | 'warning' - icon?: React.ComponentType - // eslint-disable-next-line @typescript-eslint/no-explicit-any - action?: React.ReactElement - } - /** - * @deprecated Use `secondaryAction` instead. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - footer?: string | React.ReactElement - showSelectedOptionsFirst?: boolean - /** - * Whether to disable fullscreen behavior on narrow viewports. - * When `true`, the panel will maintain its anchored position regardless of viewport size. - * When `false`, the panel will go fullscreen on narrow viewports (if feature flag is enabled). - * @default undefined (uses feature flag default) - */ - disableFullscreenOnNarrow?: boolean - showSelectAll?: boolean - /** - * Set to true to allow focus to move to elements that are dynamically prepended to the container. - * Default is false. - */ - focusPrependedElements?: boolean -} - -// onCancel is optional with variant=anchored, but required with variant=modal -type SelectPanelVariantProps = {variant?: 'anchored'; onCancel?: () => void} | {variant: 'modal'; onCancel: () => void} - -export type SelectPanelProps = SelectPanelBaseProps & - Omit & - Pick & - AnchoredOverlayWrapperAnchorProps & - (SelectPanelSingleSelection | SelectPanelMultiSelection) & - SelectPanelVariantProps - -function isMultiSelectVariant( - selected: SelectPanelSingleSelection['selected'] | SelectPanelMultiSelection['selected'], -): selected is SelectPanelMultiSelection['selected'] { - return Array.isArray(selected) -} - -const focusZoneSettings: Partial = { - // Let FilteredActionList handle focus zone - disabled: true, -} - -const closeButtonProps = {'aria-label': 'Cancel and close'} - -const areItemsEqual = (itemA: ItemInput, itemB: ItemInput) => { - // prefer checking equivality by item.id - if (typeof itemA.id !== 'undefined') return itemA.id === itemB.id - else return itemA === itemB -} - -const doesItemsIncludeItem = (items: ItemInput[], item: ItemInput) => { - return items.some(i => areItemsEqual(i, item)) -} - -const defaultRenderAnchor: NonNullable = props => { - const {children, ...rest} = props - return ( - - ) -} - -function Panel({ - open, - onOpenChange, - renderAnchor = defaultRenderAnchor, - anchorRef: externalAnchorRef, - placeholder, - placeholderText = 'Filter items', - inputLabel = placeholderText, - selected, - title = isMultiSelectVariant(selected) ? 'Select items' : 'Select an item', - subtitle, - onSelectedChange, - filterValue: externalFilterValue, - onFilterChange: externalOnFilterChange, - items, - footer, - textInputProps, - overlayProps, - loading, - initialLoadingType = 'spinner', - className, - height, - width, - id, - message, - notice, - onCancel, - variant = 'anchored', - secondaryAction, - showSelectedOptionsFirst = true, - disableFullscreenOnNarrow, - align, - showSelectAll = false, - focusPrependedElements, - virtualized, - displayInViewport, - ...listProps -}: SelectPanelProps): JSX.Element { - const titleId = useId() - const subtitleId = useId() - const [dataLoadedOnce, setDataLoadedOnce] = useState(false) - const [isLoading, setIsLoading] = useState(false) - const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate(externalFilterValue, undefined, '') - const {safeSetTimeout, safeClearTimeout} = useSafeTimeout() - const loadingDelayTimeoutId = useRef(null) - const loadingManagedInternally = loading === undefined - const loadingManagedExternally = !loadingManagedInternally - const [inputRef, setInputRef] = React.useState | null>(null) - const [listContainerElement, setListContainerElement] = useState(null) - const [needsNoItemsAnnouncement, setNeedsNoItemsAnnouncement] = useState(false) - const isNarrowScreenSize = useResponsiveValue({narrow: true, regular: false, wide: false}, false) - const [selectedOnSort, setSelectedOnSort] = useState([]) - const initialHeightRef = useRef(0) - const initialScaleRef = useRef(1) - const noticeRef = useRef(null) - const [isKeyboardVisible, setIsKeyboardVisible] = useState(false) - const [availablePanelHeight, setAvailablePanelHeight] = useState(undefined) - const KEYBOARD_VISIBILITY_THRESHOLD = 10 - - const featureFlagFullScreenOnNarrow = useFeatureFlag('primer_react_select_panel_fullscreen_on_narrow') - const usingFullScreenOnNarrow = disableFullscreenOnNarrow ? false : featureFlagFullScreenOnNarrow - const shouldOrderSelectedFirst = - useFeatureFlag('primer_react_select_panel_order_selected_at_top') && showSelectedOptionsFirst - const {isReferenced, labelId} = useFormControlContext() - const selectedValueId = id ? `${id}-selected-value` : undefined - - // Single select modals work differently, they have an intermediate state where the user has selected an item but - // has not yet confirmed the selection. This is the only time the user can cancel the selection. - const isSingleSelectModal = variant === 'modal' && !isMultiSelectVariant(selected) - const [intermediateSelected, setIntermediateSelected] = useState( - isSingleSelectModal ? selected : undefined, - ) - - // Reset the intermediate selected item when the panel is open/closed - useEffect(() => { - setIntermediateSelected(isSingleSelectModal ? selected : undefined) - }, [isSingleSelectModal, open, selected]) - - const onListContainerRefChanged: FilteredActionListProps['onListContainerRefChanged'] = useCallback( - (node: HTMLElement | null) => { - setListContainerElement(node) - if (!node && needsNoItemsAnnouncement) { - setNeedsNoItemsAnnouncement(false) - } - }, - [needsNoItemsAnnouncement], - ) - - const onInputRefChanged = useCallback( - (ref: React.RefObject) => { - setInputRef(ref) - }, - [setInputRef], - ) - - const resetSort = useCallback(() => { - if (isMultiSelectVariant(selected)) { - setSelectedOnSort(selected) - } else if (selected) { - setSelectedOnSort([selected]) - } else { - setSelectedOnSort([]) - } - }, [selected]) - - const onFilterChange: FilteredActionListProps['onFilterChange'] = useCallback( - (value, e) => { - if (loadingManagedInternally) { - if (loadingDelayTimeoutId.current) { - safeClearTimeout(loadingDelayTimeoutId.current) - } - - if (dataLoadedOnce) { - // If data has already been loaded once, delay the spinner a bit. This also helps - // not show and then immediately hide the spinner if items are loaded quickly, i.e. - // not async. - - loadingDelayTimeoutId.current = safeSetTimeout(() => { - setIsLoading(true) - announceLoading() - }, LONG_DELAY_MS) - } else { - // If this is the first data load and there are no items, show the loading spinner - // immediately - - if (items.length === 0) { - setIsLoading(true) - } - - // We still want to announce if loading is taking too long - loadingDelayTimeoutId.current = safeSetTimeout(() => { - announceLoading() - }, LONG_DELAY_MS) - } - } - - externalOnFilterChange(value, e) - setInternalFilterValue(value) - if (!value) { - resetSort() - } - }, - [ - loadingManagedInternally, - externalOnFilterChange, - setInternalFilterValue, - dataLoadedOnce, - safeSetTimeout, - safeClearTimeout, - items.length, - resetSort, - ], - ) - - // Pre-compute a Set of item IDs in the current filtered view for O(1) lookups - const itemsInViewSet = useMemo(() => { - const set = new Set() - for (const item of items) { - if (item.id !== undefined) { - set.add(item.id) - } else { - set.add(item) - } - } - return set - }, [items]) - - const handleSelectAllChange = useCallback( - (checked: boolean) => { - // Exit early if not in multi-select mode - if (!isMultiSelectVariant(selected)) { - return - } - - const multiSelectOnChange = onSelectedChange as SelectPanelMultiSelection['onSelectedChange'] - const selectedArray = selected as ItemInput[] - - // Use Set for O(1) lookup instead of O(n) items.some() - const selectedItemsNotInFilteredView = selectedArray.filter((selectedItem: ItemInput) => { - if (selectedItem.id !== undefined) { - return !itemsInViewSet.has(selectedItem.id) - } - return !itemsInViewSet.has(selectedItem) - }) - - if (checked) { - multiSelectOnChange([...selectedItemsNotInFilteredView, ...items]) - } else { - multiSelectOnChange(selectedItemsNotInFilteredView) - } - }, - [items, itemsInViewSet, onSelectedChange, selected], - ) - - // disable body scroll when the panel is open on narrow screens - useEffect(() => { - if (open && isNarrowScreenSize && usingFullScreenOnNarrow) { - const bodyOverflowStyle = document.body.style.overflow || '' - // If the body is already set to overflow: hidden, it likely means - // that there is already a modal open. In that case, we should bail - // so we don't re-enable scroll after the second dialog is closed. - if (bodyOverflowStyle === 'hidden') { - return - } - - document.body.style.overflow = 'hidden' - - return () => { - document.body.style.overflow = bodyOverflowStyle - } - } - }, [isNarrowScreenSize, open, usingFullScreenOnNarrow]) - - useEffect(() => { - if (open) { - if (items.length === 0 && !(isLoading || loading)) { - // we need to wait for the listContainerElement to disappear before announcing no items, otherwise it will be interrupted - setNeedsNoItemsAnnouncement(true) - } - } - - if (loadingManagedExternally) { - if (items.length > 0) { - setDataLoadedOnce(true) - } - - return - } - - if (isLoading || items.length > 0) { - setIsLoading(false) - setDataLoadedOnce(true) - } - - if (loadingDelayTimeoutId.current) { - safeClearTimeout(loadingDelayTimeoutId.current) - } - - // Only fire this effect if items have changed - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [items]) - - useEffect(() => { - if (inputRef?.current) { - const ref = inputRef.current - - // We would normally expect AnchoredOverlay's focus trap to automatically focus the input, - // but for some reason the ref isn't populated until _after_ the panel is open, which is - // too late. So, we focus manually here. - if (open) { - ref.focus() - } - } - }, [inputRef, open]) - - // Manage loading announcements when loadingManagedExternally - useEffect(() => { - if (loadingManagedExternally) { - if (isLoading) { - // Delay the announcement a bit, just in case the loading is quick - loadingDelayTimeoutId.current = safeSetTimeout(() => { - announceLoading() - }, LONG_DELAY_MS) - } else { - // If loading is done, we can clear the loading announcement - if (loadingDelayTimeoutId.current) { - safeClearTimeout(loadingDelayTimeoutId.current) - } - } - } - }, [isLoading, loadingManagedExternally, safeSetTimeout, safeClearTimeout]) - - // Populate panel with items on first open - useEffect(() => { - if (loadingManagedExternally) return - - // If data was already loaded once, do nothing - if (dataLoadedOnce) return - - // Only load data when the panel is open - if (open) { - // Only trigger filter change event if there are no items - if (items.length === 0) { - // Trigger filter event to populate panel on first open - onFilterChange(filterValue, null) - } - } - }, [open, dataLoadedOnce, onFilterChange, filterValue, items, loadingManagedExternally, listContainerElement]) - - useEffect(() => { - if (!window.visualViewport || !open || !isNarrowScreenSize) { - return - } - - initialHeightRef.current = window.visualViewport.height - initialScaleRef.current = window.visualViewport.scale - - const handleViewportChange = debounce(() => { - if (window.visualViewport) { - const currentScale = window.visualViewport.scale - const isZooming = currentScale !== initialScaleRef.current - if (!isZooming) { - const currentHeight = window.visualViewport.height - const keyboardVisible = initialHeightRef.current - currentHeight > KEYBOARD_VISIBILITY_THRESHOLD - setIsKeyboardVisible(keyboardVisible) - setAvailablePanelHeight(keyboardVisible ? currentHeight : undefined) - } - } - }, 100) - - // keeping this check to satisfy typescript but need eslint to ignore redundancy rule - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (window.visualViewport) { - // Using visualViewport to more reliably detect viewport changes across different browsers, which specifically requires these listeners - // eslint-disable-next-line github/prefer-observers - window.visualViewport.addEventListener('resize', handleViewportChange) - // eslint-disable-next-line github/prefer-observers - window.visualViewport.addEventListener('scroll', handleViewportChange) - } - - return () => { - if (window.visualViewport) { - window.visualViewport.removeEventListener('resize', handleViewportChange) - window.visualViewport.removeEventListener('scroll', handleViewportChange) - } - handleViewportChange.cancel() - } - }, [open, isNarrowScreenSize]) - - useEffect(() => { - const announceNotice = async () => { - if (!noticeRef.current) return - const liveRegion = document.querySelector('live-region') - - liveRegion?.clear() - - await announceFromElement(noticeRef.current, { - from: liveRegion ? liveRegion : undefined, - }) - } - - if (open && notice) { - announceNotice() - } - }, [notice, open]) - - const anchorRef = useProvidedRefOrCreate(externalAnchorRef) - const onOpen: AnchoredOverlayProps['onOpen'] = useCallback( - (gesture: Parameters>[0]) => onOpenChange(true, gesture), - [onOpenChange], - ) - - const onCancelRequested = useCallback(() => { - onOpenChange(false, 'cancel') - }, [onOpenChange]) - - const onClose = useCallback( - ( - gesture: Parameters>[0] | 'selection' | 'escape' | 'close', - ) => { - // Clicking outside should cancel the selection only on modals - if (variant === 'modal' && gesture === 'click-outside') { - onCancel?.() - } - if (gesture === 'close') { - onCancel?.() - onCancelRequested() - } else { - onOpenChange(false, gesture) - } - }, - [onOpenChange, variant, onCancel, onCancelRequested], - ) - - const renderMenuAnchor = useMemo(() => { - if (renderAnchor === null) { - return null - } - - const selectedItems = Array.isArray(selected) ? selected : [...(selected ? [selected] : [])] - const selectedValueText = selectedItems.length ? selectedItems.map(item => item.text).join(', ') : placeholder - const shouldAutoWireLabel = isReferenced === false && Boolean(labelId) && Boolean(selectedValueId) - - return >(props: T) => { - return renderAnchor({ +import React from 'react' + +import { + SecondaryActionButton, + SecondaryActionLink, + SELECT_PANEL_SLOT, + isMultiSelectVariant, + type SelectPanelNextMultiProps, + type SelectPanelNextSingleProps, + type SelectPanelProps, +} from './SelectPanel.shared' +import {SelectPanelLegacy} from './SelectPanelLegacy' +import {SelectPanelNext} from './SelectPanelNext' + +export type {InitialLoadingType, SelectPanelProps, SelectPanelSecondaryAction} from './SelectPanel.shared' + +function SelectPanelRoot(props: SelectPanelProps) { + const selectPanelNextEnabled = useFeatureFlag('primer_react_select_panel_next') + + if (selectPanelNextEnabled) { + if (isMultiSelectVariant(props.selected)) { + const nextProps = { ...props, - ...(shouldAutoWireLabel - ? { - 'aria-labelledby': [labelId, selectedValueId].filter(Boolean).join(' '), - } - : {}), - children: shouldAutoWireLabel ? {selectedValueText} : selectedValueText, - }) - } - }, [placeholder, renderAnchor, selected, isReferenced, labelId, selectedValueId]) - - // Pre-compute a Set of selected item IDs/references for O(1) lookups - // This optimizes isItemCurrentlySelected from O(m) to O(1) per call - const selectedItemsSet = useMemo(() => { - const set = new Set() - if (isMultiSelectVariant(selected)) { - for (const item of selected) { - if (item.id !== undefined) { - set.add(item.id) - } else { - set.add(item) - } - } - } - return set - }, [selected]) - - const isItemCurrentlySelected = useCallback( - (item: ItemInput) => { - // For multi-select, use the pre-computed Set for O(1) lookup - if (isMultiSelectVariant(selected)) { - if (item.id !== undefined) { - return selectedItemsSet.has(item.id) - } - return selectedItemsSet.has(item) - } - - // For single-select modal, there is an intermediate state when the user has selected - // an item but has not yet saved the selection. We need to check for this state. - if (isSingleSelectModal) { - return intermediateSelected?.id !== undefined - ? intermediateSelected.id === item.id - : intermediateSelected === item - } - - // For single-select anchored, we just need to check if the item is the selected item - return selected?.id !== undefined ? selected.id === item.id : selected === item - }, - [selected, selectedItemsSet, intermediateSelected, isSingleSelectModal], - ) - - const itemsToRender = useMemo(() => { - // Pre-compute a Set of selected item IDs/references for O(1) lookups during sorting - // This avoids O(m * k) work per comparison in the sort function - const selectedOnSortSet = new Set() - for (const item of selectedOnSort) { - if (item.id !== undefined) { - selectedOnSortSet.add(item.id) - } else { - selectedOnSortSet.add(item) - } - } - - const isSelectedForSort = (item: ItemProps): boolean => { - if (item.id !== undefined) { - return selectedOnSortSet.has(item.id) - } - return selectedOnSortSet.has(item as unknown as ItemInput) - } + mode: 'multi', + } as SelectPanelNextMultiProps - return items - .map(item => { - return { - ...item, - role: 'option', - id: item.id, - selected: 'selected' in item && item.selected === undefined ? undefined : isItemCurrentlySelected(item), - onAction: (itemFromAction, event) => { - item.onAction?.(itemFromAction, event) - - if (event.defaultPrevented) { - return - } - - if (isMultiSelectVariant(selected)) { - const otherSelectedItems = selected.filter(selectedItem => !areItemsEqual(selectedItem, item)) - const newSelectedItems = doesItemsIncludeItem(selected, item) - ? otherSelectedItems - : [...otherSelectedItems, item] - - const multiSelectOnChange = onSelectedChange as SelectPanelMultiSelection['onSelectedChange'] - multiSelectOnChange(newSelectedItems) - return - } - - if (isSingleSelectModal) { - if (intermediateSelected?.id === item.id) { - // if the item is already selected, we need to unselect it - setIntermediateSelected(undefined) - } else { - setIntermediateSelected(item) - } - return - } - // single select anchored, direct save on click - const singleSelectOnChange = onSelectedChange as SelectPanelSingleSelection['onSelectedChange'] - singleSelectOnChange(item === selected ? undefined : item) - onClose('selection') - }, - } as ItemProps - }) - .sort((itemA, itemB) => { - if (shouldOrderSelectedFirst) { - const itemASelected = isSelectedForSort(itemA) - const itemBSelected = isSelectedForSort(itemB) - - // order selected items first - if (itemASelected && !itemBSelected) { - return -1 - } else if (!itemASelected && itemBSelected) { - return 1 - } - } - - return 0 - }) - }, [ - onClose, - onSelectedChange, - items, - selected, - isItemCurrentlySelected, - isSingleSelectModal, - intermediateSelected, - shouldOrderSelectedFirst, - selectedOnSort, - ]) - - // Track previous items and reset sort when items first load - const prevItemsRef = useRef(items) - useEffect(() => { - if (prevItemsRef.current !== items) { - if (prevItemsRef.current.length === 0 && items.length > 0) { - resetSort() - } - prevItemsRef.current = items - } - }, [items, resetSort]) - - // Reset sort when panel opens - const prevOpenRef = useRef(open) - useEffect(() => { - if (prevOpenRef.current !== open) { - resetSort() - prevOpenRef.current = open + return } - }, [open, resetSort]) - const focusTrapSettings = useMemo(() => ({initialFocusRef: inputRef || undefined}), [inputRef]) + const nextProps = { + ...props, + mode: 'single', + } as SelectPanelNextSingleProps - const extendedTextInputProps: Partial = useMemo(() => { - return { - className: classes.TextInput, - contrast: true, - leadingVisual: SearchIcon, - 'aria-label': inputLabel, - ...textInputProps, - } - }, [inputLabel, textInputProps]) - - const loadingType = (): FilteredActionListLoadingType => { - if (dataLoadedOnce) { - return FilteredActionListLoadingTypes.input - } else { - if (initialLoadingType === 'spinner') { - return FilteredActionListLoadingTypes.bodySpinner - } else { - return FilteredActionListLoadingTypes.bodySkeleton - } - } + return } - function getMessage() { - if (items.length === 0 && !message) { - return DefaultEmptyMessage - } else if (message) { - return ( - - {message.body} - - ) - } - } - - // We add permanent save and cancel buttons on: - // - modals - const showPermanentCancelSaveButtons = variant === 'modal' - - // The next two could be collapsed, left them separate for readability - - // We add a responsive save and cancel button on: - // - anchored panels with multi select if there is onCancel - const showResponsiveCancelSaveButtons = - variant !== 'modal' && usingFullScreenOnNarrow && isMultiSelectVariant(selected) && onCancel !== undefined - - // The responsive save and close button is only covering a very specific case: - // - anchored panel with multi select if there is no onCancel. - // This variant should disappear in the future, once onCancel is required, - // but for now we need to support it so there is a user friendly way to close the panel. - const showResponsiveSaveAndCloseButton = - variant !== 'modal' && usingFullScreenOnNarrow && isMultiSelectVariant(selected) && onCancel === undefined - - // If there is any element in the footer, we render it. - const renderFooter = - secondaryAction !== undefined || - showPermanentCancelSaveButtons || - showResponsiveSaveAndCloseButton || - showResponsiveCancelSaveButtons - - // If there's any permanent elements in the footer, we show it always. - // The save button is only shown on small screens. - const displayFooter = - secondaryAction !== undefined || showPermanentCancelSaveButtons - ? 'always' - : showResponsiveSaveAndCloseButton || showResponsiveCancelSaveButtons - ? 'only-small' - : undefined - - const stretchSecondaryAction = - showResponsiveSaveAndCloseButton || showResponsiveCancelSaveButtons - ? 'only-big' - : showPermanentCancelSaveButtons - ? 'never' - : 'always' - - const stretchSaveButton = showResponsiveSaveAndCloseButton && secondaryAction === undefined ? 'only-small' : 'never' - - /* - * SelectPanel uses two close button implementations for different use cases: - * - * 1. AnchoredOverlay close button - Enabled on narrow screens (showXCloseIcon logic) - * - * 2. SelectPanel modal close button - Used for modal variant on wider screens - * (variant === 'modal' && !isNarrowScreenSize logic below) - * - * The dual approach handles different responsive behaviors: AnchoredOverlay manages - * close functionality for narrow fullscreen, while SelectPanel handles modal close on desktop. - */ - const showXCloseIcon = (onCancel !== undefined || !isMultiSelectVariant(selected)) && usingFullScreenOnNarrow - - const currentResponsiveVariant = useResponsiveValue( - usingFullScreenOnNarrow ? {regular: 'anchored', narrow: 'fullscreen'} : undefined, - 'anchored', - ) - - const preventBubbling = useCallback( - (event: React.KeyboardEvent) => { - overlayProps?.onKeyDown?.(event as unknown as React.KeyboardEvent) - - // skip if a TextInput has focus - const activeElement = document.activeElement as HTMLElement - if (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA') return - - // skip if used with modifier to preserve shortcuts like ⌘ + F - const hasModifier = event.ctrlKey || event.altKey || event.metaKey - if (hasModifier) return - - // skip if it's not the forward slash or an alphabet key - if (event.key !== '/' && !isAlphabetKey(event.nativeEvent as KeyboardEvent)) { - return - } - - // if this is a typeahead event, don't propagate outside of menu - event.stopPropagation() - }, - [overlayProps], - ) - - const mergedOverlayProps = useMemo( - () => ({ - role: 'dialog' as const, - 'aria-labelledby': titleId, - 'aria-describedby': subtitle ? subtitleId : undefined, - ...overlayProps, - ...(variant === 'modal' - ? { - top: '50vh' as const, - left: '50vw' as const, - anchorSide: undefined, - } - : {}), - style: { - transform: variant === 'modal' ? 'translate(-50%, -50%)' : undefined, - ...(isKeyboardVisible - ? { - maxHeight: availablePanelHeight !== undefined ? `${availablePanelHeight}px` : 'auto', - } - : {}), - } as React.CSSProperties, - onKeyDown: preventBubbling, - }), - [titleId, subtitle, subtitleId, overlayProps, variant, isKeyboardVisible, availablePanelHeight, preventBubbling], - ) - - return ( - <> - -
-
-
- - {title} - - {subtitle ? ( -
- {subtitle} -
- ) : null} -
- {/* AnchoredOverlay displays the close button on narrow screens */} - {variant === 'modal' && !isNarrowScreenSize ? ( - { - onCancel?.() - onCancelRequested() - }} - /> - ) : null} -
- {notice && ( -
- -
- )} - - {footer ? ( -
{footer}
- ) : renderFooter ? ( -
-
- {secondaryAction} -
- {showPermanentCancelSaveButtons || showResponsiveCancelSaveButtons ? ( -
- - -
- ) : null} - {showResponsiveSaveAndCloseButton ? ( -
- -
- ) : null} -
- ) : null} -
-
- {variant === 'modal' && open ?
: null} - - ) -} - -const SecondaryButton: React.FC = props => { - return ( - - ) -} - -const SecondaryLink: React.FC = props => { - return ( - - {props.children} - - ) + return } -export const SelectPanel = Object.assign(Panel, { - __SLOT__: Symbol('SelectPanel'), - SecondaryActionButton: SecondaryButton, - SecondaryActionLink: SecondaryLink, +export const SelectPanel = Object.assign(SelectPanelRoot, { + __SLOT__: SELECT_PANEL_SLOT, + SecondaryActionButton, + SecondaryActionLink, }) diff --git a/packages/react/src/SelectPanel/SelectPanelLegacy.tsx b/packages/react/src/SelectPanel/SelectPanelLegacy.tsx new file mode 100644 index 00000000000..6ba69e52c7c --- /dev/null +++ b/packages/react/src/SelectPanel/SelectPanelLegacy.tsx @@ -0,0 +1,820 @@ +import {SearchIcon, XIcon} from '@primer/octicons-react' +import {announceFromElement} from '@primer/live-region-element' +import {debounce} from '@github/mini-throttle' +import {clsx} from 'clsx' +import React, {useCallback, useEffect, useMemo, useRef, useState, type JSX} from 'react' + +import {AnchoredOverlay} from '../AnchoredOverlay' +import type {AnchoredOverlayProps} from '../AnchoredOverlay' +import {Banner} from '../Banner' +import {Button, IconButton} from '../Button' +import {useFeatureFlag} from '../FeatureFlags' +import {FilteredActionList} from '../FilteredActionList' +import type {FilteredActionListProps, ItemInput, ItemProps} from '../FilteredActionList' +import {FilteredActionListLoadingTypes} from '../FilteredActionList/FilteredActionListLoaders' +import {useFormControlContext} from '../FormControl/_FormControlContext' +import Heading from '../Heading' +import {useProvidedRefOrCreate} from '../hooks' +import {useId} from '../hooks/useId' +import {isAlphabetKey} from '../hooks/useMnemonics' +import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate' +import {useResponsiveValue} from '../hooks/useResponsiveValue' +import useSafeTimeout from '../hooks/useSafeTimeout' +import classes from './SelectPanel.module.css' +import {SelectPanelMessage} from './SelectPanelMessage' +import { + DefaultEmptyMessage, + LONG_DELAY_MS, + announceLoading, + areItemsEqual, + closeButtonProps, + defaultRenderAnchor, + doesItemsIncludeItem, + focusZoneSettings, + isMultiSelectVariant, + type SelectPanelMultiSelection, + type SelectPanelProps, + type SelectPanelSingleSelection, +} from './SelectPanel.shared' + +const EMPTY_MESSAGE = { + title: 'No items available', + description: '', +} + +export function SelectPanelLegacy({ + open, + onOpenChange, + renderAnchor = defaultRenderAnchor, + anchorRef: externalAnchorRef, + placeholder, + placeholderText = 'Filter items', + inputLabel = placeholderText, + selected, + title = isMultiSelectVariant(selected) ? 'Select items' : 'Select an item', + subtitle, + onSelectedChange, + filterValue: externalFilterValue, + onFilterChange: externalOnFilterChange, + items, + footer, + textInputProps, + overlayProps, + loading, + initialLoadingType = 'spinner', + className, + height, + width, + id, + message, + notice, + onCancel, + variant = 'anchored', + secondaryAction, + showSelectedOptionsFirst = true, + disableFullscreenOnNarrow, + align, + showSelectAll = false, + focusPrependedElements, + virtualized, + displayInViewport, + ...listProps +}: SelectPanelProps): JSX.Element { + const titleId = useId() + const subtitleId = useId() + const [dataLoadedOnce, setDataLoadedOnce] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate(externalFilterValue, undefined, '') + const {safeSetTimeout, safeClearTimeout} = useSafeTimeout() + const loadingDelayTimeoutId = useRef(null) + const loadingManagedInternally = loading === undefined + const loadingManagedExternally = !loadingManagedInternally + const [inputRef, setInputRef] = React.useState | null>(null) + const [needsNoItemsAnnouncement, setNeedsNoItemsAnnouncement] = useState(false) + const isNarrowScreenSize = useResponsiveValue({narrow: true, regular: false, wide: false}, false) + const [selectedOnSort, setSelectedOnSort] = useState([]) + const initialHeightRef = useRef(0) + const initialScaleRef = useRef(1) + const noticeRef = useRef(null) + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false) + const [availablePanelHeight, setAvailablePanelHeight] = useState(undefined) + const KEYBOARD_VISIBILITY_THRESHOLD = 10 + + const featureFlagFullScreenOnNarrow = useFeatureFlag('primer_react_select_panel_fullscreen_on_narrow') + const usingFullScreenOnNarrow = disableFullscreenOnNarrow ? false : featureFlagFullScreenOnNarrow + const shouldOrderSelectedFirst = + useFeatureFlag('primer_react_select_panel_order_selected_at_top') && showSelectedOptionsFirst + const {isReferenced, labelId} = useFormControlContext() + const selectedValueId = id ? `${id}-selected-value` : undefined + + const isSingleSelectModal = variant === 'modal' && !isMultiSelectVariant(selected) + const [intermediateSelected, setIntermediateSelected] = useState( + isSingleSelectModal ? selected : undefined, + ) + + useEffect(() => { + setIntermediateSelected(isSingleSelectModal ? selected : undefined) + }, [isSingleSelectModal, open, selected]) + + const onListContainerRefChanged: FilteredActionListProps['onListContainerRefChanged'] = useCallback( + (node: HTMLElement | null) => { + if (!node && needsNoItemsAnnouncement) { + setNeedsNoItemsAnnouncement(false) + } + }, + [needsNoItemsAnnouncement], + ) + + const onInputRefChanged = useCallback((ref: React.RefObject) => { + setInputRef(ref) + }, []) + + const resetSort = useCallback(() => { + if (isMultiSelectVariant(selected)) { + setSelectedOnSort(selected) + } else if (selected) { + setSelectedOnSort([selected]) + } else { + setSelectedOnSort([]) + } + }, [selected]) + + const onFilterChange: FilteredActionListProps['onFilterChange'] = useCallback( + (value, event) => { + if (loadingManagedInternally) { + if (loadingDelayTimeoutId.current) { + safeClearTimeout(loadingDelayTimeoutId.current) + } + + const shouldSkipInitialInternalLoading = + externalFilterValue !== undefined && !dataLoadedOnce && items.length === 0 + + if (dataLoadedOnce) { + loadingDelayTimeoutId.current = safeSetTimeout(() => { + setIsLoading(true) + announceLoading() + }, LONG_DELAY_MS) + } else if (!shouldSkipInitialInternalLoading) { + if (items.length === 0) { + setIsLoading(true) + } + + loadingDelayTimeoutId.current = safeSetTimeout(() => { + announceLoading() + }, LONG_DELAY_MS) + } + } + + externalOnFilterChange(value, event) + setInternalFilterValue(value) + if (!value) { + resetSort() + } + }, + [ + externalFilterValue, + dataLoadedOnce, + externalOnFilterChange, + items.length, + loadingManagedInternally, + resetSort, + safeClearTimeout, + safeSetTimeout, + setInternalFilterValue, + ], + ) + + const itemsInViewSet = useMemo(() => { + const set = new Set() + + for (const item of items) { + if (item.id !== undefined) { + set.add(item.id) + } else { + set.add(item) + } + } + + return set + }, [items]) + + const handleSelectAllChange = useCallback( + (checked: boolean) => { + if (!isMultiSelectVariant(selected)) { + return + } + + const multiSelectOnChange = onSelectedChange as SelectPanelMultiSelection['onSelectedChange'] + const selectedItemsNotInFilteredView = selected.filter(selectedItem => { + if (selectedItem.id !== undefined) { + return !itemsInViewSet.has(selectedItem.id) + } + + return !itemsInViewSet.has(selectedItem) + }) + + if (checked) { + multiSelectOnChange([...selectedItemsNotInFilteredView, ...items]) + } else { + multiSelectOnChange(selectedItemsNotInFilteredView) + } + }, + [items, itemsInViewSet, onSelectedChange, selected], + ) + + useEffect(() => { + if (open && isNarrowScreenSize && usingFullScreenOnNarrow) { + const bodyOverflowStyle = document.body.style.overflow || '' + if (bodyOverflowStyle === 'hidden') { + return + } + + document.body.style.overflow = 'hidden' + + return () => { + document.body.style.overflow = bodyOverflowStyle + } + } + }, [isNarrowScreenSize, open, usingFullScreenOnNarrow]) + + useEffect(() => { + if (open && items.length === 0 && !(isLoading || loading)) { + setNeedsNoItemsAnnouncement(true) + } + + if (loadingManagedExternally) { + if (items.length > 0) { + setDataLoadedOnce(true) + } + + return + } + + if (isLoading || items.length > 0) { + setIsLoading(false) + setDataLoadedOnce(true) + } + + if (loadingDelayTimeoutId.current) { + safeClearTimeout(loadingDelayTimeoutId.current) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items]) + + useEffect(() => { + if (inputRef?.current && open) { + inputRef.current.focus() + } + }, [inputRef, open]) + + useEffect(() => { + if (!loadingManagedExternally) { + return + } + + if (isLoading) { + loadingDelayTimeoutId.current = safeSetTimeout(() => { + announceLoading() + }, LONG_DELAY_MS) + } else if (loadingDelayTimeoutId.current) { + safeClearTimeout(loadingDelayTimeoutId.current) + } + }, [isLoading, loadingManagedExternally, safeClearTimeout, safeSetTimeout]) + + useEffect(() => { + if (loadingManagedExternally || dataLoadedOnce || !open || items.length > 0) { + return + } + + onFilterChange(filterValue, null) + }, [dataLoadedOnce, filterValue, items.length, loadingManagedExternally, onFilterChange, open]) + + useEffect(() => { + if (!window.visualViewport || !open || !isNarrowScreenSize) { + return + } + + initialHeightRef.current = window.visualViewport.height + initialScaleRef.current = window.visualViewport.scale + + const handleViewportChange = debounce(() => { + if (!window.visualViewport) { + return + } + + const isZooming = window.visualViewport.scale !== initialScaleRef.current + if (isZooming) { + return + } + + const currentHeight = window.visualViewport.height + const keyboardVisible = initialHeightRef.current - currentHeight > KEYBOARD_VISIBILITY_THRESHOLD + setIsKeyboardVisible(keyboardVisible) + setAvailablePanelHeight(keyboardVisible ? currentHeight : undefined) + }, 100) + + // Using visualViewport to track the virtual keyboard requires these listeners. + // eslint-disable-next-line github/prefer-observers + window.visualViewport.addEventListener('resize', handleViewportChange) + // eslint-disable-next-line github/prefer-observers + window.visualViewport.addEventListener('scroll', handleViewportChange) + + return () => { + if (window.visualViewport) { + window.visualViewport.removeEventListener('resize', handleViewportChange) + window.visualViewport.removeEventListener('scroll', handleViewportChange) + } + handleViewportChange.cancel() + } + }, [isNarrowScreenSize, open]) + + useEffect(() => { + const announceNotice = async () => { + if (!noticeRef.current) { + return + } + + const liveRegion = document.querySelector('live-region') + liveRegion?.clear() + + await announceFromElement(noticeRef.current, { + from: liveRegion || undefined, + }) + } + + if (open && notice) { + announceNotice() + } + }, [notice, open]) + + const anchorRef = useProvidedRefOrCreate(externalAnchorRef) + const onOpen: AnchoredOverlayProps['onOpen'] = useCallback( + (gesture: Parameters>[0]) => onOpenChange(true, gesture), + [onOpenChange], + ) + + const onCancelRequested = useCallback(() => { + onOpenChange(false, 'cancel') + }, [onOpenChange]) + + const onClose = useCallback( + ( + gesture: Parameters>[0] | 'selection' | 'escape' | 'close', + ) => { + if (variant === 'modal' && gesture === 'click-outside') { + onCancel?.() + } + + if (gesture === 'close') { + onCancel?.() + onCancelRequested() + } else { + onOpenChange(false, gesture) + } + }, + [onCancel, onCancelRequested, onOpenChange, variant], + ) + + const renderMenuAnchor = useMemo(() => { + if (renderAnchor === null) { + return null + } + + const selectedItems = Array.isArray(selected) ? selected : [...(selected ? [selected] : [])] + const selectedValueText = selectedItems.length ? selectedItems.map(item => item.text).join(', ') : placeholder + const shouldAutoWireLabel = isReferenced === false && Boolean(labelId) && Boolean(selectedValueId) + + return >(props: T) => { + return renderAnchor({ + ...props, + ...(shouldAutoWireLabel + ? { + 'aria-labelledby': [labelId, selectedValueId].filter(Boolean).join(' '), + } + : {}), + children: shouldAutoWireLabel ? {selectedValueText} : selectedValueText, + }) + } + }, [isReferenced, labelId, placeholder, renderAnchor, selected, selectedValueId]) + + const selectedItemsSet = useMemo(() => { + const set = new Set() + + if (isMultiSelectVariant(selected)) { + for (const item of selected) { + if (item.id !== undefined) { + set.add(item.id) + } else { + set.add(item) + } + } + } + + return set + }, [selected]) + + const isItemCurrentlySelected = useCallback( + (item: ItemInput) => { + if (isMultiSelectVariant(selected)) { + if (item.id !== undefined) { + return selectedItemsSet.has(item.id) + } + + return selectedItemsSet.has(item) + } + + if (isSingleSelectModal) { + return intermediateSelected?.id !== undefined + ? intermediateSelected.id === item.id + : intermediateSelected === item + } + + return selected?.id !== undefined ? selected.id === item.id : selected === item + }, + [intermediateSelected, isSingleSelectModal, selected, selectedItemsSet], + ) + + const itemsToRender = useMemo(() => { + const selectedOnSortSet = new Set() + for (const item of selectedOnSort) { + if (item.id !== undefined) { + selectedOnSortSet.add(item.id) + } else { + selectedOnSortSet.add(item) + } + } + + const isSelectedForSort = (item: ItemProps) => { + if (item.id !== undefined) { + return selectedOnSortSet.has(item.id) + } + + return selectedOnSortSet.has(item as unknown as ItemInput) + } + + return items + .map(item => { + return { + ...item, + role: 'option', + id: item.id, + selected: 'selected' in item && item.selected === undefined ? undefined : isItemCurrentlySelected(item), + onAction: (itemFromAction, event) => { + item.onAction?.(itemFromAction, event) + + if (event.defaultPrevented) { + return + } + + if (isMultiSelectVariant(selected)) { + const otherSelectedItems = selected.filter(selectedItem => !areItemsEqual(selectedItem, item)) + const newSelectedItems = doesItemsIncludeItem(selected, item) + ? otherSelectedItems + : [...otherSelectedItems, item] + + const multiSelectOnChange = onSelectedChange as SelectPanelMultiSelection['onSelectedChange'] + multiSelectOnChange(newSelectedItems) + return + } + + if (isSingleSelectModal) { + if (intermediateSelected?.id === item.id) { + setIntermediateSelected(undefined) + } else { + setIntermediateSelected(item) + } + return + } + + const singleSelectOnChange = onSelectedChange as SelectPanelSingleSelection['onSelectedChange'] + singleSelectOnChange(item === selected ? undefined : item) + onClose('selection') + }, + } as ItemProps + }) + .sort((itemA, itemB) => { + if (!shouldOrderSelectedFirst) { + return 0 + } + + const itemASelected = isSelectedForSort(itemA) + const itemBSelected = isSelectedForSort(itemB) + + if (itemASelected && !itemBSelected) { + return -1 + } + + if (!itemASelected && itemBSelected) { + return 1 + } + + return 0 + }) + }, [ + intermediateSelected, + isItemCurrentlySelected, + isSingleSelectModal, + items, + onClose, + onSelectedChange, + selected, + selectedOnSort, + shouldOrderSelectedFirst, + ]) + + const prevItemsRef = useRef(items) + useEffect(() => { + if (prevItemsRef.current !== items) { + if (prevItemsRef.current.length === 0 && items.length > 0) { + resetSort() + } + prevItemsRef.current = items + } + }, [items, resetSort]) + + const prevOpenRef = useRef(open) + useEffect(() => { + if (prevOpenRef.current !== open) { + resetSort() + prevOpenRef.current = open + } + }, [open, resetSort]) + + const focusTrapSettings = useMemo(() => ({initialFocusRef: inputRef || undefined}), [inputRef]) + + const extendedTextInputProps = useMemo(() => { + return { + className: classes.TextInput, + contrast: true, + leadingVisual: SearchIcon, + 'aria-label': inputLabel, + ...textInputProps, + } + }, [inputLabel, textInputProps]) + + const loadingType = () => { + if (dataLoadedOnce) { + return FilteredActionListLoadingTypes.input + } + + return initialLoadingType === 'spinner' + ? FilteredActionListLoadingTypes.bodySpinner + : FilteredActionListLoadingTypes.bodySkeleton + } + + const getMessage = () => { + if (items.length === 0 && !message) { + return DefaultEmptyMessage + } + + if (message) { + return ( + + {message.body} + + ) + } + + return undefined + } + + const showPermanentCancelSaveButtons = variant === 'modal' + const showResponsiveCancelSaveButtons = + variant !== 'modal' && usingFullScreenOnNarrow && isMultiSelectVariant(selected) && onCancel !== undefined + const showResponsiveSaveAndCloseButton = + variant !== 'modal' && usingFullScreenOnNarrow && isMultiSelectVariant(selected) && onCancel === undefined + + const renderFooter = + secondaryAction !== undefined || + showPermanentCancelSaveButtons || + showResponsiveSaveAndCloseButton || + showResponsiveCancelSaveButtons + + const displayFooter = + secondaryAction !== undefined || showPermanentCancelSaveButtons + ? 'always' + : showResponsiveSaveAndCloseButton || showResponsiveCancelSaveButtons + ? 'only-small' + : undefined + + const stretchSecondaryAction = + showResponsiveSaveAndCloseButton || showResponsiveCancelSaveButtons + ? 'only-big' + : showPermanentCancelSaveButtons + ? 'never' + : 'always' + + const stretchSaveButton = showResponsiveSaveAndCloseButton && secondaryAction === undefined ? 'only-small' : 'never' + + const showXCloseIcon = (onCancel !== undefined || !isMultiSelectVariant(selected)) && usingFullScreenOnNarrow + + const shouldShowInitialControlledEmptyState = + externalFilterValue !== undefined && !dataLoadedOnce && items.length === 0 + + const currentResponsiveVariant = useResponsiveValue( + usingFullScreenOnNarrow ? {regular: 'anchored', narrow: 'fullscreen'} : undefined, + 'anchored', + ) + + const preventBubbling = useCallback( + (event: React.KeyboardEvent) => { + overlayProps?.onKeyDown?.(event as unknown as React.KeyboardEvent) + + const activeElement = document.activeElement as HTMLElement + if (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA') { + return + } + + const hasModifier = event.ctrlKey || event.altKey || event.metaKey + if (hasModifier) { + return + } + + if (event.key !== '/' && !isAlphabetKey(event.nativeEvent as KeyboardEvent)) { + return + } + + event.stopPropagation() + }, + [overlayProps], + ) + + const mergedOverlayProps = useMemo(() => { + return { + role: 'dialog' as const, + 'aria-labelledby': titleId, + 'aria-describedby': subtitle ? subtitleId : undefined, + ...overlayProps, + ...(variant === 'modal' + ? { + top: '50vh' as const, + left: '50vw' as const, + anchorSide: undefined, + } + : {}), + style: { + transform: variant === 'modal' ? 'translate(-50%, -50%)' : undefined, + ...(isKeyboardVisible + ? { + maxHeight: availablePanelHeight !== undefined ? `${availablePanelHeight}px` : 'auto', + } + : {}), + } as React.CSSProperties, + onKeyDown: preventBubbling, + } + }, [availablePanelHeight, isKeyboardVisible, overlayProps, preventBubbling, subtitle, subtitleId, titleId, variant]) + + return ( + <> + +
+
+
+ + {title} + + {subtitle ? ( +
+ {subtitle} +
+ ) : null} +
+ {variant === 'modal' && !isNarrowScreenSize ? ( + { + onCancel?.() + onCancelRequested() + }} + /> + ) : null} +
+ {notice ? ( +
+ +
+ ) : null} + + {footer ? ( +
{footer}
+ ) : renderFooter ? ( +
+
+ {secondaryAction} +
+ {showPermanentCancelSaveButtons || showResponsiveCancelSaveButtons ? ( +
+ + +
+ ) : null} + {showResponsiveSaveAndCloseButton ? ( +
+ +
+ ) : null} +
+ ) : null} +
+
+ {variant === 'modal' && open ?
: null} + + ) +} diff --git a/packages/react/src/SelectPanel/SelectPanelNext.hooks.ts b/packages/react/src/SelectPanel/SelectPanelNext.hooks.ts new file mode 100644 index 00000000000..eba4203fd4e --- /dev/null +++ b/packages/react/src/SelectPanel/SelectPanelNext.hooks.ts @@ -0,0 +1,229 @@ +import {announceFromElement} from '@primer/live-region-element' +import {debounce} from '@github/mini-throttle' +import type React from 'react' +import {useEffect, useRef} from 'react' + +import type {FilteredActionListProps} from '../FilteredActionList' +import {LONG_DELAY_MS, announceLoading, toSelectedItemArray, type SelectPanelProps} from './SelectPanel.shared' +import type {SelectPanelNextAction} from './SelectPanelNext.state' + +const KEYBOARD_VISIBILITY_THRESHOLD = 10 + +export function useFullscreenBodyLock({ + isNarrowScreenSize, + open, + usingFullScreenOnNarrow, +}: { + isNarrowScreenSize: boolean + open: boolean + usingFullScreenOnNarrow: boolean +}) { + useEffect(() => { + if (!open || !isNarrowScreenSize || !usingFullScreenOnNarrow) { + return + } + + const bodyOverflowStyle = document.body.style.overflow || '' + if (bodyOverflowStyle === 'hidden') { + return + } + + document.body.style.overflow = 'hidden' + + return () => { + document.body.style.overflow = bodyOverflowStyle + } + }, [isNarrowScreenSize, open, usingFullScreenOnNarrow]) +} + +export function useKeyboardViewport({ + dispatch, + isNarrowScreenSize, + open, +}: { + dispatch: React.Dispatch + isNarrowScreenSize: boolean + open: boolean +}) { + const initialHeightRef = useRef(0) + const initialScaleRef = useRef(1) + + useEffect(() => { + if (!window.visualViewport || !open || !isNarrowScreenSize) { + dispatch({type: 'set-keyboard-visibility', keyboardVisible: false}) + return + } + + initialHeightRef.current = window.visualViewport.height + initialScaleRef.current = window.visualViewport.scale + + const handleViewportChange = debounce(() => { + if (!window.visualViewport) { + return + } + + const isZooming = window.visualViewport.scale !== initialScaleRef.current + if (isZooming) { + return + } + + const currentHeight = window.visualViewport.height + const keyboardVisible = initialHeightRef.current - currentHeight > KEYBOARD_VISIBILITY_THRESHOLD + + dispatch({ + type: 'set-keyboard-visibility', + keyboardVisible, + availablePanelHeight: keyboardVisible ? currentHeight : undefined, + }) + }, 100) + + // Using visualViewport to track the virtual keyboard requires these listeners. + // eslint-disable-next-line github/prefer-observers + window.visualViewport.addEventListener('resize', handleViewportChange) + // eslint-disable-next-line github/prefer-observers + window.visualViewport.addEventListener('scroll', handleViewportChange) + + return () => { + if (window.visualViewport) { + window.visualViewport.removeEventListener('resize', handleViewportChange) + window.visualViewport.removeEventListener('scroll', handleViewportChange) + } + handleViewportChange.cancel() + dispatch({type: 'set-keyboard-visibility', keyboardVisible: false}) + } + }, [dispatch, isNarrowScreenSize, open]) +} + +export function useNoticeAnnouncement({ + notice, + noticeRef, + open, +}: { + notice: SelectPanelProps['notice'] + noticeRef: React.RefObject + open: boolean +}) { + useEffect(() => { + const announceNotice = async () => { + if (!noticeRef.current) { + return + } + + const liveRegion = document.querySelector('live-region') + liveRegion?.clear() + + await announceFromElement(noticeRef.current, { + from: liveRegion || undefined, + }) + } + + if (open && notice) { + announceNotice() + } + }, [notice, noticeRef, open]) +} + +export function useSyncSelectPanelNextState({ + dispatch, + open, + selected, +}: { + dispatch: React.Dispatch + open: boolean + selected: SelectPanelProps['selected'] +}) { + useEffect(() => { + dispatch({ + type: 'sync-open-state', + selectedItems: toSelectedItemArray(selected), + }) + }, [dispatch, open, selected]) +} + +export function useMarkLoadedOnItems({ + dispatch, + itemsLength, + clearPendingLoadingAnnouncement, +}: { + dispatch: React.Dispatch + itemsLength: number + clearPendingLoadingAnnouncement: () => void +}) { + useEffect(() => { + if (itemsLength > 0) { + dispatch({type: 'mark-loaded'}) + clearPendingLoadingAnnouncement() + } + }, [clearPendingLoadingAnnouncement, dispatch, itemsLength]) +} + +export function useFocusInputOnOpen({ + inputRef, + open, +}: { + inputRef: React.RefObject | null + open: boolean +}) { + useEffect(() => { + if (!inputRef?.current || !open) { + return + } + + inputRef.current.focus() + }, [inputRef, open]) +} + +export function useManagedLoadingAnnouncement({ + loading, + loadingManagedInternally, + clearPendingLoadingAnnouncement, + scheduleLoadingAnnouncement, +}: { + loading: SelectPanelProps['loading'] + loadingManagedInternally: boolean + clearPendingLoadingAnnouncement: () => void + scheduleLoadingAnnouncement: (callback: () => void, delayMs: number) => number +}) { + useEffect(() => { + if (loadingManagedInternally) { + return + } + + if (!loading) { + clearPendingLoadingAnnouncement() + return + } + + scheduleLoadingAnnouncement(() => { + announceLoading() + }, LONG_DELAY_MS) + + return () => { + clearPendingLoadingAnnouncement() + } + }, [clearPendingLoadingAnnouncement, loading, loadingManagedInternally, scheduleLoadingAnnouncement]) +} + +export function usePopulateOnFirstOpen({ + dataLoadedOnce, + filterValue, + itemsLength, + loadingManagedInternally, + onFilterChange, + open, +}: { + dataLoadedOnce: boolean + filterValue: string + itemsLength: number + loadingManagedInternally: boolean + onFilterChange: FilteredActionListProps['onFilterChange'] + open: boolean +}) { + useEffect(() => { + if (!loadingManagedInternally || dataLoadedOnce || !open || itemsLength > 0) { + return + } + + onFilterChange(filterValue, null) + }, [dataLoadedOnce, filterValue, itemsLength, loadingManagedInternally, onFilterChange, open]) +} diff --git a/packages/react/src/SelectPanel/SelectPanelNext.multi.utils.ts b/packages/react/src/SelectPanel/SelectPanelNext.multi.utils.ts new file mode 100644 index 00000000000..f21a53be277 --- /dev/null +++ b/packages/react/src/SelectPanel/SelectPanelNext.multi.utils.ts @@ -0,0 +1,105 @@ +import type {ItemInput, ItemProps} from '../FilteredActionList' + +import {areItemsEqual, doesItemsIncludeItem, type SelectPanelMultiSelection} from './SelectPanel.shared' +import {sortItemsBySelection} from './SelectPanelNext.shared.utils' + +export function getItemsInViewSet(items: ItemInput[]) { + const set = new Set() + + for (const item of items) { + if (item.id !== undefined) { + set.add(item.id) + } else { + set.add(item) + } + } + + return set +} + +export function getSelectedItemsSet(selected: ItemInput[]) { + const set = new Set() + + for (const item of selected) { + if (item.id !== undefined) { + set.add(item.id) + } else { + set.add(item) + } + } + + return set +} + +export function getNextMultiSelectedItemsAfterSelectAll({ + checked, + items, + itemsInViewSet, + selected, +}: { + checked: boolean + items: ItemInput[] + itemsInViewSet: Set + selected: ItemInput[] +}) { + const selectedItemsNotInFilteredView = selected.filter(selectedItem => { + if (selectedItem.id !== undefined) { + return !itemsInViewSet.has(selectedItem.id) + } + + return !itemsInViewSet.has(selectedItem) + }) + + if (checked) { + return [...selectedItemsNotInFilteredView, ...items] + } + + return selectedItemsNotInFilteredView +} + +export function buildMultiItemsToRender({ + items, + onSelectedChange, + selected, + selectedItemsSet, + selectedOnSort, + shouldOrderSelectedFirst, +}: { + items: ItemInput[] + onSelectedChange: SelectPanelMultiSelection['onSelectedChange'] + selected: ItemInput[] + selectedItemsSet: Set + selectedOnSort: ItemInput[] + shouldOrderSelectedFirst: boolean +}): ItemProps[] { + const itemsWithSelectionState = items.map(item => { + const selectedValue = item.id !== undefined ? selectedItemsSet.has(item.id) : selectedItemsSet.has(item) + + return { + ...item, + role: 'option', + id: item.id, + selected: 'selected' in item && item.selected === undefined ? undefined : selectedValue, + onAction: (itemFromAction, event) => { + item.onAction?.(itemFromAction, event) + + if (event.defaultPrevented) { + return + } + + const otherSelectedItems = selected.filter(selectedItem => !areItemsEqual(selectedItem, item)) + const newSelectedItems = doesItemsIncludeItem(selected, item) + ? otherSelectedItems + : [...otherSelectedItems, item] + + onSelectedChange(newSelectedItems) + }, + } as ItemProps + }) + + return sortItemsBySelection({ + items: itemsWithSelectionState, + selectedOnSort, + shouldOrderSelectedFirst, + }) +} diff --git a/packages/react/src/SelectPanel/SelectPanelNext.shared.utils.ts b/packages/react/src/SelectPanel/SelectPanelNext.shared.utils.ts new file mode 100644 index 00000000000..ab0aa89add0 --- /dev/null +++ b/packages/react/src/SelectPanel/SelectPanelNext.shared.utils.ts @@ -0,0 +1,96 @@ +import type {ItemInput, ItemProps} from '../FilteredActionList' + +import type {SelectPanelProps} from './SelectPanel.shared' + +export type FooterLayout = + | {kind: 'legacy'; footer: SelectPanelProps['footer']} + | {kind: 'none'} + | { + kind: 'actions' + displayFooter: 'always' | 'only-small' + showCancelAndSave: boolean + showSaveAndClose: boolean + stretchSaveButton: 'only-small' | 'never' + stretchSecondaryAction: 'always' | 'only-big' | 'never' + } + +export function getFooterLayout({ + footer, + hasSecondaryAction, + hasOnCancel, + isMultiSelect, + usingFullScreenOnNarrow, + variant, +}: { + footer: SelectPanelProps['footer'] + hasOnCancel: boolean + hasSecondaryAction: boolean + isMultiSelect: boolean + usingFullScreenOnNarrow: boolean + variant: SelectPanelProps['variant'] +}): FooterLayout { + if (footer) { + return {kind: 'legacy', footer} + } + + const showPermanentCancelSave = variant === 'modal' + const showResponsiveCancelSave = variant !== 'modal' && usingFullScreenOnNarrow && isMultiSelect && hasOnCancel + const showResponsiveSaveAndClose = variant !== 'modal' && usingFullScreenOnNarrow && isMultiSelect && !hasOnCancel + + if (!hasSecondaryAction && !showPermanentCancelSave && !showResponsiveCancelSave && !showResponsiveSaveAndClose) { + return {kind: 'none'} + } + + return { + kind: 'actions', + displayFooter: hasSecondaryAction || showPermanentCancelSave ? 'always' : 'only-small', + showCancelAndSave: showPermanentCancelSave || showResponsiveCancelSave, + showSaveAndClose: showResponsiveSaveAndClose, + stretchSecondaryAction: + showResponsiveCancelSave || showResponsiveSaveAndClose + ? 'only-big' + : showPermanentCancelSave + ? 'never' + : 'always', + stretchSaveButton: showResponsiveSaveAndClose && !hasSecondaryAction ? 'only-small' : 'never', + } +} + +export function sortItemsBySelection({ + items, + selectedOnSort, + shouldOrderSelectedFirst, +}: { + items: ItemProps[] + selectedOnSort: ItemInput[] + shouldOrderSelectedFirst: boolean +}) { + if (!shouldOrderSelectedFirst) { + return items + } + + const selectedOnSortSet = new Set() + + for (const item of selectedOnSort) { + if (item.id !== undefined) { + selectedOnSortSet.add(item.id) + } else { + selectedOnSortSet.add(item) + } + } + + return items.sort((itemA, itemB) => { + const itemASelected = itemA.id !== undefined ? selectedOnSortSet.has(itemA.id) : selectedOnSortSet.has(itemA) + const itemBSelected = itemB.id !== undefined ? selectedOnSortSet.has(itemB.id) : selectedOnSortSet.has(itemB) + + if (itemASelected && !itemBSelected) { + return -1 + } + + if (!itemASelected && itemBSelected) { + return 1 + } + + return 0 + }) +} diff --git a/packages/react/src/SelectPanel/SelectPanelNext.single.state.ts b/packages/react/src/SelectPanel/SelectPanelNext.single.state.ts new file mode 100644 index 00000000000..0791020d8ec --- /dev/null +++ b/packages/react/src/SelectPanel/SelectPanelNext.single.state.ts @@ -0,0 +1,29 @@ +import type {ItemInput} from '../FilteredActionList' + +export function getInitialSingleIntermediateSelected({ + isSingleSelectModal, + selected, +}: { + isSingleSelectModal: boolean + selected: ItemInput | undefined +}) { + if (!isSingleSelectModal) { + return undefined + } + + return selected +} + +export function getSyncedSingleIntermediateSelected({ + isSingleSelectModal, + selected, +}: { + isSingleSelectModal: boolean + selected: ItemInput | undefined +}) { + if (!isSingleSelectModal) { + return undefined + } + + return selected +} diff --git a/packages/react/src/SelectPanel/SelectPanelNext.single.utils.ts b/packages/react/src/SelectPanel/SelectPanelNext.single.utils.ts new file mode 100644 index 00000000000..90489b6adea --- /dev/null +++ b/packages/react/src/SelectPanel/SelectPanelNext.single.utils.ts @@ -0,0 +1,65 @@ +import type {ItemInput, ItemProps} from '../FilteredActionList' + +import type {SelectPanelSingleSelection} from './SelectPanel.shared' +import {sortItemsBySelection} from './SelectPanelNext.shared.utils' + +export function buildSingleItemsToRender({ + intermediateSelected, + isSingleSelectModal, + items, + onIntermediateSelectedChange, + onSelectedChange, + onSelectionClose, + selected, + selectedOnSort, + shouldOrderSelectedFirst, +}: { + intermediateSelected?: ItemInput + isSingleSelectModal: boolean + items: ItemInput[] + onIntermediateSelectedChange: (selectedItem?: ItemInput) => void + onSelectedChange: SelectPanelSingleSelection['onSelectedChange'] + onSelectionClose: () => void + selected: ItemInput | undefined + selectedOnSort: ItemInput[] + shouldOrderSelectedFirst: boolean +}): ItemProps[] { + const itemsWithSelectionState = items.map(item => { + const selectedValue = isSingleSelectModal + ? intermediateSelected?.id !== undefined + ? intermediateSelected.id === item.id + : intermediateSelected === item + : selected?.id !== undefined + ? selected.id === item.id + : selected === item + + return { + ...item, + role: 'option', + id: item.id, + selected: 'selected' in item && item.selected === undefined ? undefined : selectedValue, + onAction: (itemFromAction, event) => { + item.onAction?.(itemFromAction, event) + + if (event.defaultPrevented) { + return + } + + if (isSingleSelectModal) { + const nextIntermediateSelected = intermediateSelected?.id === item.id ? undefined : item + onIntermediateSelectedChange(nextIntermediateSelected) + return + } + + onSelectedChange(item === selected ? undefined : item) + onSelectionClose() + }, + } as ItemProps + }) + + return sortItemsBySelection({ + items: itemsWithSelectionState, + selectedOnSort, + shouldOrderSelectedFirst, + }) +} diff --git a/packages/react/src/SelectPanel/SelectPanelNext.state.ts b/packages/react/src/SelectPanel/SelectPanelNext.state.ts new file mode 100644 index 00000000000..86a461e099d --- /dev/null +++ b/packages/react/src/SelectPanel/SelectPanelNext.state.ts @@ -0,0 +1,68 @@ +import type {ItemInput} from '../FilteredActionList' + +import type {SelectPanelProps} from './SelectPanel.shared' +import {toSelectedItemArray} from './SelectPanel.shared' + +export interface SelectPanelNextState { + availablePanelHeight?: number + dataLoadedOnce: boolean + internalLoading: boolean + keyboardVisible: boolean + selectedOnSort: ItemInput[] +} + +export type SelectPanelNextAction = + | {type: 'mark-loaded'} + | {type: 'reset-sort'; selectedItems: ItemInput[]} + | {type: 'set-internal-loading'; value: boolean} + | {type: 'set-keyboard-visibility'; keyboardVisible: boolean; availablePanelHeight?: number} + | {type: 'sync-open-state'; selectedItems: ItemInput[]} + +export function createInitialSelectPanelNextState(args: { + selected: SelectPanelProps['selected'] +}): SelectPanelNextState { + return { + dataLoadedOnce: false, + internalLoading: false, + selectedOnSort: toSelectedItemArray(args.selected), + keyboardVisible: false, + availablePanelHeight: undefined, + } +} + +export function selectPanelNextReducer( + state: SelectPanelNextState, + action: SelectPanelNextAction, +): SelectPanelNextState { + switch (action.type) { + case 'mark-loaded': + return { + ...state, + dataLoadedOnce: true, + internalLoading: false, + } + case 'reset-sort': + return { + ...state, + selectedOnSort: action.selectedItems, + } + case 'set-internal-loading': + return { + ...state, + internalLoading: action.value, + } + case 'set-keyboard-visibility': + return { + ...state, + keyboardVisible: action.keyboardVisible, + availablePanelHeight: action.availablePanelHeight, + } + case 'sync-open-state': + return { + ...state, + selectedOnSort: action.selectedItems, + } + default: + return state + } +} diff --git a/packages/react/src/SelectPanel/SelectPanelNext.test.tsx b/packages/react/src/SelectPanel/SelectPanelNext.test.tsx new file mode 100644 index 00000000000..091a463c9f9 --- /dev/null +++ b/packages/react/src/SelectPanel/SelectPanelNext.test.tsx @@ -0,0 +1,175 @@ +import {render, screen} from '@testing-library/react' +import {userEvent} from '@testing-library/user-event' +import React from 'react' +import {describe, expect, it, vi} from 'vitest' + +import {FeatureFlags} from '../FeatureFlags' +import FormControl from '../FormControl' +import {SelectPanel, type ItemInput} from '../SelectPanel' +import {createInitialSelectPanelNextState, selectPanelNextReducer} from './SelectPanelNext.state' +import {getFooterLayout} from './SelectPanelNext.utils' + +const items: ItemInput[] = [ + {id: 1, text: 'item one'}, + {id: 2, text: 'item two'}, + {id: 3, text: 'item three'}, +] + +function renderWithNextFlag(element: React.ReactElement) { + return render({element}) +} + +describe('SelectPanelNext', () => { + it('tracks reducer transitions for loading, sorting, and modal selection state', () => { + const initialState = createInitialSelectPanelNextState({selected: items[0]}) + + expect(initialState.selectedOnSort).toEqual([items[0]]) + expect(initialState.dataLoadedOnce).toBe(false) + + const loadingState = selectPanelNextReducer(initialState, {type: 'set-internal-loading', value: true}) + expect(loadingState.internalLoading).toBe(true) + + const loadedState = selectPanelNextReducer(loadingState, {type: 'mark-loaded'}) + expect(loadedState.internalLoading).toBe(false) + expect(loadedState.dataLoadedOnce).toBe(true) + + const syncState = selectPanelNextReducer(loadedState, { + type: 'sync-open-state', + selectedItems: [items[1]], + }) + expect(syncState.selectedOnSort).toEqual([items[1]]) + }) + + it('simplifies footer modes through an explicit layout helper', () => { + const legacyFooterLayout = getFooterLayout({ + footer:
legacy footer
, + hasOnCancel: true, + hasSecondaryAction: true, + isMultiSelect: true, + usingFullScreenOnNarrow: true, + variant: 'anchored', + }) + expect(legacyFooterLayout.kind).toBe('legacy') + + const responsiveFooterLayout = getFooterLayout({ + footer: undefined, + hasOnCancel: false, + hasSecondaryAction: false, + isMultiSelect: true, + usingFullScreenOnNarrow: true, + variant: 'anchored', + }) + + expect(responsiveFooterLayout).toMatchObject({ + kind: 'actions', + displayFooter: 'only-small', + showCancelAndSave: false, + showSaveAndClose: true, + }) + }) + + it('defers single-select modal commits until Save and discards them on Cancel', async () => { + const user = userEvent.setup() + const onSelectedChange = vi.fn() + + function ModalFixture() { + const [selected, setSelected] = React.useState(items[0]) + const [open, setOpen] = React.useState(false) + + return ( + { + onSelectedChange(value) + setSelected(value) + }} + open={open} + onOpenChange={setOpen} + onFilterChange={() => {}} + variant="modal" + onCancel={() => {}} + /> + ) + } + + renderWithNextFlag() + + await user.click(screen.getByRole('button', {name: 'item one'})) + await user.click(screen.getByRole('option', {name: 'item two'})) + + expect(onSelectedChange).not.toHaveBeenCalled() + + await user.click(screen.getByRole('button', {name: 'Cancel'})) + expect(screen.getByRole('button', {name: 'item one'})).toBeInTheDocument() + + await user.click(screen.getByRole('button', {name: 'item one'})) + await user.click(screen.getByRole('option', {name: 'item two'})) + await user.click(screen.getByRole('button', {name: 'Save'})) + + expect(onSelectedChange).toHaveBeenCalledWith(items[1]) + expect(screen.getByRole('button', {name: 'item two'})).toBeInTheDocument() + }) + + it('keeps deprecated footer support ahead of secondaryAction in the next implementation', async () => { + const user = userEvent.setup() + + function FooterFixture() { + const [selected, setSelected] = React.useState([]) + const [open, setOpen] = React.useState(false) + + return ( + {}} + footer={
deprecated footer
} + secondaryAction={secondary action} + /> + ) + } + + renderWithNextFlag() + + await user.click(screen.getByRole('button', {name: 'Select items'})) + + expect(screen.getByText('deprecated footer')).toBeInTheDocument() + expect(screen.queryByText('secondary action')).not.toBeInTheDocument() + }) + + it('keeps trigger labeling wired through FormControl when the next implementation is enabled', () => { + function LabelFixture() { + const [selected, setSelected] = React.useState([]) + + return ( + + Labels + {}} + onFilterChange={() => {}} + /> + + ) + } + + renderWithNextFlag() + + const trigger = screen.getByRole('button') + expect(trigger).toHaveAccessibleName(/Labels/i) + expect(trigger).toHaveAttribute('aria-labelledby') + }) +}) diff --git a/packages/react/src/SelectPanel/SelectPanelNext.tsx b/packages/react/src/SelectPanel/SelectPanelNext.tsx new file mode 100644 index 00000000000..78b750e2752 --- /dev/null +++ b/packages/react/src/SelectPanel/SelectPanelNext.tsx @@ -0,0 +1,523 @@ +import {SearchIcon} from '@primer/octicons-react' +import type React from 'react' +import {useCallback, useEffect, useMemo, useReducer, useRef, useState, type JSX} from 'react' + +import type {AnchoredOverlayProps} from '../AnchoredOverlay' +import {useFeatureFlag} from '../FeatureFlags' +import type {FilteredActionListProps} from '../FilteredActionList' +import {FilteredActionListLoadingTypes} from '../FilteredActionList/FilteredActionListLoaders' +import {useFormControlContext} from '../FormControl/_FormControlContext' +import {useProvidedRefOrCreate} from '../hooks' +import {useId} from '../hooks/useId' +import {isAlphabetKey} from '../hooks/useMnemonics' +import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate' +import {useResponsiveValue} from '../hooks/useResponsiveValue' +import useSafeTimeout from '../hooks/useSafeTimeout' +import classes from './SelectPanel.module.css' +import {SelectPanelNextFrame} from './SelectPanelNextFrame' +import {SelectPanelMessage} from './SelectPanelMessage' +import { + DefaultEmptyMessage, + LONG_DELAY_MS, + announceLoading, + closeButtonProps, + defaultRenderAnchor, + focusZoneSettings, + toSelectedItemArray, + type SelectPanelNextProps, +} from './SelectPanel.shared' +import { + useFocusInputOnOpen, + useFullscreenBodyLock, + useKeyboardViewport, + useManagedLoadingAnnouncement, + useMarkLoadedOnItems, + useNoticeAnnouncement, + usePopulateOnFirstOpen, + useSyncSelectPanelNextState, +} from './SelectPanelNext.hooks' +import {getInitialSingleIntermediateSelected, getSyncedSingleIntermediateSelected} from './SelectPanelNext.single.state' +import {getItemsInViewSet} from './SelectPanelNext.multi.utils' +import {createInitialSelectPanelNextState, selectPanelNextReducer} from './SelectPanelNext.state' +import {getFooterLayout} from './SelectPanelNext.shared.utils' +import {getSelectPanelNextMultiModeConfig, handleSelectAllChange} from './SelectPanelNextMulti' +import {getSelectPanelNextSingleModeConfig, handleSingleModeSave} from './SelectPanelNextSingle' + +export function SelectPanelNext({ + mode, + open, + onOpenChange, + renderAnchor = defaultRenderAnchor, + anchorRef: externalAnchorRef, + placeholder, + placeholderText = 'Filter items', + inputLabel = placeholderText, + selected, + title = mode === 'multi' ? 'Select items' : 'Select an item', + subtitle, + onSelectedChange, + filterValue: externalFilterValue, + onFilterChange: externalOnFilterChange, + items, + footer, + textInputProps, + overlayProps, + loading, + initialLoadingType = 'spinner', + className, + height, + width, + id, + message, + notice, + onCancel, + variant = 'anchored', + secondaryAction, + showSelectedOptionsFirst = true, + disableFullscreenOnNarrow, + align, + showSelectAll = false, + focusPrependedElements, + virtualized, + displayInViewport, + onInputRefChanged: externalOnInputRefChanged, + onListContainerRefChanged: externalOnListContainerRefChanged, + ...listProps +}: SelectPanelNextProps): JSX.Element { + const titleId = useId() + const subtitleId = useId() + const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate(externalFilterValue, undefined, '') + const {safeSetTimeout, safeClearTimeout} = useSafeTimeout() + const loadingDelayTimeoutId = useRef(null) + const loadingManagedInternally = loading === undefined + const isNarrowScreenSize = useResponsiveValue({narrow: true, regular: false, wide: false}, false) + const noticeRef = useRef(null) + const [inputRef, setInputRef] = useState | null>(null) + const isSingleSelectModal = variant === 'modal' && mode === 'single' + const [state, dispatch] = useReducer(selectPanelNextReducer, {selected}, createInitialSelectPanelNextState) + const [intermediateSelected, setIntermediateSelected] = useState(() => { + if (mode !== 'single') { + return undefined + } + + return getInitialSingleIntermediateSelected({ + isSingleSelectModal, + selected, + }) + }) + + const featureFlagFullScreenOnNarrow = useFeatureFlag('primer_react_select_panel_fullscreen_on_narrow') + const usingFullScreenOnNarrow = disableFullscreenOnNarrow ? false : featureFlagFullScreenOnNarrow + const shouldOrderSelectedFirst = + useFeatureFlag('primer_react_select_panel_order_selected_at_top') && showSelectedOptionsFirst + const {isReferenced, labelId} = useFormControlContext() + const selectedValueId = id ? `${id}-selected-value` : undefined + const responsiveOverlayVariant: AnchoredOverlayProps['variant'] = usingFullScreenOnNarrow + ? {regular: 'anchored', narrow: 'fullscreen'} + : undefined + const currentResponsiveVariant = useResponsiveValue(responsiveOverlayVariant, 'anchored') + const clearPendingLoadingAnnouncement = useCallback(() => { + if (loadingDelayTimeoutId.current) { + safeClearTimeout(loadingDelayTimeoutId.current) + } + }, [safeClearTimeout]) + const scheduleLoadingAnnouncement = useCallback( + (callback: () => void, delayMs: number) => { + loadingDelayTimeoutId.current = safeSetTimeout(callback, delayMs) + return loadingDelayTimeoutId.current + }, + [safeSetTimeout], + ) + + useFullscreenBodyLock({isNarrowScreenSize, open, usingFullScreenOnNarrow}) + useKeyboardViewport({dispatch, isNarrowScreenSize, open}) + useNoticeAnnouncement({notice, noticeRef, open}) + useSyncSelectPanelNextState({dispatch, open, selected}) + useMarkLoadedOnItems({ + clearPendingLoadingAnnouncement, + dispatch, + itemsLength: items.length, + }) + useFocusInputOnOpen({inputRef, open}) + useManagedLoadingAnnouncement({ + clearPendingLoadingAnnouncement, + loading, + loadingManagedInternally, + scheduleLoadingAnnouncement, + }) + + useEffect(() => { + if (mode !== 'single') { + return + } + + setIntermediateSelected( + getSyncedSingleIntermediateSelected({ + isSingleSelectModal, + selected, + }), + ) + }, [isSingleSelectModal, mode, open, selected]) + + const anchorRef = useProvidedRefOrCreate(externalAnchorRef) + + const onInputRefChanged = useCallback( + (ref: React.RefObject) => { + setInputRef(ref) + externalOnInputRefChanged?.(ref) + }, + [externalOnInputRefChanged], + ) + + const onListContainerRefChanged = useCallback( + (node: HTMLElement | null) => { + externalOnListContainerRefChanged?.(node) + }, + [externalOnListContainerRefChanged], + ) + + const resetSort = useCallback(() => { + dispatch({type: 'reset-sort', selectedItems: toSelectedItemArray(selected)}) + }, [selected]) + + const onFilterChange: FilteredActionListProps['onFilterChange'] = useCallback( + (value, event) => { + if (loadingManagedInternally) { + if (loadingDelayTimeoutId.current) { + clearPendingLoadingAnnouncement() + } + + const shouldSkipInitialInternalLoading = + externalFilterValue !== undefined && !state.dataLoadedOnce && items.length === 0 + + if (state.dataLoadedOnce) { + scheduleLoadingAnnouncement(() => { + dispatch({type: 'set-internal-loading', value: true}) + announceLoading() + }, LONG_DELAY_MS) + } else if (!shouldSkipInitialInternalLoading) { + if (items.length === 0) { + dispatch({type: 'set-internal-loading', value: true}) + } + + scheduleLoadingAnnouncement(() => { + announceLoading() + }, LONG_DELAY_MS) + } + } + + externalOnFilterChange(value, event) + setInternalFilterValue(value) + + if (!value) { + resetSort() + } + }, + [ + externalFilterValue, + externalOnFilterChange, + items.length, + loadingManagedInternally, + resetSort, + clearPendingLoadingAnnouncement, + scheduleLoadingAnnouncement, + setInternalFilterValue, + state.dataLoadedOnce, + ], + ) + usePopulateOnFirstOpen({ + dataLoadedOnce: state.dataLoadedOnce, + filterValue, + itemsLength: items.length, + loadingManagedInternally, + onFilterChange, + open, + }) + + const itemsInViewSet = useMemo(() => getItemsInViewSet(items), [items]) + + const onOpen: AnchoredOverlayProps['onOpen'] = useCallback( + (gesture: Parameters>[0]) => onOpenChange(true, gesture), + [onOpenChange], + ) + + const onCancelRequested = useCallback(() => { + onOpenChange(false, 'cancel') + }, [onOpenChange]) + + const onClose = useCallback( + ( + gesture: Parameters>[0] | 'selection' | 'escape' | 'close', + ) => { + if (variant === 'modal' && gesture === 'click-outside') { + onCancel?.() + } + + if (gesture === 'close') { + onCancel?.() + onCancelRequested() + } else { + onOpenChange(false, gesture) + } + }, + [onCancel, onCancelRequested, onOpenChange, variant], + ) + + const renderMenuAnchor = useMemo(() => { + if (renderAnchor === null) { + return null + } + + const selectedItems = toSelectedItemArray(selected) + const selectedValueText = selectedItems.length ? selectedItems.map(item => item.text).join(', ') : placeholder + const shouldAutoWireLabel = isReferenced === false && Boolean(labelId) && Boolean(selectedValueId) + + return >(props: T) => { + return renderAnchor({ + ...props, + ...(shouldAutoWireLabel + ? { + 'aria-labelledby': [labelId, selectedValueId].filter(Boolean).join(' '), + } + : {}), + children: shouldAutoWireLabel ? {selectedValueText} : selectedValueText, + }) + } + }, [isReferenced, labelId, placeholder, renderAnchor, selected, selectedValueId]) + + const modeConfig = useMemo(() => { + if (mode === 'multi') { + return getSelectPanelNextMultiModeConfig({ + items, + onSelectedChange, + selected, + selectedOnSort: state.selectedOnSort, + shouldOrderSelectedFirst, + }) + } + + return getSelectPanelNextSingleModeConfig({ + intermediateSelected, + isSingleSelectModal, + items, + onIntermediateSelectedChange: setIntermediateSelected, + onSelectedChange, + onSelectionClose: () => { + onClose('selection') + }, + selected, + selectedOnSort: state.selectedOnSort, + shouldOrderSelectedFirst, + }) + }, [ + intermediateSelected, + isSingleSelectModal, + items, + mode, + onClose, + onSelectedChange, + selected, + shouldOrderSelectedFirst, + state.selectedOnSort, + ]) + + const onSelectAllChange = useCallback( + (checked: boolean) => { + if (mode !== 'multi') { + return + } + + handleSelectAllChange({ + checked, + items, + itemsInViewSet, + onSelectedChange, + selected, + }) + }, + [items, itemsInViewSet, mode, onSelectedChange, selected], + ) + + const prevItemsLengthRef = useRef(items.length) + useEffect(() => { + if (prevItemsLengthRef.current === 0 && items.length > 0) { + resetSort() + } + + prevItemsLengthRef.current = items.length + }, [items.length, resetSort]) + + const focusTrapSettings = useMemo(() => ({initialFocusRef: inputRef || undefined}), [inputRef]) + + const extendedTextInputProps = useMemo(() => { + return { + className: classes.TextInput, + contrast: true, + leadingVisual: SearchIcon, + 'aria-label': inputLabel, + ...textInputProps, + } + }, [inputLabel, textInputProps]) + + const loadingType = state.dataLoadedOnce + ? FilteredActionListLoadingTypes.input + : initialLoadingType === 'spinner' + ? FilteredActionListLoadingTypes.bodySpinner + : FilteredActionListLoadingTypes.bodySkeleton + + const renderedMessage = useMemo(() => { + if (items.length === 0 && !message) { + return DefaultEmptyMessage + } + + if (message) { + return ( + + {message.body} + + ) + } + + return undefined + }, [items.length, message]) + + const footerLayout = getFooterLayout({ + footer, + hasOnCancel: onCancel !== undefined, + hasSecondaryAction: secondaryAction !== undefined, + isMultiSelect: modeConfig.isMultiSelect, + usingFullScreenOnNarrow, + variant, + }) + + const showXCloseIcon = (onCancel !== undefined || !modeConfig.isMultiSelect) && usingFullScreenOnNarrow + + const shouldShowInitialControlledEmptyState = + externalFilterValue !== undefined && !state.dataLoadedOnce && items.length === 0 + + const onSave = useCallback(() => { + if (mode === 'single') { + handleSingleModeSave({ + intermediateSelected, + isSingleSelectModal, + onClose, + onSelectedChange, + variant, + }) + return + } + + onClose(variant === 'modal' ? 'selection' : 'click-outside') + }, [intermediateSelected, isSingleSelectModal, mode, onClose, onSelectedChange, variant]) + + const preventBubbling = useCallback( + (event: React.KeyboardEvent) => { + overlayProps?.onKeyDown?.(event as unknown as React.KeyboardEvent) + + const activeElement = document.activeElement as HTMLElement + if (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA') { + return + } + + if (event.ctrlKey || event.altKey || event.metaKey) { + return + } + + if (event.key !== '/' && !isAlphabetKey(event.nativeEvent as KeyboardEvent)) { + return + } + + event.stopPropagation() + }, + [overlayProps], + ) + + const mergedOverlayProps = useMemo(() => { + return { + role: 'dialog' as const, + 'aria-labelledby': titleId, + 'aria-describedby': subtitle ? subtitleId : undefined, + ...overlayProps, + ...(variant === 'modal' + ? { + top: '50vh' as const, + left: '50vw' as const, + anchorSide: undefined, + } + : {}), + style: { + ...(overlayProps?.style || {}), + transform: variant === 'modal' ? 'translate(-50%, -50%)' : overlayProps?.style?.transform, + ...(state.keyboardVisible + ? { + maxHeight: state.availablePanelHeight !== undefined ? `${state.availablePanelHeight}px` : 'auto', + } + : {}), + } as React.CSSProperties, + onKeyDown: preventBubbling, + } + }, [ + overlayProps, + preventBubbling, + state.availablePanelHeight, + state.keyboardVisible, + subtitle, + subtitleId, + titleId, + variant, + ]) + + return ( + + ) +} diff --git a/packages/react/src/SelectPanel/SelectPanelNext.utils.ts b/packages/react/src/SelectPanel/SelectPanelNext.utils.ts new file mode 100644 index 00000000000..8a82fb790ce --- /dev/null +++ b/packages/react/src/SelectPanel/SelectPanelNext.utils.ts @@ -0,0 +1,10 @@ +export type {FooterLayout} from './SelectPanelNext.shared.utils' + +export {getFooterLayout, sortItemsBySelection} from './SelectPanelNext.shared.utils' + +export { + buildMultiItemsToRender as buildItemsToRender, + getItemsInViewSet, + getNextMultiSelectedItemsAfterSelectAll, + getSelectedItemsSet, +} from './SelectPanelNext.multi.utils' diff --git a/packages/react/src/SelectPanel/SelectPanelNextFooter.tsx b/packages/react/src/SelectPanel/SelectPanelNextFooter.tsx new file mode 100644 index 00000000000..967c4970c13 --- /dev/null +++ b/packages/react/src/SelectPanel/SelectPanelNextFooter.tsx @@ -0,0 +1,77 @@ +import {clsx} from 'clsx' + +import {Button} from '../Button' +import classes from './SelectPanel.module.css' +import type {SelectPanelProps} from './SelectPanel.shared' +import type {FooterLayout} from './SelectPanelNext.shared.utils' + +interface SelectPanelNextFooterProps { + footer: SelectPanelProps['footer'] + footerLayout: FooterLayout + onCancel?: () => void + onCancelRequested: () => void + onSave: () => void + secondaryAction?: SelectPanelProps['secondaryAction'] + usingFullScreenOnNarrow: boolean + variant: SelectPanelProps['variant'] +} + +export function SelectPanelNextFooter({ + footer, + footerLayout, + onCancel, + onCancelRequested, + onSave, + secondaryAction, + usingFullScreenOnNarrow, + variant, +}: SelectPanelNextFooterProps) { + if (footerLayout.kind === 'none') { + return null + } + + if (footerLayout.kind === 'legacy') { + return
{footer}
+ } + + return ( +
+
+ {secondaryAction} +
+ {footerLayout.showCancelAndSave ? ( +
+ + +
+ ) : null} + {footerLayout.showSaveAndClose ? ( +
+ +
+ ) : null} +
+ ) +} diff --git a/packages/react/src/SelectPanel/SelectPanelNextFrame.tsx b/packages/react/src/SelectPanel/SelectPanelNextFrame.tsx new file mode 100644 index 00000000000..f9c8aedd9c9 --- /dev/null +++ b/packages/react/src/SelectPanel/SelectPanelNextFrame.tsx @@ -0,0 +1,230 @@ +import {XIcon} from '@primer/octicons-react' +import {clsx} from 'clsx' +import type React from 'react' + +import {AnchoredOverlay} from '../AnchoredOverlay' +import type {AnchoredOverlayProps} from '../AnchoredOverlay' +import {Banner} from '../Banner' +import {IconButton} from '../Button' +import {FilteredActionList} from '../FilteredActionList' +import type {FilteredActionListProps, ItemProps} from '../FilteredActionList' +import type {FocusZoneHookSettings} from '../hooks/useFocusZone' +import Heading from '../Heading' +import classes from './SelectPanel.module.css' +import {SelectPanelNextFooter} from './SelectPanelNextFooter' +import type {SelectPanelListProps, SelectPanelProps} from './SelectPanel.shared' +import type {FooterLayout} from './SelectPanelNext.shared.utils' + +type SelectPanelNextFrameListProps = Omit + +interface SelectPanelNextFrameProps { + align: SelectPanelProps['align'] + anchorId: SelectPanelProps['id'] + anchorRef: React.RefObject + className: SelectPanelProps['className'] + closeButtonProps: Record + currentResponsiveVariant: 'anchored' | 'fullscreen' + displayInViewport: SelectPanelProps['displayInViewport'] + filterValue: string + focusPrependedElements: SelectPanelProps['focusPrependedElements'] + focusZoneSettings: Partial + focusTrapSettings: {initialFocusRef?: React.RefObject} + footer: SelectPanelProps['footer'] + footerLayout: FooterLayout + fullScreenOnNarrow: boolean + height: SelectPanelProps['height'] + isNarrowScreenSize: boolean + itemsToRender: ItemProps[] + listProps: SelectPanelNextFrameListProps + loading: boolean + loadingType: FilteredActionListProps['loadingType'] + mergedOverlayProps: SelectPanelProps['overlayProps'] + message: SelectPanelProps['message'] + notice: SelectPanelProps['notice'] + noticeRef: React.RefObject + onCancel: SelectPanelProps['onCancel'] + onCancelRequested: () => void + onClose: AnchoredOverlayProps['onClose'] + onFilterChange: FilteredActionListProps['onFilterChange'] + onInputRefChanged: (ref: React.RefObject) => void + onListContainerRefChanged: (node: HTMLElement | null) => void + onOpen: AnchoredOverlayProps['onOpen'] + onSave: () => void + onSelectAllChange?: (checked: boolean) => void + open: boolean + placeholderText: string + renderAnchor: AnchoredOverlayProps['renderAnchor'] + renderedMessage: React.ReactNode + responsiveOverlayVariant: AnchoredOverlayProps['variant'] + secondaryAction: SelectPanelProps['secondaryAction'] + selectionVariant: 'single' | 'multiple' | 'radio' + showXCloseIcon: boolean + subtitle: SelectPanelProps['subtitle'] + subtitleId: string + textInputProps: FilteredActionListProps['textInputProps'] + title: SelectPanelProps['title'] + titleId: string + variant: SelectPanelProps['variant'] + virtualized: SelectPanelProps['virtualized'] + width: SelectPanelProps['width'] +} + +const EMPTY_MESSAGE = { + title: 'No items available', + description: '', +} + +export function SelectPanelNextFrame({ + align, + anchorId, + anchorRef, + className, + closeButtonProps, + currentResponsiveVariant, + displayInViewport, + filterValue, + focusPrependedElements, + focusZoneSettings, + focusTrapSettings, + footer, + footerLayout, + fullScreenOnNarrow, + height, + isNarrowScreenSize, + itemsToRender, + listProps, + loading, + loadingType, + mergedOverlayProps, + message, + notice, + noticeRef, + onCancel, + onCancelRequested, + onClose, + onFilterChange, + onInputRefChanged, + onListContainerRefChanged, + onOpen, + onSave, + onSelectAllChange, + open, + placeholderText, + renderAnchor, + renderedMessage, + responsiveOverlayVariant, + secondaryAction, + selectionVariant, + showXCloseIcon, + subtitle, + subtitleId, + textInputProps, + title, + titleId, + variant, + virtualized, + width, +}: SelectPanelNextFrameProps) { + return ( + <> + +
+
+
+ + {title} + + {subtitle ? ( +
+ {subtitle} +
+ ) : null} +
+ {variant === 'modal' && !isNarrowScreenSize ? ( + { + onCancel?.() + onCancelRequested() + }} + /> + ) : null} +
+ {notice ? ( +
+ +
+ ) : null} + + +
+
+ {variant === 'modal' && open ?
: null} + + ) +} diff --git a/packages/react/src/SelectPanel/SelectPanelNextMulti.ts b/packages/react/src/SelectPanel/SelectPanelNextMulti.ts new file mode 100644 index 00000000000..d0c0ab0e145 --- /dev/null +++ b/packages/react/src/SelectPanel/SelectPanelNextMulti.ts @@ -0,0 +1,68 @@ +import type {ItemInput, ItemProps} from '../FilteredActionList' + +import {type SelectPanelMultiSelection} from './SelectPanel.shared' +import { + buildMultiItemsToRender, + getNextMultiSelectedItemsAfterSelectAll, + getSelectedItemsSet, +} from './SelectPanelNext.multi.utils' + +export interface SelectPanelNextMultiModeConfig { + ariaMultiselectable: 'true' + isMultiSelect: true + itemsToRender: ItemProps[] + selectionVariant: 'multiple' +} + +export function getSelectPanelNextMultiModeConfig({ + items, + onSelectedChange, + selected, + selectedOnSort, + shouldOrderSelectedFirst, +}: { + items: ItemInput[] + onSelectedChange: SelectPanelMultiSelection['onSelectedChange'] + selected: ItemInput[] + selectedOnSort: ItemInput[] + shouldOrderSelectedFirst: boolean +}): SelectPanelNextMultiModeConfig { + const selectedItemsSet = getSelectedItemsSet(selected) + + return { + ariaMultiselectable: 'true', + isMultiSelect: true, + selectionVariant: 'multiple', + itemsToRender: buildMultiItemsToRender({ + items, + onSelectedChange, + selected, + selectedItemsSet, + selectedOnSort, + shouldOrderSelectedFirst, + }), + } +} + +export function handleSelectAllChange({ + checked, + items, + itemsInViewSet, + onSelectedChange, + selected, +}: { + checked: boolean + items: ItemInput[] + itemsInViewSet: Set + onSelectedChange: SelectPanelMultiSelection['onSelectedChange'] + selected: ItemInput[] +}) { + onSelectedChange( + getNextMultiSelectedItemsAfterSelectAll({ + checked, + items, + itemsInViewSet, + selected, + }), + ) +} diff --git a/packages/react/src/SelectPanel/SelectPanelNextSingle.ts b/packages/react/src/SelectPanel/SelectPanelNextSingle.ts new file mode 100644 index 00000000000..17d50a23180 --- /dev/null +++ b/packages/react/src/SelectPanel/SelectPanelNextSingle.ts @@ -0,0 +1,70 @@ +import type {ItemInput, ItemProps} from '../FilteredActionList' + +import type {SelectPanelProps} from './SelectPanel.shared' +import {buildSingleItemsToRender} from './SelectPanelNext.single.utils' + +export interface SelectPanelNextSingleModeConfig { + ariaMultiselectable: 'false' + isMultiSelect: false + itemsToRender: ItemProps[] + selectionVariant: 'single' | 'radio' +} + +export function getSelectPanelNextSingleModeConfig({ + intermediateSelected, + isSingleSelectModal, + items, + onIntermediateSelectedChange, + onSelectedChange, + onSelectionClose, + selected, + selectedOnSort, + shouldOrderSelectedFirst, +}: { + intermediateSelected?: ItemInput + isSingleSelectModal: boolean + items: ItemInput[] + onIntermediateSelectedChange: (selectedItem?: ItemInput) => void + onSelectedChange: (selected: ItemInput | undefined) => void + onSelectionClose: () => void + selected: ItemInput | undefined + selectedOnSort: ItemInput[] + shouldOrderSelectedFirst: boolean +}): SelectPanelNextSingleModeConfig { + return { + ariaMultiselectable: 'false', + isMultiSelect: false, + selectionVariant: isSingleSelectModal ? 'radio' : 'single', + itemsToRender: buildSingleItemsToRender({ + intermediateSelected, + isSingleSelectModal, + items, + onIntermediateSelectedChange, + onSelectedChange, + onSelectionClose, + selected, + selectedOnSort, + shouldOrderSelectedFirst, + }), + } +} + +export function handleSingleModeSave({ + intermediateSelected, + isSingleSelectModal, + onClose, + onSelectedChange, + variant, +}: { + intermediateSelected?: ItemInput + isSingleSelectModal: boolean + onClose: (gesture: 'selection' | 'click-outside') => void + onSelectedChange: (selected: ItemInput | undefined) => void + variant: SelectPanelProps['variant'] +}) { + if (isSingleSelectModal) { + onSelectedChange(intermediateSelected) + } + + onClose(variant === 'modal' ? 'selection' : 'click-outside') +} From e936007b47b95bdbbcd66cc9ec199ab771050b36 Mon Sep 17 00:00:00 2001 From: hectahertz Date: Tue, 7 Apr 2026 18:03:57 +0200 Subject: [PATCH 2/4] Refine SelectPanel next mode boundaries and tests --- .../src/SelectPanel/SelectPanel.shared.tsx | 4 ++ .../react/src/SelectPanel/SelectPanel.tsx | 20 ++---- .../src/SelectPanel/SelectPanelNext.test.tsx | 64 +++++++++++++++++++ .../src/SelectPanel/SelectPanelNext.utils.ts | 2 +- .../src/SelectPanel/SelectPanelNextMulti.ts | 2 - .../src/SelectPanel/SelectPanelNextSingle.ts | 2 - 6 files changed, 73 insertions(+), 21 deletions(-) diff --git a/packages/react/src/SelectPanel/SelectPanel.shared.tsx b/packages/react/src/SelectPanel/SelectPanel.shared.tsx index da1b3fedefe..a21371f68af 100644 --- a/packages/react/src/SelectPanel/SelectPanel.shared.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.shared.tsx @@ -148,6 +148,10 @@ export type SelectPanelNextMultiProps = SelectPanelPropsWithoutSelection & export type SelectPanelNextProps = SelectPanelNextSingleProps | SelectPanelNextMultiProps +export function isMultiSelectProps(props: SelectPanelProps): props is SelectPanelProps & SelectPanelMultiSelection { + return isMultiSelectVariant(props.selected) +} + export function isMultiSelectVariant( selected: SelectPanelSingleSelection['selected'] | SelectPanelMultiSelection['selected'], ): selected is SelectPanelMultiSelection['selected'] { diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index 06098365057..9a16d96b1c1 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -5,9 +5,7 @@ import { SecondaryActionButton, SecondaryActionLink, SELECT_PANEL_SLOT, - isMultiSelectVariant, - type SelectPanelNextMultiProps, - type SelectPanelNextSingleProps, + isMultiSelectProps, type SelectPanelProps, } from './SelectPanel.shared' import {SelectPanelLegacy} from './SelectPanelLegacy' @@ -19,21 +17,11 @@ function SelectPanelRoot(props: SelectPanelProps) { const selectPanelNextEnabled = useFeatureFlag('primer_react_select_panel_next') if (selectPanelNextEnabled) { - if (isMultiSelectVariant(props.selected)) { - const nextProps = { - ...props, - mode: 'multi', - } as SelectPanelNextMultiProps - - return + if (isMultiSelectProps(props)) { + return } - const nextProps = { - ...props, - mode: 'single', - } as SelectPanelNextSingleProps - - return + return } return diff --git a/packages/react/src/SelectPanel/SelectPanelNext.test.tsx b/packages/react/src/SelectPanel/SelectPanelNext.test.tsx index 091a463c9f9..ae8b79f75b1 100644 --- a/packages/react/src/SelectPanel/SelectPanelNext.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanelNext.test.tsx @@ -6,6 +6,8 @@ import {describe, expect, it, vi} from 'vitest' import {FeatureFlags} from '../FeatureFlags' import FormControl from '../FormControl' import {SelectPanel, type ItemInput} from '../SelectPanel' +import {getSelectPanelNextMultiModeConfig, handleSelectAllChange} from './SelectPanelNextMulti' +import {getSelectPanelNextSingleModeConfig, handleSingleModeSave} from './SelectPanelNextSingle' import {createInitialSelectPanelNextState, selectPanelNextReducer} from './SelectPanelNext.state' import {getFooterLayout} from './SelectPanelNext.utils' @@ -68,6 +70,68 @@ describe('SelectPanelNext', () => { }) }) + it('builds mode-specific configuration for single and multi controllers', () => { + const onIntermediateSelectedChange = vi.fn() + const onSingleSelectedChange = vi.fn() + const onSelectionClose = vi.fn() + + const singleModeConfig = getSelectPanelNextSingleModeConfig({ + intermediateSelected: items[0], + isSingleSelectModal: true, + items, + onIntermediateSelectedChange, + onSelectedChange: onSingleSelectedChange, + onSelectionClose, + selected: items[0], + selectedOnSort: [items[0]], + shouldOrderSelectedFirst: true, + }) + + expect(singleModeConfig.selectionVariant).toBe('radio') + expect(singleModeConfig.itemsToRender).toHaveLength(items.length) + + const onMultiSelectedChange = vi.fn() + const multiModeConfig = getSelectPanelNextMultiModeConfig({ + items, + onSelectedChange: onMultiSelectedChange, + selected: [items[0]], + selectedOnSort: [items[0]], + shouldOrderSelectedFirst: true, + }) + + expect(multiModeConfig.selectionVariant).toBe('multiple') + expect(multiModeConfig.itemsToRender[0]).toMatchObject({selected: true}) + }) + + it('routes controller actions for Save and Select all', () => { + const onSingleClose = vi.fn() + const onSingleSelectedChange = vi.fn() + + handleSingleModeSave({ + intermediateSelected: items[1], + isSingleSelectModal: true, + onClose: onSingleClose, + onSelectedChange: onSingleSelectedChange, + variant: 'modal', + }) + + expect(onSingleSelectedChange).toHaveBeenCalledWith(items[1]) + expect(onSingleClose).toHaveBeenCalledWith('selection') + + const onMultiSelectedChange = vi.fn() + const itemsInViewSet = new Set([1, 2]) + + handleSelectAllChange({ + checked: true, + items: [items[0], items[1]], + itemsInViewSet, + onSelectedChange: onMultiSelectedChange, + selected: [items[2]], + }) + + expect(onMultiSelectedChange).toHaveBeenCalledWith([items[2], items[0], items[1]]) + }) + it('defers single-select modal commits until Save and discards them on Cancel', async () => { const user = userEvent.setup() const onSelectedChange = vi.fn() diff --git a/packages/react/src/SelectPanel/SelectPanelNext.utils.ts b/packages/react/src/SelectPanel/SelectPanelNext.utils.ts index 8a82fb790ce..a80a3210ef8 100644 --- a/packages/react/src/SelectPanel/SelectPanelNext.utils.ts +++ b/packages/react/src/SelectPanel/SelectPanelNext.utils.ts @@ -3,7 +3,7 @@ export type {FooterLayout} from './SelectPanelNext.shared.utils' export {getFooterLayout, sortItemsBySelection} from './SelectPanelNext.shared.utils' export { - buildMultiItemsToRender as buildItemsToRender, + buildMultiItemsToRender, getItemsInViewSet, getNextMultiSelectedItemsAfterSelectAll, getSelectedItemsSet, diff --git a/packages/react/src/SelectPanel/SelectPanelNextMulti.ts b/packages/react/src/SelectPanel/SelectPanelNextMulti.ts index d0c0ab0e145..810d02d3779 100644 --- a/packages/react/src/SelectPanel/SelectPanelNextMulti.ts +++ b/packages/react/src/SelectPanel/SelectPanelNextMulti.ts @@ -8,7 +8,6 @@ import { } from './SelectPanelNext.multi.utils' export interface SelectPanelNextMultiModeConfig { - ariaMultiselectable: 'true' isMultiSelect: true itemsToRender: ItemProps[] selectionVariant: 'multiple' @@ -30,7 +29,6 @@ export function getSelectPanelNextMultiModeConfig({ const selectedItemsSet = getSelectedItemsSet(selected) return { - ariaMultiselectable: 'true', isMultiSelect: true, selectionVariant: 'multiple', itemsToRender: buildMultiItemsToRender({ diff --git a/packages/react/src/SelectPanel/SelectPanelNextSingle.ts b/packages/react/src/SelectPanel/SelectPanelNextSingle.ts index 17d50a23180..2d69a4e8071 100644 --- a/packages/react/src/SelectPanel/SelectPanelNextSingle.ts +++ b/packages/react/src/SelectPanel/SelectPanelNextSingle.ts @@ -4,7 +4,6 @@ import type {SelectPanelProps} from './SelectPanel.shared' import {buildSingleItemsToRender} from './SelectPanelNext.single.utils' export interface SelectPanelNextSingleModeConfig { - ariaMultiselectable: 'false' isMultiSelect: false itemsToRender: ItemProps[] selectionVariant: 'single' | 'radio' @@ -32,7 +31,6 @@ export function getSelectPanelNextSingleModeConfig({ shouldOrderSelectedFirst: boolean }): SelectPanelNextSingleModeConfig { return { - ariaMultiselectable: 'false', isMultiSelect: false, selectionVariant: isSingleSelectModal ? 'radio' : 'single', itemsToRender: buildSingleItemsToRender({ From 65004a97356853a1777b454bc418c2c5f64ad616 Mon Sep 17 00:00:00 2001 From: hectahertz Date: Tue, 7 Apr 2026 18:11:01 +0200 Subject: [PATCH 3/4] Refactor SelectPanelNext mode action orchestration --- .../react/src/SelectPanel/SelectPanelNext.tsx | 82 +++++++++---------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/packages/react/src/SelectPanel/SelectPanelNext.tsx b/packages/react/src/SelectPanel/SelectPanelNext.tsx index 78b750e2752..5775ed5c4b7 100644 --- a/packages/react/src/SelectPanel/SelectPanelNext.tsx +++ b/packages/react/src/SelectPanel/SelectPanelNext.tsx @@ -285,18 +285,34 @@ export function SelectPanelNext({ } }, [isReferenced, labelId, placeholder, renderAnchor, selected, selectedValueId]) - const modeConfig = useMemo(() => { + const modeModel = useMemo(() => { if (mode === 'multi') { - return getSelectPanelNextMultiModeConfig({ + const multiModeConfig = getSelectPanelNextMultiModeConfig({ items, onSelectedChange, selected, selectedOnSort: state.selectedOnSort, shouldOrderSelectedFirst, }) + + return { + ...multiModeConfig, + onSave: () => { + onClose(variant === 'modal' ? 'selection' : 'click-outside') + }, + onSelectAllChange: (checked: boolean) => { + handleSelectAllChange({ + checked, + items, + itemsInViewSet, + onSelectedChange, + selected, + }) + }, + } } - return getSelectPanelNextSingleModeConfig({ + const singleModeConfig = getSelectPanelNextSingleModeConfig({ intermediateSelected, isSingleSelectModal, items, @@ -309,6 +325,20 @@ export function SelectPanelNext({ selectedOnSort: state.selectedOnSort, shouldOrderSelectedFirst, }) + + return { + ...singleModeConfig, + onSave: () => { + handleSingleModeSave({ + intermediateSelected, + isSingleSelectModal, + onClose, + onSelectedChange, + variant, + }) + }, + onSelectAllChange: undefined, + } }, [ intermediateSelected, isSingleSelectModal, @@ -319,25 +349,10 @@ export function SelectPanelNext({ selected, shouldOrderSelectedFirst, state.selectedOnSort, + variant, + itemsInViewSet, ]) - const onSelectAllChange = useCallback( - (checked: boolean) => { - if (mode !== 'multi') { - return - } - - handleSelectAllChange({ - checked, - items, - itemsInViewSet, - onSelectedChange, - selected, - }) - }, - [items, itemsInViewSet, mode, onSelectedChange, selected], - ) - const prevItemsLengthRef = useRef(items.length) useEffect(() => { if (prevItemsLengthRef.current === 0 && items.length > 0) { @@ -385,31 +400,16 @@ export function SelectPanelNext({ footer, hasOnCancel: onCancel !== undefined, hasSecondaryAction: secondaryAction !== undefined, - isMultiSelect: modeConfig.isMultiSelect, + isMultiSelect: modeModel.isMultiSelect, usingFullScreenOnNarrow, variant, }) - const showXCloseIcon = (onCancel !== undefined || !modeConfig.isMultiSelect) && usingFullScreenOnNarrow + const showXCloseIcon = (onCancel !== undefined || !modeModel.isMultiSelect) && usingFullScreenOnNarrow const shouldShowInitialControlledEmptyState = externalFilterValue !== undefined && !state.dataLoadedOnce && items.length === 0 - const onSave = useCallback(() => { - if (mode === 'single') { - handleSingleModeSave({ - intermediateSelected, - isSingleSelectModal, - onClose, - onSelectedChange, - variant, - }) - return - } - - onClose(variant === 'modal' ? 'selection' : 'click-outside') - }, [intermediateSelected, isSingleSelectModal, mode, onClose, onSelectedChange, variant]) - const preventBubbling = useCallback( (event: React.KeyboardEvent) => { overlayProps?.onKeyDown?.(event as unknown as React.KeyboardEvent) @@ -485,7 +485,7 @@ export function SelectPanelNext({ fullScreenOnNarrow={usingFullScreenOnNarrow} height={height} isNarrowScreenSize={isNarrowScreenSize} - itemsToRender={modeConfig.itemsToRender} + itemsToRender={modeModel.itemsToRender} listProps={listProps} loading={Boolean(loading) || (state.internalLoading && !message && !shouldShowInitialControlledEmptyState)} loadingType={loadingType} @@ -500,15 +500,15 @@ export function SelectPanelNext({ onInputRefChanged={onInputRefChanged} onListContainerRefChanged={onListContainerRefChanged} onOpen={onOpen} - onSave={onSave} - onSelectAllChange={showSelectAll && modeConfig.isMultiSelect ? onSelectAllChange : undefined} + onSave={modeModel.onSave} + onSelectAllChange={showSelectAll && modeModel.isMultiSelect ? modeModel.onSelectAllChange : undefined} open={open} placeholderText={placeholderText} renderAnchor={renderMenuAnchor} renderedMessage={renderedMessage} responsiveOverlayVariant={responsiveOverlayVariant} secondaryAction={secondaryAction} - selectionVariant={modeConfig.selectionVariant} + selectionVariant={modeModel.selectionVariant} showXCloseIcon={showXCloseIcon} subtitle={subtitle} subtitleId={subtitleId} From 6070498787928ad9eeecaf78e6350e8ce224316a Mon Sep 17 00:00:00 2001 From: hectahertz Date: Tue, 7 Apr 2026 19:04:07 +0200 Subject: [PATCH 4/4] Fix SelectPanelNext classname coverage and add changeset --- .changeset/curvy-lamps-smell.md | 5 +++++ .../src/SelectPanel/SelectPanelNext.test.tsx | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 .changeset/curvy-lamps-smell.md diff --git a/.changeset/curvy-lamps-smell.md b/.changeset/curvy-lamps-smell.md new file mode 100644 index 00000000000..7ef369ed18b --- /dev/null +++ b/.changeset/curvy-lamps-smell.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +SelectPanel: add classname-coverage test for the next implementation and align feature-flag migration follow-up checks. diff --git a/packages/react/src/SelectPanel/SelectPanelNext.test.tsx b/packages/react/src/SelectPanel/SelectPanelNext.test.tsx index ae8b79f75b1..a2c2a47b9bf 100644 --- a/packages/react/src/SelectPanel/SelectPanelNext.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanelNext.test.tsx @@ -6,6 +6,7 @@ import {describe, expect, it, vi} from 'vitest' import {FeatureFlags} from '../FeatureFlags' import FormControl from '../FormControl' import {SelectPanel, type ItemInput} from '../SelectPanel' +import {implementsClassName} from '../utils/testing' import {getSelectPanelNextMultiModeConfig, handleSelectAllChange} from './SelectPanelNextMulti' import {getSelectPanelNextSingleModeConfig, handleSingleModeSave} from './SelectPanelNextSingle' import {createInitialSelectPanelNextState, selectPanelNextReducer} from './SelectPanelNext.state' @@ -22,6 +23,22 @@ function renderWithNextFlag(element: React.ReactElement) { } describe('SelectPanelNext', () => { + implementsClassName(props => ( + + {}} + open={true} + onOpenChange={() => {}} + onFilterChange={() => {}} + {...props} + /> + + )) + it('tracks reducer transitions for loading, sorting, and modal selection state', () => { const initialState = createInitialSelectPanelNextState({selected: items[0]})