@diskette/use-render 0.6.1 → 0.7.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/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export { useRender } from './use-render.ts';
2
+ export { useRenderContainer } from './use-render-container.ts';
2
3
  export type { UseRenderOptions } from './use-render.ts';
4
+ export type { ContainerBaseProps, ContainerProps, UseRenderContainerOptions, UseRenderContainerResult, } from './use-render-container.ts';
3
5
  export type { BaseComponentProps, ClassName, ComponentProps, ComponentRenderer, DataAttributes, Style, } from './types.ts';
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export { useRender } from "./use-render.js";
2
+ export { useRenderContainer } from "./use-render-container.js";
@@ -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,100 @@
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
+ export function useRenderContainer(tag, containerState, options = {}) {
44
+ // Workaround for getting the prop objects to be typed. Should still be ok as the properties we need are common to all elements
45
+ const baseProps = (options.baseProps ?? {});
46
+ const props = (options.props ?? {});
47
+ const { className: baseClassName, style: baseStyle, children: baseChildren, ...base } = baseProps;
48
+ const { className, style, children, ref, render, ...rest } = props;
49
+ // Resolve container className/style with container state
50
+ const resolvedClassName = resolveClassName(containerState, baseClassName, className);
51
+ const resolvedStyle = resolveStyle(containerState, baseStyle, style);
52
+ // Compose refs
53
+ const refs = Array.isArray(options.ref)
54
+ ? [ref, ...options.ref]
55
+ : [ref, options.ref];
56
+ const mergedRef = useComposedRef(refs);
57
+ // Build resolved container props (memoized for stable useCallback reference)
58
+ const resolvedProps = useMemo(() => {
59
+ const props = {
60
+ ...mergeProps(base, rest),
61
+ ref: mergedRef,
62
+ };
63
+ if (isString(resolvedClassName)) {
64
+ props.className = resolvedClassName;
65
+ }
66
+ if (typeof resolvedStyle === 'object') {
67
+ props.style = resolvedStyle;
68
+ }
69
+ return props;
70
+ }, [base, rest, mergedRef, resolvedClassName, resolvedStyle]);
71
+ // Container component that handles render prop
72
+ const Container = useCallback(({ children: containerChildren }) => {
73
+ const propsWithChildren = {
74
+ ...resolvedProps,
75
+ children: containerChildren,
76
+ };
77
+ // For `<Component render={<section />} />`
78
+ if (isValidElement(render)) {
79
+ return cloneElement(render, propsWithChildren);
80
+ }
81
+ // For `<Component render={(props, state) => <section {...props} />} />`
82
+ if (isFunction(render)) {
83
+ return render(propsWithChildren, containerState);
84
+ }
85
+ return createElement(tag, propsWithChildren);
86
+ }, [resolvedProps, render, containerState, tag]);
87
+ // Render function for items - resolves children with item state
88
+ const renderItem = useCallback((itemState) => {
89
+ // Props children take precedence over base children
90
+ const childrenToRender = children !== undefined ? children : baseChildren;
91
+ return isFunction(childrenToRender)
92
+ ? childrenToRender(itemState)
93
+ : childrenToRender;
94
+ }, [children, baseChildren]);
95
+ return {
96
+ Container,
97
+ renderItem,
98
+ containerProps: resolvedProps,
99
+ };
100
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@diskette/use-render",
3
3
  "type": "module",
4
- "version": "0.6.1",
4
+ "version": "0.7.0",
5
5
  "exports": "./dist/index.js",
6
6
  "files": [
7
7
  "dist"
@@ -1 +0,0 @@
1
- export {};
@@ -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
- });