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} + + )}