@oxyhq/bloom 0.6.12 → 0.6.14
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/lib/commonjs/hooks/useControllableState.js +35 -0
- package/lib/commonjs/hooks/useControllableState.js.map +1 -0
- package/lib/commonjs/theme/BloomThemeProvider.js +134 -112
- package/lib/commonjs/theme/BloomThemeProvider.js.map +1 -1
- package/lib/commonjs/theme/apply-dark-class.js +6 -1
- package/lib/commonjs/theme/apply-dark-class.js.map +1 -1
- package/lib/commonjs/theme/build-theme.js +110 -0
- package/lib/commonjs/theme/build-theme.js.map +1 -0
- package/lib/commonjs/theme/color-presets.js +15 -0
- package/lib/commonjs/theme/color-presets.js.map +1 -1
- package/lib/commonjs/theme/index.js +15 -1
- package/lib/commonjs/theme/index.js.map +1 -1
- package/lib/commonjs/theme/persistence.js +127 -0
- package/lib/commonjs/theme/persistence.js.map +1 -0
- package/lib/commonjs/theme/use-isomorphic-layout-effect.js +15 -0
- package/lib/commonjs/theme/use-isomorphic-layout-effect.js.map +1 -0
- package/lib/module/hooks/useControllableState.js +31 -0
- package/lib/module/hooks/useControllableState.js.map +1 -0
- package/lib/module/theme/BloomThemeProvider.js +135 -112
- package/lib/module/theme/BloomThemeProvider.js.map +1 -1
- package/lib/module/theme/apply-dark-class.js +6 -1
- package/lib/module/theme/apply-dark-class.js.map +1 -1
- package/lib/module/theme/build-theme.js +105 -0
- package/lib/module/theme/build-theme.js.map +1 -0
- package/lib/module/theme/color-presets.js +15 -0
- package/lib/module/theme/color-presets.js.map +1 -1
- package/lib/module/theme/index.js +3 -1
- package/lib/module/theme/index.js.map +1 -1
- package/lib/module/theme/persistence.js +120 -0
- package/lib/module/theme/persistence.js.map +1 -0
- package/lib/module/theme/use-isomorphic-layout-effect.js +12 -0
- package/lib/module/theme/use-isomorphic-layout-effect.js.map +1 -0
- package/lib/typescript/commonjs/hooks/useControllableState.d.ts +19 -0
- package/lib/typescript/commonjs/hooks/useControllableState.d.ts.map +1 -0
- package/lib/typescript/commonjs/theme/BloomThemeProvider.d.ts +29 -12
- package/lib/typescript/commonjs/theme/BloomThemeProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/apply-dark-class.d.ts +5 -0
- package/lib/typescript/commonjs/theme/apply-dark-class.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/build-theme.d.ts +20 -0
- package/lib/typescript/commonjs/theme/build-theme.d.ts.map +1 -0
- package/lib/typescript/commonjs/theme/color-presets.d.ts +18 -3
- package/lib/typescript/commonjs/theme/color-presets.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/index.d.ts +7 -4
- package/lib/typescript/commonjs/theme/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/persistence.d.ts +48 -0
- package/lib/typescript/commonjs/theme/persistence.d.ts.map +1 -0
- package/lib/typescript/commonjs/theme/use-isomorphic-layout-effect.d.ts +8 -0
- package/lib/typescript/commonjs/theme/use-isomorphic-layout-effect.d.ts.map +1 -0
- package/lib/typescript/module/hooks/useControllableState.d.ts +19 -0
- package/lib/typescript/module/hooks/useControllableState.d.ts.map +1 -0
- package/lib/typescript/module/theme/BloomThemeProvider.d.ts +29 -12
- package/lib/typescript/module/theme/BloomThemeProvider.d.ts.map +1 -1
- package/lib/typescript/module/theme/apply-dark-class.d.ts +5 -0
- package/lib/typescript/module/theme/apply-dark-class.d.ts.map +1 -1
- package/lib/typescript/module/theme/build-theme.d.ts +20 -0
- package/lib/typescript/module/theme/build-theme.d.ts.map +1 -0
- package/lib/typescript/module/theme/color-presets.d.ts +18 -3
- package/lib/typescript/module/theme/color-presets.d.ts.map +1 -1
- package/lib/typescript/module/theme/index.d.ts +7 -4
- package/lib/typescript/module/theme/index.d.ts.map +1 -1
- package/lib/typescript/module/theme/persistence.d.ts +48 -0
- package/lib/typescript/module/theme/persistence.d.ts.map +1 -0
- package/lib/typescript/module/theme/use-isomorphic-layout-effect.d.ts +8 -0
- package/lib/typescript/module/theme/use-isomorphic-layout-effect.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/BloomThemeProvider.modes.test.tsx +159 -0
- package/src/__tests__/BloomThemeProvider.persistence.test.tsx +217 -0
- package/src/__tests__/persistence.test.ts +155 -0
- package/src/__tests__/theme.test.ts +1 -1
- package/src/hooks/useControllableState.ts +44 -0
- package/src/theme/BloomThemeProvider.tsx +219 -157
- package/src/theme/apply-dark-class.ts +6 -1
- package/src/theme/build-theme.ts +128 -0
- package/src/theme/color-presets.ts +19 -3
- package/src/theme/index.ts +16 -4
- package/src/theme/persistence.ts +149 -0
- package/src/theme/use-isomorphic-layout-effect.ts +10 -0
|
@@ -0,0 +1,217 @@
|
|
|
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
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createAsyncStorage(initial?: PersistedThemeState): BloomThemeStorage & {
|
|
36
|
+
store: Record<string, string>;
|
|
37
|
+
} {
|
|
38
|
+
const store: Record<string, string> = {};
|
|
39
|
+
if (initial) {
|
|
40
|
+
store['bloom-theme'] = JSON.stringify(initial);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
store,
|
|
44
|
+
getItem: (key) => Promise.resolve(key in store ? store[key]! : null),
|
|
45
|
+
setItem: (key, value) => {
|
|
46
|
+
store[key] = value;
|
|
47
|
+
return Promise.resolve();
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('BloomThemeProvider — persistence', () => {
|
|
53
|
+
it('hydrates synchronously from sync storage before first paint', () => {
|
|
54
|
+
const storage = createSyncStorage({ mode: 'dark', colorPreset: 'blue' });
|
|
55
|
+
|
|
56
|
+
const { getByTestId } = render(
|
|
57
|
+
<BloomThemeProvider persistKey="bloom-theme" storage={storage}>
|
|
58
|
+
<Display />
|
|
59
|
+
</BloomThemeProvider>,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(getByTestId('mode').props.children).toBe('dark');
|
|
63
|
+
expect(getByTestId('preset').props.children).toBe('blue');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('falls back to defaults when storage is empty', () => {
|
|
67
|
+
const storage = createSyncStorage();
|
|
68
|
+
|
|
69
|
+
const { getByTestId } = render(
|
|
70
|
+
<BloomThemeProvider
|
|
71
|
+
persistKey="bloom-theme"
|
|
72
|
+
storage={storage}
|
|
73
|
+
defaultMode="light"
|
|
74
|
+
defaultColorPreset="teal"
|
|
75
|
+
>
|
|
76
|
+
<Display />
|
|
77
|
+
</BloomThemeProvider>,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(getByTestId('mode').props.children).toBe('light');
|
|
81
|
+
expect(getByTestId('preset').props.children).toBe('teal');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('writes through to storage when setMode is called', async () => {
|
|
85
|
+
const storage = createSyncStorage();
|
|
86
|
+
|
|
87
|
+
function Controls() {
|
|
88
|
+
const ctx = useBloomTheme();
|
|
89
|
+
return <Text testID="trigger" onPress={() => ctx.setMode('dark')} />;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { getByTestId } = render(
|
|
93
|
+
<BloomThemeProvider persistKey="bloom-theme" storage={storage}>
|
|
94
|
+
<Display />
|
|
95
|
+
<Controls />
|
|
96
|
+
</BloomThemeProvider>,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
await act(async () => {
|
|
100
|
+
getByTestId('trigger').props.onPress();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(getByTestId('mode').props.children).toBe('dark');
|
|
104
|
+
expect(JSON.parse(storage.store['bloom-theme']!)).toMatchObject({ mode: 'dark' });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('writes through to storage when setColorPreset is called', async () => {
|
|
108
|
+
const storage = createSyncStorage();
|
|
109
|
+
|
|
110
|
+
function Controls() {
|
|
111
|
+
const ctx = useBloomTheme();
|
|
112
|
+
return <Text testID="trigger" onPress={() => ctx.setColorPreset('purple')} />;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { getByTestId } = render(
|
|
116
|
+
<BloomThemeProvider persistKey="bloom-theme" storage={storage}>
|
|
117
|
+
<Display />
|
|
118
|
+
<Controls />
|
|
119
|
+
</BloomThemeProvider>,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
await act(async () => {
|
|
123
|
+
getByTestId('trigger').props.onPress();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(getByTestId('preset').props.children).toBe('purple');
|
|
127
|
+
expect(JSON.parse(storage.store['bloom-theme']!)).toMatchObject({ colorPreset: 'purple' });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('hydrates asynchronously when the storage adapter is async', async () => {
|
|
131
|
+
const storage = createAsyncStorage({ mode: 'dark', colorPreset: 'pink' });
|
|
132
|
+
|
|
133
|
+
const { getByTestId } = render(
|
|
134
|
+
<BloomThemeProvider
|
|
135
|
+
persistKey="bloom-theme"
|
|
136
|
+
storage={storage}
|
|
137
|
+
awaitHydration={false}
|
|
138
|
+
defaultMode="light"
|
|
139
|
+
defaultColorPreset="oxy"
|
|
140
|
+
>
|
|
141
|
+
<Display />
|
|
142
|
+
</BloomThemeProvider>,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Before async hydration completes: defaults
|
|
146
|
+
expect(getByTestId('mode').props.children).toBe('light');
|
|
147
|
+
expect(getByTestId('preset').props.children).toBe('oxy');
|
|
148
|
+
|
|
149
|
+
// After hydration: persisted values
|
|
150
|
+
await waitFor(() => {
|
|
151
|
+
expect(getByTestId('mode').props.children).toBe('dark');
|
|
152
|
+
});
|
|
153
|
+
expect(getByTestId('preset').props.children).toBe('pink');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('gates children behind onHydrating when awaitHydration is true', async () => {
|
|
157
|
+
const storage = createAsyncStorage({ mode: 'dark', colorPreset: 'pink' });
|
|
158
|
+
|
|
159
|
+
const { queryByTestId, getByTestId } = render(
|
|
160
|
+
<BloomThemeProvider
|
|
161
|
+
persistKey="bloom-theme"
|
|
162
|
+
storage={storage}
|
|
163
|
+
awaitHydration
|
|
164
|
+
onHydrating={<Text testID="loading">loading</Text>}
|
|
165
|
+
>
|
|
166
|
+
<Display />
|
|
167
|
+
</BloomThemeProvider>,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
expect(queryByTestId('loading')).not.toBeNull();
|
|
171
|
+
expect(queryByTestId('mode')).toBeNull();
|
|
172
|
+
|
|
173
|
+
await waitFor(() => {
|
|
174
|
+
expect(queryByTestId('mode')).not.toBeNull();
|
|
175
|
+
});
|
|
176
|
+
expect(getByTestId('mode').props.children).toBe('dark');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('ignores malformed persisted state', () => {
|
|
180
|
+
const storage: BloomThemeStorage = {
|
|
181
|
+
getItem: () => 'not-json',
|
|
182
|
+
setItem: () => undefined,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const { getByTestId } = render(
|
|
186
|
+
<BloomThemeProvider
|
|
187
|
+
persistKey="bloom-theme"
|
|
188
|
+
storage={storage}
|
|
189
|
+
defaultMode="light"
|
|
190
|
+
defaultColorPreset="teal"
|
|
191
|
+
>
|
|
192
|
+
<Display />
|
|
193
|
+
</BloomThemeProvider>,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
expect(getByTestId('mode').props.children).toBe('light');
|
|
197
|
+
expect(getByTestId('preset').props.children).toBe('teal');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('controlled props win over persisted state', () => {
|
|
201
|
+
const storage = createSyncStorage({ mode: 'dark', colorPreset: 'blue' });
|
|
202
|
+
|
|
203
|
+
const { getByTestId } = render(
|
|
204
|
+
<BloomThemeProvider
|
|
205
|
+
persistKey="bloom-theme"
|
|
206
|
+
storage={storage}
|
|
207
|
+
mode="light"
|
|
208
|
+
colorPreset="oxy"
|
|
209
|
+
>
|
|
210
|
+
<Display />
|
|
211
|
+
</BloomThemeProvider>,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(getByTestId('mode').props.children).toBe('light');
|
|
215
|
+
expect(getByTestId('preset').props.children).toBe('oxy');
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -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/
|
|
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
|
+
}
|