Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
101 commits
Select commit Hold shift + click to select a range
9cee0a4
super wip
broccolinisoup Jul 30, 2024
c7aa5de
just use the actionlist component and revert the type updates
broccolinisoup Jul 30, 2024
40a0c45
Update packages/react/src/FilteredActionList/FilteredActionList.tsx
broccolinisoup Jul 30, 2024
344f6ce
Some more progress
broccolinisoup Jul 31, 2024
fa5f5a6
revert the type changes and cast it :/
broccolinisoup Jul 31, 2024
3cd9ecc
clean
broccolinisoup Jul 31, 2024
0003e59
wip
broccolinisoup Aug 2, 2024
713b5ad
wip wip
broccolinisoup Aug 5, 2024
6fc7f2b
add stories
broccolinisoup Aug 8, 2024
a246981
fix linting
broccolinisoup Aug 8, 2024
e209f47
add tests for groups
broccolinisoup Aug 8, 2024
26cddc1
Merge branch 'main' into select-panel-action-list
broccolinisoup Aug 8, 2024
b1199fb
Merge branch 'select-panel-story-and-tests' into select-panel-action-…
broccolinisoup Aug 8, 2024
b669cff
Map groups
broccolinisoup Aug 8, 2024
1012662
Merge branch 'main' into select-panel-story-and-tests
broccolinisoup Aug 12, 2024
67dc5c9
Update story names for e2e tests
broccolinisoup Aug 12, 2024
c66feb5
oops remove unintended file
broccolinisoup Aug 12, 2024
4fbb3c5
update story name
broccolinisoup Aug 12, 2024
2d5e2b2
same - update story name
broccolinisoup Aug 12, 2024
cee7737
disable animations
broccolinisoup Aug 12, 2024
9c972c3
test(vrt): update snapshots
broccolinisoup Aug 12, 2024
17e4ca8
Merge branch 'select-panel-story-and-tests' into select-panel-action-…
broccolinisoup Aug 12, 2024
26b6fe6
Update tests since new action list has different semantics for group …
broccolinisoup Aug 12, 2024
42c50c0
Merge branch 'main' into select-panel-action-list
broccolinisoup Aug 13, 2024
0d0004f
logging
broccolinisoup Aug 14, 2024
3ba9bc7
pass the rest
broccolinisoup Aug 14, 2024
92ab9c2
extract children and use before text
broccolinisoup Aug 14, 2024
7b3d5e6
remove logging
broccolinisoup Aug 14, 2024
1cf1e09
Merge branch 'main' into select-panel-action-list
siddharthkp Aug 20, 2024
92303d0
test(vrt): update snapshots
siddharthkp Aug 20, 2024
4dc3b41
add active styles to ActionList
siddharthkp Aug 21, 2024
f673034
rename component name to be clearer
siddharthkp Aug 21, 2024
3a7d490
remove variant full from examples
siddharthkp Aug 21, 2024
ab3ea9c
tiny clean up
siddharthkp Aug 21, 2024
44ff862
fix showItemDividers
siddharthkp Aug 21, 2024
46c94e6
another tiny cleanup
siddharthkp Aug 21, 2024
ff8379b
pull MappedActionListItem to make it stable
siddharthkp Aug 22, 2024
9353d71
Merge branch 'main' into select-panel-action-list
siddharthkp Aug 22, 2024
0a126ad
test(vrt): update snapshots
siddharthkp Aug 22, 2024
91cd5a3
show active styles only when used with keyboard
siddharthkp Aug 22, 2024
40322c5
backward compat: expose id as data-id
siddharthkp Aug 22, 2024
f0150e4
Merge branch 'select-panel-action-list' of github.com:primer/react in…
siddharthkp Aug 22, 2024
f72b9d4
update snapshots
siddharthkp Aug 22, 2024
85d0237
add story for long strings
siddharthkp Aug 26, 2024
2eca9fa
fishing for errors
siddharthkp Aug 26, 2024
c875223
backward compatibility for renderItem
siddharthkp Aug 26, 2024
02a6dc9
remove todo now
siddharthkp Aug 26, 2024
25c574a
add a feature flag
siddharthkp Aug 26, 2024
5dc6adb
clean up dual filter list setup
siddharthkp Aug 27, 2024
85e0bb4
run jests test with both states of feature flags
siddharthkp Aug 27, 2024
0ee74e1
refactor snapshot tests with scenarios
siddharthkp Aug 27, 2024
798008e
remove feature flag for main
siddharthkp Aug 27, 2024
beca8db
Merge branch 'main' into select-panel-action-list
siddharthkp Aug 27, 2024
14fd206
Merge branch 'selectpanel-prepare-scenarios' into select-panel-action…
siddharthkp Aug 27, 2024
185eb17
test(vrt): update snapshots
siddharthkp Aug 28, 2024
318aa99
add feature flag to e2e matrix
siddharthkp Aug 28, 2024
e9c523c
test(vrt): update snapshots
siddharthkp Aug 28, 2024
efd1216
backward compat: allow groupMetadata to be empty array
siddharthkp Aug 28, 2024
cd9a931
Merge branch 'select-panel-action-list' of github.com:primer/react in…
siddharthkp Aug 28, 2024
619ff96
Merge branch 'main' into select-panel-action-list
siddharthkp Aug 29, 2024
e3fe7b8
sigh newline
siddharthkp Aug 29, 2024
ebac341
Create sour-cooks-dress.md
siddharthkp Aug 30, 2024
0513474
Merge branch 'main' into select-panel-action-list
siddharthkp Aug 30, 2024
2afa236
copy SelectPanel snapshots from main
siddharthkp Sep 2, 2024
0f38bf4
remove unrelated changes in this PR
siddharthkp Sep 3, 2024
f72aa55
test(vrt): update snapshots
siddharthkp Sep 3, 2024
6e9d706
set active styles for both active-descendant types
siddharthkp Sep 5, 2024
0b57206
test(vrt): update snapshots
siddharthkp Sep 5, 2024
68d8cac
Add built-in "no matches" item
siddharthkp Sep 5, 2024
cd18521
deprecated FilteredList: Add built-in "no matches"
siddharthkp Sep 5, 2024
279cdbc
simpler code
siddharthkp Sep 5, 2024
6feec30
Create mighty-doors-beg.md
siddharthkp Sep 5, 2024
0bf75ff
add test for no results
siddharthkp Sep 6, 2024
1526ec2
add story and e2e test
siddharthkp Sep 6, 2024
3f5db33
test(vrt): update snapshots
siddharthkp Sep 6, 2024
2e933c8
brute force announcements
siddharthkp Sep 6, 2024
24b9805
abstract announcements, add to both
siddharthkp Sep 6, 2024
9ed9c42
add comment for context
siddharthkp Sep 6, 2024
b050329
use text instead of text content
siddharthkp Sep 6, 2024
959a509
add selection state to announceInitialFocus
siddharthkp Sep 6, 2024
be47413
add selected state to announcement
siddharthkp Sep 6, 2024
55a8100
make announcements a one line string
siddharthkp Sep 9, 2024
0a484e3
add tests (including failing)
siddharthkp Sep 9, 2024
7dc0543
use getLiveRegion helper everyone
siddharthkp Sep 9, 2024
6229793
calculate item index by looking at optionElements
siddharthkp Sep 9, 2024
f7766b8
better punctuation
siddharthkp Sep 9, 2024
436a08b
update test to include updates
siddharthkp Sep 9, 2024
45abe7d
remove only
siddharthkp Sep 9, 2024
932f3a4
expand type for old filtered actionlist
siddharthkp Sep 9, 2024
d9268a0
add timeout to help tests
siddharthkp Sep 9, 2024
b3ffdf8
Merge branch 'main' into select-panel-no-matches
siddharthkp Sep 12, 2024
996d446
remove changes from deprecated ActionList
siddharthkp Sep 12, 2024
b024e01
remove test for no results
siddharthkp Sep 12, 2024
db45eaa
update changelog
siddharthkp Sep 12, 2024
70023e6
remove unused variable
siddharthkp Sep 12, 2024
3d016c7
Merge branch 'select-panel-no-matches' into select-panel-announcements
siddharthkp Sep 12, 2024
83937c2
remove changes from deprecated version
siddharthkp Sep 12, 2024
7754ed9
Create small-melons-fail.md
siddharthkp Sep 12, 2024
8539ff8
always use data-is-active-descendant, not first
siddharthkp Sep 12, 2024
ac79865
abstractionssss
siddharthkp Sep 12, 2024
0008445
type veiligheid
siddharthkp Sep 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mighty-doors-beg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": minor
---

Select Panel: Add built-in "No matches" item (behind feature flag `primer_react_select_panel_with_modern_action_list`)
5 changes: 5 additions & 0 deletions .changeset/small-melons-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": patch
---

SelectPanel: Add announcements for screen readers (behind feature flag `primer_react_select_panel_with_modern_action_list`)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions e2e/components/SelectPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const scenarios = matrix({
id: 'components-selectpanel-examples--height-initial-with-underflowing-items-after-fetch',
name: 'Height Initial with Underflowing Items After Fetch',
},
{
id: 'components-selectpanel-examples--no-matches',
name: 'No matches',
},
],
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import type {SxProp} from '../sx'

import {isValidElementType} from 'react-is'
import type {RenderItemFn} from '../deprecated/ActionList/List'
import {CircleSlashIcon} from '@primer/octicons-react'
import {useAnnouncements} from './useAnnouncements'

const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8}

Expand Down Expand Up @@ -114,6 +116,7 @@ export function FilteredActionList({
}, [items])

useScrollFlash(scrollContainerRef)
useAnnouncements(items, listContainerRef, inputRef)

function getItemListForEachGroup(groupId: string) {
const itemsInGroup = []
Expand Down Expand Up @@ -151,7 +154,14 @@ export function FilteredActionList({
<Spinner />
</Box>
) : (
<ActionList ref={listContainerRef} showDividers={showItemDividers} {...listProps} role="listbox" id={listId}>
<ActionList
ref={listContainerRef}
showDividers={showItemDividers}
{...listProps}
role="listbox"
id={listId}
selectionVariant={items.length === 0 ? undefined : listProps.selectionVariant}
>
{groupMetadata?.length
? groupMetadata.map((group, index) => {
return (
Expand All @@ -168,6 +178,15 @@ export function FilteredActionList({
: items.map((item, index) => {
return <MappedActionListItem key={index} {...item} renderItem={listProps.renderItem} />
})}

{items.length === 0 ? (
<ActionList.Item disabled={true}>
<ActionList.LeadingVisual>
<CircleSlashIcon />
</ActionList.LeadingVisual>
No matches
</ActionList.Item>
) : undefined}
</ActionList>
)}
</Box>
Expand Down
97 changes: 97 additions & 0 deletions packages/react/src/FilteredActionList/useAnnouncements.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Announcements for FilteredActionList (and SelectPanel) based
// on https://github.com/github/multi-select-user-testing

import {announce} from '@primer/live-region-element'
import {useEffect, useRef} from 'react'
import type {FilteredActionListProps} from './FilteredActionListEntry'

// we add a delay so that it does not interrupt default screen reader announcement and queues after it
const delayMs = 500

const useFirstRender = () => {
const firstRender = useRef(true)
useEffect(() => {
firstRender.current = false
}, [])
return firstRender.current
}

const getItemWithActiveDescendant = (
listRef: React.RefObject<HTMLUListElement>,
items: FilteredActionListProps['items'],
) => {
const listElement = listRef.current
const activeItemElement = listElement?.querySelector('[data-is-active-descendant]')

if (!listElement || !activeItemElement?.textContent) return

const optionElements = listElement.querySelectorAll('[role="option"]')

const index = Array.from(optionElements).indexOf(activeItemElement)
const activeItem = items[index]

const text = activeItem.text
const selected = activeItem.selected

return {index, text, selected}
}

export const useAnnouncements = (
items: FilteredActionListProps['items'],
listContainerRef: React.RefObject<HTMLUListElement>,
inputRef: React.RefObject<HTMLInputElement>,
) => {
useEffect(
function announceInitialFocus() {
const focusHandler = () => {
// give @primer/behaviors a moment to apply active-descendant
window.requestAnimationFrame(() => {
const activeItem = getItemWithActiveDescendant(listContainerRef, items)
if (!activeItem) return
const {index, text, selected} = activeItem

const announcementText = [
`Focus on filter text box and list of labels`,
`Focused item: ${text}`,
`${selected ? 'selected' : 'not selected'}`,
`${index + 1} of ${items.length}`,
].join(', ')
announce(announcementText, {delayMs})
})
}

const inputElement = inputRef.current
inputElement?.addEventListener('focus', focusHandler)
return () => inputElement?.removeEventListener('focus', focusHandler)
},
[listContainerRef, inputRef, items],
)

const isFirstRender = useFirstRender()
useEffect(
function announceListUpdates() {
if (isFirstRender) return // ignore on first render as announceInitialFocus will also announce

if (items.length === 0) {
announce('No matching items.', {delayMs})
return
}

// give @primer/behaviors a moment to update active-descendant
window.requestAnimationFrame(() => {
const activeItem = getItemWithActiveDescendant(listContainerRef, items)
if (!activeItem) return
const {index, text, selected} = activeItem

const announcementText = [
`List updated`,
`Focused item: ${text}`,
`${selected ? 'selected' : 'not selected'}`,
`${index} of ${items.length}`,
].join(', ')
announce(announcementText, {delayMs})
})
},
[listContainerRef, inputRef, items, isFirstRender],
)
}
29 changes: 29 additions & 0 deletions packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,32 @@ export const CustomItemRenderer = () => {
</>
)
}

export const NoMatches = () => {
const [selected, setSelected] = React.useState<ItemInput[]>([items[0], items[1]])
const [filter, setFilter] = React.useState('no-matches-for-this')
const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))
const [open, setOpen] = useState(false)

return (
<>
<h1>No matches</h1>
<SelectPanel
title="Select files"
renderAnchor={({...anchorProps}) => (
<Button trailingAction={TriangleDownIcon} {...anchorProps}>
Select files
</Button>
)}
open={open}
onOpenChange={setOpen}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
filterValue={filter}
onFilterChange={setFilter}
overlayProps={{width: 'medium'}}
/>
</>
)
}
125 changes: 92 additions & 33 deletions packages/react/src/SelectPanel/SelectPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import {render, screen} from '@testing-library/react'
import {render, screen, waitFor} from '@testing-library/react'
import React from 'react'
import {SelectPanel, type SelectPanelProps} from '../SelectPanel'
import type {ItemInput, GroupedListProps} from '../deprecated/ActionList/List'
import {userEvent} from '@testing-library/user-event'
import ThemeProvider from '../ThemeProvider'
import {FeatureFlags} from '../FeatureFlags'
import {getLiveRegion} from '../utils/testing'

const renderWithFlag = (children: React.ReactNode, flag: boolean) => {
return render(
Expand Down Expand Up @@ -336,39 +337,39 @@ for (const useModernActionList of [false, true]) {
})
})

describe('filtering', () => {
function FilterableSelectPanel() {
const [selected, setSelected] = React.useState<SelectPanelProps['items']>([])
const [filter, setFilter] = React.useState('')
const [open, setOpen] = React.useState(false)

const onSelectedChange = (selected: SelectPanelProps['items']) => {
setSelected(selected)
}
function FilterableSelectPanel() {
const [selected, setSelected] = React.useState<SelectPanelProps['items']>([])
const [filter, setFilter] = React.useState('')
const [open, setOpen] = React.useState(false)

return (
<ThemeProvider>
<SelectPanel
title="test title"
subtitle="test subtitle"
items={items.filter(item => 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)
}}
/>
</ThemeProvider>
)
const onSelectedChange = (selected: SelectPanelProps['items']) => {
setSelected(selected)
}

return (
<ThemeProvider>
<SelectPanel
title="test title"
subtitle="test subtitle"
items={items.filter(item => 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)
}}
/>
</ThemeProvider>
)
}

describe('filtering', () => {
it('should filter the list of items when the user types into the input', async () => {
const user = userEvent.setup()

Expand All @@ -381,10 +382,68 @@ for (const useModernActionList of [false, true]) {
await user.type(document.activeElement!, 'two')
expect(screen.getAllByRole('option')).toHaveLength(1)
})
})

describe('screen reader announcements', () => {
// this is only implemented with the feature flag
if (!useModernActionList) return

it('should announce initial focused item', async () => {
const user = userEvent.setup()
renderWithFlag(<FilterableSelectPanel />, useModernActionList)

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 () => {
expect(getLiveRegion().getMessage('polite')).toBe(
'Focus on filter text box and list of labels, Focused item: item one, not selected, 1 of 3',
)
})
})

it('should announce filtered results', async () => {
const user = userEvent.setup()
renderWithFlag(<FilterableSelectPanel />, useModernActionList)

await user.click(screen.getByText('Select items'))
await user.type(document.activeElement!, 'o')
expect(screen.getAllByRole('option')).toHaveLength(2)

await waitFor(
async () => {
expect(getLiveRegion().getMessage('polite')).toBe(
'List updated, Focused item: item one, not selected, 1 of 2',
)
},
{timeout: 3000}, // increased timeout because we don't want the test to compare with previous announcement
)

await user.type(document.activeElement!, 'ne') // now: one
expect(screen.getAllByRole('option')).toHaveLength(1)

await waitFor(async () => {
expect(getLiveRegion().getMessage('polite')).toBe(
'List updated, Focused item: item one, not selected, 1 of 1',
)
})
})

it.todo('should announce the number of results')
it('should announce when no results are available', async () => {
const user = userEvent.setup()
renderWithFlag(<FilterableSelectPanel />, useModernActionList)

it.todo('should announce when no results are available')
await user.click(screen.getByText('Select items'))

await user.type(document.activeElement!, 'zero')
expect(screen.queryByRole('option')).toBeNull()
expect(screen.getByText('No matches')).toBeVisible()

await waitFor(async () => {
expect(getLiveRegion().getMessage('polite')).toBe('No matching items.')
})
})
})

describe('with footer', () => {
Expand Down
Loading