Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
51a76ea
feat: support custom slots
francinelucca Oct 10, 2025
95661d8
Create lucky-walls-jog.md
francinelucca Oct 10, 2025
e6d732b
use symbol instead of string
francinelucca Oct 10, 2025
323ab24
Merge branch 'chore/implement-slot-names' of https://github.com/prime…
francinelucca Oct 10, 2025
9ea6440
add optional chaining
francinelucca Oct 10, 2025
0908048
fix tests, add more slots
francinelucca Oct 10, 2025
1a44837
fix errors
francinelucca Oct 10, 2025
e6a02e8
add more slots
francinelucca Oct 10, 2025
af0fd69
add more slots
francinelucca Oct 10, 2025
c12d3ee
add more slots
francinelucca Oct 10, 2025
52f412c
Merge branch 'main' of github.com:primer/react into chore/implement-s…
francinelucca Oct 10, 2025
737b286
add more slots
francinelucca Oct 10, 2025
d93b129
add more slots
francinelucca Oct 10, 2025
2cc3f49
fix error
francinelucca Oct 10, 2025
e30a5ee
add more slots
francinelucca Oct 10, 2025
4aec1c6
fix error
francinelucca Oct 10, 2025
eb25c86
add more slots
francinelucca Oct 10, 2025
7c27f1d
fix errors
francinelucca Oct 10, 2025
ba06f45
add more slots
francinelucca Oct 10, 2025
3e2c13e
add more slots
francinelucca Oct 10, 2025
1adf9a9
add optional chaining
francinelucca Oct 10, 2025
df1c8ba
Add TS types for __SLOT__ prop
adierkens Oct 10, 2025
8a8c54f
update exports
francinelucca Oct 10, 2025
120baeb
test fix
francinelucca Oct 10, 2025
d7237cc
remove ts ignores
francinelucca Oct 10, 2025
64ddae1
slot cleanup
francinelucca Oct 10, 2025
fc39021
slot clean up
francinelucca Oct 10, 2025
17f45ae
clean up slots
francinelucca Oct 10, 2025
658f728
remove unused import
francinelucca Oct 10, 2025
a44928d
revert change
francinelucca Oct 10, 2025
ad3ef08
ts fixes
francinelucca Oct 10, 2025
22854cf
more cleanups
francinelucca Oct 10, 2025
0910a6b
more cleanups
francinelucca Oct 10, 2025
526f892
type fix
francinelucca Oct 10, 2025
69c291c
Merge branch 'main' into chore/implement-slot-names
francinelucca Oct 10, 2025
489f62e
VRT fix
francinelucca Oct 10, 2025
e74690d
Merge branch 'chore/implement-slot-names' of github.com:primer/react …
francinelucca Oct 10, 2025
07ec7da
Merge branch 'main' into chore/implement-slot-names
francinelucca Oct 10, 2025
f740169
refactor getSlot to isSlot
francinelucca Oct 10, 2025
49e51c5
correct conditional
francinelucca Oct 10, 2025
97a467f
remove slot from ActionList
francinelucca Oct 10, 2025
f0e93b2
remove type
francinelucca Oct 10, 2025
84d758a
type fixes
francinelucca Oct 10, 2025
f50d6b3
Merge branch 'main' into chore/implement-slot-names
francinelucca Oct 10, 2025
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
6 changes: 6 additions & 0 deletions .changeset/lucky-walls-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@primer/react": minor
"@primer/styled-react": patch
---

feat: support custom slots
2 changes: 1 addition & 1 deletion packages/react/src/ActionList/Group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export const Group: React.FC<React.PropsWithChildren<ActionListGroupProps>> = ({
const {role: listRole} = React.useContext(ListContext)

const [slots, childrenWithoutSlots] = useSlots(props.children, {
groupHeading: GroupHeading,
groupHeading: {type: GroupHeading, slot: 'ActionList.GroupHeading'},
})

let groupHeadingId = undefined
Expand Down
13 changes: 8 additions & 5 deletions packages/react/src/ActionList/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,16 @@ const UnwrappedItem = <As extends React.ElementType = 'li'>(
forwardedRef: React.Ref<any>,
): JSX.Element => {
const baseSlots = {
leadingVisual: LeadingVisual,
trailingVisual: TrailingVisual,
trailingAction: TrailingAction,
subItem: SubItem,
leadingVisual: {type: LeadingVisual, slot: 'ActionList.LeadingVisual'},
trailingVisual: {type: TrailingVisual, slot: 'ActionList.TrailingVisual'},
trailingAction: {type: TrailingAction, slot: 'ActionList.TrailingAction'},
subItem: {type: SubItem, slot: 'ActionList.SubItem'},
}

const [partialSlots, childrenWithoutSlots] = useSlots(props.children, {...baseSlots, description: Description})
const [partialSlots, childrenWithoutSlots] = useSlots(props.children, {
...baseSlots,
description: {type: Description, slot: 'ActionList.Description'},
})

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

Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/ActionList/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const UnwrappedList = <As extends React.ElementType = 'ul'>(
...restProps
} = props
const [slots, childrenWithoutSlots] = useSlots(restProps.children, {
heading: Heading,
heading: {type: Heading, slot: 'ActionList.Heading'},
})

const headingId = useId()
Expand Down
8 changes: 6 additions & 2 deletions packages/react/src/ActionMenu/ActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../uti
import {Tooltip} from '../TooltipV2/Tooltip'
import styles from './ActionMenu.module.css'
import {useResponsiveValue, type ResponsiveValue} from '../hooks/useResponsiveValue'
import {getSlotName} from '../utils/get-slot-name'

export type MenuCloseHandler = (
gesture: 'anchor-click' | 'click-outside' | 'escape' | 'tab' | 'item-select' | 'arrow-left' | 'close',
Expand Down Expand Up @@ -112,7 +113,7 @@ const Menu: React.FC<React.PropsWithChildren<ActionMenuProps>> = ({
// 🚨 Accounting for Tooltip wrapping ActionMenu.Button or being a direct child of ActionMenu.Anchor.
const contents = React.Children.map(children, child => {
// Is ActionMenu.Button wrapped with Tooltip? If this is the case, our anchor is the tooltip's trigger (ActionMenu.Button's grandchild)
if (child.type === Tooltip) {
if (child.type === Tooltip || getSlotName(child) === 'Tooltip') {
// tooltip trigger
const anchorChildren = child.props.children
if (anchorChildren.type === MenuButton) {
Expand All @@ -129,7 +130,10 @@ const Menu: React.FC<React.PropsWithChildren<ActionMenuProps>> = ({
return null
} else if (child.type === Anchor) {
const anchorChildren = child.props.children
const isWrappedWithTooltip = anchorChildren !== undefined ? anchorChildren.type === Tooltip : false
const isWrappedWithTooltip =
anchorChildren !== undefined
? anchorChildren.type === Tooltip || getSlotName(anchorChildren) === 'Tooltip'
: false
if (isWrappedWithTooltip) {
if (anchorChildren.props.children !== null) {
renderAnchor = anchorProps => {
Expand Down
7 changes: 5 additions & 2 deletions packages/react/src/CheckboxGroup/CheckboxGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {useRenderForcingRef} from '../hooks'
import FormControl from '../FormControl'
import Checkbox from '../Checkbox/Checkbox'
import {CheckboxGroupContext} from './CheckboxGroupContext'
import {getSlotName} from '../utils/get-slot-name'

export type CheckboxGroupProps = {
/**
Expand All @@ -19,14 +20,16 @@ export type CheckboxGroupProps = {

const CheckboxGroup: FC<React.PropsWithChildren<CheckboxGroupProps>> = ({children, disabled, onChange, ...rest}) => {
const formControlComponentChildren = React.Children.toArray(children)
.filter(child => React.isValidElement(child) && child.type === FormControl)
.filter(
child => React.isValidElement(child) && (child.type === FormControl || getSlotName(child) === 'FormControl'),
)
.map(formControlComponent =>
React.isValidElement(formControlComponent) ? formControlComponent.props.children : [],
)
.flat()

const checkedCheckboxes = React.Children.toArray(formControlComponentChildren)
.filter(child => React.isValidElement(child) && child.type === Checkbox)
.filter(child => React.isValidElement(child) && (child.type === Checkbox || getSlotName(child) === 'Checkbox'))
.map(
checkbox =>
React.isValidElement(checkbox) &&
Expand Down
41 changes: 31 additions & 10 deletions packages/react/src/FormControl/FormControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {FormControlContextProvider} from './_FormControlContext'
import {warning} from '../utils/warning'
import classes from './FormControl.module.css'
import {BoxWithFallback} from '../internal/components/BoxWithFallback'
import {getSlotName} from '../utils/get-slot-name'

export type FormControlProps = {
children?: React.ReactNode
Expand Down Expand Up @@ -48,10 +49,10 @@ export type FormControlProps = {
const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>(
({children, disabled: disabledProp, layout = 'vertical', id: idProp, required, sx, className, style}, ref) => {
const [slots, childrenWithoutSlots] = useSlots(children, {
caption: FormControlCaption,
label: FormControlLabel,
leadingVisual: FormControlLeadingVisual,
validation: FormControlValidation,
caption: {type: FormControlCaption, slot: 'FormControl.Caption'},
label: {type: FormControlLabel, slot: 'FormControl.Label'},
leadingVisual: {type: FormControlLeadingVisual, slot: 'FormControl.LeadingVisual'},
validation: {type: FormControlValidation, slot: 'FormControl.Validation'},
})
const expectedInputComponents = [
Autocomplete,
Expand All @@ -63,19 +64,36 @@ const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>(
Textarea,
SelectPanel,
]
const expectedInputSlots = [
'Autocomplete',
'Checkbox',
'Radio',
'Select',
'TextInput',
'TextInputWithTokens',
'Textarea',
'SelectPanel',
]
const choiceGroupContext = useContext(CheckboxOrRadioGroupContext)
const disabled = choiceGroupContext.disabled || disabledProp
const id = useId(idProp)
const validationMessageId = slots.validation ? `${id}-validationMessage` : undefined
const captionId = slots.caption ? `${id}-caption` : undefined
const validationStatus = slots.validation?.props.variant
const InputComponent = childrenWithoutSlots.find(child =>
expectedInputComponents.some(inputComponent => React.isValidElement(child) && child.type === inputComponent),
const InputComponent = childrenWithoutSlots.find(
child =>
expectedInputComponents.some(inputComponent => React.isValidElement(child) && child.type === inputComponent) ||
expectedInputSlots.includes(getSlotName(child)),
)
const inputProps = React.isValidElement(InputComponent) && InputComponent.props
const isChoiceInput =
React.isValidElement(InputComponent) && (InputComponent.type === Checkbox || InputComponent.type === Radio)
const isRadioInput = React.isValidElement(InputComponent) && InputComponent.type === Radio
React.isValidElement(InputComponent) &&
(InputComponent.type === Checkbox ||
InputComponent.type === Radio ||
getSlotName(InputComponent) === 'Checkbox' ||
getSlotName(InputComponent) === 'Radio')
const isRadioInput =
React.isValidElement(InputComponent) && (InputComponent.type === Radio || getSlotName(InputComponent) === 'Radio')

if (InputComponent) {
warning(
Expand Down Expand Up @@ -139,7 +157,9 @@ const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>(
: null}
{childrenWithoutSlots.filter(
child =>
React.isValidElement(child) && ![Checkbox, Radio].some(inputComponent => child.type === inputComponent),
React.isValidElement(child) &&
![Checkbox, Radio].some(inputComponent => child.type === inputComponent) &&
!['Checkbox', 'Radio'].includes(getSlotName(child)),
)}
</div>
{slots.leadingVisual ? (
Expand Down Expand Up @@ -204,7 +224,8 @@ const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>(
{childrenWithoutSlots.filter(
child =>
React.isValidElement(child) &&
!expectedInputComponents.some(inputComponent => child.type === inputComponent),
!expectedInputComponents.some(inputComponent => child.type === inputComponent) &&
!expectedInputSlots.includes(getSlotName(child)),
)}
{slots.validation ? (
<ValidationAnimationContainer show>{slots.validation}</ValidationAnimationContainer>
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/FormControl/FormControlLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,7 @@ const FormControlLabel: React.FC<
return <InputLabel {...labelProps}>{children}</InputLabel>
}

// @ts-ignore -- TypeScript doesn't know about the __SLOT__ property
FormControlLabel.__SLOT__ = Symbol('FormControl.Label')
Comment thread
francinelucca marked this conversation as resolved.

export default FormControlLabel
138 changes: 138 additions & 0 deletions packages/react/src/FormControl/__tests__/FormControl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,43 @@
const CAPTION_TEXT = 'Hint text'
const ERROR_TEXT = 'This field is invalid'

const WrappedLabelComponent = () => (
<div>
{/* eslint-disable-next-line primer-react/direct-slot-children */}
<FormControl.Label>{LABEL_TEXT}</FormControl.Label>
</div>
)

WrappedLabelComponent.__SLOT__ = Symbol('FormControl.Label')

const WrappedCaptionComponent = () => (
<div>
{/* eslint-disable-next-line primer-react/direct-slot-children */}
<FormControl.Caption>{CAPTION_TEXT}</FormControl.Caption>
</div>
)

WrappedCaptionComponent.__SLOT__ = Symbol('FormControl.Caption')

const WrappedLeadingVisualComponent = () => (
<div>
{/* eslint-disable-next-line primer-react/direct-slot-children */}
<FormControl.LeadingVisual>
<MarkGithubIcon aria-label="Icon label" />
</FormControl.LeadingVisual>
</div>
)

WrappedLeadingVisualComponent.__SLOT__ = Symbol('FormControl.LeadingVisual')

const WrappedValidationComponent = () => (
<div>
<FormControl.Validation variant="error">{ERROR_TEXT}</FormControl.Validation>
</div>
)

WrappedValidationComponent.__SLOT__ = Symbol('FormControl.Validation')

describe('FormControl', () => {
describe('vertically stacked layout (default)', () => {
describe('rendering', () => {
Expand Down Expand Up @@ -326,6 +363,107 @@
spy.mockRestore()
})
})

describe('slot identification', () => {
it('should correctly identify a label wrapped in a div using __SLOT__ property', () => {
const spy = vi.spyOn(console, 'error').mockImplementationOnce(() => {})

const {container, getByLabelText} = render(
<FormControl>
<WrappedLabelComponent />
<TextInput />
</FormControl>,
)

// The label should be found as a slot because of the __SLOT__ property
// This should NOT trigger the error about missing FormControl.Label
expect(spy).toHaveBeenCalledTimes(0)

Check failure on line 380 in packages/react/src/FormControl/__tests__/FormControl.test.tsx

View workflow job for this annotation

GitHub Actions / test

src/FormControl/__tests__/FormControl.test.tsx > FormControl > vertically stacked layout (default) > slot identification > should correctly identify a label wrapped in a div using __SLOT__ property

AssertionError: expected "spy" to be called +0 times, but got 1 times ❯ src/FormControl/__tests__/FormControl.test.tsx:380:20

// The label should function properly - input should be labeled correctly
const input = getByLabelText(LABEL_TEXT)
expect(input).toBeDefined()
expect(container.textContent).toContain(LABEL_TEXT)

spy.mockRestore()
})

it('should correctly identify a caption wrapped in a div using __SLOT__ property', () => {
const {container} = render(
<FormControl id="test-caption">
<FormControl.Label>{LABEL_TEXT}</FormControl.Label>
<TextInput />
<WrappedCaptionComponent />
</FormControl>,
)

// The caption should be found as a slot because of the __SLOT__ property
// We can verify this by checking that aria-describedby includes the caption ID
const input = container.querySelector('input')
const ariaDescribedBy = input?.getAttribute('aria-describedby') || ''
expect(ariaDescribedBy).toContain('test-caption-caption')

Check failure on line 403 in packages/react/src/FormControl/__tests__/FormControl.test.tsx

View workflow job for this annotation

GitHub Actions / test

src/FormControl/__tests__/FormControl.test.tsx > FormControl > vertically stacked layout (default) > slot identification > should correctly identify a caption wrapped in a div using __SLOT__ property

AssertionError: expected '' to contain 'test-caption-caption' - Expected + Received - test-caption-caption ❯ src/FormControl/__tests__/FormControl.test.tsx:403:32

// The caption text should be rendered and functional
expect(container.textContent).toContain(CAPTION_TEXT)
})

it('should correctly identify a validation wrapped in a div using __SLOT__ property', () => {
const {container} = render(
<FormControl id="test-validation">
<FormControl.Label>{LABEL_TEXT}</FormControl.Label>
<TextInput />
<WrappedValidationComponent />
</FormControl>,
)

// The validation should be found as a slot because of the __SLOT__ property
// We can verify this by checking that aria-describedby includes the validation ID
const input = container.querySelector('input')
const ariaDescribedBy = input?.getAttribute('aria-describedby') || ''
expect(ariaDescribedBy).toContain('test-validation-validationMessage')

Check failure on line 422 in packages/react/src/FormControl/__tests__/FormControl.test.tsx

View workflow job for this annotation

GitHub Actions / test

src/FormControl/__tests__/FormControl.test.tsx > FormControl > vertically stacked layout (default) > slot identification > should correctly identify a validation wrapped in a div using __SLOT__ property

AssertionError: expected '' to contain 'test-validation-validationMessage' - Expected + Received - test-validation-validationMessage ❯ src/FormControl/__tests__/FormControl.test.tsx:422:32

// The validation text should be rendered and functional
expect(container.textContent).toContain(ERROR_TEXT)
})

it('should correctly identify a leading visual wrapped in a div using __SLOT__ property for non-choice inputs', () => {
const spy = vi.spyOn(console, 'warn').mockImplementationOnce(() => {})

const {container} = render(
<FormControl>
<FormControl.Label>{LABEL_TEXT}</FormControl.Label>
<TextInput />
<WrappedLeadingVisualComponent />
</FormControl>,
)

// The leading visual should be found as a slot because of the __SLOT__ property
// This should trigger a warning since leading visuals are only for choice inputs
expect(spy).toHaveBeenCalledTimes(1)

Check failure on line 441 in packages/react/src/FormControl/__tests__/FormControl.test.tsx

View workflow job for this annotation

GitHub Actions / test

src/FormControl/__tests__/FormControl.test.tsx > FormControl > vertically stacked layout (default) > slot identification > should correctly identify a leading visual wrapped in a div using __SLOT__ property for non-choice inputs

AssertionError: expected "spy" to be called 1 times, but got 0 times ❯ src/FormControl/__tests__/FormControl.test.tsx:441:20

// The icon should be rendered in the DOM
expect(container.querySelector('svg')).toBeDefined()

spy.mockRestore()
})
it('should correctly identify a leading visual wrapped in a div using __SLOT__ property for choice inputs', () => {
const {container, getByLabelText} = render(
<FormControl>
<FormControl.Label>{LABEL_TEXT}</FormControl.Label>
<Checkbox />
<WrappedLeadingVisualComponent />
</FormControl>,
)

// The leading visual should be found as a slot because of the __SLOT__ property
// We can verify this by checking that the leading visual container is present
const leadingVisualContainer = container.querySelector('[data-has-leading-visual]')
expect(leadingVisualContainer).toBeDefined()

// The icon should be rendered and functional
expect(getByLabelText('Icon label')).toBeDefined()
expect(container.querySelector('svg')).toBeDefined()
})
})
})

describe('checkbox and radio layout', () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/react/src/NavList/NavList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect'
import classes from '../ActionList/ActionList.module.css'
import navListClasses from './NavList.module.css'
import {flushSync} from 'react-dom'
import {getSlotName} from '../utils/get-slot-name'

// ----------------------------------------------------------------------------
// NavList
Expand Down Expand Up @@ -57,7 +58,9 @@ const Item = React.forwardRef<HTMLAnchorElement, NavListItemProps>(
const {depth} = React.useContext(SubNavContext)

// Get SubNav from children
const subNav = React.Children.toArray(children).find(child => isValidElement(child) && child.type === SubNav)
const subNav = React.Children.toArray(children).find(
child => isValidElement(child) && (child.type === SubNav || getSlotName(child) === 'SubNav'),
)

// Get children without SubNav or TrailingAction
const childrenWithoutSubNavOrTrailingAction = React.Children.toArray(children).filter(child =>
Expand Down
10 changes: 8 additions & 2 deletions packages/react/src/PageLayout/PageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export type PageLayoutProps = {
columnGap?: keyof typeof SPACING_MAP

/** Private prop to allow SplitPageLayout to customize slot components */
_slotsConfig?: Record<'header' | 'footer', React.ElementType>
_slotsConfig?: Record<'header' | 'footer', {type: React.ElementType}>
className?: string
style?: React.CSSProperties
}
Expand All @@ -76,7 +76,13 @@ const Root: React.FC<React.PropsWithChildren<PageLayoutProps>> = ({
}) => {
const paneRef = useRef<HTMLDivElement>(null)

const [slots, rest] = useSlots(children, slotsConfig ?? {header: Header, footer: Footer})
const [slots, rest] = useSlots(
children,
slotsConfig ?? {
header: {type: Header, slot: 'PageLayout.Header'},
footer: {type: Footer, slot: 'PageLayout.Footer'},
},
)

const memoizedContextValue = React.useMemo(() => {
return {
Expand Down
Loading
Loading