@oxyhq/bloom 0.6.13 → 0.6.15

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.
Files changed (68) hide show
  1. package/lib/commonjs/hooks/useControllableState.js +35 -0
  2. package/lib/commonjs/hooks/useControllableState.js.map +1 -0
  3. package/lib/commonjs/theme/BloomThemeProvider.js +148 -112
  4. package/lib/commonjs/theme/BloomThemeProvider.js.map +1 -1
  5. package/lib/commonjs/theme/build-theme.js +110 -0
  6. package/lib/commonjs/theme/build-theme.js.map +1 -0
  7. package/lib/commonjs/theme/color-presets.js +15 -0
  8. package/lib/commonjs/theme/color-presets.js.map +1 -1
  9. package/lib/commonjs/theme/index.js +15 -1
  10. package/lib/commonjs/theme/index.js.map +1 -1
  11. package/lib/commonjs/theme/persistence.js +147 -0
  12. package/lib/commonjs/theme/persistence.js.map +1 -0
  13. package/lib/commonjs/theme/use-isomorphic-layout-effect.js +15 -0
  14. package/lib/commonjs/theme/use-isomorphic-layout-effect.js.map +1 -0
  15. package/lib/module/hooks/useControllableState.js +31 -0
  16. package/lib/module/hooks/useControllableState.js.map +1 -0
  17. package/lib/module/theme/BloomThemeProvider.js +149 -112
  18. package/lib/module/theme/BloomThemeProvider.js.map +1 -1
  19. package/lib/module/theme/build-theme.js +105 -0
  20. package/lib/module/theme/build-theme.js.map +1 -0
  21. package/lib/module/theme/color-presets.js +15 -0
  22. package/lib/module/theme/color-presets.js.map +1 -1
  23. package/lib/module/theme/index.js +3 -1
  24. package/lib/module/theme/index.js.map +1 -1
  25. package/lib/module/theme/persistence.js +139 -0
  26. package/lib/module/theme/persistence.js.map +1 -0
  27. package/lib/module/theme/use-isomorphic-layout-effect.js +12 -0
  28. package/lib/module/theme/use-isomorphic-layout-effect.js.map +1 -0
  29. package/lib/typescript/commonjs/hooks/useControllableState.d.ts +19 -0
  30. package/lib/typescript/commonjs/hooks/useControllableState.d.ts.map +1 -0
  31. package/lib/typescript/commonjs/theme/BloomThemeProvider.d.ts +35 -12
  32. package/lib/typescript/commonjs/theme/BloomThemeProvider.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/theme/build-theme.d.ts +20 -0
  34. package/lib/typescript/commonjs/theme/build-theme.d.ts.map +1 -0
  35. package/lib/typescript/commonjs/theme/color-presets.d.ts +18 -3
  36. package/lib/typescript/commonjs/theme/color-presets.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/theme/index.d.ts +7 -4
  38. package/lib/typescript/commonjs/theme/index.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/theme/persistence.d.ts +50 -0
  40. package/lib/typescript/commonjs/theme/persistence.d.ts.map +1 -0
  41. package/lib/typescript/commonjs/theme/use-isomorphic-layout-effect.d.ts +8 -0
  42. package/lib/typescript/commonjs/theme/use-isomorphic-layout-effect.d.ts.map +1 -0
  43. package/lib/typescript/module/hooks/useControllableState.d.ts +19 -0
  44. package/lib/typescript/module/hooks/useControllableState.d.ts.map +1 -0
  45. package/lib/typescript/module/theme/BloomThemeProvider.d.ts +35 -12
  46. package/lib/typescript/module/theme/BloomThemeProvider.d.ts.map +1 -1
  47. package/lib/typescript/module/theme/build-theme.d.ts +20 -0
  48. package/lib/typescript/module/theme/build-theme.d.ts.map +1 -0
  49. package/lib/typescript/module/theme/color-presets.d.ts +18 -3
  50. package/lib/typescript/module/theme/color-presets.d.ts.map +1 -1
  51. package/lib/typescript/module/theme/index.d.ts +7 -4
  52. package/lib/typescript/module/theme/index.d.ts.map +1 -1
  53. package/lib/typescript/module/theme/persistence.d.ts +50 -0
  54. package/lib/typescript/module/theme/persistence.d.ts.map +1 -0
  55. package/lib/typescript/module/theme/use-isomorphic-layout-effect.d.ts +8 -0
  56. package/lib/typescript/module/theme/use-isomorphic-layout-effect.d.ts.map +1 -0
  57. package/package.json +1 -1
  58. package/src/__tests__/BloomThemeProvider.modes.test.tsx +159 -0
  59. package/src/__tests__/BloomThemeProvider.persistence.test.tsx +321 -0
  60. package/src/__tests__/persistence.test.ts +155 -0
  61. package/src/__tests__/theme.test.ts +1 -1
  62. package/src/hooks/useControllableState.ts +44 -0
  63. package/src/theme/BloomThemeProvider.tsx +251 -157
  64. package/src/theme/build-theme.ts +128 -0
  65. package/src/theme/color-presets.ts +19 -3
  66. package/src/theme/index.ts +16 -4
  67. package/src/theme/persistence.ts +174 -0
  68. package/src/theme/use-isomorphic-layout-effect.ts +10 -0
@@ -0,0 +1,321 @@
1
+ import React from 'react';
2
+ import { Text } from 'react-native';
3
+ import { render, act, waitFor } from '@testing-library/react-native';
4
+
5
+ import { BloomThemeProvider } from '../theme/BloomThemeProvider';
6
+ import { useBloomTheme } from '../theme/use-theme';
7
+ import type { BloomThemeStorage, PersistedThemeState } from '../theme/persistence';
8
+
9
+ function Display() {
10
+ const ctx = useBloomTheme();
11
+ return (
12
+ <>
13
+ <Text testID="mode">{ctx.mode}</Text>
14
+ <Text testID="preset">{ctx.colorPreset}</Text>
15
+ </>
16
+ );
17
+ }
18
+
19
+ function createSyncStorage(initial?: PersistedThemeState): BloomThemeStorage & {
20
+ store: Record<string, string>;
21
+ } {
22
+ const store: Record<string, string> = {};
23
+ if (initial) {
24
+ store['bloom-theme'] = JSON.stringify(initial);
25
+ }
26
+ return {
27
+ store,
28
+ getItem: (key) => (key in store ? store[key]! : null),
29
+ setItem: (key, value) => {
30
+ store[key] = value;
31
+ },
32
+ removeItem: (key) => {
33
+ delete store[key];
34
+ },
35
+ };
36
+ }
37
+
38
+ function createAsyncStorage(initial?: PersistedThemeState): BloomThemeStorage & {
39
+ store: Record<string, string>;
40
+ } {
41
+ const store: Record<string, string> = {};
42
+ if (initial) {
43
+ store['bloom-theme'] = JSON.stringify(initial);
44
+ }
45
+ return {
46
+ store,
47
+ getItem: (key) => Promise.resolve(key in store ? store[key]! : null),
48
+ setItem: (key, value) => {
49
+ store[key] = value;
50
+ return Promise.resolve();
51
+ },
52
+ };
53
+ }
54
+
55
+ describe('BloomThemeProvider — persistence', () => {
56
+ it('hydrates synchronously from sync storage before first paint', () => {
57
+ const storage = createSyncStorage({ mode: 'dark', colorPreset: 'blue' });
58
+
59
+ const { getByTestId } = render(
60
+ <BloomThemeProvider persistKey="bloom-theme" storage={storage}>
61
+ <Display />
62
+ </BloomThemeProvider>,
63
+ );
64
+
65
+ expect(getByTestId('mode').props.children).toBe('dark');
66
+ expect(getByTestId('preset').props.children).toBe('blue');
67
+ });
68
+
69
+ it('falls back to defaults when storage is empty', () => {
70
+ const storage = createSyncStorage();
71
+
72
+ const { getByTestId } = render(
73
+ <BloomThemeProvider
74
+ persistKey="bloom-theme"
75
+ storage={storage}
76
+ defaultMode="light"
77
+ defaultColorPreset="teal"
78
+ >
79
+ <Display />
80
+ </BloomThemeProvider>,
81
+ );
82
+
83
+ expect(getByTestId('mode').props.children).toBe('light');
84
+ expect(getByTestId('preset').props.children).toBe('teal');
85
+ });
86
+
87
+ it('writes through to storage when setMode is called', async () => {
88
+ const storage = createSyncStorage();
89
+
90
+ function Controls() {
91
+ const ctx = useBloomTheme();
92
+ return <Text testID="trigger" onPress={() => ctx.setMode('dark')} />;
93
+ }
94
+
95
+ const { getByTestId } = render(
96
+ <BloomThemeProvider persistKey="bloom-theme" storage={storage}>
97
+ <Display />
98
+ <Controls />
99
+ </BloomThemeProvider>,
100
+ );
101
+
102
+ await act(async () => {
103
+ getByTestId('trigger').props.onPress();
104
+ });
105
+
106
+ expect(getByTestId('mode').props.children).toBe('dark');
107
+ expect(JSON.parse(storage.store['bloom-theme']!)).toMatchObject({ mode: 'dark' });
108
+ });
109
+
110
+ it('writes through to storage when setColorPreset is called', async () => {
111
+ const storage = createSyncStorage();
112
+
113
+ function Controls() {
114
+ const ctx = useBloomTheme();
115
+ return <Text testID="trigger" onPress={() => ctx.setColorPreset('purple')} />;
116
+ }
117
+
118
+ const { getByTestId } = render(
119
+ <BloomThemeProvider persistKey="bloom-theme" storage={storage}>
120
+ <Display />
121
+ <Controls />
122
+ </BloomThemeProvider>,
123
+ );
124
+
125
+ await act(async () => {
126
+ getByTestId('trigger').props.onPress();
127
+ });
128
+
129
+ expect(getByTestId('preset').props.children).toBe('purple');
130
+ expect(JSON.parse(storage.store['bloom-theme']!)).toMatchObject({ colorPreset: 'purple' });
131
+ });
132
+
133
+ it('hydrates asynchronously when the storage adapter is async', async () => {
134
+ const storage = createAsyncStorage({ mode: 'dark', colorPreset: 'pink' });
135
+
136
+ const { getByTestId } = render(
137
+ <BloomThemeProvider
138
+ persistKey="bloom-theme"
139
+ storage={storage}
140
+ awaitHydration={false}
141
+ defaultMode="light"
142
+ defaultColorPreset="oxy"
143
+ >
144
+ <Display />
145
+ </BloomThemeProvider>,
146
+ );
147
+
148
+ // Before async hydration completes: defaults
149
+ expect(getByTestId('mode').props.children).toBe('light');
150
+ expect(getByTestId('preset').props.children).toBe('oxy');
151
+
152
+ // After hydration: persisted values
153
+ await waitFor(() => {
154
+ expect(getByTestId('mode').props.children).toBe('dark');
155
+ });
156
+ expect(getByTestId('preset').props.children).toBe('pink');
157
+ });
158
+
159
+ it('gates children behind onHydrating when awaitHydration is true', async () => {
160
+ const storage = createAsyncStorage({ mode: 'dark', colorPreset: 'pink' });
161
+
162
+ const { queryByTestId, getByTestId } = render(
163
+ <BloomThemeProvider
164
+ persistKey="bloom-theme"
165
+ storage={storage}
166
+ awaitHydration
167
+ onHydrating={<Text testID="loading">loading</Text>}
168
+ >
169
+ <Display />
170
+ </BloomThemeProvider>,
171
+ );
172
+
173
+ expect(queryByTestId('loading')).not.toBeNull();
174
+ expect(queryByTestId('mode')).toBeNull();
175
+
176
+ await waitFor(() => {
177
+ expect(queryByTestId('mode')).not.toBeNull();
178
+ });
179
+ expect(getByTestId('mode').props.children).toBe('dark');
180
+ });
181
+
182
+ it('ignores malformed persisted state', () => {
183
+ const storage: BloomThemeStorage = {
184
+ getItem: () => 'not-json',
185
+ setItem: () => undefined,
186
+ };
187
+
188
+ const { getByTestId } = render(
189
+ <BloomThemeProvider
190
+ persistKey="bloom-theme"
191
+ storage={storage}
192
+ defaultMode="light"
193
+ defaultColorPreset="teal"
194
+ >
195
+ <Display />
196
+ </BloomThemeProvider>,
197
+ );
198
+
199
+ expect(getByTestId('mode').props.children).toBe('light');
200
+ expect(getByTestId('preset').props.children).toBe('teal');
201
+ });
202
+
203
+ it('controlled props win over persisted state', () => {
204
+ const storage = createSyncStorage({ mode: 'dark', colorPreset: 'blue' });
205
+
206
+ const { getByTestId } = render(
207
+ <BloomThemeProvider
208
+ persistKey="bloom-theme"
209
+ storage={storage}
210
+ mode="light"
211
+ colorPreset="oxy"
212
+ >
213
+ <Display />
214
+ </BloomThemeProvider>,
215
+ );
216
+
217
+ expect(getByTestId('mode').props.children).toBe('light');
218
+ expect(getByTestId('preset').props.children).toBe('oxy');
219
+ });
220
+ });
221
+
222
+ describe('BloomThemeProvider — resetTheme', () => {
223
+ function Harness({ onReady }: { onReady: (reset: () => void) => void }) {
224
+ const ctx = useBloomTheme();
225
+ React.useEffect(() => {
226
+ onReady(ctx.resetTheme);
227
+ }, [ctx.resetTheme, onReady]);
228
+ return (
229
+ <>
230
+ <Text testID="mode">{ctx.mode}</Text>
231
+ <Text testID="preset">{ctx.colorPreset}</Text>
232
+ </>
233
+ );
234
+ }
235
+
236
+ it('restores defaults and clears the persisted entry', async () => {
237
+ const storage = createSyncStorage({ mode: 'dark', colorPreset: 'blue' });
238
+ expect(storage.store['bloom-theme']).toBeDefined();
239
+
240
+ let resetFn: (() => void) | undefined;
241
+
242
+ const { getByTestId } = render(
243
+ <BloomThemeProvider
244
+ persistKey="bloom-theme"
245
+ storage={storage}
246
+ defaultMode="system"
247
+ defaultColorPreset="oxy"
248
+ >
249
+ <Harness onReady={(reset) => { resetFn = reset; }} />
250
+ </BloomThemeProvider>,
251
+ );
252
+
253
+ expect(getByTestId('mode').props.children).toBe('dark');
254
+ expect(getByTestId('preset').props.children).toBe('blue');
255
+
256
+ await act(async () => {
257
+ resetFn?.();
258
+ });
259
+
260
+ expect(getByTestId('mode').props.children).toBe('system');
261
+ expect(getByTestId('preset').props.children).toBe('oxy');
262
+
263
+ await waitFor(() => {
264
+ expect(storage.store['bloom-theme']).toBeUndefined();
265
+ });
266
+ });
267
+
268
+ it('uses removeItem when the adapter provides it', async () => {
269
+ const removeItem = jest.fn();
270
+ const storage: BloomThemeStorage = {
271
+ getItem: () => JSON.stringify({ mode: 'dark', colorPreset: 'blue' }),
272
+ setItem: () => undefined,
273
+ removeItem,
274
+ };
275
+
276
+ let resetFn: (() => void) | undefined;
277
+
278
+ render(
279
+ <BloomThemeProvider
280
+ persistKey="bloom-theme"
281
+ storage={storage}
282
+ defaultMode="system"
283
+ defaultColorPreset="oxy"
284
+ >
285
+ <Harness onReady={(reset) => { resetFn = reset; }} />
286
+ </BloomThemeProvider>,
287
+ );
288
+
289
+ await act(async () => {
290
+ resetFn?.();
291
+ });
292
+
293
+ await waitFor(() => {
294
+ expect(removeItem).toHaveBeenCalledWith('bloom-theme');
295
+ });
296
+ });
297
+
298
+ it('does nothing when mode is controlled', async () => {
299
+ const storage = createSyncStorage({ mode: 'dark', colorPreset: 'blue' });
300
+ let resetFn: (() => void) | undefined;
301
+
302
+ const { getByTestId } = render(
303
+ <BloomThemeProvider
304
+ persistKey="bloom-theme"
305
+ storage={storage}
306
+ mode="dark"
307
+ defaultMode="system"
308
+ defaultColorPreset="oxy"
309
+ >
310
+ <Harness onReady={(reset) => { resetFn = reset; }} />
311
+ </BloomThemeProvider>,
312
+ );
313
+
314
+ await act(async () => {
315
+ resetFn?.();
316
+ });
317
+
318
+ expect(getByTestId('mode').props.children).toBe('dark');
319
+ expect(getByTestId('preset').props.children).toBe('oxy');
320
+ });
321
+ });
@@ -0,0 +1,155 @@
1
+ import {
2
+ readPersistedTheme,
3
+ readPersistedThemeSync,
4
+ writePersistedTheme,
5
+ type BloomThemeStorage,
6
+ } from '../theme/persistence';
7
+
8
+ function createSyncStorage(initial?: Record<string, string>): BloomThemeStorage & {
9
+ store: Record<string, string>;
10
+ } {
11
+ const store: Record<string, string> = { ...initial };
12
+ return {
13
+ store,
14
+ getItem: (key) => (key in store ? store[key]! : null),
15
+ setItem: (key, value) => {
16
+ store[key] = value;
17
+ },
18
+ };
19
+ }
20
+
21
+ function createAsyncStorage(initial?: Record<string, string>): BloomThemeStorage & {
22
+ store: Record<string, string>;
23
+ } {
24
+ const store: Record<string, string> = { ...initial };
25
+ return {
26
+ store,
27
+ getItem: (key) => Promise.resolve(key in store ? store[key]! : null),
28
+ setItem: (key, value) => {
29
+ store[key] = value;
30
+ return Promise.resolve();
31
+ },
32
+ };
33
+ }
34
+
35
+ describe('readPersistedThemeSync', () => {
36
+ it('returns kind=none when persistKey is missing', () => {
37
+ expect(readPersistedThemeSync(undefined, createSyncStorage())).toEqual({ kind: 'none' });
38
+ });
39
+
40
+ it('returns kind=none when storage is missing', () => {
41
+ expect(readPersistedThemeSync('k', undefined)).toEqual({ kind: 'none' });
42
+ });
43
+
44
+ it('detects async adapters', () => {
45
+ expect(readPersistedThemeSync('k', createAsyncStorage())).toEqual({ kind: 'async' });
46
+ });
47
+
48
+ it('parses valid sync payloads', () => {
49
+ const storage = createSyncStorage({ k: JSON.stringify({ mode: 'dark', colorPreset: 'blue' }) });
50
+ expect(readPersistedThemeSync('k', storage)).toEqual({
51
+ kind: 'sync',
52
+ state: { mode: 'dark', colorPreset: 'blue' },
53
+ });
54
+ });
55
+
56
+ it('returns sync/null on empty storage', () => {
57
+ expect(readPersistedThemeSync('k', createSyncStorage())).toEqual({ kind: 'sync', state: null });
58
+ });
59
+
60
+ it('rejects malformed JSON', () => {
61
+ const storage = createSyncStorage({ k: 'not-json' });
62
+ expect(readPersistedThemeSync('k', storage)).toEqual({ kind: 'sync', state: null });
63
+ });
64
+
65
+ it('rejects unknown color presets', () => {
66
+ const storage = createSyncStorage({ k: JSON.stringify({ colorPreset: 'not-a-preset' }) });
67
+ expect(readPersistedThemeSync('k', storage)).toEqual({ kind: 'sync', state: null });
68
+ });
69
+
70
+ it('rejects unknown modes', () => {
71
+ const storage = createSyncStorage({ k: JSON.stringify({ mode: 'rainbow' }) });
72
+ expect(readPersistedThemeSync('k', storage)).toEqual({ kind: 'sync', state: null });
73
+ });
74
+
75
+ it('accepts partial payloads (mode only)', () => {
76
+ const storage = createSyncStorage({ k: JSON.stringify({ mode: 'dark' }) });
77
+ expect(readPersistedThemeSync('k', storage)).toEqual({
78
+ kind: 'sync',
79
+ state: { mode: 'dark', colorPreset: undefined },
80
+ });
81
+ });
82
+
83
+ it('accepts partial payloads (preset only)', () => {
84
+ const storage = createSyncStorage({ k: JSON.stringify({ colorPreset: 'purple' }) });
85
+ expect(readPersistedThemeSync('k', storage)).toEqual({
86
+ kind: 'sync',
87
+ state: { mode: undefined, colorPreset: 'purple' },
88
+ });
89
+ });
90
+
91
+ it('handles thrown getItem as sync/null', () => {
92
+ const storage: BloomThemeStorage = {
93
+ getItem: () => {
94
+ throw new Error('boom');
95
+ },
96
+ setItem: () => undefined,
97
+ };
98
+ expect(readPersistedThemeSync('k', storage)).toEqual({ kind: 'sync', state: null });
99
+ });
100
+ });
101
+
102
+ describe('readPersistedTheme', () => {
103
+ it('reads async storage', async () => {
104
+ const storage = createAsyncStorage({ k: JSON.stringify({ mode: 'dark' }) });
105
+ await expect(readPersistedTheme('k', storage)).resolves.toEqual({
106
+ mode: 'dark',
107
+ colorPreset: undefined,
108
+ });
109
+ });
110
+
111
+ it('returns null when persistence is off', async () => {
112
+ await expect(readPersistedTheme(undefined, createAsyncStorage())).resolves.toBeNull();
113
+ await expect(readPersistedTheme('k', undefined)).resolves.toBeNull();
114
+ });
115
+
116
+ it('swallows storage errors', async () => {
117
+ const storage: BloomThemeStorage = {
118
+ getItem: () => Promise.reject(new Error('boom')),
119
+ setItem: () => Promise.resolve(),
120
+ };
121
+ await expect(readPersistedTheme('k', storage)).resolves.toBeNull();
122
+ });
123
+ });
124
+
125
+ describe('writePersistedTheme', () => {
126
+ it('serializes only defined keys', async () => {
127
+ const storage = createSyncStorage();
128
+ await writePersistedTheme('k', storage, { mode: 'dark' });
129
+ expect(JSON.parse(storage.store.k!)).toEqual({ mode: 'dark' });
130
+ });
131
+
132
+ it('serializes both keys when both are present', async () => {
133
+ const storage = createSyncStorage();
134
+ await writePersistedTheme('k', storage, { mode: 'light', colorPreset: 'oxy' });
135
+ expect(JSON.parse(storage.store.k!)).toEqual({ mode: 'light', colorPreset: 'oxy' });
136
+ });
137
+
138
+ it('is a no-op when persistence is off', async () => {
139
+ const storage = createSyncStorage();
140
+ await writePersistedTheme(undefined, storage, { mode: 'dark' });
141
+ expect(storage.store.k).toBeUndefined();
142
+ });
143
+
144
+ it('swallows storage errors', async () => {
145
+ const storage: BloomThemeStorage = {
146
+ getItem: () => null,
147
+ setItem: () => {
148
+ throw new Error('boom');
149
+ },
150
+ };
151
+ await expect(
152
+ writePersistedTheme('k', storage, { mode: 'dark' }),
153
+ ).resolves.toBeUndefined();
154
+ });
155
+ });
@@ -4,7 +4,7 @@ import {
4
4
  HEX_TO_APP_COLOR,
5
5
  hexToAppColorName,
6
6
  } from '../theme/color-presets';
7
- import { buildTheme } from '../theme/BloomThemeProvider';
7
+ import { buildTheme } from '../theme/build-theme';
8
8
  import type { Theme, ThemeColors, ThemeMode } from '../theme/types';
9
9
 
10
10
  describe('Theme system', () => {
@@ -0,0 +1,44 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+
3
+ export interface UseControllableStateOptions<T> {
4
+ /** Controlled value. When defined, the hook does not own internal state. */
5
+ value?: T;
6
+ /** Initial value used when uncontrolled. */
7
+ defaultValue: T;
8
+ /** Notified whenever the value changes, controlled or not. */
9
+ onChange?: (next: T) => void;
10
+ }
11
+
12
+ /**
13
+ * Canonical Radix/shadcn-style controllable state hook. Returns a tuple of the
14
+ * current value and a setter that:
15
+ * - updates internal state when uncontrolled, and
16
+ * - always calls `onChange` (so controlled parents stay in sync).
17
+ *
18
+ * Switching between controlled and uncontrolled at runtime is supported but
19
+ * discouraged; the hook keeps the latest `value` for reads either way.
20
+ */
21
+ export function useControllableState<T>({
22
+ value,
23
+ defaultValue,
24
+ onChange,
25
+ }: UseControllableStateOptions<T>): [T, (next: T) => void] {
26
+ const [internal, setInternal] = useState<T>(defaultValue);
27
+ const isControlled = value !== undefined;
28
+ const current = isControlled ? (value as T) : internal;
29
+
30
+ const onChangeRef = useRef(onChange);
31
+ onChangeRef.current = onChange;
32
+
33
+ const setValue = useCallback(
34
+ (next: T) => {
35
+ if (!isControlled) {
36
+ setInternal(next);
37
+ }
38
+ onChangeRef.current?.(next);
39
+ },
40
+ [isControlled],
41
+ );
42
+
43
+ return [current, setValue];
44
+ }