diff --git a/.changeset/slot-system-consistency.md b/.changeset/slot-system-consistency.md new file mode 100644 index 00000000000..0f4cbb92baa --- /dev/null +++ b/.changeset/slot-system-consistency.md @@ -0,0 +1,12 @@ +--- +'@primer/react': minor +--- + +Slot system consistency improvements: + +- Remove orphan `__SLOT__` markers from root components that no parent scans for: `ActionMenu` (root `Menu`), `UnderlinePanels` (root), `Autocomplete` is unchanged (still used as a `FormControl` child), `PageLayout` (root), `SegmentedControl` (root), `RadioGroup` (root), `CheckboxGroup` (root), and `Dialog` (root). Sub-component markers are intentionally retained so consumers can keep wrapping them. +- Standardize `Symbol(...)` descriptions used as slot markers to the `Parent.Slot` convention: `CheckboxOrRadioGroupLabel` → `CheckboxOrRadioGroup.Label`, `CheckboxOrRadioGroupCaption` → `CheckboxOrRadioGroup.Caption`, `CheckboxOrRadioGroupValidation` → `CheckboxOrRadioGroup.Validation`, `DEPRECATED_Tooltip` → `Tooltip`, and `Table` → `DataTable.Table`. +- Migrate `PageHeader`, `NavList.Item`, and the internal `CheckboxOrRadioGroup` to use the `useSlots` hook instead of hand-rolling `React.Children` traversal with `isSlot`. The `CheckboxOrRadioGroup` migration also removes duplicated work where `useSlots` was already called but slots were re-extracted by hand immediately after. +- Export `useSlots`, `isSlot`, `asSlot`, and the `WithSlotMarker`/`FCWithSlotMarker` types publicly from `@primer/react` so downstream consumers can build their own slot-aware compound components. +- Add `asSlot(component, slotSource)` helper: a typed utility that copies a `__SLOT__` marker from a source slot component onto a wrapper component, replacing the cast-heavy `(Wrapper as typeof Wrapper & {__SLOT__?: symbol}).__SLOT__ = Source.__SLOT__` pattern. +- Add a dev-mode warning in `useSlots` when a child's `displayName` matches a slot component's `displayName` but the child is missing the `__SLOT__` marker — a common footgun when wrapping slot components. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d58f438ffe1..47f77763de8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -136,6 +136,10 @@ npm run lint:fix # Auto-fix linting issues When working on UI components, always use the `primer-storybook` MCP tools to access Storybook's component and documentation knowledge before answering or taking any action. Reference the `.github/skills/storybook/SKILL.md` file for detailed instructions on using the Storybook MCP effectively and accurately. +## Slots + +When building, modifying, or wrapping compound components that use child-component "slots" (anything involving `__SLOT__`, `useSlots`, `isSlot`, or `asSlot`), reference the `.github/skills/slots/SKILL.md` file. It documents when to add slot markers, naming conventions for `Symbol(...)` descriptions, the public slot APIs, and common pitfalls. + ## Known Issues and Workarounds **Timing Expectations:** diff --git a/.github/skills/slots/SKILL.md b/.github/skills/slots/SKILL.md new file mode 100644 index 00000000000..3724385ed9b --- /dev/null +++ b/.github/skills/slots/SKILL.md @@ -0,0 +1,200 @@ +--- +name: slots +description: 'Use when: building, modifying, or wrapping compound components that use child-component "slots" in Primer React. Covers when to add a `__SLOT__` marker, the `useSlots` hook, the `isSlot` helper, the `asSlot` wrapper helper, naming conventions for slot symbols, and common pitfalls.' +--- + +# Slots in Primer React + +Primer React uses a lightweight, SSR-compatible slot system to let compound components extract specific child elements (e.g. `` extracting `` out of its children). This skill documents the conventions and the public API surface. + +## TL;DR + +- **Building a compound component that needs to find specific children?** Use `useSlots(children, config)`. +- **Building a slot sub-component?** Add a `__SLOT__ = Symbol('Parent.Slot')` marker so it can be wrapped by consumers. +- **Wrapping an existing slot component?** Use `asSlot(wrapper, ParentSlotComponent)` to copy the marker. +- **Branching on whether a child is a slot, outside of `useSlots`?** Use `isSlot(child, SlotComponent)`. + +All four primitives are exported publicly from `@primer/react`. + +## Architecture + +| File | Purpose | +| ---------------------- | -------------------------------------------------------------- | +| `hooks/useSlots.ts` | Hook that extracts named slot children from `children` | +| `utils/is-slot.ts` | `isSlot(element, slot)` predicate | +| `utils/as-slot.ts` | `asSlot(component, source)` marker-copy helper | +| `utils/types/Slots.ts` | `SlotMarker`, `WithSlotMarker`, `FCWithSlotMarker

` types | + +### Public exports + +```ts +import {useSlots, isSlot, asSlot, type SlotMarker, type WithSlotMarker, type FCWithSlotMarker} from '@primer/react' +// or +import {useSlots} from '@primer/react/hooks' +``` + +## When to use slots + +Slots are the right tool when: + +- A parent compound component needs to **extract specific named children** and render them in particular positions (e.g. `Dialog.Header`, `Dialog.Body`, `Dialog.Footer`). +- The extraction must **survive SSR** (you can't rely on refs or rendered DOM). +- Consumers should be able to **wrap your slot sub-components** without breaking the parent's matching. + +Slots are **not** the right tool when: + +- You just want to render `children` as-is. +- You need to find _all_ children of a given type (`useSlots` is single-match-per-key — see "Limitations"). +- You need to inspect grandchildren or do per-child transforms with side effects (see `ActionMenu` for the pattern when you have to). + +## Building a slot consumer (parent component) + +Use `useSlots`. It returns `[slots, rest]`, where `slots` is keyed by your config and `rest` is everything else (in encounter order). + +```tsx +import {useSlots} from '@primer/react/hooks' + +function MyCompound({children}: {children: React.ReactNode}) { + const [slots, rest] = useSlots(children, { + header: MyCompound.Header, + footer: MyCompound.Footer, + }) + + return ( +

+ {slots.header} +
{rest}
+ {slots.footer} +
+ ) +} +``` + +### Matcher styles + +```ts +useSlots(children, { + // 1. Component reference (most common) + header: Header, + + // 2. Component + props test fn (variant matching) + block: [Description, props => props.variant === 'block'], +}) +``` + +Each slot key produces `ReactElement | undefined`. Slots are single-match: the first matching child wins; duplicates emit a dev-mode warning. + +## Building a slot sub-component (child component) + +Add a `__SLOT__` symbol after the component declaration so wrappers and parent scanners can recognise it. Use `Parent.Slot` for the symbol description and the `WithSlotMarker` / `FCWithSlotMarker` types for the export. + +```tsx +import type {FCWithSlotMarker} from '@primer/react' + +export const MyHeader: FCWithSlotMarker = (props) => { ... } +MyHeader.displayName = 'MyCompound.Header' +MyHeader.__SLOT__ = Symbol('MyCompound.Header') +``` + +For `forwardRef`'d or otherwise non-FC components, assert with `WithSlotMarker`: + +```tsx +;(MyHeader as WithSlotMarker).__SLOT__ = Symbol('MyCompound.Header') +``` + +## Naming conventions for `Symbol(...)` descriptions + +The Symbol description shows up in React DevTools and crash logs. Keep it predictable: + +| Component shape | Convention | Example | +| --------------------------------------------------- | ------------------------------- | ---------------------------------------------------------------------------- | +| Sub-component of a compound | `Symbol('Parent.Slot')` | `Symbol('ActionList.LeadingVisual')` | +| Root component used as a child of another component | `Symbol('Component')` | `Symbol('FormControl')` (FormControl is scanned as a child by CheckboxGroup) | +| Deeply nested slot | `Symbol('Parent.Slot.SubSlot')` | `Symbol('ActionList.GroupHeading.TrailingAction')` | + +**Don't:** + +- Use lifecycle annotations (`Symbol('DEPRECATED_X')`) — handle deprecation via other means. +- Use camelCase without separators (`Symbol('ActionListItem')` instead of `Symbol('ActionList.Item')`). +- Add a `__SLOT__` marker to a root component that no other component scans for — the marker is dead weight and confusing for anyone searching the codebase. + +## Should I add a `__SLOT__` marker at all? + +Add it when **either**: + +1. **A parent in this codebase scans for it** (use `grep "isSlot.*ComponentName"` to check), or +2. **The component is a documented compound sub-component** (e.g. `Dialog.Header`) that consumers may want to wrap. Sub-component markers are intentionally generous because wrapping is a common downstream pattern. + +Don't add it to top-level root components unless they're explicitly used as a child somewhere (e.g. `FormControl` is, because `CheckboxGroup` scans for it). + +## Wrapping a slot component (`asSlot`) + +When consumers wrap a Primer slot component, the wrapper must carry the same `__SLOT__` marker for the parent scanner to recognise it. Use `asSlot`: + +```tsx +import {asSlot, ActionList} from '@primer/react' + +const ColoredLeadingVisual = asSlot( + function ColoredLeadingVisual({color, children}: {color: string; children: React.ReactNode}) { + return ( + + {children} + + ) + }, + ActionList.LeadingVisual, +) + +// Now ActionList.Item recognises ColoredLeadingVisual as a leading visual slot. + + + Approved + +``` + +`asSlot` is the preferred replacement for the older cast-heavy pattern: + +```tsx +// ❌ Old, footgun-prone +;(ColoredLeadingVisual as typeof ColoredLeadingVisual & {__SLOT__?: symbol}).__SLOT__ = + ActionList.LeadingVisual.__SLOT__ + +// ✅ New, typed, dev-warns if the source has no marker +const ColoredLeadingVisual = asSlot(ColoredLeadingVisual, ActionList.LeadingVisual) +``` + +## Branching on slot membership (`isSlot`) + +If you need to check whether an element matches a particular slot outside of `useSlots` (e.g. inside an existing `Children.map` with side effects), use `isSlot`: + +```tsx +import {isSlot} from '@primer/react' + +React.Children.map(children, child => { + if (React.isValidElement(child) && (child.type === Tooltip || isSlot(child, Tooltip))) { + // ... + } +}) +``` + +`isSlot` always checks both the direct type and the slot marker so wrappers built with `asSlot` are recognised. + +## Limitations and follow-ups + +- **`useSlots` is single-match.** It cannot collect _all_ children of a given type into an array. Consumers that need that pattern (e.g. `UnderlinePanels` collecting all `Tab` children, `CheckboxGroup` collecting all `FormControl` children) currently hand-roll the logic. A future extension to `useSlots` may add a multi-match option. +- **Per-child classification / side-effect transforms** (e.g. `ActionMenu` rewriting Tooltip-wrapped Anchors) are not a slot pattern — keep them as bespoke `Children.map` loops with `isSlot` predicates. +- **Avoid calling `useSlots` purely for dev-mode warnings** in render-hot paths. Although `useSlots` is implemented as a plain function (no internal React hooks), running it on every render just to compute a dev-only assertion is wasteful — call it in the regular render flow when you also need its output. +- **Don't call `useSlots` inside `useMemo`, `useCallback`, `useEffect`, or any other callback.** The `react-hooks/rules-of-hooks` lint rule enforces this based on the `use*` naming convention, even though `useSlots` itself doesn't call any React hooks. Always call it at the top level of your component or custom hook. If you need to extract slots before a memoised computation, call `useSlots` first and then reference the result inside `useMemo`. + +## Dev-mode warnings emitted by `useSlots` + +- **Duplicate slot:** if two children match the same slot key, only the first is rendered and a warning fires. +- **`displayName` mismatch:** if a child's `displayName` matches a slot component's `displayName` but the child is missing the `__SLOT__` marker, a warning fires suggesting `asSlot`. This catches the most common wrapping footgun. + +## Quick checklist for a new compound component + +1. Define the sub-components (`MyCompound.Header`, etc.) with `displayName` set and `__SLOT__ = Symbol('MyCompound.Header')` applied. +2. In the parent, call `useSlots(children, {header: MyCompound.Header, ...})` once and render `slots.header` / `rest` in the right places. +3. Export `MyCompound` with `Object.assign(Root, {Header, Footer, ...})`. +4. Don't add `__SLOT__` to the root unless another component scans for it. +5. If the new component will be wrapped downstream, document that consumers should use `asSlot` (or call out which slot to pass). diff --git a/packages/react/src/ActionMenu/ActionMenu.tsx b/packages/react/src/ActionMenu/ActionMenu.tsx index c0dfb464f39..7762d0307ee 100644 --- a/packages/react/src/ActionMenu/ActionMenu.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.tsx @@ -378,7 +378,6 @@ const Overlay: FCWithSlotMarker> = ({ Menu.displayName = 'ActionMenu' -Menu.__SLOT__ = Symbol('ActionMenu') MenuButton.__SLOT__ = Symbol('ActionMenu.Button') Anchor.__SLOT__ = Symbol('ActionMenu.Anchor') Overlay.__SLOT__ = Symbol('ActionMenu.Overlay') diff --git a/packages/react/src/CheckboxGroup/CheckboxGroup.tsx b/packages/react/src/CheckboxGroup/CheckboxGroup.tsx index c3970e956b8..d17b19b4d08 100644 --- a/packages/react/src/CheckboxGroup/CheckboxGroup.tsx +++ b/packages/react/src/CheckboxGroup/CheckboxGroup.tsx @@ -80,5 +80,3 @@ export default Object.assign(CheckboxGroup, { Label: CheckboxOrRadioGroupLabel, Validation: CheckboxOrRadioGroupValidation, }) - -CheckboxGroup.__SLOT__ = Symbol('CheckboxGroup') diff --git a/packages/react/src/DataTable/index.ts b/packages/react/src/DataTable/index.ts index ce7f9514a8e..93431844645 100644 --- a/packages/react/src/DataTable/index.ts +++ b/packages/react/src/DataTable/index.ts @@ -51,7 +51,7 @@ const Table: typeof TableImpl & ErrorDialog, }) -Table.__SLOT__ = Symbol('Table') +Table.__SLOT__ = Symbol('DataTable.Table') export {DataTable, Table} export type {DataTableProps} from './DataTable' diff --git a/packages/react/src/Dialog/Dialog.tsx b/packages/react/src/Dialog/Dialog.tsx index 4fdcb899d78..7015367bff9 100644 --- a/packages/react/src/Dialog/Dialog.tsx +++ b/packages/react/src/Dialog/Dialog.tsx @@ -545,7 +545,6 @@ Footer.__SLOT__ = Symbol('Dialog.Footer') Body.__SLOT__ = Symbol('Dialog.Body') export const Dialog = Object.assign(_Dialog, { - __SLOT__: Symbol('Dialog'), Header, Title, Subtitle, diff --git a/packages/react/src/NavList/NavList.tsx b/packages/react/src/NavList/NavList.tsx index c81795db695..524281f4018 100644 --- a/packages/react/src/NavList/NavList.tsx +++ b/packages/react/src/NavList/NavList.tsx @@ -17,7 +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 {isSlot} from '../utils/is-slot' +import {useSlots} from '../hooks/useSlots' import {fixedForwardRef, type PolymorphicProps} from '../utils/modern-polymorphic' // ---------------------------------------------------------------------------- @@ -65,20 +65,12 @@ const ItemComponent = fixedForwardRef( ) => { const {depth} = React.useContext(SubNavContext) - // Get SubNav from children - const subNav = React.Children.toArray(children).find( - child => isValidElement(child) && (child.type === SubNav || isSlot(child, SubNav)), - ) - - // Get children without SubNav or TrailingAction - const childrenWithoutSubNavOrTrailingAction = React.Children.toArray(children).filter(child => - isValidElement(child) - ? child.type !== SubNav && - child.type !== TrailingAction && - !isSlot(child, SubNav) && - !isSlot(child, TrailingAction) - : true, - ) + // Extract SubNav from children; useSlots also returns `rest` with TrailingAction filtered out. + const [slots, childrenWithoutSubNavOrTrailingAction] = useSlots(children, { + subNav: SubNav, + trailingAction: TrailingAction, + }) + const subNav = slots.subNav if (!isValidElement(subNav) && defaultOpen) // eslint-disable-next-line no-console diff --git a/packages/react/src/PageHeader/PageHeader.tsx b/packages/react/src/PageHeader/PageHeader.tsx index 1acaadf06f3..2f3b93e6bbc 100644 --- a/packages/react/src/PageHeader/PageHeader.tsx +++ b/packages/react/src/PageHeader/PageHeader.tsx @@ -10,12 +10,11 @@ import {getResponsiveAttributes} from '../internal/utils/getResponsiveAttributes import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' import {areAllValuesTheSame, haveRegularAndWideSameValue} from '../utils/getBreakpointDeclarations' import {warning} from '../utils/warning' -import {useProvidedRefOrCreate} from '../hooks' +import {useProvidedRefOrCreate, useSlots} from '../hooks' import type {AriaRole, FCWithSlotMarker} from '../utils/types' import {clsx} from 'clsx' import classes from './PageHeader.module.css' -import {isSlot} from '../utils/is-slot' // Types that are shared between PageHeader children components export type ChildrenPropTypes = { @@ -50,6 +49,12 @@ export type PageHeaderProps = { const Root = React.forwardRef>( ({children, className, as: BaseComponent = 'div', 'aria-label': ariaLabel, role, hasBorder}, forwardedRef) => { const rootRef = useProvidedRefOrCreate(forwardedRef as React.RefObject) + const [slots] = useSlots(children, { + contextArea: ContextArea, + leadingAction: LeadingAction, + }) + const hasContextArea = slots.contextArea !== undefined + const hasLeadingAction = slots.leadingAction !== undefined const isInteractive = (element: HTMLElement) => { return ( @@ -64,9 +69,6 @@ const Root = React.forwardRef { return child instanceof HTMLElement && child.getAttribute('data-component') === 'TitleArea' @@ -75,14 +77,6 @@ const Root = React.forwardRef { return ( @@ -100,7 +94,7 @@ const Root = React.forwardRef, diff --git a/packages/react/src/RadioGroup/RadioGroup.tsx b/packages/react/src/RadioGroup/RadioGroup.tsx index ff589e0a3c8..cc9b64b78b0 100644 --- a/packages/react/src/RadioGroup/RadioGroup.tsx +++ b/packages/react/src/RadioGroup/RadioGroup.tsx @@ -58,7 +58,6 @@ const RadioGroup: FC> = ({children, dis } export default Object.assign(RadioGroup, { - __SLOT__: Symbol('RadioGroup'), Caption: CheckboxOrRadioGroupCaption, Label: CheckboxOrRadioGroupLabel, Validation: CheckboxOrRadioGroupValidation, diff --git a/packages/react/src/SegmentedControl/SegmentedControl.tsx b/packages/react/src/SegmentedControl/SegmentedControl.tsx index 0963b8bccf8..fa2c7e0614f 100644 --- a/packages/react/src/SegmentedControl/SegmentedControl.tsx +++ b/packages/react/src/SegmentedControl/SegmentedControl.tsx @@ -225,7 +225,6 @@ const Root: React.FC> = ({ Root.displayName = 'SegmentedControl' export const SegmentedControl = Object.assign(Root, { - __SLOT__: Symbol('SegmentedControl'), Button, IconButton: SegmentedControlIconButton, }) diff --git a/packages/react/src/Tooltip/Tooltip.tsx b/packages/react/src/Tooltip/Tooltip.tsx index 8122db14ae5..230ee4a3216 100644 --- a/packages/react/src/Tooltip/Tooltip.tsx +++ b/packages/react/src/Tooltip/Tooltip.tsx @@ -56,6 +56,6 @@ Tooltip.alignments = ['left', 'right'] Tooltip.directions = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'] -Tooltip.__SLOT__ = Symbol('DEPRECATED_Tooltip') +Tooltip.__SLOT__ = Symbol('Tooltip') export default Tooltip diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index be5bfb50502..da0293203ad 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -23,6 +23,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "AnchoredOverlay", "type AnchoredOverlayProps", "AnchoredPositionHookSettings", + "asSlot", "Autocomplete", "type AutocompleteInputProps", "type AutocompleteMenuProps", @@ -69,6 +70,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "type DialogHeight", "type DialogProps", "type DialogWidth", + "type FCWithSlotMarker", "Flash", "type FlashProps", "FocusKeys", @@ -87,6 +89,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "type HeadingProps", "IconButton", "type IconButtonProps", + "isSlot", "IssueLabelToken", "type IssueLabelTokenProps", "Label", @@ -234,10 +237,12 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "useResponsiveValue", "useRovingTabIndex", "useSafeTimeout", + "useSlots", "useSyncedState", "useTheme", "VisuallyHidden", "type VisuallyHiddenProps", + "type WithSlotMarker", ] `; diff --git a/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx b/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx index aa23618937f..ea2039fc545 100644 --- a/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx +++ b/packages/react/src/experimental/UnderlinePanels/UnderlinePanels.tsx @@ -238,6 +238,5 @@ Panel.displayName = 'UnderlinePanels.Panel' export default Object.assign(UnderlinePanels, {Panel, Tab}) -UnderlinePanels.__SLOT__ = Symbol('UnderlinePanels') Tab.__SLOT__ = Symbol('UnderlinePanels.Tab') Panel.__SLOT__ = Symbol('UnderlinePanels.Panel') diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 5938cacc2c0..eece539e4a4 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -17,3 +17,4 @@ export {useRefObjectAsForwardedRef} from './useRefObjectAsForwardedRef' export {useId} from './useId' export {useIsMacOS} from './useIsMacOS' export {useMergedRefs} from './useMergedRefs' +export {useSlots} from './useSlots' diff --git a/packages/react/src/hooks/useSlots.ts b/packages/react/src/hooks/useSlots.ts index fd643f1428e..cbdf9c1160e 100644 --- a/packages/react/src/hooks/useSlots.ts +++ b/packages/react/src/hooks/useSlots.ts @@ -126,6 +126,9 @@ export function useSlots( const matchedIndex = findMatchingSlot(child, values, totalSlots) if (matchedIndex === -1) { + if (__DEV__) { + warnIfDisplayNameMatchesWithoutMarker(child, keys, values, totalSlots) + } rest.push(child) return } @@ -183,3 +186,38 @@ function warnIfDuplicate( } return false } + +/** + * Dev-only: detect children whose displayName matches a slot's component + * displayName but that don't share the `__SLOT__` marker. Catches a common + * footgun where a wrapper around a slot component forgets to copy the marker. + */ +function warnIfDisplayNameMatchesWithoutMarker( + child: React.ReactElement, + keys: Array, + values: Array, + totalSlots: number, +): void { + const childType = child.type as {displayName?: string; name?: string} | string | undefined + if (typeof childType !== 'function' && typeof childType !== 'object') return + const childName = (childType as {displayName?: string; name?: string}).displayName + + if (!childName) return + + for (let i = 0; i < totalSlots; i++) { + const slotValue = values[i] + const component = (Array.isArray(slotValue) ? slotValue[0] : slotValue) as { + displayName?: string + name?: string + } + const slotName = component.displayName + if (slotName && slotName === childName) { + warning( + true, + `useSlots: child with displayName "${childName}" matches slot "${String(keys[i])}" by name but is missing the \`__SLOT__\` marker. ` + + `Did you forget to copy the marker? Use \`asSlot(wrapper, ParentSlotComponent)\` to copy it.`, + ) + return + } + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 0c723ddcc2e..8fd9d2e8ee9 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -50,7 +50,10 @@ export {useAnchoredPosition, type AnchoredPositionHookSettings} from './hooks/us // Utils export {createComponent} from './utils/create-component' -export type {SlotMarker} from './utils/types' +export type {SlotMarker, WithSlotMarker, FCWithSlotMarker} from './utils/types' +export {asSlot} from './utils/as-slot' +export {isSlot} from './utils/is-slot' +export {useSlots} from './hooks/useSlots' // Components export {default as Radio} from './Radio' diff --git a/packages/react/src/internal/components/CheckboxOrRadioGroup/CheckboxOrRadioGroup.tsx b/packages/react/src/internal/components/CheckboxOrRadioGroup/CheckboxOrRadioGroup.tsx index d35ee431962..133a6c1c52d 100644 --- a/packages/react/src/internal/components/CheckboxOrRadioGroup/CheckboxOrRadioGroup.tsx +++ b/packages/react/src/internal/components/CheckboxOrRadioGroup/CheckboxOrRadioGroup.tsx @@ -9,7 +9,6 @@ import VisuallyHidden from '../../../_VisuallyHidden' import {useSlots} from '../../../hooks/useSlots' import classes from './CheckboxOrRadioGroup.module.css' import {clsx} from 'clsx' -import {isSlot} from '../../../utils/is-slot' export type CheckboxOrRadioGroupProps = { /** Class name for custom styling */ @@ -48,23 +47,9 @@ const CheckboxOrRadioGroup: React.FC - React.isValidElement(child) && - (child.type === CheckboxOrRadioGroupLabel || isSlot(child, CheckboxOrRadioGroupLabel)), - ) - const validationChild = React.Children.toArray(children).find(child => - React.isValidElement(child) && - (child.type === CheckboxOrRadioGroupValidation || isSlot(child, CheckboxOrRadioGroupValidation)) - ? child - : null, - ) - const captionChild = React.Children.toArray(children).find(child => - React.isValidElement(child) && - (child.type === CheckboxOrRadioGroupCaption || isSlot(child, CheckboxOrRadioGroupCaption)) - ? child - : null, - ) + const labelChild = slots.label + const validationChild = slots.validation + const captionChild = slots.caption const id = useId(idProp) const validationMessageId = validationChild ? `${id}-validationMessage` : undefined const captionId = captionChild ? `${id}-caption` : undefined diff --git a/packages/react/src/internal/components/CheckboxOrRadioGroup/CheckboxOrRadioGroupCaption.tsx b/packages/react/src/internal/components/CheckboxOrRadioGroup/CheckboxOrRadioGroupCaption.tsx index 6c26b67d3bf..3cbecd93e94 100644 --- a/packages/react/src/internal/components/CheckboxOrRadioGroup/CheckboxOrRadioGroupCaption.tsx +++ b/packages/react/src/internal/components/CheckboxOrRadioGroup/CheckboxOrRadioGroupCaption.tsx @@ -22,4 +22,4 @@ const CheckboxOrRadioGroupCaption: FCWithSlotMarker) { + return
+} +Source.__SLOT__ = Symbol('Source') + +function SourceWithoutMarker(props: React.PropsWithChildren) { + return
+} + +describe('asSlot', () => { + let warnSpy: ReturnType + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + return () => { + warnSpy.mockRestore() + } + }) + + test('copies the __SLOT__ marker from source to the wrapper component', () => { + function Wrapper(props: React.PropsWithChildren) { + return + } + + const wrapped = asSlot(Wrapper, Source) + + expect(wrapped).toBe(Wrapper) + expect(wrapped.__SLOT__).toBe(Source.__SLOT__) + expect(warnSpy).not.toHaveBeenCalled() + }) + + test('warns in dev when the source has no __SLOT__ marker', () => { + function Wrapper(props: React.PropsWithChildren) { + return + } + + asSlot(Wrapper, SourceWithoutMarker as unknown as Parameters[1]) + + expect(warnSpy).toHaveBeenCalledWith('Warning:', expect.stringMatching(/asSlot:/)) + }) +}) diff --git a/packages/react/src/utils/as-slot.ts b/packages/react/src/utils/as-slot.ts new file mode 100644 index 00000000000..c29d0f04bb2 --- /dev/null +++ b/packages/react/src/utils/as-slot.ts @@ -0,0 +1,30 @@ +import {warning} from './warning' +import type {SlotMarker, WithSlotMarker} from './types' + +/** + * Copies the `__SLOT__` marker from a slot-aware source component onto another + * component, returning the same component reference typed as a slot. + * + * Useful when building reusable wrappers around Primer slot components that + * still need to be recognised by the parent's slot scanner. + * + * @example + * ```tsx + * const ColoredLeadingVisual = asSlot( + * function ColoredLeadingVisual({color, children}) { + * return {children} + * }, + * ActionList.LeadingVisual, + * ) + * ``` + */ +export function asSlot(component: T, slotSource: WithSlotMarker): WithSlotMarker { + if (__DEV__) { + warning( + !slotSource.__SLOT__, + 'asSlot: the provided slotSource does not have a `__SLOT__` marker. The wrapper will not be recognised by the parent slot scanner.', + ) + } + ;(component as unknown as SlotMarker).__SLOT__ = slotSource.__SLOT__ + return component as WithSlotMarker +}