UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

175 lines (157 loc) 6.15 kB
import React from 'react'; import { warning } from '../utils/warning.js'; import { isSlot } from '../utils/is-slot.js'; /** * useSlots - Extract slot components from children for SSR-compatible slot APIs. * * Given a list of children and a config mapping slot names to component types, * separates children into two groups: matched slots and the rest. * * Config: { leadingVisual: LeadingVisual, description: Description } * * Children: Matching: Output: * +----------------+ slots = { * | LeadingVisual | -> matches slot 0 --> leadingVisual: <LeadingVisual /> * | "Project name" | -> no match --> description: <Description /> * | Description | -> matches slot 1 --> } * | TrailingVisual | -> no match --> rest = ["Project name", <TrailingVisual />] * +----------------+ * * Performance-sensitive: called per item in lists (e.g. 100-item ActionList * calls this 101 times per render). * * Once all slots are filled, remaining children skip matching entirely in * production (single integer comparison). In dev, we still scan for duplicates * to emit a warning. * * Flow per child: * * child ──> isValidElement? ──no──> rest[] * | * yes * | * v * all slots filled? ──yes──> rest[] (prod) * | | * no [dev: check for * | duplicate warning] * v * match against unfilled slots * | * found match? ──no──> rest[] * | * yes * | * v * slots[key] = child, slotsFound++ * * Slot config supports two matcher styles: * 1. Component reference: { visual: LeadingVisual } * 2. Component + test fn: { block: [Description, props => props.variant === 'block'] } */ // --- Types --- // eslint-disable-next-line @typescript-eslint/no-explicit-any // --- Matching --- /** Check if a child element matches a slot config entry, either by direct type comparison or slot symbol. */ function childMatchesSlot(child, slotValue) { if (Array.isArray(slotValue)) { const [component, testFn] = slotValue; return (child.type === component || isSlot(child, component)) && testFn(child.props); } return child.type === slotValue || isSlot(child, slotValue); } // --- Hook --- /** Extract slot components from children. See file header for details. */ function useSlots(children, config) { const rest = []; const keys = Object.keys(config); const values = Object.values(config); const totalSlots = keys.length; // Initialize all slot keys to undefined so callers can check `slots.x === undefined` const slots = {}; for (let i = 0; i < totalSlots; i++) { slots[keys[i]] = undefined; } let slotsFound = 0; // eslint-disable-next-line github/array-foreach React.Children.forEach(children, child => { if (! /*#__PURE__*/React.isValidElement(child)) { rest.push(child); return; } // Short-circuit: all slots filled, no more matching needed if (slotsFound === totalSlots) { if (process.env.NODE_ENV !== "production" && warnIfDuplicate(child, keys, values, totalSlots)) { return; } rest.push(child); return; } // Try to match child against a slot const matchedIndex = findMatchingSlot(child, values, totalSlots); if (matchedIndex === -1) { if (process.env.NODE_ENV !== "production") { warnIfDisplayNameMatchesWithoutMarker(child, keys, values, totalSlots); } rest.push(child); return; } const slotKey = keys[matchedIndex]; // Duplicate: slot already filled by an earlier child if (slots[slotKey] !== undefined) { if (process.env.NODE_ENV !== "production") { process.env.NODE_ENV !== "production" ? warning(true, `Found duplicate "${String(slotKey)}" slot. Only the first will be rendered.`) : void 0; } return; } slots[slotKey] = child; slotsFound++; }); return [slots, rest]; } // --- Helpers --- /** * Find the first slot config entry matching this child. * Returns the config index, or -1 if no match. */ function findMatchingSlot(child, values, totalSlots) { for (let i = 0; i < totalSlots; i++) { if (childMatchesSlot(child, values[i])) return i; } return -1; } /** * Dev-only: check if a child duplicates an already-filled slot. * Returns true (and warns) if a duplicate is found, false otherwise. * Used in the short-circuit path where all slots are already filled. */ function warnIfDuplicate(child, keys, values, totalSlots) { for (let i = 0; i < totalSlots; i++) { if (childMatchesSlot(child, values[i])) { process.env.NODE_ENV !== "production" ? warning(true, `Found duplicate "${String(keys[i])}" slot. Only the first will be rendered.`) : void 0; return true; } } 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, keys, values, totalSlots) { const childType = child.type; if (typeof childType !== 'function' && typeof childType !== 'object') return; const childName = childType.displayName; if (!childName) return; for (let i = 0; i < totalSlots; i++) { const slotValue = values[i]; const component = Array.isArray(slotValue) ? slotValue[0] : slotValue; const slotName = component.displayName; if (slotName && slotName === childName) { process.env.NODE_ENV !== "production" ? 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.`) : void 0; return; } } } export { useSlots };