Skip to content

Commit e865e3e

Browse files
authored
Refactor FilteredActionList to address a11y violations and use new ActionList. (#3247)
* Update FilteredActionList to use non-deprecated ActionList. * Use non-deprecated ActionList in FilteredActionList. * Fix a11y issues in FilteredActionList story. * Add prop to hide selection component if needed. * Remove unused hook import. * Get SavedReplies to look as it did with deprecated ActionList. * Fix failing test. * Create weak-jokes-chew.md * Update generated/components.json * Fix themePreval snapshot. * Linting fixes. * Fix type-check errors. * Update themePreval snapshot again. * Fix themePreval snapshot to match origin. Unsure why it's not generating the same way. * Hide selections in MarkdownEditor saved replies. * Remove hideSelection prop and add defaultRenderFn to FilteredActionList so these don't have to be defined manually everywhere. * Fix selection rendering (needed explicit selected boolean) and fix SelectPanel docs. * Pass selectionVariant illegally to SelectPanel in src/MarkdownEditor/_SavedReplies.tsx so Selection components don't render. * Remove remaining references of hideSelection prop. * Update changeset to reflect that changes impact SelectPanel. * Remove renderFn prop from SelectPanel and use default for FilteredActionList, which seems to cover most cases. * Fix truncation in SavedReplies descriptions. * Update generated/components.json * Fix linting error. * Don't make renderFn a prop (if we need to make this configurable, we can expose it later. Use maxWidth 100% for SavedReplies truncation. * Use showDividers prop in SelectPanel story. * Formatting. * Add temporary support for showItemDividers prop to SelectPanel to keep backwards compatibility. * Support passing deprecated showItemDividers prop in ActionList. --------- Co-authored-by: radglob <[email protected]>
1 parent 1fd6d32 commit e865e3e

File tree

12 files changed

+165
-127
lines changed

12 files changed

+165
-127
lines changed

.changeset/weak-jokes-chew.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/react": patch
3+
---
4+
5+
FilteredActionList now uses new ActionList as a base, and SelectPanel reflects those changes.

docs/content/SelectPanel.mdx

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,18 @@ A `SelectPanel` provides an anchor that will open an overlay with a list of sele
1212

1313
```javascript live noinline
1414
function getColorCircle(color) {
15-
return function () {
16-
return (
17-
<Box
18-
borderWidth="1px"
19-
borderStyle="solid"
20-
bg={color}
21-
borderColor={color}
22-
width={14}
23-
height={14}
24-
borderRadius={10}
25-
margin="auto"
26-
/>
27-
)
28-
}
15+
return (
16+
<Box
17+
borderWidth="1px"
18+
borderStyle="solid"
19+
bg={color}
20+
borderColor={color}
21+
width={14}
22+
height={14}
23+
borderRadius={10}
24+
margin="auto"
25+
/>
26+
)
2927
}
3028

3129
const items = [

generated/components.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3626,7 +3626,7 @@
36263626
"stories": [
36273627
{
36283628
"id": "components-selectpanel--default",
3629-
"code": "() => {\n const [selected, setSelected] = React.useState<ItemInput[]>([\n items[0],\n items[1],\n ])\n const [filter, setFilter] = React.useState('')\n const filteredItems = items.filter((item) =>\n item.text.toLowerCase().startsWith(filter.toLowerCase()),\n )\n const [open, setOpen] = useState(false)\n return (\n <>\n <h1>Multi Select Panel</h1>\n <div>Please select labels that describe your issue:</div>\n <SelectPanel\n title=\"Select labels\"\n renderAnchor={({\n children,\n 'aria-labelledby': ariaLabelledBy,\n ...anchorProps\n }) => (\n <Button\n trailingAction={TriangleDownIcon}\n aria-labelledby={` ${ariaLabelledBy}`}\n {...anchorProps}\n >\n {children ?? 'Select Labels'}\n </Button>\n )}\n placeholderText=\"Filter labels\"\n open={open}\n onOpenChange={setOpen}\n items={filteredItems}\n selected={selected}\n onSelectedChange={setSelected}\n onFilterChange={setFilter}\n showItemDividers={true}\n overlayProps={{\n width: 'small',\n height: 'xsmall',\n }}\n />\n </>\n )\n}"
3629+
"code": "() => {\n const [selected, setSelected] = React.useState<ItemInput[]>([\n items[0],\n items[1],\n ])\n const [filter, setFilter] = React.useState('')\n const filteredItems = items.filter((item) =>\n item.text.toLowerCase().startsWith(filter.toLowerCase()),\n )\n const [open, setOpen] = useState(false)\n return (\n <>\n <h1>Multi Select Panel</h1>\n <div>Please select labels that describe your issue:</div>\n <SelectPanel\n title=\"Select labels\"\n renderAnchor={({\n children,\n 'aria-labelledby': ariaLabelledBy,\n ...anchorProps\n }) => (\n <Button\n trailingAction={TriangleDownIcon}\n aria-labelledby={` ${ariaLabelledBy}`}\n {...anchorProps}\n >\n {children ?? 'Select Labels'}\n </Button>\n )}\n placeholderText=\"Filter labels\"\n open={open}\n onOpenChange={setOpen}\n items={filteredItems}\n selected={selected}\n onSelectedChange={setSelected}\n onFilterChange={setFilter}\n overlayProps={{\n width: 'small',\n height: 'xsmall',\n }}\n />\n </>\n )\n}"
36303630
}
36313631
],
36323632
"props": [

src/ActionList/List.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ export const List = React.forwardRef<HTMLUListElement, ActionListProps>(
6969
value={{
7070
variant,
7171
selectionVariant: selectionVariant || containerSelectionVariant,
72-
showDividers,
72+
// @ts-ignore showItemDividers may be passed by some components until next major.
73+
showDividers: showDividers || !!props.showItemDividers,
7374
role: role || listRole,
7475
headingId,
7576
}}

src/FilteredActionList/FilteredActionList.stories.tsx

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {Meta} from '@storybook/react'
22
import React from 'react'
33
import {ThemeProvider} from '..'
4-
import {FilteredActionList} from '../FilteredActionList'
4+
import {FilteredActionList, ItemInput} from '../FilteredActionList'
55
import BaseStyles from '../BaseStyles'
66
import Box from '../Box'
77

@@ -26,35 +26,33 @@ const meta: Meta = {
2626
export default meta
2727

2828
function getColorCircle(color: string) {
29-
return function () {
30-
return (
31-
<Box
32-
bg={color}
33-
borderColor={color}
34-
width={14}
35-
height={14}
36-
borderRadius={10}
37-
margin="auto"
38-
borderWidth="1px"
39-
borderStyle="solid"
40-
/>
41-
)
42-
}
29+
return (
30+
<Box
31+
bg={color}
32+
borderColor={color}
33+
width={14}
34+
height={14}
35+
borderRadius={10}
36+
margin="auto"
37+
borderWidth="1px"
38+
borderStyle="solid"
39+
/>
40+
)
4341
}
4442

4543
const items = [
46-
{leadingVisual: getColorCircle('#a2eeef'), text: 'enhancement', id: 1},
47-
{leadingVisual: getColorCircle('#d73a4a'), text: 'bug', id: 2},
48-
{leadingVisual: getColorCircle('#0cf478'), text: 'good first issue', id: 3},
49-
{leadingVisual: getColorCircle('#ffd78e'), text: 'design', id: 4},
50-
{leadingVisual: getColorCircle('#ff0000'), text: 'blocker', id: 5},
51-
{leadingVisual: getColorCircle('#a4f287'), text: 'backend', id: 6},
52-
{leadingVisual: getColorCircle('#8dc6fc'), text: 'frontend', id: 7},
53-
]
44+
{leadingVisual: getColorCircle('#a2eeef'), text: 'enhancement', id: '1'},
45+
{leadingVisual: getColorCircle('#d73a4a'), text: 'bug', id: '2'},
46+
{leadingVisual: getColorCircle('#0cf478'), text: 'good first issue', id: '3'},
47+
{leadingVisual: getColorCircle('#ffd78e'), text: 'design', id: '4'},
48+
{leadingVisual: getColorCircle('#ff0000'), text: 'blocker', id: '5'},
49+
{leadingVisual: getColorCircle('#a4f287'), text: 'backend', id: '6'},
50+
{leadingVisual: getColorCircle('#8dc6fc'), text: 'frontend', id: '7'},
51+
] as ItemInput[]
5452

5553
export function Default(): JSX.Element {
5654
const [filter, setFilter] = React.useState('')
57-
const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))
55+
const filteredItems = items.filter(item => item.text?.toLowerCase().startsWith(filter.toLowerCase()))
5856

5957
return (
6058
<>

src/FilteredActionList/FilteredActionList.tsx

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,70 @@
1-
import type {ScrollIntoViewOptions} from '@primer/behaviors'
2-
import {scrollIntoView} from '@primer/behaviors'
31
import React, {KeyboardEventHandler, useCallback, useEffect, useRef} from 'react'
4-
import styled from 'styled-components'
2+
import TextInput, {TextInputProps} from '../TextInput'
53
import Box from '../Box'
4+
import {ActionList, ActionListProps, ActionListItemProps} from '../ActionList'
65
import Spinner from '../Spinner'
7-
import TextInput, {TextInputProps} from '../TextInput'
8-
import {get} from '../constants'
9-
import {ActionList} from '../deprecated/ActionList'
10-
import {GroupedListProps, ListPropsBase} from '../deprecated/ActionList/List'
116
import {useFocusZone} from '../hooks/useFocusZone'
12-
import {useId} from '../hooks/useId'
13-
import {useProvidedRefOrCreate} from '../hooks/useProvidedRefOrCreate'
147
import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate'
8+
import styled from 'styled-components'
9+
import {get} from '../constants'
10+
import {useProvidedRefOrCreate} from '../hooks/useProvidedRefOrCreate'
1511
import useScrollFlash from '../hooks/useScrollFlash'
12+
import {scrollIntoView} from '@primer/behaviors'
13+
import type {ScrollIntoViewOptions} from '@primer/behaviors'
14+
import {useId} from '../hooks/useId'
1615
import {VisuallyHidden} from '../internal/components/VisuallyHidden'
1716
import {SxProp} from '../sx'
1817

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

21-
export interface FilteredActionListProps
22-
extends Partial<Omit<GroupedListProps, keyof ListPropsBase>>,
23-
ListPropsBase,
24-
SxProp {
20+
export type ItemInput = Partial<
21+
ActionListItemProps & {
22+
description?: string | React.ReactElement
23+
descriptionVariant?: 'inline' | 'block'
24+
leadingVisual?: JSX.Element
25+
onAction?: (itemFromAction: ItemInput, event: React.MouseEvent) => void
26+
selected?: boolean
27+
text?: string
28+
trailingVisual?: string
29+
}
30+
>
31+
32+
export interface FilteredActionListProps extends ActionListProps, SxProp {
2533
loading?: boolean
2634
placeholderText?: string
2735
filterValue?: string
2836
onFilterChange: (value: string, e: React.ChangeEvent<HTMLInputElement>) => void
2937
textInputProps?: Partial<Omit<TextInputProps, 'onChange'>>
3038
inputRef?: React.RefObject<HTMLInputElement>
39+
items: ItemInput[]
3140
}
3241

3342
const StyledHeader = styled.div`
3443
box-shadow: 0 1px 0 ${get('colors.border.default')};
3544
z-index: 1;
3645
`
3746

47+
const renderFn = ({
48+
description,
49+
descriptionVariant,
50+
id,
51+
sx,
52+
text,
53+
trailingVisual,
54+
leadingVisual,
55+
onSelect,
56+
selected,
57+
}: ItemInput): React.ReactElement => {
58+
return (
59+
<ActionList.Item key={id} sx={sx} role="option" onSelect={onSelect} selected={selected}>
60+
{!!leadingVisual && <ActionList.LeadingVisual>{leadingVisual}</ActionList.LeadingVisual>}
61+
<Box>{text ? text : null}</Box>
62+
{description ? <ActionList.Description variant={descriptionVariant}>{description}</ActionList.Description> : null}
63+
{!!trailingVisual && <ActionList.TrailingVisual>{trailingVisual}</ActionList.TrailingVisual>}
64+
</ActionList.Item>
65+
)
66+
}
67+
3868
export function FilteredActionList({
3969
loading = false,
4070
placeholderText,
@@ -57,7 +87,7 @@ export function FilteredActionList({
5787
)
5888

5989
const scrollContainerRef = useRef<HTMLDivElement>(null)
60-
const listContainerRef = useRef<HTMLDivElement>(null)
90+
const listContainerRef = useRef<HTMLUListElement>(null)
6191
const inputRef = useProvidedRefOrCreate<HTMLInputElement>(providedInputRef)
6292
const activeDescendantRef = useRef<HTMLElement>()
6393
const listId = useId()
@@ -84,7 +114,7 @@ export function FilteredActionList({
84114
return !(element instanceof HTMLInputElement)
85115
},
86116
activeDescendantFocus: inputRef,
87-
onActiveDescendantChanged: (current, previous, directlyActivated) => {
117+
onActiveDescendantChanged: (current, _previous, directlyActivated) => {
88118
activeDescendantRef.current = current
89119

90120
if (current && scrollContainerRef.current && directlyActivated) {
@@ -132,7 +162,15 @@ export function FilteredActionList({
132162
<Spinner />
133163
</Box>
134164
) : (
135-
<ActionList ref={listContainerRef} items={items} {...listProps} role="listbox" id={listId} />
165+
<ActionList
166+
ref={listContainerRef}
167+
{...listProps}
168+
role="listbox"
169+
id={listId}
170+
aria-label={`${placeholderText} options`}
171+
>
172+
{items.map(i => renderFn(i))}
173+
</ActionList>
136174
)}
137175
</Box>
138176
</Box>

src/FilteredActionList/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export {FilteredActionList} from './FilteredActionList'
2-
export type {FilteredActionListProps} from './FilteredActionList'
2+
export type {FilteredActionListProps, ItemInput} from './FilteredActionList'

src/SelectPanel/SelectPanel.features.stories.tsx

Lines changed: 21 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {ComponentMeta} from '@storybook/react'
33

44
import Box from '../Box'
55
import {Button} from '../Button'
6-
import {ItemInput} from '../deprecated/ActionList/List'
6+
import {ItemInput} from '../FilteredActionList'
77
import {SelectPanel} from './SelectPanel'
88
import {TriangleDownIcon} from '@primer/octicons-react'
99
import type {OverlayProps} from '../Overlay'
@@ -14,30 +14,28 @@ export default {
1414
} as ComponentMeta<typeof SelectPanel>
1515

1616
function getColorCircle(color: string) {
17-
return function () {
18-
return (
19-
<Box
20-
bg={color}
21-
borderColor={color}
22-
width={14}
23-
height={14}
24-
borderRadius={10}
25-
margin="auto"
26-
borderWidth="1px"
27-
borderStyle="solid"
28-
/>
29-
)
30-
}
17+
return (
18+
<Box
19+
bg={color}
20+
borderColor={color}
21+
width={14}
22+
height={14}
23+
borderRadius={10}
24+
margin="auto"
25+
borderWidth="1px"
26+
borderStyle="solid"
27+
/>
28+
)
3129
}
3230

3331
const items = [
34-
{leadingVisual: getColorCircle('#a2eeef'), text: 'enhancement', id: 1},
35-
{leadingVisual: getColorCircle('#d73a4a'), text: 'bug', id: 2},
36-
{leadingVisual: getColorCircle('#0cf478'), text: 'good first issue', id: 3},
37-
{leadingVisual: getColorCircle('#ffd78e'), text: 'design', id: 4},
38-
{leadingVisual: getColorCircle('#ff0000'), text: 'blocker', id: 5},
39-
{leadingVisual: getColorCircle('#a4f287'), text: 'backend', id: 6},
40-
{leadingVisual: getColorCircle('#8dc6fc'), text: 'frontend', id: 7},
32+
{leadingVisual: getColorCircle('#a2eeef'), text: 'enhancement', id: '1'},
33+
{leadingVisual: getColorCircle('#d73a4a'), text: 'bug', id: '2'},
34+
{leadingVisual: getColorCircle('#0cf478'), text: 'good first issue', id: '3'},
35+
{leadingVisual: getColorCircle('#ffd78e'), text: 'design', id: '4'},
36+
{leadingVisual: getColorCircle('#ff0000'), text: 'blocker', id: '5'},
37+
{leadingVisual: getColorCircle('#a4f287'), text: 'backend', id: '6'},
38+
{leadingVisual: getColorCircle('#8dc6fc'), text: 'frontend', id: '7'},
4139
]
4240

4341
export const SingleSelectStory = () => {
@@ -63,6 +61,7 @@ export const SingleSelectStory = () => {
6361
selected={selected}
6462
onSelectedChange={setSelected}
6563
onFilterChange={setFilter}
64+
showDividers={true}
6665
showItemDividers={true}
6766
overlayProps={{width: 'small', height: 'xsmall'}}
6867
/>
@@ -94,7 +93,6 @@ export const ExternalAnchorStory = () => {
9493
selected={selected}
9594
onSelectedChange={setSelected}
9695
onFilterChange={setFilter}
97-
showItemDividers={true}
9896
overlayProps={{width: 'small', height: 'xsmall'}}
9997
/>
10098
</>
@@ -125,7 +123,6 @@ export const SelectPanelHeightInitialWithOverflowingItemsStory = () => {
125123
selected={selected}
126124
onSelectedChange={setSelected}
127125
onFilterChange={setFilter}
128-
showItemDividers={true}
129126
overlayProps={{width: 'small', height: 'initial', maxHeight: 'xsmall'}}
130127
/>
131128
</>
@@ -157,7 +154,6 @@ export const SelectPanelHeightInitialWithUnderflowingItemsStory = () => {
157154
selected={selected}
158155
onSelectedChange={setSelected}
159156
onFilterChange={setFilter}
160-
showItemDividers={true}
161157
overlayProps={{width: 'small', height: 'initial', maxHeight: 'xsmall'}}
162158
/>
163159
</>
@@ -202,7 +198,6 @@ export const SelectPanelHeightInitialWithUnderflowingItemsAfterFetch = () => {
202198
selected={selected}
203199
onSelectedChange={setSelected}
204200
onFilterChange={setFilter}
205-
showItemDividers={true}
206201
overlayProps={{width: 'small', height, maxHeight: 'xsmall'}}
207202
/>
208203
</>
@@ -234,7 +229,6 @@ export const SelectPanelAboveTallBody = () => {
234229
selected={selected}
235230
onSelectedChange={setSelected}
236231
onFilterChange={setFilter}
237-
showItemDividers={true}
238232
overlayProps={{width: 'small', height: 'xsmall'}}
239233
/>
240234
<div
@@ -276,7 +270,6 @@ export const SelectPanelHeightAndScroll = () => {
276270
selected={selectedA}
277271
onSelectedChange={setSelectedA}
278272
onFilterChange={setFilter}
279-
showItemDividers={true}
280273
overlayProps={{height: 'medium'}}
281274
/>
282275
<h2>With height:auto, maxheight:medium</h2>
@@ -293,7 +286,6 @@ export const SelectPanelHeightAndScroll = () => {
293286
selected={selectedB}
294287
onSelectedChange={setSelectedB}
295288
onFilterChange={setFilter}
296-
showItemDividers={true}
297289
overlayProps={{
298290
height: 'auto',
299291
maxHeight: 'medium',

0 commit comments

Comments
 (0)