Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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/giant-radios-sniff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

ActionList: Add ActionList.GroupHeading component to label the group lists and update labelling semantics for accessibility
140 changes: 140 additions & 0 deletions src/ActionList/ActionList.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,146 @@ export const WithVisualListHeading = () => (
</ActionList>
)

export const ListWithNewGroupHeadingAPI = () => (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a documentation perspective, would it help to have the story name be more like ListWithHeading or something similar? Trying to think of when someone might be looking for how to do X (where X is adding a heading for a list) and having a story name that makes sense to them.

The terminology "NewGroupedHeadingAPI" feels very clear for maintainers but from a user perspective might not make as much sense.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh sorry I forgot to mention that I added these stories for dev/code review only to make the changes easily viewable. Since we are going to deprecate the title prop on ActionList.Group and our new API for providing group title is ActionList.GroupHeading, I was thinking to update the stories that use title prop with ActionList.GroupHeading such as GroupWithFilledTitle and GroupWithSubtleTitle. Would that be helpful and enough you think for consumers to find how to add group headings? Also do you think we should still keep stories with the title prop at all? 🤔

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to take these stories back in this PR. I am thinking to do a follow up one that adds a @deprecated notation to the title prop and update all title usages with the new API in stories and docs. Let me know if there is any concern!

<ActionList>
<ActionList.Heading as="h2">List Heading</ActionList.Heading>
<ActionList.Group data-test-id="ActionList.Group">
<ActionList.GroupHeading as="h3">Group 1 Heading</ActionList.GroupHeading>
<ActionList.Item onClick={() => {}}>
<ActionList.LeadingVisual>
<FileDirectoryIcon />
</ActionList.LeadingVisual>
app/assets/modules
</ActionList.Item>
<ActionList.Item onClick={() => {}}>
<ActionList.LeadingVisual>
<FileDirectoryIcon />
</ActionList.LeadingVisual>
src/react/components
</ActionList.Item>
<ActionList.Item onClick={() => {}}>
<ActionList.LeadingVisual>
<FileDirectoryIcon />
</ActionList.LeadingVisual>
memex/shared-ui/components
</ActionList.Item>
<ActionList.Item onClick={() => {}}>
<ActionList.LeadingVisual>
<FileDirectoryIcon />
</ActionList.LeadingVisual>
views/assets/modules
</ActionList.Item>
</ActionList.Group>

<ActionList.Group>
<ActionList.GroupHeading as="h3">Group 2 Heading</ActionList.GroupHeading>
<ActionList.Item onClick={() => {}}>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what our conventions are, but when would we use an empty handler for events versus using storybook actions?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a really good question. I am not really sure either. I find storybook actions useful when the story is about the action like the data table action stories you added are great examples for this use case in my opinion. The stories I added or the ones I mentioned here to update are not focusing on actions, they are focusing on semantics, visuals, and having group titles. This is why, I didn't worry about the action itself but I am more than happy to update them to use storybook actions if it is going to be helpful.
Re-thinking about it now, it is probably helpful to log something (storybook actions could be very beneficial in this case) when the click event is trigger at least so empty event handler isn't very helpful here.

What are your thoughts??

<ActionList.LeadingVisual>
<PlusCircleIcon />
</ActionList.LeadingVisual>
Owner
</ActionList.Item>
<ActionList.Item onClick={() => {}}>
<ActionList.LeadingVisual>
<PlusCircleIcon />
</ActionList.LeadingVisual>
Symbol
</ActionList.Item>
<ActionList.Item onClick={() => {}}>
<ActionList.LeadingVisual>
<PlusCircleIcon />
</ActionList.LeadingVisual>
Exclude archived
</ActionList.Item>
</ActionList.Group>
</ActionList>
)

export const MenuWithNewGroupHeadingAPI = () => {
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 (
<ActionList selectionVariant="multiple" role="menu" showDividers aria-label="Reviewers">
<ActionList.Group>
<ActionList.GroupHeading>Everyone</ActionList.GroupHeading>
{users.slice(2).map(user => (
<ActionList.Item
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>
<Avatar src={`https://github.com/${user.login}.png`} />
</ActionList.LeadingVisual>
{user.login}
<ActionList.Description>{user.name}</ActionList.Description>
</ActionList.Item>
))}
</ActionList.Group>
</ActionList>
)
}

export const ListboxWithNewGroupHeadingAPI = () => {
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 (
<ActionList selectionVariant="multiple" role="listbox" showDividers aria-label="Reviewers">
<ActionList.Group>
<ActionList.GroupHeading>Everyone</ActionList.GroupHeading>
{users.slice(2).map(user => (
<ActionList.Item
role="option"
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>
<Avatar src={`https://github.com/${user.login}.png`} />
</ActionList.LeadingVisual>
{user.login}
<ActionList.Description>{user.name}</ActionList.Description>
</ActionList.Item>
))}
</ActionList.Group>
<ActionList.Group>
<ActionList.GroupHeading>Review Requested</ActionList.GroupHeading>
{users.slice(2).map(user => (
<ActionList.Item
role="option"
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>
<Avatar src={`https://github.com/${user.login}.png`} />
</ActionList.LeadingVisual>
{user.login}
<ActionList.Description>{user.name}</ActionList.Description>
</ActionList.Item>
))}
</ActionList.Group>
</ActionList>
)
}
export const WithCustomHeading = () => (
<>
<Heading as="h1" id="list-heading" sx={{fontSize: 3, marginX: 3}}>
Expand Down
97 changes: 97 additions & 0 deletions src/ActionList/ActionList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import theme from '../theme'
import {ActionList} from '.'
import {behavesAsComponent, checkExports} from '../utils/testing'
import {BaseStyles, ThemeProvider, SSRProvider, ActionMenu} from '..'
import data from '../drafts/SelectPanel2/mock-data'
Comment thread
broccolinisoup marked this conversation as resolved.
Outdated

function SimpleActionList(): JSX.Element {
return (
Expand Down Expand Up @@ -215,4 +216,100 @@ describe('ActionList', () => {
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
it('should render the ActionList.GroupHeading component as a heading with the given heading level', async () => {
const container = HTMLRender(
<ActionList>
<ActionList.Heading as="h1">Heading</ActionList.Heading>
<ActionList.Group>
<ActionList.GroupHeading as="h2">Group Heading</ActionList.GroupHeading>
</ActionList.Group>
</ActionList>,
)
const heading = container.getByRole('heading', {level: 2})
expect(heading).toBeInTheDocument()
expect(heading).toHaveTextContent('Group Heading')
})
it('should throw a warning if ActionList.Group is used without as prop when no role is specified (for list role)', async () => {
const spy = jest.spyOn(console, 'warn').mockImplementationOnce(() => {})

HTMLRender(
<ActionList>
<ActionList.Heading as="h1">Heading</ActionList.Heading>
<ActionList.Group>
<ActionList.GroupHeading>Group Heading</ActionList.GroupHeading>
</ActionList.Group>
</ActionList>,
)
expect(spy).toHaveBeenCalledTimes(1)
spy.mockRestore()
})
it('should render the ActionList.GroupHeading component as a span (not a heading tag) when role is specified as listbox', async () => {
const container = HTMLRender(
<ActionList role="listbox">
<ActionList.Heading as="h1">Heading</ActionList.Heading>
<ActionList.Group>
<ActionList.GroupHeading>Group Heading</ActionList.GroupHeading>
</ActionList.Group>
</ActionList>,
)
const label = container.getByText('Group Heading')
expect(label).toBeInTheDocument()
expect(label.tagName).toEqual('SPAN')
})
it('should render the ActionList.GroupHeading component as a span with role="presentation" and aria-hidden="true" when role is specified as listbox', async () => {
const container = HTMLRender(
<ActionList role="listbox">
<ActionList.Heading as="h1">Heading</ActionList.Heading>
<ActionList.Group>
<ActionList.GroupHeading>Group Heading</ActionList.GroupHeading>
</ActionList.Group>
</ActionList>,
)
const label = container.getByText('Group Heading')
const wrapper = label.parentElement
expect(wrapper).toHaveAttribute('role', 'presentation')
expect(wrapper).toHaveAttribute('aria-hidden', 'true')
})
it('should label the list with the group heading id', async () => {
const {container, getByText} = HTMLRender(
<ActionList>
<ActionList.Heading as="h1">Heading</ActionList.Heading>
<ActionList.Group data-test-id="ActionList.Group">
<ActionList.GroupHeading as="h2">Group Heading</ActionList.GroupHeading>
<ActionList.Item>Item</ActionList.Item>
</ActionList.Group>
</ActionList>,
)
const list = container.querySelector(`li[data-test-id='ActionList.Group'] > ul`)
const heading = getByText('Group Heading')
expect(list).toHaveAttribute('aria-labelledby', heading.id)
})
it('should NOT label the list with the group heading id when role is specified', async () => {
const {container, getByText} = HTMLRender(
<ActionList role="listbox">
<ActionList.Heading as="h1">Heading</ActionList.Heading>
<ActionList.Group data-test-id="ActionList.Group">
<ActionList.GroupHeading>Group Heading</ActionList.GroupHeading>
<ActionList.Item>Item</ActionList.Item>
</ActionList.Group>
</ActionList>,
)
const list = container.querySelector(`li[data-test-id='ActionList.Group'] > ul`)
const heading = getByText('Group Heading')
expect(list).not.toHaveAttribute('aria-labelledby', heading.id)
})
it('should label the list using aria-label when role is specified', async () => {
const {container, getByText} = HTMLRender(
<ActionList role="listbox">
<ActionList.Heading as="h1">Heading</ActionList.Heading>
<ActionList.Group data-test-id="ActionList.Group">
<ActionList.GroupHeading>Group Heading</ActionList.GroupHeading>
<ActionList.Item>Item</ActionList.Item>
</ActionList.Group>
</ActionList>,
)
const list = container.querySelector(`li[data-test-id='ActionList.Group'] > ul`)
const heading = getByText('Group Heading')
expect(list).toHaveAttribute('aria-label', heading.textContent)
})
})
Loading