Skip to content

Commit

Permalink
Add ActionList.Heading component (#3581)
Browse files Browse the repository at this point in the history
* Add ActionList.Heading component

* Update .changeset/slimy-starfishes-agree.md

* heading styles

* Update heading style and add visually hidden wrapper with story updates

* warning for heading in menu & make as prop required

* fix linting 🙄

* import useId from hooks not react

* snaps

* remove the full variant story because there is no full variant list heading
  • Loading branch information
broccolinisoup authored Sep 27, 2023
1 parent 5cad920 commit cc12464
Show file tree
Hide file tree
Showing 8 changed files with 273 additions and 49 deletions.
7 changes: 7 additions & 0 deletions .changeset/slimy-starfishes-agree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@primer/react': minor
---

ActionList: Add ActionList.Heading component

<!-- Changed components: ActionList -->
94 changes: 94 additions & 0 deletions src/ActionList/ActionList.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
AlertIcon,
TableIcon,
PeopleIcon,
FileDirectoryIcon,
PlusCircleIcon,
} from '@primer/octicons-react'

export default {
Expand All @@ -41,6 +43,98 @@ export const SimpleList = () => (
</ActionList>
)

export const WithVisualListHeading = () => (
<ActionList>
<ActionList.Heading as="h2">Filter by</ActionList.Heading>
<ActionList.Group title="Path">
<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 title="Advanced">
<ActionList.Item onClick={() => {}}>
<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 WithCustomHeading = () => (
<>
<Heading as="h1" id="list-heading" sx={{fontSize: 3, marginX: 3}}>
Details
</Heading>
<ActionList aria-labelledby="list-heading">
<ActionList.LinkItem href="https://github.com/primer/react#readme">
<ActionList.LeadingVisual>
<BookIcon />
</ActionList.LeadingVisual>
Readme
</ActionList.LinkItem>
<ActionList.LinkItem href="https://github.com/primer/react/blob/main/LICENSE">
<ActionList.LeadingVisual>
<LawIcon />
</ActionList.LeadingVisual>
MIT License
</ActionList.LinkItem>
<ActionList.LinkItem href="https://github.com/primer/react/stargazers">
<ActionList.LeadingVisual>
<StarIcon />
</ActionList.LeadingVisual>
<strong>1.5k</strong> stars
</ActionList.LinkItem>
<ActionList.LinkItem href="https://github.com/primer/react/watchers">
<ActionList.LeadingVisual>
<EyeIcon />
</ActionList.LeadingVisual>
<strong>21</strong> watching
</ActionList.LinkItem>
<ActionList.LinkItem href="https://github.com/primer/react/network/members">
<ActionList.LeadingVisual>
<RepoForkedIcon />
</ActionList.LeadingVisual>
<strong>225</strong> forks
</ActionList.LinkItem>
</ActionList>
</>
)
export const WithIcons = () => (
<ActionList>
<ActionList.Item>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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(
<ActionList>
<ActionList.Heading as="h1">Heading</ActionList.Heading>
</ActionList>,
)
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(
<ActionList>
<ActionList.Heading as="h1">Heading</ActionList.Heading>
<ActionList.Item>Item</ActionList.Item>
</ActionList>,
)
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(
<ThemeProvider theme={theme}>
<SSRProvider>
<BaseStyles>
<ActionMenu open={true}>
<ActionMenu.Button>Trigger</ActionMenu.Button>
<ActionMenu.Overlay>
<ActionList>
<ActionList.Heading as="h1">Heading</ActionList.Heading>
<ActionList.Item>Item</ActionList.Item>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</BaseStyles>
</SSRProvider>
</ThemeProvider>,
),
).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()
})
})
54 changes: 54 additions & 0 deletions src/ActionList/Heading.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLHeadingElement>(null)
useRefObjectAsForwardedRef(forwardedRef, innerRef)

const {headingId: headingId, variant: listVariant} = React.useContext(ListContext)
const {container} = React.useContext(ActionListContainerContext)

// Semantic <menu>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 (
<VisuallyHidden isVisible={!visuallyHidden}>
<HeadingComponent
as={as}
ref={innerRef}
// use custom id if it is provided. Otherwise, use the id from the context
id={props.id ?? headingId}
sx={merge<BetterSystemStyleObject>(styles, sx)}
{...props}
>
{children}
</HeadingComponent>
</VisuallyHidden>
)
},
) as PolymorphicForwardRefComponent<HeadingLevels, ActionListHeadingProps>

Heading.displayName = 'ActionList.Heading'
50 changes: 33 additions & 17 deletions src/ActionList/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
/**
Expand All @@ -26,7 +29,10 @@ export type ActionListProps = React.PropsWithChildren<{
}> &
SxProp

type ContextProps = Pick<ActionListProps, 'variant' | 'selectionVariant' | 'showDividers' | 'role'>
type ContextProps = Pick<ActionListProps, 'variant' | 'selectionVariant' | 'showDividers' | 'role'> & {
headingId?: string
}

export const ListContext = React.createContext<ContextProps>({})

const ListBox = styled.ul<SxProp>(sx)
Expand All @@ -42,32 +48,42 @@ export const List = React.forwardRef<HTMLUListElement, ActionListProps>(
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,
listLabelledBy,
selectionVariant: containerSelectionVariant, // TODO: Remove after DropdownMenu2 deprecation
} = React.useContext(ActionListContainerContext)

const ariaLabelledBy = slots.heading ? slots.heading.props.id ?? headingId : listLabelledBy

return (
<ListBox
sx={merge(styles, sxProp as SxProp)}
role={role || listRole}
aria-labelledby={listLabelledBy}
{...props}
ref={forwardedRef}
<ListContext.Provider
value={{
variant,
selectionVariant: selectionVariant || containerSelectionVariant,
showDividers,
role: role || listRole,
headingId,
}}
>
<ListContext.Provider
value={{
variant,
selectionVariant: selectionVariant || containerSelectionVariant,
showDividers,
role: role || listRole,
}}
{slots.heading}
<ListBox
sx={merge(styles, sxProp as SxProp)}
role={role || listRole}
aria-labelledby={ariaLabelledBy}
{...props}
ref={forwardedRef}
>
{props.children}
</ListContext.Provider>
</ListBox>
{childrenWithoutSlots}
</ListBox>
</ListContext.Provider>
)
},
) as PolymorphicForwardRefComponent<'ul', ActionListProps>
Expand Down
5 changes: 5 additions & 0 deletions src/ActionList/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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.
Expand All @@ -38,4 +40,7 @@ export const ActionList = Object.assign(List, {

/** Icon (or similar) positioned after `Item` text. */
TrailingVisual,

/** Heading for an `ActionList`. */
Heading,
})
Loading

0 comments on commit cc12464

Please sign in to comment.