Skip to content
Draft
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/actionlist-mixed-descriptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

ActionList: Support rendering inline and block descriptions together on an item.
2 changes: 1 addition & 1 deletion packages/react/src/ActionList/ActionList.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@
"type": "React.ReactNode",
"defaultValue": "",
"required": true,
"description": ""
"description": "Items may include one inline description and one block description."
},
{
"name": "variant",
Expand Down
19 changes: 19 additions & 0 deletions packages/react/src/ActionList/ActionList.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,25 @@ export const BlockDescription = () => (
</ActionList>
)

export const InlineAndBlockDescription = () => (
<ActionList>
<ActionList.Item>
Low
<ActionList.Description>Standard review</ActionList.Description>
<ActionList.Description variant="block">Estimated cost: $0.25 - $0.35</ActionList.Description>
</ActionList.Item>
<ActionList.Item>
Medium
<ActionList.Description>Deeper analysis</ActionList.Description>
<ActionList.Description variant="block">Estimated cost: $1.25 - $2.00</ActionList.Description>
</ActionList.Item>
<ActionList.Item disabled>
High
<ActionList.Description>Most thorough review (coming soon)</ActionList.Description>
</ActionList.Item>
</ActionList>
)

const projects = [
{name: 'Primer Backlog', scope: 'GitHub'},
{name: 'Accessibility', scope: 'GitHub'},
Expand Down
35 changes: 33 additions & 2 deletions packages/react/src/ActionList/ActionList.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
}

/* if inline description, move pseudo divider to description wrapper */
& [data-description-variant='inline'] {
& [data-description-variant='inline'],
& [data-description-variant='inline-block'] {
&::before {
position: absolute;

Expand Down Expand Up @@ -297,7 +298,9 @@
}

& [data-description-variant='inline']::before,
& + .ActionListItem [data-description-variant='inline']::before {
& [data-description-variant='inline-block']::before,
& + .ActionListItem [data-description-variant='inline']::before,
& + .ActionListItem [data-description-variant='inline-block']::before {
visibility: hidden;
}
}
Expand Down Expand Up @@ -655,6 +658,34 @@ default block */
line-height: 16px;
}
}

&:where([data-description-variant='inline-block']) {
position: relative;
word-break: normal;

& .InlineDescriptionWrap {
display: flex;
flex-direction: row;
gap: var(--base-size-8);
align-items: baseline;
}

& .ItemLabel {
word-break: normal;
}

/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&:has([data-truncate='true']) {
& .ItemLabel {
flex: 1 0 auto;
}
}

& .Description {
/* stylelint-disable-next-line primer/typography */
line-height: 16px;
}
}
}

/* description */
Expand Down
25 changes: 25 additions & 0 deletions packages/react/src/ActionList/ActionList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,31 @@ describe('ActionList', () => {
expect(item.getAttribute('aria-labelledby')).not.toContain(descriptionId)
})

it('renders and references inline and block descriptions together', () => {
const {container} = HTMLRender(
<ActionList role="listbox" selectionVariant="single" aria-label="List">
<ActionList.Item role="option">
Item label
<ActionList.Description>Inline description</ActionList.Description>
<ActionList.Description variant="block">Block description</ActionList.Description>
</ActionList.Item>
</ActionList>,
)

const item = container.querySelector('[role="option"]')!
const descriptions = container.querySelectorAll('[data-component="ActionList.Description"]')
const descriptionWrap = container.querySelector('[data-description-variant="inline-block"]')
const inlineDescriptionId = descriptions[0].getAttribute('id')!
const blockDescriptionId = descriptions[1].getAttribute('id')!

expect(descriptionWrap).toBeInTheDocument()
expect(descriptions[0]).toHaveTextContent('Inline description')
expect(descriptions[1]).toHaveTextContent('Block description')
expect(item.getAttribute('aria-describedby')).toBe(`${inlineDescriptionId} ${blockDescriptionId}`)
expect(item.getAttribute('aria-labelledby')).not.toContain(inlineDescriptionId)
expect(item.getAttribute('aria-labelledby')).not.toContain(blockDescriptionId)
})

it('should support size prop on LinkItem', () => {
const {container} = HTMLRender(
<ActionList>
Expand Down
64 changes: 45 additions & 19 deletions packages/react/src/ActionList/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, {type JSX} from 'react'
import {useId} from '../hooks/useId'
import {useSlots} from '../hooks/useSlots'
import {ActionListContainerContext} from './ActionListContainerContext'
import {Description} from './Description'
import {Description, type ActionListDescriptionProps} from './Description'
import {GroupContext} from './Group'
import type {ActionListItemProps, ActionListProps} from './shared'
import {Selection} from './Selection'
Expand Down Expand Up @@ -77,7 +77,17 @@ const baseSlots = {
subItem: SubItem,
}

const slotsConfig = {...baseSlots, description: Description}
const slotsConfig = {
...baseSlots,
inlineDescription: [Description, (props: ActionListDescriptionProps) => props.variant !== 'block'] as [
typeof Description,
(props: ActionListDescriptionProps) => boolean,
],
blockDescription: [Description, (props: ActionListDescriptionProps) => props.variant === 'block'] as [
typeof Description,
(props: ActionListDescriptionProps) => boolean,
],
}

// Pre-allocated array for selectableRoles check, avoids per-render allocation
const selectableRoles = ['menuitemradio', 'menuitemcheckbox', 'option', 'treeitem']
Expand Down Expand Up @@ -107,7 +117,7 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
): JSX.Element => {
const [partialSlots, childrenWithoutSlots] = useSlots(props.children, slotsConfig)

const slots = {description: undefined, ...partialSlots}
const slots = {inlineDescription: undefined, blockDescription: undefined, ...partialSlots}

const {container, afterSelect, selectionAttribute, defaultTrailingVisual} =
React.useContext(ActionListContainerContext)
Expand Down Expand Up @@ -223,11 +233,12 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(

const focusable = showInactiveIndicator ? true : undefined

// Extract the variant prop value from the description slot component
const descriptionVariant = slots.description?.props.variant ?? 'inline'

const hasTrailingVisualSlot = Boolean(slots.trailingVisual)
const hasDescriptionSlot = Boolean(slots.description)
const hasInlineDescriptionSlot = Boolean(slots.inlineDescription)
const hasBlockDescriptionSlot = Boolean(slots.blockDescription)
const hasDescriptionSlot = hasInlineDescriptionSlot || hasBlockDescriptionSlot
const descriptionVariant =
hasInlineDescriptionSlot && hasBlockDescriptionSlot ? 'inline-block' : hasInlineDescriptionSlot ? 'inline' : 'block'

const ariaLabelledBy = React.useMemo(() => {
const parts = [labelId]
Expand All @@ -237,11 +248,11 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(

const ariaDescribedBy = React.useMemo(() => {
const parts: string[] = []
if (hasDescriptionSlot && descriptionVariant === 'block') parts.push(blockDescriptionId)
if (hasDescriptionSlot && descriptionVariant === 'inline') parts.push(inlineDescriptionId)
if (hasInlineDescriptionSlot) parts.push(inlineDescriptionId)
if (hasBlockDescriptionSlot) parts.push(blockDescriptionId)
if (inactiveWarningId) parts.push(inactiveWarningId)
return parts.length > 0 ? parts.join(' ') : undefined
}, [hasDescriptionSlot, descriptionVariant, blockDescriptionId, inlineDescriptionId, inactiveWarningId])
}, [hasInlineDescriptionSlot, hasBlockDescriptionSlot, inlineDescriptionId, blockDescriptionId, inactiveWarningId])

const menuItemProps = React.useMemo(
() => ({
Expand Down Expand Up @@ -316,6 +327,14 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
// (see the JSX below). Mirror the same condition for the styling-related data
// attributes so the CSS only kicks in when the action is actually in the DOM.
const trailingActionRendered = !inactive && !loading && !menuContext && Boolean(slots.trailingAction)
const itemLabel = (
<span id={labelId} className={classes.ItemLabel} data-component="ActionList.Item.Label">
{childrenWithoutSlots}
{/* Loading message needs to be in here so it is read with the label */}
{/* If the item is inactive, we do not simultaneously announce that it is loading */}
{loading === true && !inactive && <VisuallyHidden>Loading</VisuallyHidden>}
</span>
)

return (
<ItemContext.Provider value={itemContextValue}>
Expand All @@ -328,7 +347,7 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
data-inactive={inactiveText ? true : undefined}
data-is-disabled={disabled ? true : undefined}
data-has-subitem={slots.subItem ? true : undefined}
data-has-description={slots.description ? true : false}
data-has-description={hasDescriptionSlot ? true : false}
data-has-trailing-action={trailingActionRendered ? true : undefined}
data-trailing-action-loading={trailingActionRendered && slots.trailingAction?.props.loading ? true : undefined}
className={clsx(classes.ActionListItem, className)}
Expand Down Expand Up @@ -358,17 +377,24 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
{/* TODO: next-major: change to data-component="ActionList.Item.DividerContainer" next major version */}
<span className={classes.ActionListSubContent} data-component="ActionList.Item--DividerContainer">
<ConditionalWrapper
if={!!slots.description}
if={hasDescriptionSlot}
className={classes.ItemDescriptionWrap}
data-description-variant={descriptionVariant}
>
<span id={labelId} className={classes.ItemLabel} data-component="ActionList.Item.Label">
{childrenWithoutSlots}
{/* Loading message needs to be in here so it is read with the label */}
{/* If the item is inactive, we do not simultaneously announce that it is loading */}
{loading === true && !inactive && <VisuallyHidden>Loading</VisuallyHidden>}
</span>
{slots.description}
{hasInlineDescriptionSlot && hasBlockDescriptionSlot ? (
<>
<span className={classes.InlineDescriptionWrap}>
{itemLabel}
{slots.inlineDescription}
</span>
{slots.blockDescription}
</>
) : (
<>
{itemLabel}
{slots.inlineDescription ?? slots.blockDescription}
</>
)}
</ConditionalWrapper>
<VisualOrIndicator
inactiveText={showInactiveIndicator ? inactiveText : undefined}
Expand Down
Loading