From 51a76ea462e04311768e185bf2dcb0824d3149cf Mon Sep 17 00:00:00 2001 From: Marie Lucca <40550942+francinelucca@users.noreply.github.com> Date: Fri, 10 Oct 2025 00:30:26 +0000 Subject: [PATCH 01/38] feat: support custom slots --- packages/react/src/ActionList/Group.tsx | 2 +- packages/react/src/ActionList/Item.tsx | 13 +- packages/react/src/ActionList/List.tsx | 2 +- packages/react/src/ActionMenu/ActionMenu.tsx | 8 +- .../react/src/CheckboxGroup/CheckboxGroup.tsx | 7 +- .../react/src/FormControl/FormControl.tsx | 41 +++- .../src/FormControl/FormControlLabel.tsx | 3 + .../__tests__/FormControl.test.tsx | 138 ++++++++++++ packages/react/src/NavList/NavList.tsx | 5 +- packages/react/src/PageLayout/PageLayout.tsx | 10 +- .../src/SegmentedControl/SegmentedControl.tsx | 7 +- .../src/SplitPageLayout/SplitPageLayout.tsx | 4 +- packages/react/src/TreeView/TreeView.tsx | 6 +- .../experimental/SelectPanel2/SelectPanel.tsx | 7 +- .../src/hooks/__tests__/useSlots.test.tsx | 204 +++++++++++++++++- packages/react/src/hooks/useSlots.ts | 51 +++-- .../CheckboxOrRadioGroup.tsx | 6 +- packages/react/src/utils/get-slot-name.ts | 7 + .../src/components/ActionList.tsx | 15 ++ .../src/components/Autocomplete.tsx | 3 + .../styled-react/src/components/Checkbox.tsx | 3 + .../src/components/SegmentedControl.tsx | 7 + .../styled-react/src/components/SubNav.tsx | 5 + .../styled-react/src/components/Tooltip.tsx | 3 + 24 files changed, 488 insertions(+), 69 deletions(-) create mode 100644 packages/react/src/utils/get-slot-name.ts diff --git a/packages/react/src/ActionList/Group.tsx b/packages/react/src/ActionList/Group.tsx index 10fec9a3934..6cb8489d96d 100644 --- a/packages/react/src/ActionList/Group.tsx +++ b/packages/react/src/ActionList/Group.tsx @@ -81,7 +81,7 @@ export const Group: React.FC> = ({ const {role: listRole} = React.useContext(ListContext) const [slots, childrenWithoutSlots] = useSlots(props.children, { - groupHeading: GroupHeading, + groupHeading: {type: GroupHeading, slot: 'ActionList.GroupHeading'}, }) let groupHeadingId = undefined diff --git a/packages/react/src/ActionList/Item.tsx b/packages/react/src/ActionList/Item.tsx index de12e491d8c..b90bc5eaa31 100644 --- a/packages/react/src/ActionList/Item.tsx +++ b/packages/react/src/ActionList/Item.tsx @@ -70,13 +70,16 @@ const UnwrappedItem = ( forwardedRef: React.Ref, ): 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} diff --git a/packages/react/src/ActionList/List.tsx b/packages/react/src/ActionList/List.tsx index c5c3860f6fb..95cf3afe5cd 100644 --- a/packages/react/src/ActionList/List.tsx +++ b/packages/react/src/ActionList/List.tsx @@ -25,7 +25,7 @@ const UnwrappedList = ( ...restProps } = props const [slots, childrenWithoutSlots] = useSlots(restProps.children, { - heading: Heading, + heading: {type: Heading, slot: 'ActionList.Heading'}, }) const headingId = useId() diff --git a/packages/react/src/ActionMenu/ActionMenu.tsx b/packages/react/src/ActionMenu/ActionMenu.tsx index 23e0cf7e001..1856f9c169e 100644 --- a/packages/react/src/ActionMenu/ActionMenu.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.tsx @@ -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', @@ -112,7 +113,7 @@ const Menu: React.FC> = ({ // 🚨 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) { @@ -129,7 +130,10 @@ const Menu: React.FC> = ({ 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 => { diff --git a/packages/react/src/CheckboxGroup/CheckboxGroup.tsx b/packages/react/src/CheckboxGroup/CheckboxGroup.tsx index 30c4fb9b6d3..6032ea03a5f 100644 --- a/packages/react/src/CheckboxGroup/CheckboxGroup.tsx +++ b/packages/react/src/CheckboxGroup/CheckboxGroup.tsx @@ -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 = { /** @@ -19,14 +20,16 @@ export type CheckboxGroupProps = { const CheckboxGroup: FC> = ({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) && diff --git a/packages/react/src/FormControl/FormControl.tsx b/packages/react/src/FormControl/FormControl.tsx index 8dea4aadb0d..7090c92c825 100644 --- a/packages/react/src/FormControl/FormControl.tsx +++ b/packages/react/src/FormControl/FormControl.tsx @@ -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 @@ -48,10 +49,10 @@ export type FormControlProps = { const FormControl = React.forwardRef( ({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, @@ -63,19 +64,36 @@ const FormControl = React.forwardRef( 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( @@ -139,7 +157,9 @@ const FormControl = React.forwardRef( : 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)), )} {slots.leadingVisual ? ( @@ -204,7 +224,8 @@ const FormControl = React.forwardRef( {childrenWithoutSlots.filter( child => React.isValidElement(child) && - !expectedInputComponents.some(inputComponent => child.type === inputComponent), + !expectedInputComponents.some(inputComponent => child.type === inputComponent) && + !expectedInputSlots.includes(getSlotName(child)), )} {slots.validation ? ( {slots.validation} diff --git a/packages/react/src/FormControl/FormControlLabel.tsx b/packages/react/src/FormControl/FormControlLabel.tsx index ff2a3ffe97c..1321c97fbeb 100644 --- a/packages/react/src/FormControl/FormControlLabel.tsx +++ b/packages/react/src/FormControl/FormControlLabel.tsx @@ -54,4 +54,7 @@ const FormControlLabel: React.FC< return {children} } +// @ts-ignore -- TypeScript doesn't know about the __SLOT__ property +FormControlLabel.__SLOT__ = Symbol('FormControl.Label') + export default FormControlLabel diff --git a/packages/react/src/FormControl/__tests__/FormControl.test.tsx b/packages/react/src/FormControl/__tests__/FormControl.test.tsx index 3149faf4efd..ac6268e7886 100644 --- a/packages/react/src/FormControl/__tests__/FormControl.test.tsx +++ b/packages/react/src/FormControl/__tests__/FormControl.test.tsx @@ -15,6 +15,43 @@ const LABEL_TEXT = 'Form control' const CAPTION_TEXT = 'Hint text' const ERROR_TEXT = 'This field is invalid' +const WrappedLabelComponent = () => ( +
+ {/* eslint-disable-next-line primer-react/direct-slot-children */} + {LABEL_TEXT} +
+) + +WrappedLabelComponent.__SLOT__ = 'FormControl.Label' + +const WrappedCaptionComponent = () => ( +
+ {/* eslint-disable-next-line primer-react/direct-slot-children */} + {CAPTION_TEXT} +
+) + +WrappedCaptionComponent.__SLOT__ = 'FormControl.Caption' + +const WrappedLeadingVisualComponent = () => ( +
+ {/* eslint-disable-next-line primer-react/direct-slot-children */} + + + +
+) + +WrappedLeadingVisualComponent.__SLOT__ = 'FormControl.LeadingVisual' + +const WrappedValidationComponent = () => ( +
+ {ERROR_TEXT} +
+) + +WrappedValidationComponent.__SLOT__ = 'FormControl.Validation' + describe('FormControl', () => { describe('vertically stacked layout (default)', () => { describe('rendering', () => { @@ -326,6 +363,107 @@ describe('FormControl', () => { 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( + + + + , + ) + + // 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) + + // 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( + + {LABEL_TEXT} + + + , + ) + + // 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') + + // 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( + + {LABEL_TEXT} + + + , + ) + + // 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') + + // 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( + + {LABEL_TEXT} + + + , + ) + + // 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) + + // 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( + + {LABEL_TEXT} + + + , + ) + + // 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', () => { diff --git a/packages/react/src/NavList/NavList.tsx b/packages/react/src/NavList/NavList.tsx index 282bdae1e75..eecfe87672e 100644 --- a/packages/react/src/NavList/NavList.tsx +++ b/packages/react/src/NavList/NavList.tsx @@ -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 @@ -57,7 +58,9 @@ const Item = React.forwardRef( 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 => diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 4fc12fe3805..2a634911e47 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -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 } @@ -76,7 +76,13 @@ const Root: React.FC> = ({ }) => { const paneRef = useRef(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 { diff --git a/packages/react/src/SegmentedControl/SegmentedControl.tsx b/packages/react/src/SegmentedControl/SegmentedControl.tsx index b7cb8760152..31ba907543e 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControl.tsx @@ -11,6 +11,7 @@ import type {WidthOnlyViewportRangeKeys} from '../utils/types/ViewportRangeKeys' import {isElement} from 'react-is' import classes from './SegmentedControl.module.css' import {clsx} from 'clsx' +import {getSlotName} from '../utils/get-slot-name' export type SegmentedControlProps = { 'aria-label'?: string @@ -63,7 +64,7 @@ const Root: React.FC> = ({ const getChildIcon = (childArg: React.ReactNode): React.ReactElement | null => { if ( React.isValidElement(childArg) && - childArg.type === Button && + (childArg.type === Button || getSlotName(childArg) === 'Button') && childArg.props.leadingIcon ) { if (isElement(childArg.props.leadingIcon)) { @@ -76,7 +77,7 @@ const Root: React.FC> = ({ if ( React.isValidElement(childArg) && - childArg.type === SegmentedControlIconButton + (childArg.type === SegmentedControlIconButton || getSlotName(childArg) === 'SegmentedControl.IconButton') ) { if (isElement(childArg.props.icon)) { childArg.props.icon @@ -183,7 +184,7 @@ const Root: React.FC> = ({ if ( responsiveVariant === 'hideLabels' && React.isValidElement(child) && - child.type === Button + (child.type === Button || getSlotName(child) === 'Button') ) { const { 'aria-label': childAriaLabel, diff --git a/packages/react/src/SplitPageLayout/SplitPageLayout.tsx b/packages/react/src/SplitPageLayout/SplitPageLayout.tsx index 1e902129248..cdc4f237cdd 100644 --- a/packages/react/src/SplitPageLayout/SplitPageLayout.tsx +++ b/packages/react/src/SplitPageLayout/SplitPageLayout.tsx @@ -20,8 +20,8 @@ export const Root: React.FC> = pro columnGap="none" rowGap="none" _slotsConfig={{ - header: Header, - footer: Footer, + header: {type: Header}, + footer: {type: Footer}, }} {...props} /> diff --git a/packages/react/src/TreeView/TreeView.tsx b/packages/react/src/TreeView/TreeView.tsx index ccb08352102..c313ce68cc9 100644 --- a/packages/react/src/TreeView/TreeView.tsx +++ b/packages/react/src/TreeView/TreeView.tsx @@ -202,9 +202,9 @@ const Item = React.forwardRef( ref, ) => { const [slots, rest] = useSlots(children, { - leadingAction: LeadingAction, - leadingVisual: LeadingVisual, - trailingVisual: TrailingVisual, + leadingAction: {type: LeadingAction, slot: 'TreeView.LeadingAction'}, + leadingVisual: {type: LeadingVisual, slot: 'TreeView.LeadingVisual'}, + trailingVisual: {type: TrailingVisual, slot: 'TreeView.TrailingVisual'}, }) const {expandedStateCache} = React.useContext(RootContext) const labelId = useId() diff --git a/packages/react/src/experimental/SelectPanel2/SelectPanel.tsx b/packages/react/src/experimental/SelectPanel2/SelectPanel.tsx index 099d0dc5f24..694319fd412 100644 --- a/packages/react/src/experimental/SelectPanel2/SelectPanel.tsx +++ b/packages/react/src/experimental/SelectPanel2/SelectPanel.tsx @@ -170,7 +170,10 @@ const Panel: React.FC = ({ /* Panel plumbing */ const panelId = useId(id) - const [slots, childrenInBody] = useSlots(contents, {header: SelectPanelHeader, footer: SelectPanelFooter}) + const [slots, childrenInBody] = useSlots(contents, { + header: {type: SelectPanelHeader, slot: 'SelectPanel.Header'}, + footer: {type: SelectPanelFooter, slot: 'SelectPanel.Footer'}, + }) // used in SelectPanel.SearchInput const moveFocusToList = () => { @@ -347,7 +350,7 @@ const SelectPanelHeader: React.FC & {onBac ...props }) => { const [slots, childrenWithoutSlots] = useSlots(children, { - searchInput: SelectPanelSearchInput, + searchInput: {type: SelectPanelSearchInput, slot: 'SelectPanel.SearchInput'}, }) const {title, description, panelId, onCancel, onClearSelection} = React.useContext(SelectPanelContext) diff --git a/packages/react/src/hooks/__tests__/useSlots.test.tsx b/packages/react/src/hooks/__tests__/useSlots.test.tsx index 3a25be6ba5e..56ee81dc771 100644 --- a/packages/react/src/hooks/__tests__/useSlots.test.tsx +++ b/packages/react/src/hooks/__tests__/useSlots.test.tsx @@ -17,8 +17,8 @@ test('extracts elements based on config object', () => { const calls: Array> = [] const children = [, ,
Hello World
] const slotsConfig = { - a: TestComponentA, - b: TestComponentB, + a: {type: TestComponentA}, + b: {type: TestComponentB}, } function TestComponent(_props: {children: React.ReactNode}) { @@ -77,8 +77,8 @@ test('handles empty children', () => { const calls: Array> = [] const children: React.ReactNode = [] const slotsConfig = { - a: TestComponentA, - b: TestComponentB, + a: {type: TestComponentA}, + b: {type: TestComponentB}, } function TestComponent(_props: {children: React.ReactNode}) { @@ -109,8 +109,8 @@ test('ignores nested slots', () => { , ] const slotsConfig = { - a: TestComponentA, - b: TestComponentB, + a: {type: TestComponentA}, + b: {type: TestComponentB}, } function TestComponent(_props: {children: React.ReactNode}) { @@ -142,7 +142,7 @@ test('warns about duplicate slots', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const children = [A1, A2] const slotsConfig = { - a: TestComponentA, + a: {type: TestComponentA}, } function TestComponent(_props: {children: React.ReactNode}) { @@ -178,8 +178,8 @@ test('extracts elements based on condition in config object', () => { function TestComponent(_props: {children: React.ReactNode}) { calls.push( useSlots(children, { - a: [TestComponentA, (props: TestComponentAProps) => props.variant === 'a'], - b: [TestComponentA, (props: TestComponentAProps) => props.variant === 'b'], + a: {type: TestComponentA, props: (props: TestComponentAProps) => props.variant === 'a'}, + b: {type: TestComponentA, props: (props: TestComponentAProps) => props.variant === 'b'}, }), ) return null @@ -207,3 +207,189 @@ test('extracts elements based on condition in config object', () => { ] `) }) + +test('extracts elements with configuration that has only slot property', () => { + const calls: Array> = [] + + // Create components with __SLOT__ prop to match slot configuration + const SlottedComponentA = (props: React.PropsWithChildren<{__SLOT__?: string}>) =>
+ SlottedComponentA.__SLOT__ = 'header' + const SlottedComponentB = (props: React.PropsWithChildren<{__SLOT__?: string}>) => + SlottedComponentB.__SLOT__ = 'footer' + + const children = [ + + Header Content + , + + Footer Content + , +
Main Content
, + ] + + const slotsConfig = { + header: {slot: 'header'}, + footer: {slot: 'footer'}, + } + + function TestComponent(_props: {children: React.ReactNode}) { + calls.push(useSlots(children, slotsConfig)) + return null + } + + render({children}) + + expect(calls).toMatchInlineSnapshot(` + [ + [ + { + "footer": + Footer Content + , + "header": + Header Content + , + }, + [ +
+ Main Content +
, + ], + ], + ] + `) +}) + +test('extracts elements with configuration that has slot and type properties', () => { + const calls: Array> = [] + + // Create components with __SLOT__ prop and specific types + const HeaderComponent = (props: React.PropsWithChildren<{__SLOT__?: string}>) =>
+ HeaderComponent.__SLOT__ = 'header' + const FooterComponent = (props: React.PropsWithChildren<{__SLOT__?: string}>) =>