@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.
Files changed (77) 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 +134 -112
  4. package/lib/commonjs/theme/BloomThemeProvider.js.map +1 -1
  5. package/lib/commonjs/theme/apply-dark-class.js +6 -1
  6. package/lib/commonjs/theme/apply-dark-class.js.map +1 -1
  7. package/lib/commonjs/theme/build-theme.js +110 -0
  8. package/lib/commonjs/theme/build-theme.js.map +1 -0
  9. package/lib/commonjs/theme/color-presets.js +15 -0
  10. package/lib/commonjs/theme/color-presets.js.map +1 -1
  11. package/lib/commonjs/theme/index.js +15 -1
  12. package/lib/commonjs/theme/index.js.map +1 -1
  13. package/lib/commonjs/theme/persistence.js +127 -0
  14. package/lib/commonjs/theme/persistence.js.map +1 -0
  15. package/lib/commonjs/theme/use-isomorphic-layout-effect.js +15 -0
  16. package/lib/commonjs/theme/use-isomorphic-layout-effect.js.map +1 -0
  17. package/lib/module/hooks/useControllableState.js +31 -0
  18. package/lib/module/hooks/useControllableState.js.map +1 -0
  19. package/lib/module/theme/BloomThemeProvider.js +135 -112
  20. package/lib/module/theme/BloomThemeProvider.js.map +1 -1
  21. package/lib/module/theme/apply-dark-class.js +6 -1
  22. package/lib/module/theme/apply-dark-class.js.map +1 -1
  23. package/lib/module/theme/build-theme.js +105 -0
  24. package/lib/module/theme/build-theme.js.map +1 -0
  25. package/lib/module/theme/color-presets.js +15 -0
  26. package/lib/module/theme/color-presets.js.map +1 -1
  27. package/lib/module/theme/index.js +3 -1
  28. package/lib/module/theme/index.js.map +1 -1
  29. package/lib/module/theme/persistence.js +120 -0
  30. package/lib/module/theme/persistence.js.map +1 -0
  31. package/lib/module/theme/use-isomorphic-layout-effect.js +12 -0
  32. package/lib/module/theme/use-isomorphic-layout-effect.js.map +1 -0
  33. package/lib/typescript/commonjs/hooks/useControllableState.d.ts +19 -0
  34. package/lib/typescript/commonjs/hooks/useControllableState.d.ts.map +1 -0
  35. package/lib/typescript/commonjs/theme/BloomThemeProvider.d.ts +29 -12
  36. package/lib/typescript/commonjs/theme/BloomThemeProvider.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/theme/apply-dark-class.d.ts +5 -0
  38. package/lib/typescript/commonjs/theme/apply-dark-class.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/theme/build-theme.d.ts +20 -0
  40. package/lib/typescript/commonjs/theme/build-theme.d.ts.map +1 -0
  41. package/lib/typescript/commonjs/theme/color-presets.d.ts +18 -3
  42. package/lib/typescript/commonjs/theme/color-presets.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/theme/index.d.ts +7 -4
  44. package/lib/typescript/commonjs/theme/index.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/theme/persistence.d.ts +48 -0
  46. package/lib/typescript/commonjs/theme/persistence.d.ts.map +1 -0
  47. package/lib/typescript/commonjs/theme/use-isomorphic-layout-effect.d.ts +8 -0
  48. package/lib/typescript/commonjs/theme/use-isomorphic-layout-effect.d.ts.map +1 -0
  49. package/lib/typescript/module/hooks/useControllableState.d.ts +19 -0
  50. package/lib/typescript/module/hooks/useControllableState.d.ts.map +1 -0
  51. package/lib/typescript/module/theme/BloomThemeProvider.d.ts +29 -12
  52. package/lib/typescript/module/theme/BloomThemeProvider.d.ts.map +1 -1
  53. package/lib/typescript/module/theme/apply-dark-class.d.ts +5 -0
  54. package/lib/typescript/module/theme/apply-dark-class.d.ts.map +1 -1
  55. package/lib/typescript/module/theme/build-theme.d.ts +20 -0
  56. package/lib/typescript/module/theme/build-theme.d.ts.map +1 -0
  57. package/lib/typescript/module/theme/color-presets.d.ts +18 -3
  58. package/lib/typescript/module/theme/color-presets.d.ts.map +1 -1
  59. package/lib/typescript/module/theme/index.d.ts +7 -4
  60. package/lib/typescript/module/theme/index.d.ts.map +1 -1
  61. package/lib/typescript/module/theme/persistence.d.ts +48 -0
  62. package/lib/typescript/module/theme/persistence.d.ts.map +1 -0
  63. package/lib/typescript/module/theme/use-isomorphic-layout-effect.d.ts +8 -0
  64. package/lib/typescript/module/theme/use-isomorphic-layout-effect.d.ts.map +1 -0
  65. package/package.json +1 -1
  66. package/src/__tests__/BloomThemeProvider.modes.test.tsx +159 -0
  67. package/src/__tests__/BloomThemeProvider.persistence.test.tsx +217 -0
  68. package/src/__tests__/persistence.test.ts +155 -0
  69. package/src/__tests__/theme.test.ts +1 -1
  70. package/src/hooks/useControllableState.ts +44 -0
  71. package/src/theme/BloomThemeProvider.tsx +219 -157
  72. package/src/theme/apply-dark-class.ts +6 -1
  73. package/src/theme/build-theme.ts +128 -0
  74. package/src/theme/color-presets.ts +19 -3
  75. package/src/theme/index.ts +16 -4
  76. package/src/theme/persistence.ts +149 -0
  77. 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/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
+ }