diff --git a/.changeset/slimy-starfishes-agree.md b/.changeset/slimy-starfishes-agree.md new file mode 100644 index 00000000000..be22781c088 --- /dev/null +++ b/.changeset/slimy-starfishes-agree.md @@ -0,0 +1,7 @@ +--- +'@primer/react': minor +--- + +ActionList: Add ActionList.Heading component + + diff --git a/src/ActionList/ActionList.features.stories.tsx b/src/ActionList/ActionList.features.stories.tsx index d342d981263..5751f9038b4 100644 --- a/src/ActionList/ActionList.features.stories.tsx +++ b/src/ActionList/ActionList.features.stories.tsx @@ -23,6 +23,8 @@ import { AlertIcon, TableIcon, PeopleIcon, + FileDirectoryIcon, + PlusCircleIcon, } from '@primer/octicons-react' export default { @@ -41,6 +43,98 @@ export const SimpleList = () => ( ) +export const WithVisualListHeading = () => ( + + Filter by + + {}}> + + + + app/assets/modules + + {}}> + + + + src/react/components + + {}}> + + + + memex/shared-ui/components + + {}}> + + + + views/assets/modules + + + + + {}}> + + + + Owner + + {}}> + + + + Symbol + + {}}> + + + + Exclude archived + + + +) + +export const WithCustomHeading = () => ( + <> + + Details + + + + + + + Readme + + + + + + MIT License + + + + + + 1.5k stars + + + + + + 21 watching + + + + + + 225 forks + + + +) export const WithIcons = () => ( diff --git a/src/__tests__/ActionList.test.tsx b/src/ActionList/ActionList.test.tsx similarity index 74% rename from src/__tests__/ActionList.test.tsx rename to src/ActionList/ActionList.test.tsx index ca070d04305..5613411dda3 100644 --- a/src/__tests__/ActionList.test.tsx +++ b/src/ActionList/ActionList.test.tsx @@ -2,9 +2,9 @@ import {render as HTMLRender, waitFor, fireEvent} from '@testing-library/react' import {axe} from 'jest-axe' import React from 'react' import theme from '../theme' -import {ActionList} from '../ActionList' +import {ActionList} from '.' import {behavesAsComponent, checkExports} from '../utils/testing' -import {BaseStyles, ThemeProvider, SSRProvider} from '..' +import {BaseStyles, ThemeProvider, SSRProvider, ActionMenu} from '..' function SimpleActionList(): JSX.Element { return ( @@ -166,4 +166,52 @@ describe('ActionList', () => { fireEvent.click(link) expect(onClick).toHaveBeenCalled() }) + + it('should render the ActionList.Heading component as a heading with the given heading level', async () => { + const container = HTMLRender( + + Heading + , + ) + const heading = container.getByRole('heading', {level: 1}) + expect(heading).toBeInTheDocument() + expect(heading).toHaveTextContent('Heading') + }) + it('should label the action list with the heading id', async () => { + const {container, getByRole} = HTMLRender( + + Heading + Item + , + ) + const list = container.querySelector('ul') + const heading = getByRole('heading', {level: 1}) + expect(list).toHaveAttribute('aria-labelledby', heading.id) + }) + it('should throw an error when ActionList.Heading is used within ActionMenu context', async () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => jest.fn()) + expect(() => + HTMLRender( + + + + + Trigger + + + Heading + Item + + + + + + , + ), + ).toThrow( + "ActionList.Heading shouldn't be used within an ActionMenu container. Menus are labelled by the menu button's name.", + ) + expect(spy).toHaveBeenCalled() + spy.mockRestore() + }) }) diff --git a/src/ActionList/Heading.tsx b/src/ActionList/Heading.tsx new file mode 100644 index 00000000000..076d690df28 --- /dev/null +++ b/src/ActionList/Heading.tsx @@ -0,0 +1,54 @@ +import React, {forwardRef} from 'react' +import {BetterSystemStyleObject, SxProp, merge} from '../sx' +import {defaultSxProp} from '../utils/defaultSxProp' +import {useRefObjectAsForwardedRef} from '../hooks' +import {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' +import {default as HeadingComponent} from '../Heading' +import {ListContext} from './List' +import VisuallyHidden from '../_VisuallyHidden' +import {ActionListContainerContext} from './ActionListContainerContext' +import {invariant} from '../utils/invariant' + +type HeadingLevels = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' +export type ActionListHeadingProps = { + as: HeadingLevels + visuallyHidden?: boolean +} & SxProp + +export const Heading = forwardRef( + ({as, children, sx = defaultSxProp, visuallyHidden = false, ...props}, forwardedRef) => { + const innerRef = React.useRef(null) + useRefObjectAsForwardedRef(forwardedRef, innerRef) + + const {headingId: headingId, variant: listVariant} = React.useContext(ListContext) + const {container} = React.useContext(ActionListContainerContext) + + // Semantic s don't have a place for headers within them, they should be aria-labelledby the menu button's name. + invariant( + container !== 'ActionMenu', + `ActionList.Heading shouldn't be used within an ActionMenu container. Menus are labelled by the menu button's name.`, + ) + + const styles = { + marginBottom: 2, + marginX: listVariant === 'full' ? 2 : 3, + } + + return ( + + (styles, sx)} + {...props} + > + {children} + + + ) + }, +) as PolymorphicForwardRefComponent + +Heading.displayName = 'ActionList.Heading' diff --git a/src/ActionList/List.tsx b/src/ActionList/List.tsx index 29bc2f54953..ba2178d3efa 100644 --- a/src/ActionList/List.tsx +++ b/src/ActionList/List.tsx @@ -5,6 +5,9 @@ import sx, {SxProp, merge} from '../sx' import {AriaRole} from '../utils/types' import {ActionListContainerContext} from './ActionListContainerContext' import {defaultSxProp} from '../utils/defaultSxProp' +import {useSlots} from '../hooks/useSlots' +import {Heading} from './Heading' +import {useId} from '../hooks/useId' export type ActionListProps = React.PropsWithChildren<{ /** @@ -26,7 +29,10 @@ export type ActionListProps = React.PropsWithChildren<{ }> & SxProp -type ContextProps = Pick +type ContextProps = Pick & { + headingId?: string +} + export const ListContext = React.createContext({}) const ListBox = styled.ul(sx) @@ -42,6 +48,12 @@ export const List = React.forwardRef( paddingY: variant === 'inset' ? 2 : 0, } + const [slots, childrenWithoutSlots] = useSlots(props.children, { + heading: Heading, + }) + + const headingId = useId() + /** if list is inside a Menu, it will get a role from the Menu */ const { listRole, @@ -49,25 +61,29 @@ export const List = React.forwardRef( selectionVariant: containerSelectionVariant, // TODO: Remove after DropdownMenu2 deprecation } = React.useContext(ActionListContainerContext) + const ariaLabelledBy = slots.heading ? slots.heading.props.id ?? headingId : listLabelledBy + return ( - - - {props.children} - - + {childrenWithoutSlots} + + ) }, ) as PolymorphicForwardRefComponent<'ul', ActionListProps> diff --git a/src/__tests__/__snapshots__/ActionList.test.tsx.snap b/src/ActionList/__snapshots__/ActionList.test.tsx.snap similarity index 100% rename from src/__tests__/__snapshots__/ActionList.test.tsx.snap rename to src/ActionList/__snapshots__/ActionList.test.tsx.snap diff --git a/src/ActionList/index.ts b/src/ActionList/index.ts index c87e5bc7f65..202a96a1a4b 100644 --- a/src/ActionList/index.ts +++ b/src/ActionList/index.ts @@ -5,6 +5,7 @@ import {LinkItem} from './LinkItem' import {Divider} from './Divider' import {Description} from './Description' import {LeadingVisual, TrailingVisual} from './Visuals' +import {Heading} from './Heading' export type {ActionListProps} from './List' export type {ActionListGroupProps} from './Group' @@ -13,6 +14,7 @@ export type {ActionListLinkItemProps} from './LinkItem' export type {ActionListDividerProps} from './Divider' export type {ActionListDescriptionProps} from './Description' export type {ActionListLeadingVisualProps, ActionListTrailingVisualProps} from './Visuals' +export type {ActionListHeadingProps} from './Heading' /** * Collection of list-related components. @@ -38,4 +40,7 @@ export const ActionList = Object.assign(List, { /** Icon (or similar) positioned after `Item` text. */ TrailingVisual, + + /** Heading for an `ActionList`. */ + Heading, }) diff --git a/src/NavList/__snapshots__/NavList.test.tsx.snap b/src/NavList/__snapshots__/NavList.test.tsx.snap index b50e0e3e252..7253cc36ff8 100644 --- a/src/NavList/__snapshots__/NavList.test.tsx.snap +++ b/src/NavList/__snapshots__/NavList.test.tsx.snap @@ -304,7 +304,7 @@ exports[`NavList renders a simple list 1`] = ` > Home @@ -326,7 +326,7 @@ exports[`NavList renders a simple list 1`] = ` class="c1 c6" > About @@ -348,7 +348,7 @@ exports[`NavList renders a simple list 1`] = ` class="c1 c6" > Contact @@ -718,13 +718,13 @@ exports[`NavList renders with groups 1`] = ` role="presentation" > Overview