@diskette/use-render 0.8.0 → 0.10.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 +88 -24
- package/dist/fns/index.d.ts +2 -0
- package/dist/fns/index.js +1 -0
- package/dist/fns/render-slot.d.ts +36 -0
- package/dist/fns/render-slot.js +52 -0
- package/dist/types.d.ts +1 -1
- package/package.json +15 -12
package/README.md
CHANGED
|
@@ -8,11 +8,35 @@ pnpm add @diskette/use-render
|
|
|
8
8
|
|
|
9
9
|
## Overview
|
|
10
10
|
|
|
11
|
-
When building component libraries, you often need to let consumers customize rendering
|
|
11
|
+
When building component libraries, you often need to let consumers customize rendering to:
|
|
12
|
+
|
|
13
|
+
- swap the underlying element
|
|
14
|
+
- access internal state for styling
|
|
15
|
+
- compose refs and event handlers.
|
|
16
|
+
|
|
17
|
+
These hooks handle that plumbing:
|
|
12
18
|
|
|
13
19
|
- **Component authors** get prop merging, ref composition, and render prop support out of the box
|
|
14
20
|
- **Consumers** get type-safe APIs where `className`, `style`, and `children` can be functions of component state
|
|
15
21
|
|
|
22
|
+
## `useRender` — Stateful Components
|
|
23
|
+
|
|
24
|
+
For components that expose internal state to consumers. The state flows through `className`, `style`, `children`, and `render` as callback parameters.
|
|
25
|
+
|
|
26
|
+
**Component author:**
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
import { useRender, ComponentProps } from '@diskette/use-render'
|
|
30
|
+
|
|
31
|
+
type State = { disabled: boolean; loading: boolean }
|
|
32
|
+
type ButtonProps = ComponentProps<'button', State>
|
|
33
|
+
|
|
34
|
+
function Button(props: ButtonProps) {
|
|
35
|
+
const state: State = { disabled: props.disabled ?? false, loading: false }
|
|
36
|
+
return useRender('button', state, { props })
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
16
40
|
## `useRenderSlot` — Stateless Slots
|
|
17
41
|
|
|
18
42
|
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.
|
|
@@ -39,24 +63,6 @@ function Card(props: CardProps) {
|
|
|
39
63
|
|
|
40
64
|
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
65
|
|
|
42
|
-
## `useRender` — Stateful Components
|
|
43
|
-
|
|
44
|
-
For components that expose internal state to consumers. The state flows through `className`, `style`, `children`, and `render` as callback parameters.
|
|
45
|
-
|
|
46
|
-
**Component author:**
|
|
47
|
-
|
|
48
|
-
```tsx
|
|
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
|
-
}
|
|
58
|
-
```
|
|
59
|
-
|
|
60
66
|
**Consumer:**
|
|
61
67
|
|
|
62
68
|
```tsx
|
|
@@ -101,7 +107,11 @@ function List({ items, ...props }: ListProps & { items: string[] }) {
|
|
|
101
107
|
{ props, baseProps: { children: (item) => <li>{item.value}</li> } },
|
|
102
108
|
)
|
|
103
109
|
// containerProps provides direct access to resolved props if needed
|
|
104
|
-
return
|
|
110
|
+
return (
|
|
111
|
+
<Container>
|
|
112
|
+
{items.map((v, i) => renderItem({ index: i, value: v }))}
|
|
113
|
+
</Container>
|
|
114
|
+
)
|
|
105
115
|
}
|
|
106
116
|
```
|
|
107
117
|
|
|
@@ -122,10 +132,10 @@ function List({ items, ...props }: ListProps & { items: string[] }) {
|
|
|
122
132
|
|
|
123
133
|
Each hook has a corresponding type for your component's public API:
|
|
124
134
|
|
|
125
|
-
| Hook
|
|
126
|
-
|
|
127
|
-
| `useRenderSlot`
|
|
128
|
-
| `useRender`
|
|
135
|
+
| Hook | Props Type | State |
|
|
136
|
+
| -------------------- | --------------------------- | ---------------- |
|
|
137
|
+
| `useRenderSlot` | `SlotProps<T>` | None |
|
|
138
|
+
| `useRender` | `ComponentProps<T, S>` | Single state |
|
|
129
139
|
| `useRenderContainer` | `ContainerProps<T, CS, IS>` | Container + Item |
|
|
130
140
|
|
|
131
141
|
These extend the element's native props, adding `render` and (for stateful hooks) function forms of `className`, `style`, and `children`.
|
|
@@ -136,3 +146,57 @@ These extend the element's native props, adding `render` and (for stateful hooks
|
|
|
136
146
|
- **Ref composition** — refs from consumer, base props, and options are merged
|
|
137
147
|
- **Event handler chaining** — consumer handlers run first, then base handlers
|
|
138
148
|
- **className/style merging** — static values combine; functions receive state and the resolved base value as parameters
|
|
149
|
+
|
|
150
|
+
## Ref Composition
|
|
151
|
+
|
|
152
|
+
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.
|
|
153
|
+
|
|
154
|
+
**Component author:**
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
import { useRef, useImperativeHandle } from 'react'
|
|
158
|
+
import { useRender, ComponentProps } from '@diskette/use-render'
|
|
159
|
+
|
|
160
|
+
type State = { open: boolean }
|
|
161
|
+
type ComboboxProps = ComponentProps<'input', State>
|
|
162
|
+
|
|
163
|
+
export interface ComboboxRef {
|
|
164
|
+
focus: () => void
|
|
165
|
+
clear: () => void
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function Combobox({
|
|
169
|
+
ref,
|
|
170
|
+
...props
|
|
171
|
+
}: ComboboxProps & { ref?: React.Ref<ComboboxRef> }) {
|
|
172
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
173
|
+
const state: State = { open: false }
|
|
174
|
+
|
|
175
|
+
// Expose imperative API to consumers
|
|
176
|
+
useImperativeHandle(ref, () => ({
|
|
177
|
+
focus: () => inputRef.current?.focus(),
|
|
178
|
+
clear: () => {
|
|
179
|
+
if (inputRef.current) inputRef.current.value = ''
|
|
180
|
+
},
|
|
181
|
+
}))
|
|
182
|
+
|
|
183
|
+
// Internal ref composes with any ref passed through props
|
|
184
|
+
return useRender('input', state, { props, ref: inputRef })
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Consumer:**
|
|
189
|
+
|
|
190
|
+
```tsx
|
|
191
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
192
|
+
const comboboxRef = useRef<ComboboxRef>(null)
|
|
193
|
+
|
|
194
|
+
// Direct element access
|
|
195
|
+
<Combobox ref={inputRef} />
|
|
196
|
+
|
|
197
|
+
// Imperative handle access
|
|
198
|
+
<Combobox ref={comboboxRef} />
|
|
199
|
+
comboboxRef.current?.focus()
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderSlot } from "./render-slot.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { CSSProperties, ElementType, HTMLAttributes, JSX, ReactNode } from 'react';
|
|
2
|
+
import type { DataAttributes } from '../types.ts';
|
|
3
|
+
export type SlotRenderer = (props: HTMLAttributes<any>) => ReactNode;
|
|
4
|
+
export type SlotProps<T extends ElementType> = Omit<React.ComponentProps<T>, 'className' | 'style' | 'ref'> & {
|
|
5
|
+
className?: string | undefined;
|
|
6
|
+
style?: CSSProperties | undefined;
|
|
7
|
+
render?: SlotRenderer | JSX.Element;
|
|
8
|
+
};
|
|
9
|
+
export interface RenderSlotOptions<T extends ElementType> {
|
|
10
|
+
baseProps?: Omit<React.ComponentProps<T>, 'ref'> & DataAttributes;
|
|
11
|
+
props?: SlotProps<T> & DataAttributes;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Pure function for rendering slot elements with render prop support and prop merging.
|
|
15
|
+
* RSC-compatible version of useRenderSlot without ref handling.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* import { renderSlot, SlotProps } from '@diskette/use-render/fns'
|
|
20
|
+
*
|
|
21
|
+
* type CardProps = SlotProps<'div'>
|
|
22
|
+
*
|
|
23
|
+
* function Card(props: CardProps) {
|
|
24
|
+
* return renderSlot('div', {
|
|
25
|
+
* props,
|
|
26
|
+
* baseProps: { className: 'card' },
|
|
27
|
+
* })
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* // Usage:
|
|
31
|
+
* <Card className="card-primary" />
|
|
32
|
+
* <Card render={<section />} />
|
|
33
|
+
* <Card render={(props) => <article {...props} />} />
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export declare function renderSlot<T extends ElementType>(tag: T, options?: RenderSlotOptions<T>): ReactNode;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { cloneElement, createElement, isValidElement } from 'react';
|
|
2
|
+
import { cx, isFunction, isString, mergeProps } from "../utils.js";
|
|
3
|
+
/**
|
|
4
|
+
* Pure function for rendering slot elements with render prop support and prop merging.
|
|
5
|
+
* RSC-compatible version of useRenderSlot without ref handling.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* import { renderSlot, SlotProps } from '@diskette/use-render/fns'
|
|
10
|
+
*
|
|
11
|
+
* type CardProps = SlotProps<'div'>
|
|
12
|
+
*
|
|
13
|
+
* function Card(props: CardProps) {
|
|
14
|
+
* return renderSlot('div', {
|
|
15
|
+
* props,
|
|
16
|
+
* baseProps: { className: 'card' },
|
|
17
|
+
* })
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* // Usage:
|
|
21
|
+
* <Card className="card-primary" />
|
|
22
|
+
* <Card render={<section />} />
|
|
23
|
+
* <Card render={(props) => <article {...props} />} />
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function renderSlot(tag, options = {}) {
|
|
27
|
+
const baseProps = (options.baseProps ?? {});
|
|
28
|
+
const props = (options.props ?? {});
|
|
29
|
+
const { className: baseClassName, style: baseStyle, children: baseChildren, ...base } = baseProps;
|
|
30
|
+
const { className, style, children, render, ...rest } = props;
|
|
31
|
+
const resolvedClassName = cx(baseClassName, className);
|
|
32
|
+
const resolvedStyle = baseStyle || style ? { ...baseStyle, ...style } : undefined;
|
|
33
|
+
const resolvedProps = {
|
|
34
|
+
...mergeProps(base, rest),
|
|
35
|
+
};
|
|
36
|
+
if (isString(resolvedClassName)) {
|
|
37
|
+
resolvedProps.className = resolvedClassName;
|
|
38
|
+
}
|
|
39
|
+
if (typeof resolvedStyle === 'object') {
|
|
40
|
+
resolvedProps.style = resolvedStyle;
|
|
41
|
+
}
|
|
42
|
+
const resolvedChildren = children ?? baseChildren;
|
|
43
|
+
// For `<Component render={<a />} />`
|
|
44
|
+
if (isValidElement(render)) {
|
|
45
|
+
return cloneElement(render, resolvedProps, resolvedChildren);
|
|
46
|
+
}
|
|
47
|
+
// For `<Component render={(props) => <a {...props} />)} />`
|
|
48
|
+
if (isFunction(render)) {
|
|
49
|
+
return render({ ...resolvedProps, children: resolvedChildren });
|
|
50
|
+
}
|
|
51
|
+
return createElement(tag, resolvedProps, resolvedChildren);
|
|
52
|
+
}
|
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 | {
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diskette/use-render",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"exports":
|
|
4
|
+
"version": "0.10.0",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./dist/index.js",
|
|
7
|
+
"./fns": "./dist/fns/index.js"
|
|
8
|
+
},
|
|
6
9
|
"files": [
|
|
7
10
|
"dist"
|
|
8
11
|
],
|
|
@@ -21,18 +24,18 @@
|
|
|
21
24
|
}
|
|
22
25
|
},
|
|
23
26
|
"devDependencies": {
|
|
24
|
-
"@changesets/cli": "^2.29.
|
|
25
|
-
"@types/react": "^19.2.
|
|
27
|
+
"@changesets/cli": "^2.29.8",
|
|
28
|
+
"@types/react": "^19.2.7",
|
|
26
29
|
"@types/react-dom": "^19.2.3",
|
|
27
|
-
"@vitejs/plugin-react": "^5.1.
|
|
28
|
-
"@vitest/browser-playwright": "^4.0.
|
|
29
|
-
"oxlint": "^1.
|
|
30
|
-
"oxlint-tsgolint": "^0.8.
|
|
31
|
-
"prettier": "^3.
|
|
32
|
-
"react": "^19.2.
|
|
33
|
-
"react-dom": "^19.2.
|
|
30
|
+
"@vitejs/plugin-react": "^5.1.2",
|
|
31
|
+
"@vitest/browser-playwright": "^4.0.15",
|
|
32
|
+
"oxlint": "^1.32.0",
|
|
33
|
+
"oxlint-tsgolint": "^0.8.6",
|
|
34
|
+
"prettier": "^3.7.4",
|
|
35
|
+
"react": "^19.2.3",
|
|
36
|
+
"react-dom": "^19.2.3",
|
|
34
37
|
"typescript": "^5.9.3",
|
|
35
|
-
"vitest": "^4.0.
|
|
38
|
+
"vitest": "^4.0.15",
|
|
36
39
|
"vitest-browser-react": "^2.0.2"
|
|
37
40
|
},
|
|
38
41
|
"description": "_description_",
|