diff --git a/example/components/RFCItem.tsx b/example/components/RFCItem.tsx new file mode 100644 index 0000000..f685d2b --- /dev/null +++ b/example/components/RFCItem.tsx @@ -0,0 +1,37 @@ +import React, { useId } from 'react' +import { createHost, createSlot } from 'create-slots/rfc' + +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 = slots.find((slot) => slot.type === ItemTitle) + const descriptionSlot = slots.find((slot) => slot.type === 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..01c96ef --- /dev/null +++ b/example/components/RFCSelect.tsx @@ -0,0 +1,59 @@ +import React, { useState } from 'react' +import { createHost, createSlot } from 'create-slots/rfc' + +import { Item } from './RFCItem' + +const SelectItem = createSlot() +const SelectDivider = createSlot<'hr'>() + +export const Select = (props: React.ComponentProps<'ul'>) => { + const [selected, setSelected] = useState(null) + + return ( +
    +
    Selected: {selected}
    + {createHost(props.children, (slots) => { + let index = 0 + return ( +
      + {slots.map((slot) => { + if (slot.type === SelectItem) { + const itemProps = slot.props + + return ( + { + setSelected(itemProps.value) + }, + onKeyDown: ( + event: React.KeyboardEvent + ) => { + if (event.key === 'Enter' || event.key === ' ') { + setSelected(itemProps.value) + } + }, + }} + /> + ) + } + return
      + })} +
    + ) + })} +
    + ) +} + +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..b6ff1d3 --- /dev/null +++ b/example/components/SimpleField.tsx @@ -0,0 +1,60 @@ +import React, { useId, useState } from 'react' +import { createHost, createSlot } from 'create-slots/simple' + +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/package.json b/package.json index 2458652..0ba7eb3 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,26 @@ "import": "./dist/static/index.mjs", "require": "./dist/static/index.js" } + }, + "./simple": { + "development": { + "import": "./dev/simple/index.mjs", + "require": "./dev/simple/index.js" + }, + "default": { + "import": "./dist/simple/index.mjs", + "require": "./dist/simple/index.js" + } + }, + "./rfc": { + "development": { + "import": "./dev/rfc/index.mjs", + "require": "./dev/rfc/index.js" + }, + "default": { + "import": "./dist/rfc/index.mjs", + "require": "./dist/rfc/index.js" + } } }, "sideEffects": false, @@ -60,7 +80,9 @@ "entry": [ "src/index.tsx", "src/list/index.tsx", - "src/static/index.tsx" + "src/static/index.tsx", + "src/simple/index.tsx", + "src/rfc/index.tsx" ], "format": [ "esm", diff --git a/rfc/package.json b/rfc/package.json new file mode 100644 index 0000000..e4f698a --- /dev/null +++ b/rfc/package.json @@ -0,0 +1,15 @@ +{ + "main": "../dist/rfc/index.js", + "module": "../dist/rfc/index.mjs", + "types": "../dist/rfc/index.d.ts", + "exports": { + "development": { + "import": "./../dev/rfc/index.mjs", + "require": "./../dev/rfc/index.js" + }, + "default": { + "import": "./../dist/rfc/index.mjs", + "require": "./../dist/rfc/index.js" + } + } +} diff --git a/simple/package.json b/simple/package.json new file mode 100644 index 0000000..d4321ee --- /dev/null +++ b/simple/package.json @@ -0,0 +1,15 @@ +{ + "main": "../dist/simple/index.js", + "module": "../dist/simple/index.mjs", + "types": "../dist/simple/index.d.ts", + "exports": { + "development": { + "import": "./../dev/simple/index.mjs", + "require": "./../dev/simple/index.js" + }, + "default": { + "import": "./../dist/simple/index.mjs", + "require": "./../dist/simple/index.js" + } + } +} 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/list/index.tsx b/src/list/index.tsx index 345834b..7fa1e4c 100644 --- a/src/list/index.tsx +++ b/src/list/index.tsx @@ -10,8 +10,8 @@ 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' const createSlots = >( diff --git a/src/rfc/SlotsManager.ts b/src/rfc/SlotsManager.ts new file mode 100644 index 0000000..5bf0bff --- /dev/null +++ b/src/rfc/SlotsManager.ts @@ -0,0 +1,30 @@ +import * as React from 'react' + +type Key = React.Key +type Slot = React.ElementType + +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() + }, + getItems() { + return Array.from(itemMap.values()) + }, + has(key: Key) { + return itemMap.has(key) + }, + } +} diff --git a/src/rfc/index.tsx b/src/rfc/index.tsx new file mode 100644 index 0000000..a014d0d --- /dev/null +++ b/src/rfc/index.tsx @@ -0,0 +1,78 @@ +import * as React from 'react' + +import { createSlotsContext } from '../utils' +import { ScanContext, ScanProvider } from '../ScanContext' +import { createSlotsManager } from './SlotsManager' + +type Slots = ReturnType +type Callback = (Slots: React.ReactElement[]) => JSX.Element | null + +const SlotsContext = createSlotsContext(undefined) + +const Caller = ({ children }: { children: () => ReturnType }) => { + return children() +} + +let _id = 0 +const getId = () => { + return `$c${_id++}s$` +} + +export const SlotsHost = ({ + children, + callback, +}: { + children: React.ReactNode + callback: Callback +}) => { + const forceUpdate = React.useReducer(() => [], [])[1] + const Slots = React.useMemo( + () => createSlotsManager(forceUpdate), + [forceUpdate] + ) + + return ( + <> + + {children} + + {() => callback(Slots.getItems())} + + ) +} + +export const createHost = (children: React.ReactNode, callback: Callback) => { + return +} + +export const createSlot = () => { + const Slot = React.forwardRef( + ({ $slot_key$: key, ...props }: any, ref: any) => { + const Slots = React.useContext(SlotsContext) + if (!Slots) return null + 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]) + + return null + } + ) as unknown as T + + // provide stable key in StrictMode + const SlotWithKey = React.forwardRef((props: any, ref) => { + const [key] = React.useState(getId) + return + }) as unknown as T + + return SlotWithKey +} 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..eedfe51 --- /dev/null +++ b/src/simple/index.tsx @@ -0,0 +1,59 @@ +import * as React from 'react' + +import { createSlotsContext, getComponentName } from '../utils' +import { createSlotsManager } from './SlotsManager' + +type Slots = ReturnType +type Callback = (Slots: Slots) => JSX.Element | null + +const SlotsContext = createSlotsContext(undefined) + +const Caller = ({ children }: { children: () => ReturnType }) => { + return children() +} + +export const SlotsHost = ({ + children, + callback, +}: { + children: React.ReactNode + callback: Callback +}) => { + const forceUpdate = React.useReducer(() => [], [])[1] + const Slots = React.useMemo( + () => createSlotsManager(forceUpdate), + [forceUpdate] + ) + + return ( + <> + {children} + {() => callback(Slots)} + + ) +} + +export const createHost = (children: React.ReactNode, callback: Callback) => { + return +} + +export const createSlot = ( + Target: T, + fallback?: boolean +) => { + const ForwardRef = (props: any, ref: any) => { + const Slots = React.useContext(SlotsContext) + if (!Slots) return fallback ? : null + + const element = + React.useState(() => Slots.register(Slot, element)) + React.useEffect(() => Slots.update(Slot, element)) + React.useEffect(() => () => Slots.unmount(Slot), [Slots]) + + return null + } + ForwardRef.displayName = `Slot[${getComponentName(Target)}]` + 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' }