@diskette/use-render 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,158 +1,189 @@
1
1
  # useRender
2
2
 
3
- A React hook for component libraries that lets consumers swap the rendered element—like rendering a `<a>` instead of a `<button>`—while keeping all your component's behavior intact. It handles the tricky parts: merging refs, combining props, and letting className and style respond to internal state.
4
-
5
- ## Installation
3
+ React hooks for building components with render prop composition and type-safe state-driven styling.
6
4
 
7
5
  ```bash
8
6
  pnpm add @diskette/use-render
9
7
  ```
10
8
 
11
- ## Quick Start
9
+ ## Overview
10
+
11
+ When building component libraries, you often need to let consumers customize rendering—swap the underlying element, access internal state for styling, or compose refs and event handlers. These hooks handle that plumbing:
12
+
13
+ - **Component authors** get prop merging, ref composition, and render prop support out of the box
14
+ - **Consumers** get type-safe APIs where `className`, `style`, and `children` can be functions of component state
15
+
16
+ ## `useRenderSlot` — Stateless Slots
17
+
18
+ For wrapper components that don't expose internal state. Consumers can still swap the element or pass props—they just won't get state callbacks.
12
19
 
13
- Here's a simple button component that consumers can render as any element:
20
+ **Component author:**
14
21
 
15
22
  ```tsx
16
- import { useRender, type ComponentProps } from '@diskette/use-render'
23
+ import { useRenderSlot, SlotProps } from '@diskette/use-render'
17
24
 
18
- type ButtonProps = ComponentProps<'button', { isPressed: boolean }>
25
+ type CardProps = SlotProps<'div'>
19
26
 
20
- function Button(props: ButtonProps) {
21
- const [isPressed, setIsPressed] = useState(false)
22
-
23
- return useRender(
24
- 'button',
25
- { isPressed },
26
- {
27
- baseProps: {
28
- className: 'btn',
29
- onMouseDown: () => setIsPressed(true),
30
- onMouseUp: () => setIsPressed(false),
31
- },
32
- props,
33
- },
34
- )
27
+ function Card(props: CardProps) {
28
+ return useRenderSlot('div', { props, baseProps: { className: 'card' } })
35
29
  }
36
30
  ```
37
31
 
38
- Now consumers can use it normally, or swap the element entirely:
32
+ **Consumer:**
39
33
 
40
34
  ```tsx
41
- // Renders a <button>
42
- <Button className="primary">Click me</Button>
43
-
44
- // Renders an <a> with all the button's behavior
45
- <Button render={<a href="/path" />}>Go somewhere</Button>
35
+ <Card className="card-primary">Content</Card>
36
+ <Card render={<section />}>Content</Card>
37
+ <Card render={(props) => <article {...props} />}>Content</Card>
46
38
  ```
47
39
 
48
- ## Usage
40
+ Props merge automatically—`className` and `style` combine, refs compose, event handlers chain (consumer runs first). The `render` prop receives only `props` since there's no state to pass.
41
+
42
+ ## `useRender` — Stateful Components
49
43
 
50
- ### Overriding the `Element`
44
+ For components that expose internal state to consumers. The state flows through `className`, `style`, `children`, and `render` as callback parameters.
51
45
 
52
- Pass a JSX element to `render` and it will be cloned with your component's props:
46
+ **Component author:**
53
47
 
54
48
  ```tsx
55
- <Button render={<a href="/path" />}>Link styled as button</Button>
49
+ import { useRender, ComponentProps } from '@diskette/use-render'
50
+
51
+ type State = { disabled: boolean; loading: boolean }
52
+ type ButtonProps = ComponentProps<'button', State>
53
+
54
+ function Button(props: ButtonProps) {
55
+ const state: State = { disabled: props.disabled ?? false, loading: false }
56
+ return useRender('button', state, { props })
57
+ }
56
58
  ```
57
59
 
58
- Or pass a function to get full control over rendering, with access to both props and state:
60
+ **Consumer:**
59
61
 
60
62
  ```tsx
61
- <Button
62
- render={(props, state) => (
63
- <a {...props} href="/path" data-pressed={state.isPressed} />
64
- )}
65
- >
66
- Link with state access
67
- </Button>
68
- ```
63
+ // State-driven className
64
+ <Button className={(state) => state.disabled ? 'opacity-50' : undefined} />
69
65
 
70
- ### State-Aware `className`
66
+ // State-driven className with access to base className
67
+ <Button className={(state, base) => `${base} ${state.disabled ? 'opacity-50' : ''}`} />
71
68
 
72
- Pass a function to compute `className` based on component state. The second argument gives you access to the base `className` set by the component:
69
+ // State-driven style
70
+ <Button style={(state) => ({ opacity: state.disabled ? 0.5 : 1 })} />
73
71
 
74
- ```tsx
75
- <Button
76
- className={(state, baseClassName) =>
77
- `${baseClassName} ${state.isPressed ? 'pressed' : ''}`
78
- }
79
- >
80
- Press me
81
- </Button>
82
- ```
72
+ // State-driven style with access to base style
73
+ <Button style={(state, base) => ({ ...base, opacity: state.disabled ? 0.5 : 1 })} />
83
74
 
84
- Or just pass a string. It will be merged with the default `className`:
75
+ // State-driven children
76
+ <Button>{(state) => state.loading ? 'Loading...' : 'Submit'}</Button>
85
77
 
86
- ```tsx
87
- <Button className="primary large">Click me</Button>
78
+ // Render prop with state access
79
+ <Button render={(props, state) => <a {...props} aria-busy={state.loading} />} />
88
80
  ```
89
81
 
90
- ### State-Aware style `(CSSProperties)`
82
+ The `render` callback receives `(props, state)` for full control over both props and rendering.
83
+
84
+ ## `useRenderContainer` — Containers with Items
85
+
86
+ For list-like components with two levels of state: container-level (e.g., item count) and item-level (e.g., index, value). The `className` and `style` callbacks receive container state, while `children` receives item state.
91
87
 
92
- Same pattern works for inline styles:
88
+ **Component author:**
93
89
 
94
90
  ```tsx
95
- <Button
96
- style={(state) => ({
97
- backgroundColor: state.isPressed ? 'darkblue' : 'blue',
98
- transform: state.isPressed ? 'scale(0.98)' : undefined,
99
- })}
100
- >
101
- Press me
102
- </Button>
103
- ```
91
+ import { useRenderContainer, ContainerProps } from '@diskette/use-render'
92
+
93
+ type ContainerState = { count: number }
94
+ type ItemState = { index: number; value: string }
95
+ type ListProps = ContainerProps<'ul', ContainerState, ItemState>
104
96
 
105
- ### Children as Render Function
97
+ function List({ items, ...props }: ListProps & { items: string[] }) {
98
+ const { Container, renderItem, containerProps } = useRenderContainer(
99
+ 'ul',
100
+ { count: items.length },
101
+ { props, baseProps: { children: (item) => <li>{item.value}</li> } },
102
+ )
103
+ // containerProps provides direct access to resolved props if needed
104
+ return <Container>{items.map((v, i) => renderItem({ index: i, value: v }))}</Container>
105
+ }
106
+ ```
106
107
 
107
- Access state by passing a function:
108
+ **Consumer:**
108
109
 
109
110
  ```tsx
110
- <Button>{(state) => (state.isPressed ? 'Pressing...' : 'Click me')}</Button>
111
+ // Container className receives ContainerState
112
+ <List items={data} className={(state) => state.count > 5 ? 'scrollable' : undefined} />
113
+
114
+ // Children function receives ItemState
115
+ <List items={data}>{(item) => <li>{item.index + 1}. {item.value}</li>}</List>
116
+
117
+ // Swap container element
118
+ <List items={data} render={<ol />} />
111
119
  ```
112
120
 
113
- ### `render` vs `children` as Function
121
+ ## Props Types
122
+
123
+ Each hook has a corresponding type for your component's public API:
124
+
125
+ | Hook | Props Type | State |
126
+ |------|-----------|-------|
127
+ | `useRenderSlot` | `SlotProps<T>` | None |
128
+ | `useRender` | `ComponentProps<T, S>` | Single state |
129
+ | `useRenderContainer` | `ContainerProps<T, CS, IS>` | Container + Item |
130
+
131
+ These extend the element's native props, adding `render` and (for stateful hooks) function forms of `className`, `style`, and `children`.
114
132
 
115
- You might wonder why both exist—most libraries pick one or the other. They serve different purposes:
133
+ ## What the Hooks Handle
116
134
 
117
- - **`render`** swaps the element itself. Use it when you need a `<a>` instead of a `<button>`, or want to integrate with a router's `<Link>`.
118
- - **`children` as function** changes what's inside the element. Use it when the content should react to internal state.
135
+ - **Render prop** — swap the element via `render={<a />}` or `render={(props) => ...}`
136
+ - **Ref composition** refs from consumer, base props, and options are merged
137
+ - **Event handler chaining** — consumer handlers run first, then base handlers
138
+ - **className/style merging** — static values combine; functions receive state and the resolved base value as parameters
119
139
 
120
- They compose naturally—you can use both at once:
140
+ ## Ref Composition
141
+
142
+ Component libraries often need internal ref access for focus management, measurements, or imperative APIs—while still letting consumers attach their own refs. The hooks handle this automatically.
143
+
144
+ **Component author:**
121
145
 
122
146
  ```tsx
123
- <Button render={<a href="/path" />}>
124
- {(state) => (state.isPressed ? 'Going...' : 'Go somewhere')}
125
- </Button>
126
- ```
147
+ import { useRef, useImperativeHandle } from 'react'
148
+ import { useRender, ComponentProps } from '@diskette/use-render'
149
+
150
+ type State = { open: boolean }
151
+ type ComboboxProps = ComponentProps<'input', State>
152
+
153
+ export interface ComboboxRef {
154
+ focus: () => void
155
+ clear: () => void
156
+ }
127
157
 
128
- ## API
158
+ function Combobox({ ref, ...props }: ComboboxProps & { ref?: React.Ref<ComboboxRef> }) {
159
+ const inputRef = useRef<HTMLInputElement>(null)
160
+ const state: State = { open: false }
129
161
 
130
- ### `useRender(tag, state, options)`
162
+ // Expose imperative API to consumers
163
+ useImperativeHandle(ref, () => ({
164
+ focus: () => inputRef.current?.focus(),
165
+ clear: () => {
166
+ if (inputRef.current) inputRef.current.value = ''
167
+ },
168
+ }))
131
169
 
132
- ```ts
133
- function useRender<T extends ElementType, S>(
134
- tag: T,
135
- state: S,
136
- options: UseRenderOptions<T, S>,
137
- ): ReactNode
170
+ // Internal ref composes with any ref passed through props
171
+ return useRender('input', state, { props, ref: inputRef })
172
+ }
138
173
  ```
139
174
 
140
- | Parameter | Description |
141
- | ------------------- | ------------------------------------------------------------------ |
142
- | `tag` | Default element type (e.g., `'button'`, `'div'`) |
143
- | `state` | Component state passed to resolvers and render functions |
144
- | `options.baseProps` | Base props applied to the element (your component's defaults) |
145
- | `options.props` | Consumer-provided props (typically forwarded from component props) |
146
- | `options.ref` | Ref(s) to merge with the consumer's ref |
175
+ **Consumer:**
147
176
 
148
- ### `ComponentProps<ElementType, State>`
177
+ ```tsx
178
+ const inputRef = useRef<HTMLInputElement>(null)
179
+ const comboboxRef = useRef<ComboboxRef>(null)
149
180
 
150
- Use this type for your component's public props. It extends `React.ComponentProps<T>` and augments `className`, `style`, and `children` to be state-aware as well as provide the `render` prop:
181
+ // Direct element access
182
+ <Combobox ref={inputRef} />
151
183
 
152
- ```ts
153
- type ButtonProps = ComponentProps<'button', ButtonState>
184
+ // Imperative handle access
185
+ <Combobox ref={comboboxRef} />
186
+ comboboxRef.current?.focus()
154
187
  ```
155
188
 
156
- ## License
157
-
158
- [MIT](./LICENSE) License
189
+ The `options.ref` parameter accepts a single ref or an array of refs. All refs—from `options.ref`, `baseProps.ref`, and consumer `props.ref`—are composed into a single callback ref that updates all sources and handles cleanup.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { useRender } from './use-render.ts';
2
2
  export { useRenderContainer } from './use-render-container.ts';
3
+ export { useRenderSlot } from './use-render-slot.ts';
3
4
  export type { UseRenderOptions } from './use-render.ts';
5
+ export type { SlotProps, SlotRenderer, UseRenderSlotOptions, } from './use-render-slot.ts';
4
6
  export type { ContainerBaseProps, ContainerProps, UseRenderContainerOptions, UseRenderContainerResult, } from './use-render-container.ts';
5
7
  export type { BaseComponentProps, ClassName, ComponentProps, ComponentRenderer, DataAttributes, Style, } from './types.ts';
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { useRender } from "./use-render.js";
2
2
  export { useRenderContainer } from "./use-render-container.js";
3
+ export { useRenderSlot } from "./use-render-slot.js";
package/dist/types.d.ts CHANGED
@@ -4,7 +4,7 @@ export type Style<State> = ((state: State, baseStyle?: CSSProperties) => CSSProp
4
4
  export type ComponentRenderer<S> = (props: HTMLAttributes<any> & {
5
5
  ref?: Ref<any> | undefined;
6
6
  }, state: S) => ReactNode;
7
- export type DataAttributes = Record<`data-${string}`, string | number | boolean>;
7
+ export type DataAttributes = Record<`data-${string}`, string | number | boolean | undefined>;
8
8
  export type BaseComponentProps<T extends ElementType> = Omit<ComponentPropsWithRef<T>, 'children' | 'className' | 'style'>;
9
9
  export type ComponentProps<T extends ElementType, S> = BaseComponentProps<T> & {
10
10
  children?: ReactNode | {
@@ -6,9 +6,12 @@ import { type Ref, type RefCallback } from 'react';
6
6
  * @returns The ref cleanup callback, if any.
7
7
  */
8
8
  export declare function assignRef<T>(ref: Ref<T> | undefined | null, value: T | null): ReturnType<RefCallback<T>>;
9
+ type RefInput<T> = Ref<T> | undefined | (Ref<T> | undefined)[];
9
10
  /**
10
11
  * Composes multiple refs into a single one and memoizes the result to avoid refs execution on each render.
11
- * @param refs List of refs to merge.
12
+ * Accepts individual refs or arrays of refs, which are flattened automatically.
13
+ * @param refs Refs to merge (individual or arrays).
12
14
  * @returns Merged ref.
13
15
  */
14
- export declare function useComposedRef<T>(refs: (Ref<T> | undefined)[]): Ref<T>;
16
+ export declare function useComposedRef<T>(...refs: RefInput<T>[]): Ref<T>;
17
+ export {};
@@ -29,9 +29,11 @@ function mergeRefs(refs) {
29
29
  }
30
30
  /**
31
31
  * Composes multiple refs into a single one and memoizes the result to avoid refs execution on each render.
32
- * @param refs List of refs to merge.
32
+ * Accepts individual refs or arrays of refs, which are flattened automatically.
33
+ * @param refs Refs to merge (individual or arrays).
33
34
  * @returns Merged ref.
34
35
  */
35
- export function useComposedRef(refs) {
36
- return useMemo(() => mergeRefs(refs), refs);
36
+ export function useComposedRef(...refs) {
37
+ const flatRefs = refs.flat();
38
+ return useMemo(() => mergeRefs(flatRefs), flatRefs);
37
39
  }
@@ -40,6 +40,7 @@ import { isFunction, isString, mergeProps, resolveClassName, resolveStyle, } fro
40
40
  * <DateList>{(state) => <CustomDateItem date={state.date} />}</DateList>
41
41
  * ```
42
42
  */
43
+ // TODO: refactor args order, useRenderContainer('div', props, options). `props` is what defines what the interface is for the state
43
44
  export function useRenderContainer(tag, containerState, options = {}) {
44
45
  // Workaround for getting the prop objects to be typed. Should still be ok as the properties we need are common to all elements
45
46
  const baseProps = (options.baseProps ?? {});
@@ -50,10 +51,7 @@ export function useRenderContainer(tag, containerState, options = {}) {
50
51
  const resolvedClassName = resolveClassName(containerState, baseClassName, className);
51
52
  const resolvedStyle = resolveStyle(containerState, baseStyle, style);
52
53
  // Compose refs
53
- const refs = Array.isArray(options.ref)
54
- ? [ref, ...options.ref]
55
- : [ref, options.ref];
56
- const mergedRef = useComposedRef(refs);
54
+ const mergedRef = useComposedRef(ref, options.ref);
57
55
  // Build resolved container props (memoized for stable useCallback reference)
58
56
  const resolvedProps = useMemo(() => {
59
57
  const props = {
@@ -0,0 +1,39 @@
1
+ import type { ComponentPropsWithRef, CSSProperties, ElementType, HTMLAttributes, JSX, ReactNode, Ref } from 'react';
2
+ import type { DataAttributes } from './types.ts';
3
+ export type SlotRenderer = (props: HTMLAttributes<any> & {
4
+ ref?: Ref<any> | undefined;
5
+ }) => ReactNode;
6
+ export type SlotProps<T extends ElementType> = Omit<ComponentPropsWithRef<T>, 'className' | 'style'> & {
7
+ className?: string | undefined;
8
+ style?: CSSProperties | undefined;
9
+ render?: SlotRenderer | JSX.Element;
10
+ };
11
+ export interface UseRenderSlotOptions<T extends ElementType> {
12
+ baseProps?: React.ComponentProps<T> & DataAttributes;
13
+ props?: SlotProps<T> & DataAttributes;
14
+ ref?: Ref<any> | (Ref<any> | undefined)[];
15
+ }
16
+ /**
17
+ * Hook for rendering slot elements with render prop support and prop merging.
18
+ * A simpler version of useRender for stateless components.
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * import { useRenderSlot, SlotProps } from '@diskette/use-render'
23
+ *
24
+ * type CardProps = SlotProps<'div'>
25
+ *
26
+ * function Card(props: CardProps) {
27
+ * return useRenderSlot('div', {
28
+ * props,
29
+ * baseProps: { className: 'card' },
30
+ * })
31
+ * }
32
+ *
33
+ * // Usage:
34
+ * <Card className="card-primary" />
35
+ * <Card render={<section />} />
36
+ * <Card render={(props) => <article {...props} />} />
37
+ * ```
38
+ */
39
+ export declare function useRenderSlot<T extends ElementType>(tag: T, options?: UseRenderSlotOptions<T>): ReactNode;
@@ -0,0 +1,55 @@
1
+ import { cloneElement, createElement, isValidElement } from 'react';
2
+ import { useComposedRef } from "./use-composed-ref.js";
3
+ import { cx, isFunction, isString, mergeProps } from "./utils.js";
4
+ /**
5
+ * Hook for rendering slot elements with render prop support and prop merging.
6
+ * A simpler version of useRender for stateless components.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * import { useRenderSlot, SlotProps } from '@diskette/use-render'
11
+ *
12
+ * type CardProps = SlotProps<'div'>
13
+ *
14
+ * function Card(props: CardProps) {
15
+ * return useRenderSlot('div', {
16
+ * props,
17
+ * baseProps: { className: 'card' },
18
+ * })
19
+ * }
20
+ *
21
+ * // Usage:
22
+ * <Card className="card-primary" />
23
+ * <Card render={<section />} />
24
+ * <Card render={(props) => <article {...props} />} />
25
+ * ```
26
+ */
27
+ export function useRenderSlot(tag, options = {}) {
28
+ const baseProps = options.baseProps ?? {};
29
+ const props = (options.props ?? {});
30
+ const { className: baseClassName, style: baseStyle, children: baseChildren, ...base } = baseProps;
31
+ const { className, style, children, ref, render, ...rest } = props;
32
+ const resolvedClassName = cx(baseClassName, className);
33
+ const resolvedStyle = baseStyle || style ? { ...baseStyle, ...style } : undefined;
34
+ const mergedRef = useComposedRef(ref, options.ref);
35
+ const resolvedProps = {
36
+ ...mergeProps(base, rest),
37
+ ref: mergedRef,
38
+ };
39
+ if (isString(resolvedClassName)) {
40
+ resolvedProps.className = resolvedClassName;
41
+ }
42
+ if (typeof resolvedStyle === 'object') {
43
+ resolvedProps.style = resolvedStyle;
44
+ }
45
+ const resolvedChildren = children ?? baseChildren;
46
+ // For `<Component render={<a />} />`
47
+ if (isValidElement(render)) {
48
+ return cloneElement(render, resolvedProps, resolvedChildren);
49
+ }
50
+ // For `<Component render={(props) => <a {...props} />)} />`
51
+ if (isFunction(render)) {
52
+ return render({ ...resolvedProps, children: resolvedChildren });
53
+ }
54
+ return createElement(tag, resolvedProps, resolvedChildren);
55
+ }
@@ -29,10 +29,7 @@ export function useRender(tag, state, options = {}) {
29
29
  const { className, style, children, ref, render, ...rest } = props ?? {};
30
30
  const resolvedClassName = resolveClassName(state, baseClassName, className);
31
31
  const resolvedStyle = resolveStyle(state, baseStyle, style);
32
- const refs = Array.isArray(options.ref)
33
- ? [ref, ...options.ref]
34
- : [ref, options.ref];
35
- const mergedRef = useComposedRef(refs);
32
+ const mergedRef = useComposedRef(ref, options.ref);
36
33
  // Another workaround for getting component props typed
37
34
  const resolvedProps = {
38
35
  ...mergeProps(base, rest),
@@ -51,10 +48,7 @@ export function useRender(tag, state, options = {}) {
51
48
  }
52
49
  // For `<Component render={(props) => <a {...props} />)} />`
53
50
  if (isFunction(render)) {
54
- return render({
55
- ...resolvedProps,
56
- children: resolvedChildren,
57
- }, state);
51
+ return render({ ...resolvedProps, children: resolvedChildren }, state);
58
52
  }
59
53
  return createElement(tag, resolvedProps, resolvedChildren ?? baseChildren);
60
54
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@diskette/use-render",
3
3
  "type": "module",
4
- "version": "0.7.0",
4
+ "version": "0.9.0",
5
5
  "exports": "./dist/index.js",
6
6
  "files": [
7
7
  "dist"
@@ -21,18 +21,18 @@
21
21
  }
22
22
  },
23
23
  "devDependencies": {
24
- "@changesets/cli": "^2.29.7",
25
- "@types/react": "^19.2.6",
24
+ "@changesets/cli": "^2.29.8",
25
+ "@types/react": "^19.2.7",
26
26
  "@types/react-dom": "^19.2.3",
27
- "@vitejs/plugin-react": "^5.1.1",
28
- "@vitest/browser-playwright": "^4.0.13",
29
- "oxlint": "^1.29.0",
30
- "oxlint-tsgolint": "^0.8.1",
31
- "prettier": "^3.6.2",
32
- "react": "^19.2.0",
33
- "react-dom": "^19.2.0",
27
+ "@vitejs/plugin-react": "^5.1.2",
28
+ "@vitest/browser-playwright": "^4.0.15",
29
+ "oxlint": "^1.32.0",
30
+ "oxlint-tsgolint": "^0.8.6",
31
+ "prettier": "^3.7.4",
32
+ "react": "^19.2.3",
33
+ "react-dom": "^19.2.3",
34
34
  "typescript": "^5.9.3",
35
- "vitest": "^4.0.13",
35
+ "vitest": "^4.0.15",
36
36
  "vitest-browser-react": "^2.0.2"
37
37
  },
38
38
  "description": "_description_",