@diskette/use-render 0.6.1 → 0.8.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 +85 -105
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/use-composed-ref.d.ts +5 -2
- package/dist/use-composed-ref.js +5 -3
- package/dist/use-render-container.d.ts +81 -0
- package/dist/use-render-container.js +98 -0
- package/dist/use-render-slot.d.ts +39 -0
- package/dist/use-render-slot.js +55 -0
- package/dist/use-render.js +2 -8
- package/package.json +1 -1
- package/dist/use-render.test.d.ts +0 -1
- package/dist/use-render.test.jsx +0 -241
package/README.md
CHANGED
|
@@ -1,158 +1,138 @@
|
|
|
1
1
|
# useRender
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
##
|
|
9
|
+
## Overview
|
|
12
10
|
|
|
13
|
-
|
|
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:
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
17
15
|
|
|
18
|
-
|
|
16
|
+
## `useRenderSlot` — Stateless Slots
|
|
19
17
|
|
|
20
|
-
|
|
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
|
-
)
|
|
35
|
-
}
|
|
36
|
-
```
|
|
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.
|
|
37
19
|
|
|
38
|
-
|
|
20
|
+
**Component author:**
|
|
39
21
|
|
|
40
22
|
```tsx
|
|
41
|
-
|
|
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>
|
|
46
|
-
```
|
|
23
|
+
import { useRenderSlot, SlotProps } from '@diskette/use-render'
|
|
47
24
|
|
|
48
|
-
|
|
25
|
+
type CardProps = SlotProps<'div'>
|
|
49
26
|
|
|
50
|
-
|
|
27
|
+
function Card(props: CardProps) {
|
|
28
|
+
return useRenderSlot('div', { props, baseProps: { className: 'card' } })
|
|
29
|
+
}
|
|
30
|
+
```
|
|
51
31
|
|
|
52
|
-
|
|
32
|
+
**Consumer:**
|
|
53
33
|
|
|
54
34
|
```tsx
|
|
55
|
-
<
|
|
35
|
+
<Card className="card-primary">Content</Card>
|
|
36
|
+
<Card render={<section />}>Content</Card>
|
|
37
|
+
<Card render={(props) => <article {...props} />}>Content</Card>
|
|
56
38
|
```
|
|
57
39
|
|
|
58
|
-
|
|
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.
|
|
59
41
|
|
|
60
|
-
|
|
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
|
-
```
|
|
42
|
+
## `useRender` — Stateful Components
|
|
69
43
|
|
|
70
|
-
|
|
44
|
+
For components that expose internal state to consumers. The state flows through `className`, `style`, `children`, and `render` as callback parameters.
|
|
71
45
|
|
|
72
|
-
|
|
46
|
+
**Component author:**
|
|
73
47
|
|
|
74
48
|
```tsx
|
|
75
|
-
|
|
76
|
-
className={(state, baseClassName) =>
|
|
77
|
-
`${baseClassName} ${state.isPressed ? 'pressed' : ''}`
|
|
78
|
-
}
|
|
79
|
-
>
|
|
80
|
-
Press me
|
|
81
|
-
</Button>
|
|
82
|
-
```
|
|
49
|
+
import { useRender, ComponentProps } from '@diskette/use-render'
|
|
83
50
|
|
|
84
|
-
|
|
51
|
+
type State = { disabled: boolean; loading: boolean }
|
|
52
|
+
type ButtonProps = ComponentProps<'button', State>
|
|
85
53
|
|
|
86
|
-
|
|
87
|
-
|
|
54
|
+
function Button(props: ButtonProps) {
|
|
55
|
+
const state: State = { disabled: props.disabled ?? false, loading: false }
|
|
56
|
+
return useRender('button', state, { props })
|
|
57
|
+
}
|
|
88
58
|
```
|
|
89
59
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
Same pattern works for inline styles:
|
|
60
|
+
**Consumer:**
|
|
93
61
|
|
|
94
62
|
```tsx
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
backgroundColor: state.isPressed ? 'darkblue' : 'blue',
|
|
98
|
-
transform: state.isPressed ? 'scale(0.98)' : undefined,
|
|
99
|
-
})}
|
|
100
|
-
>
|
|
101
|
-
Press me
|
|
102
|
-
</Button>
|
|
103
|
-
```
|
|
63
|
+
// State-driven className
|
|
64
|
+
<Button className={(state) => state.disabled ? 'opacity-50' : undefined} />
|
|
104
65
|
|
|
105
|
-
|
|
66
|
+
// State-driven className with access to base className
|
|
67
|
+
<Button className={(state, base) => `${base} ${state.disabled ? 'opacity-50' : ''}`} />
|
|
106
68
|
|
|
107
|
-
|
|
69
|
+
// State-driven style
|
|
70
|
+
<Button style={(state) => ({ opacity: state.disabled ? 0.5 : 1 })} />
|
|
108
71
|
|
|
109
|
-
|
|
110
|
-
<Button
|
|
72
|
+
// State-driven style with access to base style
|
|
73
|
+
<Button style={(state, base) => ({ ...base, opacity: state.disabled ? 0.5 : 1 })} />
|
|
74
|
+
|
|
75
|
+
// State-driven children
|
|
76
|
+
<Button>{(state) => state.loading ? 'Loading...' : 'Submit'}</Button>
|
|
77
|
+
|
|
78
|
+
// Render prop with state access
|
|
79
|
+
<Button render={(props, state) => <a {...props} aria-busy={state.loading} />} />
|
|
111
80
|
```
|
|
112
81
|
|
|
113
|
-
|
|
82
|
+
The `render` callback receives `(props, state)` for full control over both props and rendering.
|
|
114
83
|
|
|
115
|
-
|
|
84
|
+
## `useRenderContainer` — Containers with Items
|
|
116
85
|
|
|
117
|
-
-
|
|
118
|
-
- **`children` as function** changes what's inside the element. Use it when the content should react to internal state.
|
|
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.
|
|
119
87
|
|
|
120
|
-
|
|
88
|
+
**Component author:**
|
|
121
89
|
|
|
122
90
|
```tsx
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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>
|
|
96
|
+
|
|
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
|
+
}
|
|
126
106
|
```
|
|
127
107
|
|
|
128
|
-
|
|
108
|
+
**Consumer:**
|
|
129
109
|
|
|
130
|
-
|
|
110
|
+
```tsx
|
|
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>
|
|
131
116
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
tag: T,
|
|
135
|
-
state: S,
|
|
136
|
-
options: UseRenderOptions<T, S>,
|
|
137
|
-
): ReactNode
|
|
117
|
+
// Swap container element
|
|
118
|
+
<List items={data} render={<ol />} />
|
|
138
119
|
```
|
|
139
120
|
|
|
140
|
-
|
|
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 |
|
|
121
|
+
## Props Types
|
|
147
122
|
|
|
148
|
-
|
|
123
|
+
Each hook has a corresponding type for your component's public API:
|
|
149
124
|
|
|
150
|
-
|
|
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 |
|
|
151
130
|
|
|
152
|
-
|
|
153
|
-
type ButtonProps = ComponentProps<'button', ButtonState>
|
|
154
|
-
```
|
|
131
|
+
These extend the element's native props, adding `render` and (for stateful hooks) function forms of `className`, `style`, and `children`.
|
|
155
132
|
|
|
156
|
-
##
|
|
133
|
+
## What the Hooks Handle
|
|
157
134
|
|
|
158
|
-
|
|
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
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export { useRender } from './use-render.ts';
|
|
2
|
+
export { useRenderContainer } from './use-render-container.ts';
|
|
3
|
+
export { useRenderSlot } from './use-render-slot.ts';
|
|
2
4
|
export type { UseRenderOptions } from './use-render.ts';
|
|
5
|
+
export type { SlotProps, SlotRenderer, UseRenderSlotOptions, } from './use-render-slot.ts';
|
|
6
|
+
export type { ContainerBaseProps, ContainerProps, UseRenderContainerOptions, UseRenderContainerResult, } from './use-render-container.ts';
|
|
3
7
|
export type { BaseComponentProps, ClassName, ComponentProps, ComponentRenderer, DataAttributes, Style, } from './types.ts';
|
package/dist/index.js
CHANGED
|
@@ -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
|
-
*
|
|
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:
|
|
16
|
+
export declare function useComposedRef<T>(...refs: RefInput<T>[]): Ref<T>;
|
|
17
|
+
export {};
|
package/dist/use-composed-ref.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
36
|
+
export function useComposedRef(...refs) {
|
|
37
|
+
const flatRefs = refs.flat();
|
|
38
|
+
return useMemo(() => mergeRefs(flatRefs), flatRefs);
|
|
37
39
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { CSSProperties, ElementType, JSX, ReactNode, Ref } from 'react';
|
|
2
|
+
import type { BaseComponentProps, ClassName, ComponentRenderer, DataAttributes, Style } from './types.ts';
|
|
3
|
+
type Children<S> = ((state: S) => ReactNode) | ReactNode;
|
|
4
|
+
/**
|
|
5
|
+
* Props for the container element.
|
|
6
|
+
* - className/style resolve with ContainerState
|
|
7
|
+
* - children resolves with ItemState (per-item rendering)
|
|
8
|
+
* - render prop swaps the container element
|
|
9
|
+
*/
|
|
10
|
+
export type ContainerProps<T extends ElementType, ContainerState, ItemState = ContainerState> = BaseComponentProps<T> & {
|
|
11
|
+
className?: ClassName<ContainerState> | undefined;
|
|
12
|
+
style?: Style<ContainerState> | undefined;
|
|
13
|
+
children?: Children<ItemState> | undefined;
|
|
14
|
+
render?: ComponentRenderer<ContainerState> | JSX.Element;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Base props for the container with defaults.
|
|
18
|
+
* - className/style are static (no state resolution)
|
|
19
|
+
* - children can be a function of ItemState for default item rendering
|
|
20
|
+
*/
|
|
21
|
+
export type ContainerBaseProps<T extends ElementType, ItemState> = Omit<React.ComponentProps<T>, 'children' | 'className' | 'style'> & DataAttributes & {
|
|
22
|
+
className?: string | undefined;
|
|
23
|
+
style?: CSSProperties | undefined;
|
|
24
|
+
children?: Children<ItemState> | undefined;
|
|
25
|
+
};
|
|
26
|
+
export interface UseRenderContainerOptions<T extends ElementType, ContainerState, ItemState> {
|
|
27
|
+
props?: ContainerProps<T, ContainerState, ItemState> & DataAttributes;
|
|
28
|
+
baseProps?: ContainerBaseProps<T, ItemState>;
|
|
29
|
+
ref?: Ref<any> | (Ref<any> | undefined)[];
|
|
30
|
+
}
|
|
31
|
+
export interface UseRenderContainerResult<T extends ElementType, ItemState> {
|
|
32
|
+
/** Renders the container element with resolved props */
|
|
33
|
+
Container: (props: {
|
|
34
|
+
children?: ReactNode;
|
|
35
|
+
}) => ReactNode;
|
|
36
|
+
/** Resolves children with item-specific state */
|
|
37
|
+
renderItem: (itemState: ItemState) => ReactNode;
|
|
38
|
+
/** Direct access to resolved container props */
|
|
39
|
+
containerProps: React.ComponentProps<T>;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Hook for rendering container elements with item-level render control.
|
|
43
|
+
*
|
|
44
|
+
* Combines the prop resolution of useRender with the render control of useRenderProps.
|
|
45
|
+
* - Container: handles className/style resolution, ref composition, and render prop
|
|
46
|
+
* - renderItem: resolves children function with per-item state
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```tsx
|
|
50
|
+
* type ContainerState = { locale: string }
|
|
51
|
+
* type ItemState = ContainerState & { date: Date; index: number }
|
|
52
|
+
*
|
|
53
|
+
* function DateList(props: ContainerProps<'div', ContainerState, ItemState>) {
|
|
54
|
+
* const { Container, renderItem } = useRenderContainer('div', { locale: 'en' }, {
|
|
55
|
+
* props,
|
|
56
|
+
* baseProps: {
|
|
57
|
+
* role: 'list',
|
|
58
|
+
* children: (state) => <DateItem date={state.date} />,
|
|
59
|
+
* },
|
|
60
|
+
* })
|
|
61
|
+
* const dates = generateDates()
|
|
62
|
+
*
|
|
63
|
+
* return (
|
|
64
|
+
* <Container>
|
|
65
|
+
* {dates.map((date, index) => (
|
|
66
|
+
* <ItemContext key={date.toString()}>
|
|
67
|
+
* {renderItem({ date, index, locale: 'en' })}
|
|
68
|
+
* </ItemContext>
|
|
69
|
+
* ))}
|
|
70
|
+
* </Container>
|
|
71
|
+
* )
|
|
72
|
+
* }
|
|
73
|
+
*
|
|
74
|
+
* // Consumer usage:
|
|
75
|
+
* <DateList className={(state) => state.locale === 'en' && 'ltr'} />
|
|
76
|
+
* <DateList render={<section />} />
|
|
77
|
+
* <DateList>{(state) => <CustomDateItem date={state.date} />}</DateList>
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export declare function useRenderContainer<T extends ElementType, ContainerState, ItemState = ContainerState>(tag: T, containerState: ContainerState, options?: UseRenderContainerOptions<T, ContainerState, ItemState>): UseRenderContainerResult<T, ItemState>;
|
|
81
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { cloneElement, createElement, isValidElement, useCallback, useMemo, } from 'react';
|
|
2
|
+
import { useComposedRef } from "./use-composed-ref.js";
|
|
3
|
+
import { isFunction, isString, mergeProps, resolveClassName, resolveStyle, } from "./utils.js";
|
|
4
|
+
/**
|
|
5
|
+
* Hook for rendering container elements with item-level render control.
|
|
6
|
+
*
|
|
7
|
+
* Combines the prop resolution of useRender with the render control of useRenderProps.
|
|
8
|
+
* - Container: handles className/style resolution, ref composition, and render prop
|
|
9
|
+
* - renderItem: resolves children function with per-item state
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```tsx
|
|
13
|
+
* type ContainerState = { locale: string }
|
|
14
|
+
* type ItemState = ContainerState & { date: Date; index: number }
|
|
15
|
+
*
|
|
16
|
+
* function DateList(props: ContainerProps<'div', ContainerState, ItemState>) {
|
|
17
|
+
* const { Container, renderItem } = useRenderContainer('div', { locale: 'en' }, {
|
|
18
|
+
* props,
|
|
19
|
+
* baseProps: {
|
|
20
|
+
* role: 'list',
|
|
21
|
+
* children: (state) => <DateItem date={state.date} />,
|
|
22
|
+
* },
|
|
23
|
+
* })
|
|
24
|
+
* const dates = generateDates()
|
|
25
|
+
*
|
|
26
|
+
* return (
|
|
27
|
+
* <Container>
|
|
28
|
+
* {dates.map((date, index) => (
|
|
29
|
+
* <ItemContext key={date.toString()}>
|
|
30
|
+
* {renderItem({ date, index, locale: 'en' })}
|
|
31
|
+
* </ItemContext>
|
|
32
|
+
* ))}
|
|
33
|
+
* </Container>
|
|
34
|
+
* )
|
|
35
|
+
* }
|
|
36
|
+
*
|
|
37
|
+
* // Consumer usage:
|
|
38
|
+
* <DateList className={(state) => state.locale === 'en' && 'ltr'} />
|
|
39
|
+
* <DateList render={<section />} />
|
|
40
|
+
* <DateList>{(state) => <CustomDateItem date={state.date} />}</DateList>
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
// TODO: refactor args order, useRenderContainer('div', props, options). `props` is what defines what the interface is for the state
|
|
44
|
+
export function useRenderContainer(tag, containerState, options = {}) {
|
|
45
|
+
// Workaround for getting the prop objects to be typed. Should still be ok as the properties we need are common to all elements
|
|
46
|
+
const baseProps = (options.baseProps ?? {});
|
|
47
|
+
const props = (options.props ?? {});
|
|
48
|
+
const { className: baseClassName, style: baseStyle, children: baseChildren, ...base } = baseProps;
|
|
49
|
+
const { className, style, children, ref, render, ...rest } = props;
|
|
50
|
+
// Resolve container className/style with container state
|
|
51
|
+
const resolvedClassName = resolveClassName(containerState, baseClassName, className);
|
|
52
|
+
const resolvedStyle = resolveStyle(containerState, baseStyle, style);
|
|
53
|
+
// Compose refs
|
|
54
|
+
const mergedRef = useComposedRef(ref, options.ref);
|
|
55
|
+
// Build resolved container props (memoized for stable useCallback reference)
|
|
56
|
+
const resolvedProps = useMemo(() => {
|
|
57
|
+
const props = {
|
|
58
|
+
...mergeProps(base, rest),
|
|
59
|
+
ref: mergedRef,
|
|
60
|
+
};
|
|
61
|
+
if (isString(resolvedClassName)) {
|
|
62
|
+
props.className = resolvedClassName;
|
|
63
|
+
}
|
|
64
|
+
if (typeof resolvedStyle === 'object') {
|
|
65
|
+
props.style = resolvedStyle;
|
|
66
|
+
}
|
|
67
|
+
return props;
|
|
68
|
+
}, [base, rest, mergedRef, resolvedClassName, resolvedStyle]);
|
|
69
|
+
// Container component that handles render prop
|
|
70
|
+
const Container = useCallback(({ children: containerChildren }) => {
|
|
71
|
+
const propsWithChildren = {
|
|
72
|
+
...resolvedProps,
|
|
73
|
+
children: containerChildren,
|
|
74
|
+
};
|
|
75
|
+
// For `<Component render={<section />} />`
|
|
76
|
+
if (isValidElement(render)) {
|
|
77
|
+
return cloneElement(render, propsWithChildren);
|
|
78
|
+
}
|
|
79
|
+
// For `<Component render={(props, state) => <section {...props} />} />`
|
|
80
|
+
if (isFunction(render)) {
|
|
81
|
+
return render(propsWithChildren, containerState);
|
|
82
|
+
}
|
|
83
|
+
return createElement(tag, propsWithChildren);
|
|
84
|
+
}, [resolvedProps, render, containerState, tag]);
|
|
85
|
+
// Render function for items - resolves children with item state
|
|
86
|
+
const renderItem = useCallback((itemState) => {
|
|
87
|
+
// Props children take precedence over base children
|
|
88
|
+
const childrenToRender = children !== undefined ? children : baseChildren;
|
|
89
|
+
return isFunction(childrenToRender)
|
|
90
|
+
? childrenToRender(itemState)
|
|
91
|
+
: childrenToRender;
|
|
92
|
+
}, [children, baseChildren]);
|
|
93
|
+
return {
|
|
94
|
+
Container,
|
|
95
|
+
renderItem,
|
|
96
|
+
containerProps: resolvedProps,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/use-render.js
CHANGED
|
@@ -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
|
|
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 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/use-render.test.jsx
DELETED
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
2
|
-
import { describe, expect, test, vi } from 'vitest';
|
|
3
|
-
import { render } from 'vitest-browser-react';
|
|
4
|
-
import {} from "./types.js";
|
|
5
|
-
import { useRender } from "./use-render.js";
|
|
6
|
-
function createBtn(defaultProps) {
|
|
7
|
-
return function Button(props) {
|
|
8
|
-
const [isActive, setIsActive] = useState(false);
|
|
9
|
-
const state = { isActive };
|
|
10
|
-
return useRender('button', state, {
|
|
11
|
-
props,
|
|
12
|
-
baseProps: {
|
|
13
|
-
onClick: () => setIsActive((cur) => !cur),
|
|
14
|
-
...defaultProps,
|
|
15
|
-
},
|
|
16
|
-
});
|
|
17
|
-
};
|
|
18
|
-
}
|
|
19
|
-
describe('useRender', () => {
|
|
20
|
-
describe('className', () => {
|
|
21
|
-
test('renders with static string className', async () => {
|
|
22
|
-
const Button = createBtn();
|
|
23
|
-
const { getByRole } = await render(<Button className="btn-primary">Click</Button>);
|
|
24
|
-
const button = getByRole('button');
|
|
25
|
-
await expect.element(button).toHaveClass('btn-primary');
|
|
26
|
-
});
|
|
27
|
-
test('className function receives state', async () => {
|
|
28
|
-
const Button = createBtn();
|
|
29
|
-
const { getByRole } = await render(<Button className={(state) => (state.isActive ? 'active' : 'inactive')}>
|
|
30
|
-
Click
|
|
31
|
-
</Button>);
|
|
32
|
-
const button = getByRole('button');
|
|
33
|
-
await expect.element(button).toHaveClass('inactive');
|
|
34
|
-
await button.click();
|
|
35
|
-
await expect.element(button).toHaveClass('active');
|
|
36
|
-
});
|
|
37
|
-
test('className function receives baseClassName as second argument', async () => {
|
|
38
|
-
const Button = createBtn({ className: 'btn-default' });
|
|
39
|
-
const { getByRole } = await render(<Button className={(_state, baseClassName) => `custom ${baseClassName ?? ''}`}>
|
|
40
|
-
Click
|
|
41
|
-
</Button>);
|
|
42
|
-
const button = getByRole('button');
|
|
43
|
-
await expect.element(button).toHaveClass('custom');
|
|
44
|
-
await expect.element(button).toHaveClass('btn-default');
|
|
45
|
-
});
|
|
46
|
-
test('merges props className with defaultProps className', async () => {
|
|
47
|
-
const Button = createBtn({ className: 'btn-base' });
|
|
48
|
-
const { getByRole } = await render(<Button className="btn-primary">Click</Button>);
|
|
49
|
-
const button = getByRole('button');
|
|
50
|
-
await expect.element(button).toHaveClass('btn-base');
|
|
51
|
-
await expect.element(button).toHaveClass('btn-primary');
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
describe('style', () => {
|
|
55
|
-
test('renders with static style object', async () => {
|
|
56
|
-
const Button = createBtn();
|
|
57
|
-
const { getByRole } = await render(<Button style={{ backgroundColor: 'red' }}>Click</Button>);
|
|
58
|
-
const button = getByRole('button');
|
|
59
|
-
await expect.element(button).toHaveStyle({ backgroundColor: 'red' });
|
|
60
|
-
});
|
|
61
|
-
test('style function receives state', async () => {
|
|
62
|
-
const Button = createBtn();
|
|
63
|
-
const { getByRole } = await render(<Button style={(state) => ({
|
|
64
|
-
backgroundColor: state.isActive ? 'green' : 'gray',
|
|
65
|
-
})}>
|
|
66
|
-
Click
|
|
67
|
-
</Button>);
|
|
68
|
-
const button = getByRole('button');
|
|
69
|
-
await expect.element(button).toHaveStyle({ backgroundColor: 'gray' });
|
|
70
|
-
await button.click();
|
|
71
|
-
await expect.element(button).toHaveStyle({ backgroundColor: 'green' });
|
|
72
|
-
});
|
|
73
|
-
test('style function receives baseStyle as second argument', async () => {
|
|
74
|
-
const Button = createBtn({ style: { padding: '10px' } });
|
|
75
|
-
const { getByRole } = await render(<Button style={(_state, baseStyle) => ({
|
|
76
|
-
...baseStyle,
|
|
77
|
-
color: 'blue',
|
|
78
|
-
})}>
|
|
79
|
-
Click
|
|
80
|
-
</Button>);
|
|
81
|
-
const button = getByRole('button');
|
|
82
|
-
await expect
|
|
83
|
-
.element(button)
|
|
84
|
-
.toHaveStyle({ padding: '10px', color: 'blue' });
|
|
85
|
-
});
|
|
86
|
-
test('merges props style with defaultProps style', async () => {
|
|
87
|
-
const Button = createBtn({
|
|
88
|
-
style: { padding: '10px', margin: '5px' },
|
|
89
|
-
});
|
|
90
|
-
const { getByRole } = await render(<Button style={{ padding: '20px', color: 'red' }}>Click</Button>);
|
|
91
|
-
const button = getByRole('button');
|
|
92
|
-
// Props style should override default where they overlap
|
|
93
|
-
await expect.element(button).toHaveStyle({
|
|
94
|
-
padding: '20px',
|
|
95
|
-
margin: '5px',
|
|
96
|
-
color: 'red',
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
describe('children', () => {
|
|
101
|
-
test('renders static children', async () => {
|
|
102
|
-
const Button = createBtn();
|
|
103
|
-
const { getByRole } = await render(<Button>Static Text</Button>);
|
|
104
|
-
const button = getByRole('button');
|
|
105
|
-
await expect.element(button).toHaveTextContent('Static Text');
|
|
106
|
-
});
|
|
107
|
-
test('children function receives state', async () => {
|
|
108
|
-
const Button = createBtn();
|
|
109
|
-
const { getByRole } = await render(<Button>{(state) => (state.isActive ? 'Active!' : 'Inactive')}</Button>);
|
|
110
|
-
const button = getByRole('button');
|
|
111
|
-
await expect.element(button).toHaveTextContent('Inactive');
|
|
112
|
-
await button.click();
|
|
113
|
-
await expect.element(button).toHaveTextContent('Active!');
|
|
114
|
-
});
|
|
115
|
-
test('uses defaultProps children when no children provided', async () => {
|
|
116
|
-
const Button = createBtn({ children: 'Default Text' });
|
|
117
|
-
const { getByRole } = await render(<Button />);
|
|
118
|
-
const button = getByRole('button');
|
|
119
|
-
await expect.element(button).toHaveTextContent('Default Text');
|
|
120
|
-
});
|
|
121
|
-
});
|
|
122
|
-
describe('render', () => {
|
|
123
|
-
test('renders element from render prop instead of default tag', async () => {
|
|
124
|
-
const Button = createBtn();
|
|
125
|
-
const { getByRole } = await render(<Button render={<a href="/path"/>}>Link</Button>);
|
|
126
|
-
const link = getByRole('link');
|
|
127
|
-
await expect.element(link).toHaveTextContent('Link');
|
|
128
|
-
await expect.element(link).toHaveAttribute('href', '/path');
|
|
129
|
-
});
|
|
130
|
-
test('render function receives props and state', async () => {
|
|
131
|
-
const Button = createBtn();
|
|
132
|
-
const { getByRole } = await render(<Button className="custom" render={(props, state) => (<a {...props} href="/path" data-active={state.isActive} onClick={(e) => {
|
|
133
|
-
e.preventDefault();
|
|
134
|
-
props.onClick?.(e);
|
|
135
|
-
}}/>)}>
|
|
136
|
-
Link
|
|
137
|
-
</Button>);
|
|
138
|
-
const link = getByRole('link');
|
|
139
|
-
await expect.element(link).toHaveTextContent('Link');
|
|
140
|
-
await expect.element(link).toHaveAttribute('href', '/path');
|
|
141
|
-
await expect.element(link).toHaveClass('custom');
|
|
142
|
-
await expect.element(link).toHaveAttribute('data-active', 'false');
|
|
143
|
-
await link.click();
|
|
144
|
-
await expect.element(link).toHaveAttribute('data-active', 'true');
|
|
145
|
-
});
|
|
146
|
-
test('element render prop with children as function', async () => {
|
|
147
|
-
const Button = createBtn();
|
|
148
|
-
const { getByRole } = await render(<Button render={<a href="/path"/>}>
|
|
149
|
-
{(state) => (state.isActive ? 'Active Link' : 'Inactive Link')}
|
|
150
|
-
</Button>);
|
|
151
|
-
const link = getByRole('link');
|
|
152
|
-
await expect.element(link).toHaveTextContent('Inactive Link');
|
|
153
|
-
await expect.element(link).toHaveAttribute('href', '/path');
|
|
154
|
-
});
|
|
155
|
-
test('function render prop with children as function', async () => {
|
|
156
|
-
const Button = createBtn();
|
|
157
|
-
const { getByRole } = await render(<Button render={(props, state) => (<a {...props} href="/path" data-active={state.isActive} onClick={(e) => {
|
|
158
|
-
e.preventDefault();
|
|
159
|
-
props.onClick?.(e);
|
|
160
|
-
}}/>)}>
|
|
161
|
-
{(state) => (state.isActive ? 'Active Link' : 'Inactive Link')}
|
|
162
|
-
</Button>);
|
|
163
|
-
const link = getByRole('link');
|
|
164
|
-
await expect.element(link).toHaveTextContent('Inactive Link');
|
|
165
|
-
await expect.element(link).toHaveAttribute('data-active', 'false');
|
|
166
|
-
await link.click();
|
|
167
|
-
await expect.element(link).toHaveTextContent('Active Link');
|
|
168
|
-
await expect.element(link).toHaveAttribute('data-active', 'true');
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
describe('event handler composition', () => {
|
|
172
|
-
test('composes event handlers - both run, user first then default', async () => {
|
|
173
|
-
const callOrder = [];
|
|
174
|
-
const defaultHandler = vi.fn(() => callOrder.push('default'));
|
|
175
|
-
const userHandler = vi.fn(() => callOrder.push('user'));
|
|
176
|
-
const Button = createBtn({ onMouseEnter: defaultHandler });
|
|
177
|
-
const { getByRole } = await render(<Button onMouseEnter={userHandler}>Click</Button>);
|
|
178
|
-
const button = getByRole('button');
|
|
179
|
-
await button.hover();
|
|
180
|
-
expect(defaultHandler).toHaveBeenCalledTimes(1);
|
|
181
|
-
expect(userHandler).toHaveBeenCalledTimes(1);
|
|
182
|
-
expect(callOrder).toEqual(['user', 'default']);
|
|
183
|
-
});
|
|
184
|
-
test('user handler return value is preserved', async () => {
|
|
185
|
-
const defaultHandler = vi.fn(() => 'default-result');
|
|
186
|
-
const userHandler = vi.fn(() => 'user-result');
|
|
187
|
-
let capturedResult;
|
|
188
|
-
function TestButton(props) {
|
|
189
|
-
const [isActive, setIsActive] = useState(false);
|
|
190
|
-
return useRender('button', { isActive }, {
|
|
191
|
-
props,
|
|
192
|
-
baseProps: {
|
|
193
|
-
onClick: (_e) => {
|
|
194
|
-
setIsActive((cur) => !cur);
|
|
195
|
-
return defaultHandler();
|
|
196
|
-
},
|
|
197
|
-
},
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
const { getByRole } = await render(<TestButton onClick={() => {
|
|
201
|
-
capturedResult = userHandler();
|
|
202
|
-
return capturedResult;
|
|
203
|
-
}}>
|
|
204
|
-
Click
|
|
205
|
-
</TestButton>);
|
|
206
|
-
const button = getByRole('button');
|
|
207
|
-
await button.click();
|
|
208
|
-
expect(userHandler).toHaveBeenCalled();
|
|
209
|
-
expect(defaultHandler).toHaveBeenCalled();
|
|
210
|
-
expect(capturedResult).toBe('user-result');
|
|
211
|
-
});
|
|
212
|
-
test('only default handler runs when no user handler provided', async () => {
|
|
213
|
-
const defaultHandler = vi.fn();
|
|
214
|
-
const Button = createBtn({ onMouseEnter: defaultHandler });
|
|
215
|
-
const { getByRole } = await render(<Button>Click</Button>);
|
|
216
|
-
const button = getByRole('button');
|
|
217
|
-
await button.hover();
|
|
218
|
-
expect(defaultHandler).toHaveBeenCalledTimes(1);
|
|
219
|
-
});
|
|
220
|
-
test('only user handler runs when no default handler', async () => {
|
|
221
|
-
const userHandler = vi.fn();
|
|
222
|
-
const Button = createBtn();
|
|
223
|
-
const { getByRole } = await render(<Button onMouseEnter={userHandler}>Click</Button>);
|
|
224
|
-
const button = getByRole('button');
|
|
225
|
-
await button.hover();
|
|
226
|
-
expect(userHandler).toHaveBeenCalledTimes(1);
|
|
227
|
-
});
|
|
228
|
-
test('composed onClick still updates internal state', async () => {
|
|
229
|
-
const userHandler = vi.fn();
|
|
230
|
-
const Button = createBtn();
|
|
231
|
-
const { getByRole } = await render(<Button className={(state) => (state.isActive ? 'active' : 'inactive')} onClick={userHandler}>
|
|
232
|
-
Click
|
|
233
|
-
</Button>);
|
|
234
|
-
const button = getByRole('button');
|
|
235
|
-
await expect.element(button).toHaveClass('inactive');
|
|
236
|
-
await button.click();
|
|
237
|
-
await expect.element(button).toHaveClass('active');
|
|
238
|
-
expect(userHandler).toHaveBeenCalledTimes(1);
|
|
239
|
-
});
|
|
240
|
-
});
|
|
241
|
-
});
|