Skip to content

Fix ActionList group roles #1876

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Feb 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/drafts-actionlist-group-roles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Better aria roles for ActionList group
3 changes: 2 additions & 1 deletion src/ActionList2/ActionListContainerContext.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
/** This context can be used by components that compose ActionList inside a Menu */

import React from 'react'
import {AriaRole} from '../utils/types'

type ContextProps = {
container?: string
listRole?: string
listRole?: AriaRole
selectionVariant?: 'single' | 'multiple' // TODO: Remove after DropdownMenu2 deprecation
selectionAttribute?: 'aria-selected' | 'aria-checked'
listLabelledBy?: string
Expand Down
9 changes: 8 additions & 1 deletion src/ActionList2/Group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@ export const Group: React.FC<GroupProps> = ({
...props
}) => {
const labelId = useSSRSafeId()
const {role: listRole} = React.useContext(ListContext)

return (
<Box
as="li"
role="none"
sx={{
'&:not(:first-child)': {marginTop: 2},
listStyle: 'none', // hide the ::marker inserted by browser's stylesheet
Expand All @@ -58,7 +60,12 @@ export const Group: React.FC<GroupProps> = ({
>
{title && <Header title={title} variant={variant} auxiliaryText={auxiliaryText} labelId={labelId} />}
<GroupContext.Provider value={{selectionVariant}}>
<Box as="ul" sx={{paddingInlineStart: 0}} aria-labelledby={title ? labelId : undefined} role={role}>
<Box
as="ul"
sx={{paddingInlineStart: 0}}
aria-labelledby={title ? labelId : undefined}
role={role || (listRole && 'group')}
>
{props.children}
</Box>
</GroupContext.Provider>
Expand Down
4 changes: 2 additions & 2 deletions src/ActionList2/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export const Item = React.forwardRef<HTMLLIElement, ItemProps>(
): JSX.Element => {
const {variant: listVariant, showDividers, selectionVariant: listSelectionVariant} = React.useContext(ListContext)
const {selectionVariant: groupSelectionVariant} = React.useContext(GroupContext)
const {container, afterSelect, selectionAttribute = 'aria-selected'} = React.useContext(ActionListContainerContext)
const {container, afterSelect, selectionAttribute} = React.useContext(ActionListContainerContext)

let selectionVariant: ListProps['selectionVariant'] | GroupProps['selectionVariant']
if (typeof groupSelectionVariant !== 'undefined') selectionVariant = groupSelectionVariant
Expand Down Expand Up @@ -227,7 +227,7 @@ export const Item = React.forwardRef<HTMLLIElement, ItemProps>(
aria-labelledby={`${labelId} ${slots.InlineDescription ? inlineDescriptionId : ''}`}
aria-describedby={slots.BlockDescription ? blockDescriptionId : undefined}
role={role || itemRole}
{...{[selectionAttribute]: selected}}
{...(selectionAttribute && {[selectionAttribute]: selected})}
{...props}
>
<ItemWrapper>
Expand Down
9 changes: 7 additions & 2 deletions src/ActionList2/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export type ListProps = {
role?: AriaRole
} & SxProp

type ContextProps = Pick<ListProps, 'variant' | 'selectionVariant' | 'showDividers'>
type ContextProps = Pick<ListProps, 'variant' | 'selectionVariant' | 'showDividers' | 'role'>
export const ListContext = React.createContext<ContextProps>({})

const ListBox = styled.ul<SxProp>(sx)
Expand Down Expand Up @@ -56,7 +56,12 @@ export const List = React.forwardRef<HTMLUListElement, ListProps>(
ref={forwardedRef}
>
<ListContext.Provider
value={{variant, selectionVariant: selectionVariant || containerSelectionVariant, showDividers}}
value={{
variant,
selectionVariant: selectionVariant || containerSelectionVariant,
showDividers,
role: role || listRole
}}
>
{props.children}
</ListContext.Provider>
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/ActionList2.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function SingleSelectListStory(): JSX.Element {
key={index}
role="option"
selected={index === selectedIndex}
aria-selected={index === selectedIndex}
onSelect={() => setSelectedIndex(index)}
disabled={project.disabled}
>
Expand Down
12 changes: 7 additions & 5 deletions src/stories/ActionList2/examples.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,13 +190,14 @@ export function Groups(): JSX.Element {

<p>Grouped content with labels and description. This patterns appears in pull requests to pick a reviewer.</p>

<ActionList selectionVariant="multiple" showDividers aria-label="Select reviewers">
<ActionList.Group title="Suggestions" role="listbox">
<ActionList selectionVariant="multiple" role="menu" showDividers aria-label="Select reviewers">
<ActionList.Group title="Suggestions">
{users.slice(0, 2).map(user => (
<ActionList.Item
key={user.login}
role="option"
role="menuitemcheckbox"
selected={Boolean(assignees.find(assignee => assignee.login === user.login))}
aria-checked={Boolean(assignees.find(assignee => assignee.login === user.login))}
onSelect={() => toggleAssignee(user)}
>
<ActionList.LeadingVisual>
Expand All @@ -208,12 +209,13 @@ export function Groups(): JSX.Element {
</ActionList.Item>
))}
</ActionList.Group>
<ActionList.Group title="Everyone" role="listbox">
<ActionList.Group title="Everyone">
{users.slice(2).map(user => (
<ActionList.Item
role="option"
role="menuitemcheckbox"
key={user.login}
selected={Boolean(assignees.find(assignee => assignee.login === user.login))}
aria-checked={Boolean(assignees.find(assignee => assignee.login === user.login))}
onSelect={() => toggleAssignee(user)}
>
<ActionList.LeadingVisual>
Expand Down
69 changes: 7 additions & 62 deletions src/stories/ActionList2/fixtures.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,12 +213,13 @@ export function DisabledStory(): JSX.Element {
<>
<h1>Disabled Items</h1>
<ErsatzOverlay>
<ActionList selectionVariant="single" showDividers role="listbox" aria-label="Select a project">
<ActionList selectionVariant="single" showDividers role="menu" aria-label="Select a project">
{projects.map((project, index) => (
<ActionList.Item
key={index}
role="option"
role="menuitemradio"
selected={index === selectedIndex}
aria-checked={index === selectedIndex}
onSelect={() => setSelectedIndex(index)}
disabled={index === 1}
>
Expand All @@ -236,61 +237,6 @@ export function DisabledStory(): JSX.Element {
}
DisabledStory.storyName = 'Disabled Items'

export function GroupsStory(): JSX.Element {
const [assignees, setAssignees] = React.useState(users.slice(0, 1))

const toggleAssignee = (assignee: typeof users[number]) => {
const assigneeIndex = assignees.findIndex(a => a.login === assignee.login)

if (assigneeIndex === -1) setAssignees([...assignees, assignee])
else setAssignees(assignees.filter((_, index) => index !== assigneeIndex))
}

return (
<>
<h1>Groups</h1>
<ErsatzOverlay>
<ActionList selectionVariant="multiple" showDividers aria-label="Select reviewers">
<ActionList.Group title="Suggestions" variant="filled" role="listbox">
{users.slice(0, 2).map(user => (
<ActionList.Item
key={user.login}
role="option"
selected={Boolean(assignees.find(assignee => assignee.login === user.login))}
onSelect={() => toggleAssignee(user)}
>
<ActionList.LeadingVisual>
<Avatar src={`https://github.com/${user.login}.png`} />
</ActionList.LeadingVisual>
{user.login}
<ActionList.Description>{user.name}</ActionList.Description>
<ActionList.Description variant="block">Recently edited these files</ActionList.Description>
</ActionList.Item>
))}
</ActionList.Group>
<ActionList.Group title="Everyone" variant="filled" role="listbox">
{users.slice(2).map(user => (
<ActionList.Item
role="option"
key={user.login}
selected={Boolean(assignees.find(assignee => assignee.login === user.login))}
onSelect={() => toggleAssignee(user)}
>
<ActionList.LeadingVisual>
<Avatar src={`https://github.com/${user.login}.png`} />
</ActionList.LeadingVisual>
{user.login}
<ActionList.Description>{user.name}</ActionList.Description>
</ActionList.Item>
))}
</ActionList.Group>
</ActionList>
</ErsatzOverlay>
</>
)
}
GroupsStory.storyName = 'Groups'

export function ActionsStory(): JSX.Element {
return (
<>
Expand Down Expand Up @@ -1007,20 +953,19 @@ export function MemexSortable(): JSX.Element {
<h1>Memex Sortable List</h1>
<ErsatzOverlay>
<DndProvider backend={HTML5Backend}>
<ActionList selectionVariant="multiple">
<ActionList.Group title="Visible fields (can be reordered)" role="listbox">
<ActionList selectionVariant="multiple" role="menu">
<ActionList.Group title="Visible fields (can be reordered)">
{visibleOptions.map(option => (
<SortableItem
key={option.text}
role="option"
role="menuitemcheckbox"
option={option}
onSelect={() => toggle(option.text)}
reorder={reorder}
/>
))}
</ActionList.Group>
<ActionList.Group
role="listbox"
title="Hidden fields"
selectionVariant={
/** selectionVariant override on Group: disable selection if there are no options */
Expand All @@ -1030,7 +975,7 @@ export function MemexSortable(): JSX.Element {
{hiddenOptions.map((option, index) => (
<ActionList.Item
key={index}
role="option"
role="menuitemcheckbox"
selected={option.selected}
onSelect={() => toggle(option.text)}
>
Expand Down
15 changes: 7 additions & 8 deletions src/stories/ActionMenu2/examples.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,8 @@ export function GroupsAndDescription(): JSX.Element {
Milestone
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList selectionVariant="single" showDividers role="none">
<ActionList.Group title="Open" role="menu">
<ActionList selectionVariant="single" showDividers>
<ActionList.Group title="Open">
{milestones
.filter(milestone => !milestone.name.includes('21'))
.map((milestone, index) => (
Expand All @@ -189,7 +189,7 @@ export function GroupsAndDescription(): JSX.Element {
</ActionList.Item>
))}
</ActionList.Group>
<ActionList.Group title="Closed" role="menu">
<ActionList.Group title="Closed">
{milestones
.filter(milestone => milestone.name.includes('21'))
.map((milestone, index) => (
Expand Down Expand Up @@ -270,7 +270,6 @@ export function MultipleSelection(): JSX.Element {
<ActionList selectionVariant="multiple" showDividers>
{users.map(user => (
<ActionList.Item
role="option"
key={user.login}
selected={Boolean(assignees.find(assignee => assignee.login === user.login))}
onSelect={() => toggleAssignee(user)}
Expand Down Expand Up @@ -321,8 +320,8 @@ export function MixedSelection(): JSX.Element {
{selectedOption ? `Group by ${selectedOption.text}` : 'Group items by'}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList role="none">
<ActionList.Group selectionVariant="single" title="Group by" role="menu">
<ActionList>
<ActionList.Group selectionVariant="single" title="Group by">
{options.map((option, index) => (
<ActionList.Item
key={index}
Expand All @@ -337,9 +336,9 @@ export function MixedSelection(): JSX.Element {
))}
</ActionList.Group>
{typeof selectedIndex === 'number' && (
<ActionList.Group role="menu">
<ActionList.Group>
<ActionList.Divider />
<ActionList.Item onSelect={() => setSelectedIndex(null)} role="menuitem">
<ActionList.Item onSelect={() => setSelectedIndex(null)}>
<ActionList.LeadingVisual>
<XIcon />
</ActionList.LeadingVisual>
Expand Down
3 changes: 2 additions & 1 deletion src/utils/testing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,8 @@ export function checkStoriesForAxeViolations(name: string) {
Object.values(Stories).map(Story => {
if (typeof Story !== 'function') return

it(`story ${(Story as StoryType).storyName} should have no axe violations`, async () => {
const {storyName, name: StoryFunctionName} = Story as StoryType
it(`story ${storyName || StoryFunctionName} should have no axe violations`, async () => {
const {container} = HTMLRender(<Story />)
const results = await axe(container)
expect(results).toHaveNoViolations()
Expand Down