diff --git a/.changeset/actionlist-mixed-descriptions.md b/.changeset/actionlist-mixed-descriptions.md
new file mode 100644
index 00000000000..3908f432565
--- /dev/null
+++ b/.changeset/actionlist-mixed-descriptions.md
@@ -0,0 +1,5 @@
+---
+'@primer/react': minor
+---
+
+ActionList: Support rendering inline and block descriptions together on an item.
diff --git a/packages/react/src/ActionList/ActionList.docs.json b/packages/react/src/ActionList/ActionList.docs.json
index ccaf7865466..9aadec37750 100644
--- a/packages/react/src/ActionList/ActionList.docs.json
+++ b/packages/react/src/ActionList/ActionList.docs.json
@@ -244,7 +244,7 @@
"type": "React.ReactNode",
"defaultValue": "",
"required": true,
- "description": ""
+ "description": "Items may include one inline description and one block description."
},
{
"name": "variant",
diff --git a/packages/react/src/ActionList/ActionList.features.stories.tsx b/packages/react/src/ActionList/ActionList.features.stories.tsx
index ef111d40bcf..99a0244784e 100644
--- a/packages/react/src/ActionList/ActionList.features.stories.tsx
+++ b/packages/react/src/ActionList/ActionList.features.stories.tsx
@@ -250,6 +250,25 @@ export const BlockDescription = () => (
)
+export const InlineAndBlockDescription = () => (
+
+
+ Low
+ Standard review
+ Estimated cost: $0.25 - $0.35
+
+
+ Medium
+ Deeper analysis
+ Estimated cost: $1.25 - $2.00
+
+
+ High
+ Most thorough review (coming soon)
+
+
+)
+
const projects = [
{name: 'Primer Backlog', scope: 'GitHub'},
{name: 'Accessibility', scope: 'GitHub'},
diff --git a/packages/react/src/ActionList/ActionList.module.css b/packages/react/src/ActionList/ActionList.module.css
index e6f148da4a7..a5b50553b2e 100644
--- a/packages/react/src/ActionList/ActionList.module.css
+++ b/packages/react/src/ActionList/ActionList.module.css
@@ -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;
@@ -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;
}
}
@@ -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 */
diff --git a/packages/react/src/ActionList/ActionList.test.tsx b/packages/react/src/ActionList/ActionList.test.tsx
index dc5f1f2e575..c43106e196e 100644
--- a/packages/react/src/ActionList/ActionList.test.tsx
+++ b/packages/react/src/ActionList/ActionList.test.tsx
@@ -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(
+
+
+ Item label
+ Inline description
+ Block description
+
+ ,
+ )
+
+ 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(
diff --git a/packages/react/src/ActionList/Item.tsx b/packages/react/src/ActionList/Item.tsx
index 1181f90c110..2dc43849a57 100644
--- a/packages/react/src/ActionList/Item.tsx
+++ b/packages/react/src/ActionList/Item.tsx
@@ -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'
@@ -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']
@@ -107,7 +117,7 @@ const UnwrappedItem = (
): 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)
@@ -223,11 +233,12 @@ const UnwrappedItem = (
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]
@@ -237,11 +248,11 @@ const UnwrappedItem = (
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(
() => ({
@@ -316,6 +327,14 @@ const UnwrappedItem = (
// (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 = (
+
+ {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 && Loading}
+
+ )
return (
@@ -328,7 +347,7 @@ const UnwrappedItem = (
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)}
@@ -358,17 +377,24 @@ const UnwrappedItem = (
{/* TODO: next-major: change to data-component="ActionList.Item.DividerContainer" next major version */}
-
- {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 && Loading}
-
- {slots.description}
+ {hasInlineDescriptionSlot && hasBlockDescriptionSlot ? (
+ <>
+
+ {itemLabel}
+ {slots.inlineDescription}
+
+ {slots.blockDescription}
+ >
+ ) : (
+ <>
+ {itemLabel}
+ {slots.inlineDescription ?? slots.blockDescription}
+ >
+ )}