diff --git a/README.md b/README.md index 0fdd1e5..9a91067 100644 --- a/README.md +++ b/README.md @@ -12,39 +12,41 @@ Bring [Slots](https://github.com/reactjs/rfcs/pull/223) to React, with SSR suppo ## Usage +### Simple version (only one slot is used per slot type) + 1. Create your component with slots ```tsx import React, { useId } from 'react' -import createSlots from 'create-slots' +import { createHost, createSlot } from 'create-slots' -const { createHost, SlotComponents, useSlots } = createSlots({ - Label: 'label', - Input: 'input', - Description: 'div', -}) +const FieldLabel = createSlot('label') +const FieldInput = createSlot('input') +const FieldDescription = createSlot('div') type FieldProps = React.ComponentPropsWithoutRef<'div'> -const FieldBase: React.FC = (props) => { - const Slots = useSlots() +export const Field = (props: FieldProps) => { const id = useId() - const inputId = Slots.getProps('Input')?.id || `${id}-label` - const descriptionId = Slots.has('Description') ? `${id}-desc` : undefined - return ( -
- {Slots.render('Label', { htmlFor: inputId })} - {Slots.render('Input', { - id: inputId, - 'aria-describedby': descriptionId, - })} - {Slots.render('Description', { id: descriptionId })} -
- ) + return createHost(props.children, (Slots) => { + const labelProps = Slots.getProps(FieldLabel) + const inputProps = Slots.getProps(FieldInput) + const inputId = inputProps?.id || id + + return ( +
+ {labelProps &&
+ ) + }) } -export const Field = Object.assign(createHost(FieldBase), SlotComponents) +Field.Label = FieldLabel +Field.Input = FieldInput +Field.Description = FieldDescription ``` 2. Use it @@ -57,43 +59,62 @@ export const Field = Object.assign(createHost(FieldBase), SlotComponents) ``` -### List slots +### List slots (fully implemented the [React Slots RFC](https://github.com/reactjs/rfcs/pull/223) with utils) ```tsx import React, { useState } from 'react' -import createSlots from 'create-slots/list' - -const { createHost, SlotComponents, useSlots } = createSlots({ - Item: 'li', - Divider: 'hr', -}) - -const SelectBase: React.FC> = (props) => { - const [selected, setSelected] = useState(null) - const slotItems = useSlots().renderItems( - ({ name, props: itemProps, index }) => { - if (name === 'Item') { - return { - ...itemProps, - 'data-index': index, - 'aria-selected': itemProps.children === selected, - onClick: () => { - setSelected(itemProps.value) - }, - } - } - } - ) +import { createHost, createSlot, getSlotProps, isSlot } from 'create-slots/list' + +const SelectItem = createSlot('li') +const SelectDivider = createSlot('hr') + +type SelectProps = React.FC> + +const Select = (props: SelectProps) => { + const [selected, setSelected] = useState() + const indexRef = React.useRef(0) return (
-
Selected: {selected}
-
    {slotItems}
+
Selected: {selected ?? ''}
+ {createHost(props.children, (slots) => { + indexRef.current = 0 + return ( +
    + {slots.map((slot) => { + if (isSlot(slot, SelectItem)) { + const slotProps = getSlotProps(slot) + return ( +
  • setSelected(slotProps.value as string)} + /> + ) + } + + return slot + })} +
+ ) + })}
) } -export const Select = Object.assign(createHost(SelectBase), SlotComponents) +Select.Item = SelectItem +Select.Divider = SelectDivider +``` + +2. Use it + +```tsx + ``` ## License diff --git a/example/components/RFCItem.tsx b/example/components/RFCItem.tsx new file mode 100644 index 0000000..d0d6228 --- /dev/null +++ b/example/components/RFCItem.tsx @@ -0,0 +1,38 @@ +import React, { useId } from 'react' +import { createHost, createSlot, getSlot } from 'create-slots/list' + +type ItemProps = Omit, 'value'> & { + value: string +} + +const ItemTitle = createSlot<'h4'>() +const ItemDescription = createSlot<'div'>() + +export const Item = (props: ItemProps) => { + const id = useId() + + return createHost(props.children, (slots) => { + const titleSlot = getSlot(slots, ItemTitle) + const descriptionSlot = getSlot(slots, ItemDescription) + const titleId = titleSlot ? `${id}-title` : undefined + const descId = descriptionSlot ? `${id}-desc` : undefined + + return ( +
  • + {titleSlot && ( +

    + )} + {descriptionSlot && ( +
    + )} +

  • + ) + }) +} + +Item.Title = ItemTitle +Item.Description = ItemDescription diff --git a/example/components/RFCSelect.tsx b/example/components/RFCSelect.tsx new file mode 100644 index 0000000..4ed3b88 --- /dev/null +++ b/example/components/RFCSelect.tsx @@ -0,0 +1,55 @@ +import React, { useRef, useState } from 'react' +import { createHost, createSlot, getSlotProps, isSlot } from 'create-slots/list' + +import { Item } from './RFCItem' + +const SelectItem = createSlot() +const SelectDivider = createSlot('hr') + +export const Select = (props: React.ComponentProps<'ul'>) => { + const [selected, setSelected] = useState(null) + const indexRef = useRef(0) + + return ( +
    +
    Selected: {selected}
    + {createHost(props.children, (slots) => { + indexRef.current = 0 + return ( +
      + {slots.map((slot) => { + if (isSlot(slot, SelectItem)) { + const itemProps = getSlotProps(slot) + + return ( + setSelected(itemProps.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + setSelected(itemProps.value) + } + }} + /> + ) + } + + return slot + })} +
    + ) + })} +
    + ) +} + +Select.Item = SelectItem +Select.Divider = SelectDivider + +Select.Item.Title = Item.Title +Select.Item.Description = Item.Description diff --git a/example/components/SimpleField.tsx b/example/components/SimpleField.tsx new file mode 100644 index 0000000..3eddd9b --- /dev/null +++ b/example/components/SimpleField.tsx @@ -0,0 +1,60 @@ +import React, { useId, useState } from 'react' +import { createHost, createSlot } from 'create-slots' + +const Description = (props: React.ComponentPropsWithoutRef<'div'>) => ( +
    +) + +const FieldLabel = createSlot('label') +const FieldInput = createSlot('input') +const FieldDescription = createSlot(Description) + +const StyledLabel = (props: React.ComponentPropsWithoutRef<'label'>) => ( + +) + +export const Field = (props: React.ComponentPropsWithoutRef<'div'>) => { + const id = useId() + const [value, setValue] = useState('') + + if (value === 'a') return null + + return ( +
    + {createHost(props.children, (Slots) => { + const labelProps = Slots.getProps(FieldLabel) + const inputProps = Slots.getProps(FieldInput) + const descriptionIdProps = Slots.getProps(FieldDescription) + + const inputId = inputProps?.id || `${id}-label` + const descriptionId = descriptionIdProps ? `${id}-desc` : undefined + + return ( + <> + {labelProps &&
    + ) +} + +Field.Label = FieldLabel +Field.Input = FieldInput +Field.Description = FieldDescription +Field.StyledLabel = StyledLabel diff --git a/example/pages/index.tsx b/example/pages/index.tsx index dc5f081..fb69ace 100755 --- a/example/pages/index.tsx +++ b/example/pages/index.tsx @@ -6,6 +6,8 @@ import styles from '../styles/Home.module.css' import { Field } from '../components/Field' import { StaticField } from '../components/StaticField' import { Select } from '../components/Select' +import { Field as SimpleField } from '../components/SimpleField' +import { Select as RFCSelect } from '../components/RFCSelect' const Home: NextPage = () => { const [count, setCount] = React.useState(0) @@ -27,6 +29,80 @@ const Home: NextPage = () => {
    +
    +

    RFC

    +
    + + + Foo + + + {count % 3 !== 2 && ( + <> + + Bar + + + + )} + + Baz + + count {count} + + + +
    +
    + +
    +

    List

    +
    + +
    +
    + +
    +

    Simple

    +
    + + + Label + {count % 3 !== 0 && ( + + Description {count} + + + Label + + Nested SimpleField {count} + + + + )} + +
    +
    +

    Dynamic

    @@ -71,32 +147,6 @@ const Home: NextPage = () => {
    - -
    -

    List

    -
    - -
    -
    diff --git a/example/styles/Home.module.css b/example/styles/Home.module.css index 5835f20..c81b9c2 100755 --- a/example/styles/Home.module.css +++ b/example/styles/Home.module.css @@ -64,7 +64,7 @@ border: 1px solid #eaeaea; border-radius: 10px; transition: color 0.15s ease, border-color 0.15s ease; - + width: 300px; } .card:hover, diff --git a/src/list/ScanContext.tsx b/src/ScanContext.tsx similarity index 100% rename from src/list/ScanContext.tsx rename to src/ScanContext.tsx diff --git a/src/__fixtures__/RFCField.tsx b/src/__fixtures__/RFCField.tsx new file mode 100644 index 0000000..2af0b36 --- /dev/null +++ b/src/__fixtures__/RFCField.tsx @@ -0,0 +1,51 @@ +import * as React from 'react' + +import { + createHost, + createSlot, + getSlot, + getLastSlot, + getSlots, + getSlotProps, +} from '../rfc' + +const Description = (props: React.ComponentPropsWithoutRef<'span'>) => ( + +) + +const FieldLabel = createSlot<'label'>() +const FieldInput = createSlot('input') +const FieldDescription = createSlot(Description) +const FieldIcon = createSlot('span') + +type FieldProps = React.ComponentPropsWithoutRef<'div'> + +export const Field = (props: FieldProps) => { + return createHost(props.children, (slots) => { + const labelSlot = getSlot(slots, FieldLabel) + const lastLabelProps = getSlotProps(getLastSlot(slots, FieldLabel)) + const inputSlot = getSlot(slots, FieldInput) + const lastInputProps = getSlotProps(getLastSlot(slots, FieldInput)) + const descriptionSlot = getSlot(slots, FieldDescription) + const lastDescriptionProps = getSlotProps( + getLastSlot(slots, FieldDescription) + ) + const icons = getSlots(slots, FieldIcon) + return ( +
    + {lastLabelProps &&
    + ) + }) +} + +Field.Label = FieldLabel +Field.Input = FieldInput +Field.Description = FieldDescription +Field.Icon = FieldIcon diff --git a/src/__fixtures__/RFCSelect.tsx b/src/__fixtures__/RFCSelect.tsx new file mode 100644 index 0000000..f7f74cd --- /dev/null +++ b/src/__fixtures__/RFCSelect.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' + +import { createHost, createSlot, isSlot, getSlotProps } from '../rfc' + +const Divider = (props: React.ComponentPropsWithoutRef<'hr'>) => ( +
    +) + +const SelectItem = createSlot('li') +const SelectDivider = createSlot(Divider) + +export const Select = (props: React.ComponentPropsWithoutRef<'ul'>) => { + const [selected, setSelected] = React.useState() + const indexRef = React.useRef(0) + + return ( +
    +
    Selected: {selected ?? ''}
    + {createHost(props.children, (slots) => { + indexRef.current = 0 + return ( +
      + {slots.map((slot) => { + if (isSlot(slot, SelectItem)) { + const slotProps = getSlotProps(slot) + return ( +
    • setSelected(slotProps.value as string)} + /> + ) + } + + return slot + })} +
    + ) + })} +
    + ) +} + +Select.Item = SelectItem +Select.Divider = SelectDivider diff --git a/src/__fixtures__/SimpleField.tsx b/src/__fixtures__/SimpleField.tsx new file mode 100644 index 0000000..07c1532 --- /dev/null +++ b/src/__fixtures__/SimpleField.tsx @@ -0,0 +1,77 @@ +import * as React from 'react' + +import { createHost, createSlot } from '../simple' + +const Description = (props: React.ComponentPropsWithoutRef<'span'>) => ( + +) + +const FieldLabel = createSlot<'label'>() +const FieldInput = createSlot('input') +const FieldDescription = createSlot(Description) +const FieldIcon = createSlot('span') + +type Slots = Parameters[1]>[0] +const FillContext = React.createContext(undefined) + +const createFill = (Slot: T) => { + const FillComponent = React.forwardRef((props: any, ref) => { + const Slots = React.useContext(FillContext)! + const [original] = React.useState(() => Slots.get(Slot)) + + React.useEffect(() => Slots.update(Slot, )) + React.useEffect( + () => () => { + original ? Slots.update(Slot, original) : Slots.unmount(Slot) + }, + [Slots, original] + ) + + return null + }) as unknown as T + return FillComponent +} + +type FieldProps = React.ComponentPropsWithoutRef<'div'> + +export const Field = (props: FieldProps) => { + const id = ':r0:' + const descriptionId = ':r1:' + + return createHost(props.children, (Slots) => { + const labelProps = Slots.getProps(FieldLabel) + const inputProps = Slots.getProps(FieldInput) + const descriptionProps = Slots.getProps(FieldDescription) + const iconProps = Slots.getProps(FieldIcon) + return ( + +
    + {labelProps && ( +
    +
    + ) + }) +} + +Field.Label = FieldLabel +Field.Input = FieldInput +Field.Description = FieldDescription +Field.Icon = FieldIcon +Field.LabelFill = createFill(FieldLabel) diff --git a/src/__tests__/rfc-utils.test.tsx b/src/__tests__/rfc-utils.test.tsx new file mode 100644 index 0000000..f84b489 --- /dev/null +++ b/src/__tests__/rfc-utils.test.tsx @@ -0,0 +1,36 @@ +import * as React from 'react' + +import { create } from '../__fixtures__/utils' +import { Field } from '../__fixtures__/RFCField' + +test('render slots', () => { + const instance = create( + + + + Label 1 + Label 2 + Icon 1 + Icon 2 + + ) + expect(instance).toMatchInlineSnapshot(` +
    + + + + Icon 1 + + + Icon 2 + + +
    + `) +}) diff --git a/src/__tests__/rfc.test.tsx b/src/__tests__/rfc.test.tsx new file mode 100644 index 0000000..2b4cfca --- /dev/null +++ b/src/__tests__/rfc.test.tsx @@ -0,0 +1,285 @@ +import * as React from 'react' +import { screen, fireEvent } from '@testing-library/react' + +import { create, render } from '../__fixtures__/utils' +import { Select } from '../__fixtures__/RFCSelect' + +test('render slots', () => { + const instance = create( + + ) + expect(instance).toMatchInlineSnapshot(` +
    +
    + Selected: +
    +
      +
    • + Foo +
    • +
      +
    • + Bar +
    • +
    +
    +`) + + // insert item + instance.update( + + ) + expect(instance).toMatchInlineSnapshot(` +
    +
    + Selected: +
    +
      +
    • + Foo +
    • +
      +
    • + Baz +
    • +
      +
    • + Bar +
    • +
    +
    +`) + + // remove item + instance.update( + + ) + expect(instance).toMatchInlineSnapshot(` +
    +
    + Selected: +
    +
      +
    • + Foo +
    • +
      +
    • + Bar +
    • +
    +
    +`) + + // update item + instance.update( + + ) + expect(instance).toMatchInlineSnapshot(` +
    +
    + Selected: +
    +
      +
    • + FooFoo +
    • +
      +
    • + Bar +
    • +
    +
    +`) + + // nested slots + instance.update( + + FooFoo + + Bar + + + + Bar + + ) + + expect(instance).toMatchInlineSnapshot(` +
    +
    + Selected: +
    +
      +
    • +
      +
      + Selected: +
      +
        +
      • + FooFoo +
      • +
        +
      • + Bar +
      • +
      +
      +
    • +
      +
    • + Bar +
    • +
    +
    +`) +}) + +test('ref', () => { + const ref = { current: null } + render( + + ) + + expect(ref.current).toMatchInlineSnapshot(` +
  • + Foo +
  • +`) +}) + +test('interaction', () => { + render( + + ) + + fireEvent.click(screen.getAllByRole('listitem')[0]) + expect(screen.getByText('Selected: foo')).not.toBeNull() + + fireEvent.click(screen.getAllByRole('listitem')[1]) + expect(screen.getByText('Selected: bar')).not.toBeNull() +}) + +test('dev warning', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation() + render( + + ) + expect(warn).toHaveBeenCalledTimes(0) + + // @ts-ignore + process.env.NODE_ENV = 'development' + render( + + ) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + 'Unwrapped children found in "HostSlots", either wrap them in slots or remove' + ) +}) diff --git a/src/__tests__/simple.test.tsx b/src/__tests__/simple.test.tsx new file mode 100644 index 0000000..88cfa0c --- /dev/null +++ b/src/__tests__/simple.test.tsx @@ -0,0 +1,254 @@ +import * as React from 'react' + +import { create, render } from '../__fixtures__/utils' +import { Field } from '../__fixtures__/SimpleField' + +test('render slots', () => { + const instance = create( + + Label + + - + Description + + ) + expect(instance).toMatchInlineSnapshot(` +
    + + +
    + + - + + + Description + +
    +
    + `) + + // arbitrary order + instance.update( + + - + + Description + Label + + ) + expect(instance).toMatchInlineSnapshot(` +
    + + +
    + + - + + + Description + +
    +
    + `) + + // dynamic content + instance.update( + + Label + + + ) + + expect(instance).toMatchInlineSnapshot(` +
    + + +
    + `) + + // nested slots + instance.update( + + Label + + + + Label + + + + + ) + + expect(instance).toMatchInlineSnapshot(` +
    + + +
    + +
    + + +
    +
    +
    +
    + `) + // filling slots + instance.update( + + Label + + + Description + Filled Label + + + ) + + expect(instance).toMatchInlineSnapshot(` +
    + + +
    + + Description + +
    +
    + `) + + // unmount filling slots + instance.update( + + Label + + Description + + ) + + expect(instance).toMatchInlineSnapshot(` +
    + + +
    + + Description + +
    +
    + `) +}) + +test('ref', () => { + const ref = { current: null } + render( + + Label + + - + Description + + ) + + expect(ref.current).toMatchInlineSnapshot(` + + `) +}) + +test('dev warning', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation() + render( + + Label + Input + + ) + expect(warn).toHaveBeenCalledTimes(0) + + // @ts-ignore + process.env.NODE_ENV = 'development' + render( + + Label + Input + + ) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + 'Unwrapped children found in "HostSlots", either wrap them in slots or remove' + ) +}) + +test('without host', () => { + const instance = create(Label) + expect(instance).toMatchInlineSnapshot(`null`) + + instance.update(Description) + expect(instance).toMatchInlineSnapshot(` + + Description + + `) +}) diff --git a/src/__tests__/ssr.test.tsx b/src/__tests__/ssr.test.tsx index 3673bd7..0d570d9 100644 --- a/src/__tests__/ssr.test.tsx +++ b/src/__tests__/ssr.test.tsx @@ -8,6 +8,8 @@ import { renderToStaticMarkup } from 'react-dom/server' import { Field } from '../__fixtures__/Field' import { StaticField } from '../__fixtures__/StaticField' import { Select } from '../__fixtures__/Select' +import { Select as RFCSelect } from '../__fixtures__/RFCSelect' +import { Field as SimpleField } from '../__fixtures__/SimpleField' test('default SSR', () => { const markup = renderToStaticMarkup( @@ -91,3 +93,51 @@ test('list SSR', () => { `"
    Selected:
    • Foo

    • Bar
    "` ) }) + +test('rfc SSR', () => { + const markup = renderToStaticMarkup( + + Foo + + Bar + + ) + expect(markup).toMatchInlineSnapshot( + `"
    Selected:
    • Foo

    • Bar
    "` + ) +}) + +test('simple SSR', () => { + const markup = renderToStaticMarkup( + + Label + + + + Label + + + + + ) + + expect(markup).toMatchInlineSnapshot( + `"
    "` + ) + + // arbitrary order + const markup1 = renderToStaticMarkup( + + + + Label + + + + Label + + + ) + + expect(markup1).toEqual(markup) +}) diff --git a/src/index.tsx b/src/index.tsx index cb41c1b..2003f05 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -12,6 +12,7 @@ import { createSlotsContext, getComponentName, hoistStatics } from './utils' import { createSlotsManager } from './SlotsManager' import { DevChildren } from './DevChildren' +/** @deprecated */ const createSlots = >( components: T ) => { @@ -68,3 +69,5 @@ const createSlots = >( } export default createSlots + +export * from './simple' diff --git a/src/list/index.tsx b/src/list/index.tsx index 345834b..9f55dbe 100644 --- a/src/list/index.tsx +++ b/src/list/index.tsx @@ -10,10 +10,11 @@ import React, { import { createSlotsContext, getComponentName, hoistStatics } from '../utils' import { DevChildren } from '../DevChildren' +import { ScanContext, ScanProvider } from '../ScanContext' import { createSlotsManager } from './SlotsManager' -import { ScanContext, ScanProvider } from './ScanContext' export type { GetPropsArgs } from './SlotsManager' +/** @deprecated */ const createSlots = >( components: T ) => { @@ -89,3 +90,5 @@ const createSlots = >( } export default createSlots + +export * from '../rfc' diff --git a/src/rfc/SlotsManager.ts b/src/rfc/SlotsManager.ts new file mode 100644 index 0000000..edf7160 --- /dev/null +++ b/src/rfc/SlotsManager.ts @@ -0,0 +1,31 @@ +import * as React from 'react' + +import { SlotElement } from './utils' + +type Key = React.Key + +export const createSlotsManager = (onChange: (key: Key) => void) => { + const itemMap = new Map() + return { + register(key: Key, element: React.ReactElement) { + itemMap.set(key, element) + }, + update(key: Key, element: React.ReactElement) { + itemMap.set(key, element) + onChange?.(key) + }, + unmount(key: Key) { + itemMap.delete(key) + onChange?.(key) + }, + clear() { + itemMap.clear() + }, + has(key: Key) { + return itemMap.has(key) + }, + get() { + return Array.from(itemMap.values()) as SlotElement[] + }, + } +} diff --git a/src/rfc/index.tsx b/src/rfc/index.tsx new file mode 100644 index 0000000..497cfff --- /dev/null +++ b/src/rfc/index.tsx @@ -0,0 +1,103 @@ +import * as React from 'react' + +import { createSlotsContext, getComponentName } from '../utils' +import { DevChildren } from '../DevChildren' +import { ScanContext, ScanProvider } from '../ScanContext' +import { createSlotsManager } from './SlotsManager' +import { SlotElement } from './utils' + +export * from './utils' + +type Slots = ReturnType +type Callback = (slots: SlotElement[]) => JSX.Element | null + +const SlotsContext = createSlotsContext(undefined) + +const Template = ({ children }: { children: () => ReturnType }) => { + return children() +} + +const createIdGenerator = (prefix: string) => { + let id = 0 + return () => `${prefix}_${id++}` +} + +const genSlotId = createIdGenerator('s') + +export const HostSlots = ({ + children, + callback, +}: { + children: React.ReactNode + callback: Callback +}) => { + const forceUpdate = React.useReducer(() => [], [])[1] + const Slots = React.useMemo( + () => createSlotsManager(forceUpdate), + [forceUpdate] + ) + + return ( + <> + + + {process.env.NODE_ENV === 'development' ? ( + {children} + ) : ( + children + )} + + + + + ) +} + +export const createHost = (children: React.ReactNode, callback: Callback) => { + return +} + +export const createSlot = (Fallback?: T) => { + const genId = createIdGenerator(genSlotId()) + + const Slot = React.forwardRef( + ({ $slot_key$: key, ...props }: any, ref: any) => { + const Slots = React.useContext(SlotsContext) + // istanbul ignore next + if (!Slots) return null + /* eslint-disable react-hooks/rules-of-hooks */ + const Scan = React.useContext(ScanContext) + + const element = + Slots.register(key, element) + React.useEffect(() => { + Slots.has(key) && Slots.update(key, element) + }) + React.useEffect(() => { + Slots.clear() + Scan.rescan() + return () => Slots.unmount(key) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [Slots]) + /* eslint-enable react-hooks/rules-of-hooks */ + + return null + } + ) as unknown as T + + // provide stable key in StrictMode + const ForwardRef = (props: any, ref: any) => { + const Slots = React.useContext(SlotsContext) + if (!Slots) return Fallback ? : null + + // eslint-disable-next-line react-hooks/rules-of-hooks + const [key] = React.useState(genId) + return + } + ForwardRef.displayName = Fallback + ? `Slot(${getComponentName(Fallback)})` + : 'Slot' + const SlotWithKey = React.forwardRef(ForwardRef) as unknown as T + + return SlotWithKey +} diff --git a/src/rfc/utils.ts b/src/rfc/utils.ts new file mode 100644 index 0000000..721d4d4 --- /dev/null +++ b/src/rfc/utils.ts @@ -0,0 +1,55 @@ +import * as React from 'react' + +export type SlotElement = React.ReactElement< + React.ComponentPropsWithRef, + T +> & { + ref: React.ComponentPropsWithRef['ref'] +} + +export const getSlots = ( + slots: React.ReactElement[], + slot: T +) => { + return slots.filter((x) => x.type === slot) as SlotElement[] +} + +export const getSlot = ( + slots: React.ReactElement[], + slot: T +) => { + return slots.find((x) => x.type === slot) as SlotElement | undefined +} + +export const getLastSlot = ( + slots: React.ReactElement[], + slot: T +) => { + for (let i = slots.length - 1; i >= 0; i--) { + if (slots[i].type === slot) { + return slots[i] as SlotElement + } + } + return undefined +} + +export const isSlot = ( + slotElement: SlotElement, + slot: T +): slotElement is SlotElement => { + return slotElement.type === slot +} + +export const getSlotProps = < + T extends SlotElement | undefined, + Props = T extends undefined + ? undefined + : React.ComponentPropsWithRef ? P : never> +>( + slotElement: T +): Props => { + if (!slotElement) return undefined as any + + const { key, ref, props } = slotElement + return { ...props, key, ref } +} diff --git a/src/simple/SlotsManager.ts b/src/simple/SlotsManager.ts new file mode 100644 index 0000000..e6a0ffd --- /dev/null +++ b/src/simple/SlotsManager.ts @@ -0,0 +1,31 @@ +import * as React from 'react' + +type Slot = React.ElementType + +export const createSlotsManager = (onChange: (slot: Slot) => void) => { + const elementMap = new Map() + return { + register(slot: Slot, element: React.ReactElement) { + elementMap.set(slot, element) + }, + update(slot: Slot, element: React.ReactElement) { + elementMap.set(slot, element) + onChange(slot) + }, + unmount(slot: Slot) { + elementMap.delete(slot) + onChange(slot) + }, + get(slot: T) { + return elementMap.get(slot) as + | React.ReactElement, T> + | undefined + }, + getProps(slot: T) { + const element = elementMap.get(slot) + if (!element) return undefined + const { ref, props } = element as any + return (ref ? { ...props, ref } : props) as React.ComponentProps + }, + } +} diff --git a/src/simple/index.tsx b/src/simple/index.tsx new file mode 100644 index 0000000..1d8c57e --- /dev/null +++ b/src/simple/index.tsx @@ -0,0 +1,67 @@ +import * as React from 'react' + +import { createSlotsContext, getComponentName } from '../utils' +import { DevChildren } from '../DevChildren' +import { createSlotsManager } from './SlotsManager' + +type Slots = ReturnType +type Callback = (Slots: Slots) => JSX.Element | null + +const SlotsContext = createSlotsContext(undefined) + +const Template = ({ children }: { children: () => ReturnType }) => { + return children() +} + +export const HostSlots = ({ + children, + callback, +}: { + children: React.ReactNode + callback: Callback +}) => { + const forceUpdate = React.useReducer(() => [], [])[1] + const Slots = React.useMemo( + () => createSlotsManager(forceUpdate), + [forceUpdate] + ) + + return ( + <> + + {process.env.NODE_ENV === 'development' ? ( + {children} + ) : ( + children + )} + + + + ) +} + +export const createHost = (children: React.ReactNode, callback: Callback) => { + return +} + +export const createSlot = (Fallback?: T) => { + const ForwardRef = (props: any, ref: any) => { + const Slots = React.useContext(SlotsContext) + if (!Slots) return Fallback ? : null + + const element = + /* eslint-disable react-hooks/rules-of-hooks */ + React.useState(() => Slots.register(Slot, element)) + React.useEffect(() => Slots.update(Slot, element)) + React.useEffect(() => () => Slots.unmount(Slot), [Slots]) + /* eslint-enable react-hooks/rules-of-hooks */ + + return null + } + ForwardRef.displayName = Fallback + ? `Slot(${getComponentName(Fallback)})` + : 'Slot' + const Slot = React.forwardRef(ForwardRef) as unknown as T + + return Slot +} diff --git a/src/utils.ts b/src/utils.ts index 9ce2a67..bf22f6e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -6,7 +6,8 @@ export const createSlotsContext = (defaultValue: T) => { return context } -export const getComponentName = (Component: React.ComponentType) => { +export const getComponentName = (Component: React.ElementType) => { + if (typeof Component === 'string') return Component // istanbul ignore next return Component.displayName || Component.name || 'Component' }