From cbd48a6e105ef112853623957b619a7ef4ddea96 Mon Sep 17 00:00:00 2001 From: Matt Obee Date: Tue, 9 Jun 2026 13:22:27 +0100 Subject: [PATCH 1/3] Add ActionList.GroupHeading.LeadingVisual Mirror the existing ActionList.GroupHeading.TrailingAction to add a leading visual to group headings, behind the primer_react_action_list_group_heading_leading_visual feature flag. The slot config is built from only the enabled flags so a disabled feature passes its child through unchanged and is never stripped by the other. For github/github-ui#22642. --- ...ction-list-group-heading-leading-visual.md | 21 +++ .../ActionList.features.stories.tsx | 29 ++++ .../react/src/ActionList/Group.module.css | 17 +++ packages/react/src/ActionList/Group.test.tsx | 143 ++++++++++++++++++ packages/react/src/ActionList/Group.tsx | 37 +++-- .../ActionList/GroupHeadingLeadingVisual.tsx | 32 ++++ packages/react/src/ActionList/index.ts | 1 + .../src/FeatureFlags/DefaultFeatureFlags.ts | 1 + .../__snapshots__/exports.test.ts.snap | 1 + packages/react/src/index.ts | 1 + 10 files changed, 271 insertions(+), 12 deletions(-) create mode 100644 .changeset/action-list-group-heading-leading-visual.md create mode 100644 packages/react/src/ActionList/GroupHeadingLeadingVisual.tsx diff --git a/.changeset/action-list-group-heading-leading-visual.md b/.changeset/action-list-group-heading-leading-visual.md new file mode 100644 index 00000000000..0b1ebaed9a2 --- /dev/null +++ b/.changeset/action-list-group-heading-leading-visual.md @@ -0,0 +1,21 @@ +--- +'@primer/react': minor +--- + +ActionList: Add `ActionList.GroupHeading.LeadingVisual` for a leading icon (or similar) on grouped list headings. + +When the `primer_react_action_list_group_heading_leading_visual` feature flag is enabled, you can place an `ActionList.GroupHeading.LeadingVisual` inside `ActionList.GroupHeading` to render a decorative visual before the group's heading text. It can be combined with `ActionList.GroupHeading.TrailingAction`. + +```tsx + + + + + + + Custom fields + + ... + + +``` diff --git a/packages/react/src/ActionList/ActionList.features.stories.tsx b/packages/react/src/ActionList/ActionList.features.stories.tsx index ef111d40bcf..8ff059f2020 100644 --- a/packages/react/src/ActionList/ActionList.features.stories.tsx +++ b/packages/react/src/ActionList/ActionList.features.stories.tsx @@ -1036,6 +1036,35 @@ export const WithTrailingActionOnGroupHeading = () => ( WithTrailingActionOnGroupHeading.storyName = 'With TrailingAction on GroupHeading (behind feature flag)' +export const WithLeadingVisualOnGroupHeading = () => ( + + + + + + + + Custom fields + + Field 1 + Field 2 + + + + + + + Repositories + + primer/react + primer/primitives + + + +) + +WithLeadingVisualOnGroupHeading.storyName = 'With LeadingVisual on GroupHeading (behind feature flag)' + export const FullVariant = () => ( Copy link diff --git a/packages/react/src/ActionList/Group.module.css b/packages/react/src/ActionList/Group.module.css index 0273e901e77..471a7253bcd 100644 --- a/packages/react/src/ActionList/Group.module.css +++ b/packages/react/src/ActionList/Group.module.css @@ -75,3 +75,20 @@ align-items: center; margin-inline-start: auto; } + +.GroupHeadingWrap[data-has-leading-visual] { + flex-direction: row; + align-items: center; + gap: var(--base-size-8); + + & > .GroupHeading { + align-self: center; + } +} + +.GroupHeadingLeadingVisual { + display: inline-flex; + align-items: center; + color: var(--fgColor-muted); + fill: var(--fgColor-muted); +} diff --git a/packages/react/src/ActionList/Group.test.tsx b/packages/react/src/ActionList/Group.test.tsx index 7d282da6296..ae433754cbd 100644 --- a/packages/react/src/ActionList/Group.test.tsx +++ b/packages/react/src/ActionList/Group.test.tsx @@ -231,4 +231,147 @@ describe('ActionList.Group', () => { ).toThrow(/can not be used inside an ActionList with an ARIA role of "listbox"/) }) }) + + describe('GroupHeading.LeadingVisual (behind feature flag)', () => { + it('renders GroupHeading.LeadingVisual as a sibling of the heading element when the feature flag is enabled', () => { + const {getByRole, getByTestId} = HTMLRender( + + + Heading + + + + + + Group Heading + + Item + + + , + ) + + const heading = getByRole('heading', {level: 2}) + const visual = getByTestId('leading-visual') + + // The visual must NOT be inside the heading element + expect(heading).not.toContainElement(visual) + // The heading text should only contain the heading text + expect(heading).toHaveTextContent('Group Heading') + // The visual should be inside the same wrapper as the heading + expect(heading.parentElement).toContainElement(visual) + }) + + it('passes GroupHeading.LeadingVisual through as a child of the heading when the feature flag is disabled', () => { + const {getByRole, getByTestId} = HTMLRender( + + Heading + + + + + + Group Heading + + Item + + , + ) + + const heading = getByRole('heading', {level: 2}) + const visual = getByTestId('leading-visual') + + // Without the flag, the slot is not consumed: the visual passes through + // and still renders inside the heading element. + expect(heading).toContainElement(visual) + }) + + it('renders GroupHeading.LeadingVisual inside a listbox role without throwing', () => { + const {getByText, getByTestId} = HTMLRender( + + + + + + + + Group Heading + + Item + + + , + ) + + const label = getByText('Group Heading') + const visual = getByTestId('leading-visual') + // The visual renders before the presentational heading span, not inside it. + expect(label).not.toContainElement(visual) + expect(label.parentElement).toContainElement(visual) + }) + + it('renders both LeadingVisual and TrailingAction when both feature flags are enabled', () => { + const {getByRole, getByTestId} = HTMLRender( + + + Heading + + + + + + Group Heading + + + Item + + + , + ) + + const heading = getByRole('heading', {level: 2}) + const button = getByRole('button', {name: 'New field'}) + const visual = getByTestId('leading-visual') + + expect(heading).toHaveTextContent('Group Heading') + expect(heading).not.toContainElement(button) + expect(heading).not.toContainElement(visual) + expect(heading.parentElement).toContainElement(button) + expect(heading.parentElement).toContainElement(visual) + }) + + it('does not strip LeadingVisual when only the TrailingAction feature flag is enabled', () => { + const {getByRole, getByTestId} = HTMLRender( + + + Heading + + + + + + Group Heading + + + Item + + + , + ) + + const heading = getByRole('heading', {level: 2}) + const button = getByRole('button', {name: 'New field'}) + const visual = getByTestId('leading-visual') + + // TrailingAction is extracted (its flag is on), but the LeadingVisual flag + // is off so it must remain inside the heading rather than being stripped. + expect(heading).not.toContainElement(button) + expect(heading).toContainElement(visual) + }) + }) }) diff --git a/packages/react/src/ActionList/Group.tsx b/packages/react/src/ActionList/Group.tsx index df319996f67..9ce66b61159 100644 --- a/packages/react/src/ActionList/Group.tsx +++ b/packages/react/src/ActionList/Group.tsx @@ -9,9 +9,12 @@ import classes from './ActionList.module.css' import groupClasses from './Group.module.css' import type {FCWithSlotMarker} from '../utils/types/Slots' import {GroupHeadingTrailingAction} from './GroupHeadingTrailingAction' +import {GroupHeadingLeadingVisual} from './GroupHeadingLeadingVisual' import {useFeatureFlag} from '../FeatureFlags' +import type {SlotConfig} from '../hooks/useSlots' const GROUP_HEADING_TRAILING_ACTION_FEATURE_FLAG = 'primer_react_action_list_group_heading_trailing_action' +const GROUP_HEADING_LEADING_VISUAL_FEATURE_FLAG = 'primer_react_action_list_group_heading_leading_visual' type HeadingProps = { as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' @@ -162,18 +165,22 @@ const GroupHeadingImpl: FCWithSlotMarker