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..5cde2dd5c96 100644
--- a/packages/react/src/ActionList/Group.module.css
+++ b/packages/react/src/ActionList/Group.module.css
@@ -75,3 +75,13 @@
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;
+ }
+}
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..d291a35af2e 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 {LeadingVisual} from './Visuals'
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
+ {leadingVisual}
{_internalBackwardCompatibleTitle ?? headingChildren}
@@ -222,11 +231,13 @@ const GroupHeadingImpl: FCWithSlotMarker
+ {leadingVisual}