@fibery/ui-kit 1.40.3 → 1.41.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.
Files changed (174) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/eslint.config.mjs +16 -0
  3. package/package.json +11 -13
  4. package/src/a11y-color.test.ts +162 -0
  5. package/src/a11y-color.ts +13 -20
  6. package/src/actions-menu/actions-menu-item.tsx +9 -12
  7. package/src/actions-menu/context-actions-menu.tsx +8 -3
  8. package/src/ai/model.tsx +0 -1
  9. package/src/ai/temperature.tsx +0 -1
  10. package/src/animated-height-container.tsx +3 -3
  11. package/src/antd/ant-modal.tsx +10 -5
  12. package/src/antd/index.tsx +3 -0
  13. package/src/antd/input.tsx +0 -1
  14. package/src/antd/styles.ts +15 -15
  15. package/src/app-icon-with-fallback.tsx +6 -6
  16. package/src/app-icon-wrapper.tsx +2 -3
  17. package/src/app-icon.tsx +4 -4
  18. package/src/avatar.tsx +3 -3
  19. package/src/button/base-button.tsx +0 -1
  20. package/src/button/button.tsx +1 -0
  21. package/src/button/make-button-colors.ts +5 -13
  22. package/src/checkbox.tsx +2 -2
  23. package/src/collapsible-section.tsx +11 -1
  24. package/src/color-picker/ColorPickerOrLoader.js +2 -2
  25. package/src/color-utils.test.ts +307 -0
  26. package/src/color-utils.ts +215 -0
  27. package/src/command-menu/index.tsx +1 -0
  28. package/src/comment.tsx +3 -2
  29. package/src/context-menu/index.tsx +12 -7
  30. package/src/date-picker/contexts.ts +2 -2
  31. package/src/date-picker/date-range-picker.tsx +3 -4
  32. package/src/date-picker/single-date-picker.tsx +147 -119
  33. package/src/date-picker/types.ts +4 -3
  34. package/src/day-select/iso-week-day-select.tsx +2 -2
  35. package/src/day-select/week-day-select.tsx +2 -2
  36. package/src/delayed.tsx +0 -1
  37. package/src/design-system/alpha.test.ts +59 -0
  38. package/src/design-system/alpha.ts +41 -0
  39. package/src/design-system/animation.ts +10 -0
  40. package/src/design-system/colors-css.test.ts +39 -0
  41. package/src/design-system/colors-css.ts +25 -0
  42. package/src/design-system/colors-js.test.ts +232 -0
  43. package/src/design-system/colors-js.ts +107 -0
  44. package/src/design-system/colors.test.ts +74 -0
  45. package/src/design-system/colors.ts +759 -0
  46. package/src/design-system/date.ts +3 -0
  47. package/src/design-system/layout.ts +106 -0
  48. package/src/design-system/theme.test.ts +94 -0
  49. package/src/design-system/theme.ts +93 -0
  50. package/src/design-system/typography.ts +179 -0
  51. package/src/design-system/vars.test.ts +1679 -0
  52. package/src/design-system/vars.ts +52 -0
  53. package/src/design-system.test.ts +45 -22
  54. package/src/design-system.ts +25 -1339
  55. package/src/dropdown-menu/index.tsx +21 -16
  56. package/src/emoji-picker/app-icon-picker.tsx +5 -5
  57. package/src/emoji-picker/emoji-picker-content-with-color.tsx +4 -4
  58. package/src/emoji-picker/icon-emoji-picker.tsx +3 -3
  59. package/src/favorites-icon.tsx +1 -1
  60. package/src/fibermoji-placeholder.tsx +3 -3
  61. package/src/field-container.tsx +11 -2
  62. package/src/file-item/file-icon.tsx +169 -0
  63. package/src/file-item/file-menu-items.tsx +68 -0
  64. package/src/file-item/file-preview-actions.tsx +38 -0
  65. package/src/file-item/file-title.tsx +48 -0
  66. package/src/file-item/types.ts +27 -0
  67. package/src/file-item/use-register-in-image-gallery.tsx +70 -0
  68. package/src/file-item-2.tsx +32 -348
  69. package/src/file-item.tsx +6 -3
  70. package/src/hue-shift.test.ts +91 -0
  71. package/src/icons/ast/ChatBubble.ts +8 -0
  72. package/src/icons/ast/ChatFloat.ts +8 -0
  73. package/src/icons/ast/ChatSidebar.ts +8 -0
  74. package/src/icons/ast/FileCounter.ts +8 -0
  75. package/src/icons/ast/FileMultiple.ts +8 -0
  76. package/src/icons/ast/FileOther.ts +8 -0
  77. package/src/icons/ast/NoBorder.ts +8 -0
  78. package/src/icons/ast/NoFill.ts +8 -0
  79. package/src/icons/ast/ValueEdit.ts +8 -0
  80. package/src/icons/ast/index.tsx +9 -0
  81. package/src/icons/react/ChatBubble.tsx +13 -0
  82. package/src/icons/react/ChatFloat.tsx +13 -0
  83. package/src/icons/react/ChatSidebar.tsx +13 -0
  84. package/src/icons/react/FileCounter.tsx +13 -0
  85. package/src/icons/react/FileMultiple.tsx +13 -0
  86. package/src/icons/react/FileOther.tsx +13 -0
  87. package/src/icons/react/NoBorder.tsx +13 -0
  88. package/src/icons/react/NoFill.tsx +13 -0
  89. package/src/icons/react/ValueEdit.tsx +13 -0
  90. package/src/icons/react/index.tsx +9 -0
  91. package/src/icons/svg/chat-bubble.svg +4 -0
  92. package/src/icons/svg/chat-float.svg +4 -0
  93. package/src/icons/svg/chat-sidebar.svg +4 -0
  94. package/src/icons/svg/file-counter.svg +3 -0
  95. package/src/icons/svg/file-multiple.svg +3 -0
  96. package/src/icons/svg/file-other.svg +3 -0
  97. package/src/icons/svg/no-border.svg +17 -0
  98. package/src/icons/svg/no-fill.svg +4 -0
  99. package/src/icons/svg/value-edit.svg +3 -0
  100. package/src/images-gallery/images-gallery.tsx +8 -6
  101. package/src/images-gallery/slide-buttons.tsx +4 -11
  102. package/src/is-iOS.ts +0 -1
  103. package/src/is-in-popup.ts +2 -1
  104. package/src/lists/actions-menu-row-surface.tsx +7 -7
  105. package/src/loading-sausage.tsx +2 -2
  106. package/src/media-query-utils.ts +1 -2
  107. package/src/mobile-keyboard-aware-popup.tsx +6 -5
  108. package/src/modal-menu/modal-menu-content.tsx +2 -2
  109. package/src/number-input/decimal.js +9 -7
  110. package/src/number-input/{number-inline-input-with-autosize.tsx → number-input-inline-with-autosize.tsx} +20 -41
  111. package/src/number-input/number-input-inline.tsx +118 -0
  112. package/src/number-input/{index.test.js → number-input.test.tsx} +38 -43
  113. package/src/number-input/number-input.tsx +63 -121
  114. package/src/number-input/types.ts +19 -0
  115. package/src/number-input/utils.ts +61 -0
  116. package/src/online-users.tsx +2 -2
  117. package/src/palette-generator.test.ts +309 -0
  118. package/src/palette-generator.ts +160 -0
  119. package/src/palettes/_.ts +72 -0
  120. package/src/palettes/slate-arch.colors-snapshot.test.ts +1689 -0
  121. package/src/palettes/slate-arch.ts +188 -0
  122. package/src/palettes/slate-user.colors-snapshot.test.ts +1689 -0
  123. package/src/palettes/slate-user.ts +187 -0
  124. package/src/palettes/warm-arch.colors-snapshot.test.ts +1689 -0
  125. package/src/palettes/warm-arch.ts +222 -0
  126. package/src/palettes/warm-user.colors-snapshot.test.ts +1689 -0
  127. package/src/palettes/warm-user.ts +222 -0
  128. package/src/platform.ts +0 -3
  129. package/src/popover/get-element-ref.ts +28 -0
  130. package/src/popover/index.tsx +236 -339
  131. package/src/popover/{mobile-popup-context.tsx → mobile-popover-context.tsx} +7 -7
  132. package/src/popover/mobile-popover.tsx +169 -0
  133. package/src/popover/modifiers.tsx +2 -2
  134. package/src/popover/popup-stack-context.tsx +8 -9
  135. package/src/progress.tsx +2 -2
  136. package/src/reactions/reaction-button.tsx +12 -6
  137. package/src/root-theme-provider.test.tsx +411 -0
  138. package/src/scale-generator.ts +356 -0
  139. package/src/select/components/menu-list-virtualized.tsx +12 -25
  140. package/src/select/components/menu.tsx +12 -2
  141. package/src/select/index.tsx +6 -5
  142. package/src/select/select.tsx +38 -39
  143. package/src/select/styles.ts +0 -1
  144. package/src/select/util.ts +1 -1
  145. package/src/static-palettes.ts +356 -0
  146. package/src/thematic-color-picker.tsx +266 -0
  147. package/src/thematic-constants.tsx +27 -0
  148. package/src/thematic-controls.tsx +144 -0
  149. package/src/thematic-scales.tsx +122 -0
  150. package/src/thematic-state.ts +370 -0
  151. package/src/thematic.tsx +386 -0
  152. package/src/theme-provider.test.tsx +820 -0
  153. package/src/theme-provider.tsx +158 -92
  154. package/src/theme-settings.ts +67 -12
  155. package/src/theme-styles.ts +58 -6
  156. package/src/toast/toast-action.tsx +1 -1
  157. package/src/toast/toast.tsx +1 -2
  158. package/src/toggle-on-off.tsx +2 -2
  159. package/src/toggle.tsx +7 -8
  160. package/src/tooltip.tsx +14 -10
  161. package/src/type-badge.tsx +7 -14
  162. package/src/unit/styles.ts +2 -25
  163. package/src/unit/unit-with-tooltip.tsx +3 -2
  164. package/src/use-is-phone.tsx +7 -2
  165. package/src/use-long-press.tsx +2 -2
  166. package/src/use-on-screen-keyboard-data.tsx +2 -2
  167. package/src/with-data.tsx +4 -3
  168. package/src/workflow-progress-icon.tsx +2 -2
  169. package/.eslintignore +0 -3
  170. package/.eslintrc +0 -14
  171. package/src/__mocks__/createInlineTheme.js +0 -3
  172. package/src/create-inline-theme.ts +0 -66
  173. package/src/number-input/index.js +0 -191
  174. package/src/palette.ts +0 -237
@@ -0,0 +1,820 @@
1
+ /* eslint-disable max-lines, no-console */
2
+ import {render, screen} from "@testing-library/react";
3
+ import {
4
+ MenuThemeProvider,
5
+ RootThemeProvider,
6
+ ThemeProvider,
7
+ useTheme,
8
+ useThemeMenuPreference,
9
+ useThemeMode,
10
+ useThemePreference,
11
+ useThemeStyles,
12
+ } from "./theme-provider";
13
+ import {defaultDarkTheme, defaultLightTheme, defaultLightThemeStyles} from "./theme-styles";
14
+ import {createInlineStyles, getThemeColors, themeVars} from "./design-system";
15
+ import {makeDefaultPalette} from "./palette-generator";
16
+
17
+ const palette = makeDefaultPalette();
18
+
19
+ function ThemeDisplay({testId = "theme-display"}: {testId?: string}) {
20
+ const theme = useTheme();
21
+ const mode = useThemeMode();
22
+ return (
23
+ <div data-testid={testId} data-mode={mode} data-primary={theme.primary}>
24
+ {theme.key}
25
+ </div>
26
+ );
27
+ }
28
+
29
+ describe("ThemeProvider", () => {
30
+ describe("basic rendering", () => {
31
+ it("renders children", () => {
32
+ render(
33
+ <ThemeProvider theme={defaultLightTheme}>
34
+ <div data-testid="child">Hello</div>
35
+ </ThemeProvider>
36
+ );
37
+ expect(screen.getByTestId("child")).toHaveTextContent("Hello");
38
+ });
39
+
40
+ it("provides theme via useTheme", () => {
41
+ render(
42
+ <ThemeProvider theme={defaultLightTheme}>
43
+ <ThemeDisplay />
44
+ </ThemeProvider>
45
+ );
46
+ expect(screen.getByTestId("theme-display")).toHaveAttribute("data-mode", "light");
47
+ });
48
+
49
+ it("provides dark theme correctly", () => {
50
+ render(
51
+ <ThemeProvider theme={defaultDarkTheme}>
52
+ <ThemeDisplay />
53
+ </ThemeProvider>
54
+ );
55
+ expect(screen.getByTestId("theme-display")).toHaveAttribute("data-mode", "dark");
56
+ });
57
+ });
58
+
59
+ describe("diff behavior", () => {
60
+ it("when theme equals parent, applies no styles", () => {
61
+ const {container} = render(
62
+ <ThemeProvider theme={defaultLightTheme}>
63
+ <ThemeProvider theme={defaultLightTheme}>
64
+ <ThemeDisplay />
65
+ </ThemeProvider>
66
+ </ThemeProvider>
67
+ );
68
+
69
+ const innerDiv = container.querySelectorAll("[style]")[1];
70
+ const style = innerDiv?.getAttribute("style") || "";
71
+ expect(style).toBe("");
72
+ });
73
+
74
+ it("nested provider only sets properties that differ from parent", () => {
75
+ const lightTheme = getThemeColors(null, "light", palette);
76
+ const darkTheme = getThemeColors(null, "dark", palette);
77
+
78
+ // Compare full inline themes to verify they differ
79
+ const lightStyles = createInlineStyles(lightTheme);
80
+ const darkStyles = createInlineStyles(darkTheme);
81
+
82
+ // Count how many properties differ between light and dark
83
+ let diffCount = 0;
84
+ for (const key in darkStyles) {
85
+ if (darkStyles[key] !== lightStyles[key]) {
86
+ diffCount++;
87
+ }
88
+ }
89
+
90
+ // Dark and light themes should have differences
91
+ expect(diffCount).toBeGreaterThan(0);
92
+
93
+ // When nesting dark inside light, the inner provider should only set the diff
94
+ const {container} = render(
95
+ <ThemeProvider theme={lightTheme}>
96
+ <ThemeProvider theme={darkTheme}>
97
+ <ThemeDisplay />
98
+ </ThemeProvider>
99
+ </ThemeProvider>
100
+ );
101
+
102
+ // Get all divs (ThemeProvider creates divs with display:contents)
103
+ const allDivs = container.querySelectorAll("div");
104
+ // Find divs with style attribute
105
+ const styledDivs = Array.from(allDivs).filter((d) => d.hasAttribute("style"));
106
+
107
+ // Inner provider's div should have styles (the diff)
108
+ expect(styledDivs.length).toBeGreaterThanOrEqual(1);
109
+ });
110
+
111
+ it("deeply nested providers accumulate only diffs", () => {
112
+ const theme1 = getThemeColors(null, "light", palette);
113
+ const theme2 = getThemeColors(null, "dark", palette);
114
+ const theme3 = getThemeColors(null, "light", palette); // back to light
115
+
116
+ // Verify themes differ
117
+ const styles1 = createInlineStyles(theme1);
118
+ const styles2 = createInlineStyles(theme2);
119
+
120
+ let diffCount = 0;
121
+ for (const key in styles2) {
122
+ if (styles2[key] !== styles1[key]) {
123
+ diffCount++;
124
+ }
125
+ }
126
+ expect(diffCount).toBeGreaterThan(0);
127
+
128
+ const {container} = render(
129
+ <ThemeProvider theme={theme1}>
130
+ <ThemeProvider theme={theme2}>
131
+ <ThemeProvider theme={theme3}>
132
+ <ThemeDisplay />
133
+ </ThemeProvider>
134
+ </ThemeProvider>
135
+ </ThemeProvider>
136
+ );
137
+
138
+ // Get styled divs
139
+ const allDivs = container.querySelectorAll("div");
140
+ const styledDivs = Array.from(allDivs).filter((d) => d.hasAttribute("style"));
141
+
142
+ // At least the middle one (dark) should have styles since it differs from light
143
+ // The innermost (light again) should also have styles since it differs from dark
144
+ expect(styledDivs.length).toBeGreaterThanOrEqual(2);
145
+ });
146
+ });
147
+
148
+ describe("context-based diff", () => {
149
+ it("diffs against parent context value", () => {
150
+ const lightTheme = getThemeColors(null, "light", palette);
151
+
152
+ // When parent context has same theme, diff should be empty
153
+ const {container} = render(
154
+ <ThemeProvider theme={lightTheme}>
155
+ <ThemeProvider theme={lightTheme}>
156
+ <ThemeDisplay />
157
+ </ThemeProvider>
158
+ </ThemeProvider>
159
+ );
160
+
161
+ // Find the inner ThemeProvider's div
162
+ const divs = Array.from(container.querySelectorAll("div"));
163
+ const styledDivs = divs.filter((d) => d.hasAttribute("style"));
164
+
165
+ // Inner should have empty style (same theme as parent)
166
+ if (styledDivs.length >= 2) {
167
+ const innerStyle = styledDivs[1]?.getAttribute("style") || "";
168
+ expect(innerStyle).toBe("");
169
+ }
170
+ });
171
+
172
+ it("setting context before ThemeProvider controls diff base", () => {
173
+ // Outer ThemeProvider sets context to dark theme
174
+ // Inner ThemeProvider with same theme should have empty diff
175
+ const {container} = render(
176
+ <ThemeProvider theme={defaultDarkTheme}>
177
+ <ThemeProvider theme={defaultDarkTheme}>
178
+ <ThemeDisplay />
179
+ </ThemeProvider>
180
+ </ThemeProvider>
181
+ );
182
+
183
+ const divs = Array.from(container.querySelectorAll("div"));
184
+ const styledDivs = divs.filter((d) => d.hasAttribute("style"));
185
+
186
+ // Inner should have empty style (same as parent context)
187
+ if (styledDivs.length >= 2) {
188
+ const innerStyle = styledDivs[1]?.getAttribute("style") || "";
189
+ expect(innerStyle).toBe("");
190
+ }
191
+ });
192
+ });
193
+
194
+ describe("rejectKeys", () => {
195
+ it("by default, all properties are included in diff", () => {
196
+ const lightTheme = getThemeColors(null, "light", palette);
197
+ const darkTheme = getThemeColors(null, "dark", palette);
198
+
199
+ const lightStyles = createInlineStyles(lightTheme);
200
+ const darkStyles = createInlineStyles(darkTheme);
201
+
202
+ let diffCount = 0;
203
+ for (const key in darkStyles) {
204
+ if (darkStyles[key] !== lightStyles[key]) {
205
+ diffCount++;
206
+ }
207
+ }
208
+
209
+ // Should have differences (dark differs from light)
210
+ expect(diffCount).toBeGreaterThan(0);
211
+
212
+ // Render and verify the diff is applied
213
+ const {container} = render(
214
+ <ThemeProvider theme={lightTheme}>
215
+ <ThemeProvider theme={darkTheme}>
216
+ <ThemeDisplay />
217
+ </ThemeProvider>
218
+ </ThemeProvider>
219
+ );
220
+
221
+ // At least one div should have styles
222
+ const styledDivs = Array.from(container.querySelectorAll("div")).filter((d) => d.hasAttribute("style"));
223
+ expect(styledDivs.length).toBeGreaterThan(0);
224
+ });
225
+
226
+ it("without rejectKeys, uses default reject keys (fewer properties)", () => {
227
+ const theme = getThemeColors(null, "dark", palette);
228
+
229
+ const {container: withEmptyReject} = render(
230
+ <ThemeProvider theme={defaultLightTheme}>
231
+ <ThemeProvider theme={theme}>
232
+ <ThemeDisplay testId="with-empty" />
233
+ </ThemeProvider>
234
+ </ThemeProvider>
235
+ );
236
+
237
+ const {container: withDefaultReject} = render(
238
+ <ThemeProvider theme={defaultLightTheme}>
239
+ <ThemeProvider theme={theme}>
240
+ <ThemeDisplay testId="with-default" />
241
+ </ThemeProvider>
242
+ </ThemeProvider>
243
+ );
244
+
245
+ const divsEmpty = Array.from(withEmptyReject.querySelectorAll("div")).filter((d) => d.hasAttribute("style"));
246
+ const divsDefault = Array.from(withDefaultReject.querySelectorAll("div")).filter((d) => d.hasAttribute("style"));
247
+
248
+ const styleWithEmpty = divsEmpty[1]?.getAttribute("style") || "";
249
+ const styleWithDefault = divsDefault[1]?.getAttribute("style") || "";
250
+
251
+ const countEmpty = styleWithEmpty.split(";").filter((s) => s.includes(":")).length;
252
+ const countDefault = styleWithDefault.split(";").filter((s) => s.includes(":")).length;
253
+
254
+ // Default rejectKeys should result in fewer or equal properties
255
+ expect(countDefault).toBeLessThanOrEqual(countEmpty);
256
+ });
257
+ });
258
+
259
+ describe("useTheme and useThemeMode", () => {
260
+ it("useTheme returns current theme in nested context", () => {
261
+ const lightTheme = getThemeColors(null, "light", palette);
262
+ const darkTheme = getThemeColors(null, "dark", palette);
263
+
264
+ render(
265
+ <ThemeProvider theme={lightTheme}>
266
+ <ThemeDisplay testId="outer" />
267
+ <ThemeProvider theme={darkTheme}>
268
+ <ThemeDisplay testId="inner" />
269
+ </ThemeProvider>
270
+ </ThemeProvider>
271
+ );
272
+
273
+ expect(screen.getByTestId("outer")).toHaveAttribute("data-mode", "light");
274
+ expect(screen.getByTestId("inner")).toHaveAttribute("data-mode", "dark");
275
+ });
276
+
277
+ it("useThemeMode returns correct mode for each nesting level", () => {
278
+ render(
279
+ <ThemeProvider mode="light">
280
+ <ThemeDisplay testId="level1" />
281
+ <ThemeProvider mode="dark">
282
+ <ThemeDisplay testId="level2" />
283
+ <ThemeProvider mode="light">
284
+ <ThemeDisplay testId="level3" />
285
+ </ThemeProvider>
286
+ </ThemeProvider>
287
+ </ThemeProvider>
288
+ );
289
+
290
+ expect(screen.getByTestId("level1")).toHaveAttribute("data-mode", "light");
291
+ expect(screen.getByTestId("level2")).toHaveAttribute("data-mode", "dark");
292
+ expect(screen.getByTestId("level3")).toHaveAttribute("data-mode", "light");
293
+ });
294
+ });
295
+
296
+ describe("color prop", () => {
297
+ it("generates theme with specified color", () => {
298
+ const customColor = "#FF0000";
299
+
300
+ render(
301
+ <ThemeProvider color={customColor} mode="light">
302
+ <ThemeDisplay />
303
+ </ThemeProvider>
304
+ );
305
+
306
+ // Theme should be generated with the custom color
307
+ expect(screen.getByTestId("theme-display")).toBeInTheDocument();
308
+ });
309
+
310
+ it("color={var(--...)} is a no-op (same theme as parent)", () => {
311
+ const {container} = render(
312
+ <ThemeProvider mode="light">
313
+ <ThemeProvider color={themeVars.accentTextColor}>
314
+ <ThemeDisplay />
315
+ </ThemeProvider>
316
+ </ThemeProvider>
317
+ );
318
+
319
+ const divs = Array.from(container.querySelectorAll("div")).filter((d) => !d.hasAttribute("data-testid"));
320
+ const innerDiv = divs[divs.length - 1];
321
+ const style = innerDiv?.getAttribute("style") || "";
322
+ expect(style).toBe("");
323
+ });
324
+ });
325
+
326
+ describe("style optimization verification", () => {
327
+ it("same theme nested produces no inline styles", () => {
328
+ const theme = getThemeColors(null, "light", palette);
329
+
330
+ const {container} = render(
331
+ <ThemeProvider theme={theme}>
332
+ <div data-testid="outer-content">
333
+ <ThemeProvider theme={theme}>
334
+ <div data-testid="inner-content">content</div>
335
+ </ThemeProvider>
336
+ </div>
337
+ </ThemeProvider>
338
+ );
339
+
340
+ // Find the inner ThemeProvider's div (second div with display:contents behavior)
341
+ const divs = Array.from(container.querySelectorAll("div"));
342
+ const themeProviderDivs = divs.filter(
343
+ (d) => !d.hasAttribute("data-testid") && d.textContent?.includes("content")
344
+ );
345
+
346
+ // The innermost ThemeProvider div should have empty or no style
347
+ const innerProviderDiv = themeProviderDivs.find((d) => {
348
+ const style = d.getAttribute("style");
349
+ return style === "" || style === null;
350
+ });
351
+
352
+ expect(innerProviderDiv).toBeTruthy();
353
+ });
354
+
355
+ it("switching light→dark→light produces symmetric diffs", () => {
356
+ const lightTheme = getThemeColors(null, "light", palette);
357
+ const darkTheme = getThemeColors(null, "dark", palette);
358
+
359
+ const lightStyles = createInlineStyles(lightTheme);
360
+ const darkStyles = createInlineStyles(darkTheme);
361
+
362
+ // Calculate light→dark diff
363
+ const lightToDarkDiff: Record<string, string> = {};
364
+ for (const key in darkStyles) {
365
+ if (darkStyles[key] !== lightStyles[key]) {
366
+ lightToDarkDiff[key] = darkStyles[key];
367
+ }
368
+ }
369
+
370
+ // Calculate dark→light diff
371
+ const darkToLightDiff: Record<string, string> = {};
372
+ for (const key in lightStyles) {
373
+ if (lightStyles[key] !== darkStyles[key]) {
374
+ darkToLightDiff[key] = lightStyles[key];
375
+ }
376
+ }
377
+
378
+ // Both diffs should have the same keys (symmetric)
379
+ expect(Object.keys(lightToDarkDiff).sort()).toEqual(Object.keys(darkToLightDiff).sort());
380
+ });
381
+
382
+ it("diff count is significantly less than full theme count", () => {
383
+ const lightTheme = getThemeColors(null, "light", palette);
384
+ const darkTheme = getThemeColors(null, "dark", palette);
385
+
386
+ const fullStyles = createInlineStyles(darkTheme);
387
+ const fullCount = Object.keys(fullStyles).length;
388
+
389
+ // Calculate diff
390
+ const lightStyles = createInlineStyles(lightTheme);
391
+ let diffCount = 0;
392
+ for (const key in fullStyles) {
393
+ if (fullStyles[key] !== lightStyles[key]) {
394
+ diffCount++;
395
+ }
396
+ }
397
+
398
+ // Diff should be smaller than full (many colors are the same between themes)
399
+ expect(diffCount).toBeLessThan(fullCount);
400
+ // But diff should still have meaningful changes
401
+ expect(diffCount).toBeGreaterThan(10);
402
+ });
403
+ });
404
+
405
+ describe("style helpers verification", () => {
406
+ it("getStyleCount returns correct number of CSS properties", () => {
407
+ const lightTheme = getThemeColors(null, "light", palette);
408
+ const darkTheme = getThemeColors(null, "dark", palette);
409
+
410
+ // Same theme nested = inner div has no style attribute (empty diff)
411
+ const {container: sameContainer} = render(
412
+ <ThemeProvider theme={lightTheme}>
413
+ <ThemeProvider theme={lightTheme}>
414
+ <span>light</span>
415
+ </ThemeProvider>
416
+ </ThemeProvider>
417
+ );
418
+
419
+ // Different theme nested = more styled divs
420
+ const {container: diffContainer} = render(
421
+ <ThemeProvider theme={lightTheme}>
422
+ <ThemeProvider theme={darkTheme}>
423
+ <span>dark</span>
424
+ </ThemeProvider>
425
+ </ThemeProvider>
426
+ );
427
+
428
+ const sameStyledDivCount = Array.from(sameContainer.querySelectorAll("div")).filter((d) =>
429
+ d.hasAttribute("style")
430
+ ).length;
431
+ const diffStyledDivCount = Array.from(diffContainer.querySelectorAll("div")).filter((d) =>
432
+ d.hasAttribute("style")
433
+ ).length;
434
+
435
+ // When themes are same, only outer has styles (inner diff is empty)
436
+ // When themes differ, both have styles
437
+ // So diff case should have >= same case
438
+ expect(diffStyledDivCount).toBeGreaterThanOrEqual(sameStyledDivCount);
439
+ });
440
+
441
+ it("getStyleObject returns parseable CSS properties", () => {
442
+ const darkTheme = getThemeColors(null, "dark", palette);
443
+
444
+ const {container} = render(
445
+ <ThemeProvider theme={defaultLightTheme}>
446
+ <ThemeProvider theme={darkTheme}>
447
+ <span>content</span>
448
+ </ThemeProvider>
449
+ </ThemeProvider>
450
+ );
451
+
452
+ // Get the inner provider's div
453
+ const divs = Array.from(container.querySelectorAll("div")).filter((d) => d.hasAttribute("style"));
454
+ const innerDiv = divs[1];
455
+
456
+ if (innerDiv) {
457
+ const style = innerDiv.getAttribute("style") || "";
458
+ const styleObj: Record<string, string> = {};
459
+ style.split(";").forEach((s) => {
460
+ const [key, value] = s.split(":").map((x) => x.trim());
461
+ if (key && value) {
462
+ styleObj[key] = value;
463
+ }
464
+ });
465
+
466
+ const keys = Object.keys(styleObj);
467
+ expect(keys.length).toBeGreaterThan(0);
468
+
469
+ // All keys should be CSS custom properties starting with --
470
+ const cssVarKeys = keys.filter((k) => k.startsWith("--"));
471
+ expect(cssVarKeys.length).toBe(keys.length);
472
+ }
473
+ });
474
+
475
+ it("nested providers have decreasing style counts", () => {
476
+ const darkTheme = getThemeColors(null, "dark", palette);
477
+
478
+ // Outer: diff from context default
479
+ // Inner: only diff from outer (which should be 0 since same theme)
480
+ const {container} = render(
481
+ <ThemeProvider theme={darkTheme}>
482
+ <ThemeProvider theme={darkTheme}>
483
+ <span>nested same</span>
484
+ </ThemeProvider>
485
+ </ThemeProvider>
486
+ );
487
+
488
+ const styledDivs = Array.from(container.querySelectorAll("div")).filter((d) => d.hasAttribute("style"));
489
+
490
+ if (styledDivs.length >= 2) {
491
+ const outerStyle = styledDivs[0].getAttribute("style") || "";
492
+ const innerStyle = styledDivs[1].getAttribute("style") || "";
493
+
494
+ const outerCount = outerStyle.split(";").filter((s) => s.includes(":")).length;
495
+ const innerCount = innerStyle.split(";").filter((s) => s.includes(":")).length;
496
+
497
+ // Inner (same theme as parent) should have 0 styles
498
+ expect(innerCount).toBe(0);
499
+ // Outer should have styles (diff from context default)
500
+ expect(outerCount).toBeGreaterThanOrEqual(0);
501
+ }
502
+ });
503
+
504
+ it("getStyleObject can verify specific CSS variable values", () => {
505
+ const darkTheme = getThemeColors(null, "dark", palette);
506
+
507
+ const {container} = render(
508
+ <ThemeProvider theme={defaultLightTheme}>
509
+ <ThemeProvider theme={darkTheme}>
510
+ <span>content</span>
511
+ </ThemeProvider>
512
+ </ThemeProvider>
513
+ );
514
+
515
+ // Get inner provider's style
516
+ const divs = Array.from(container.querySelectorAll("div")).filter((d) => d.hasAttribute("style"));
517
+ const innerDiv = divs[1];
518
+
519
+ if (innerDiv) {
520
+ const style = innerDiv.getAttribute("style") || "";
521
+ const values = style
522
+ .split(";")
523
+ .map((s) => s.split(":")[1]?.trim())
524
+ .filter(Boolean);
525
+
526
+ const colorValues = values.filter(
527
+ (v) => v.includes("rgb") || v.includes("hsl") || v.includes("#") || v.includes("oklch")
528
+ );
529
+
530
+ // Most theme values should be colors
531
+ expect(colorValues.length).toBeGreaterThan(0);
532
+ }
533
+ });
534
+ });
535
+
536
+ describe("useThemeStyles", () => {
537
+ function StylesDisplay({testId = "styles"}: {testId?: string}) {
538
+ const styles = useThemeStyles();
539
+ return <div data-testid={testId} data-style-count={Object.keys(styles).length} />;
540
+ }
541
+
542
+ it("returns computed styles from context", () => {
543
+ render(
544
+ <ThemeProvider theme={defaultLightTheme}>
545
+ <StylesDisplay />
546
+ </ThemeProvider>
547
+ );
548
+ const count = Number(screen.getByTestId("styles").getAttribute("data-style-count"));
549
+ expect(count).toBeGreaterThan(0);
550
+ });
551
+
552
+ it("returns default styles outside ThemeProvider", () => {
553
+ render(<StylesDisplay />);
554
+ const count = Number(screen.getByTestId("styles").getAttribute("data-style-count"));
555
+ expect(count).toBe(Object.keys(defaultLightThemeStyles).length);
556
+ });
557
+
558
+ it("nested provider updates styles context", () => {
559
+ render(
560
+ <ThemeProvider theme={defaultLightTheme}>
561
+ <StylesDisplay testId="outer" />
562
+ <ThemeProvider theme={defaultDarkTheme}>
563
+ <StylesDisplay testId="inner" />
564
+ </ThemeProvider>
565
+ </ThemeProvider>
566
+ );
567
+ const outerCount = Number(screen.getByTestId("outer").getAttribute("data-style-count"));
568
+ const innerCount = Number(screen.getByTestId("inner").getAttribute("data-style-count"));
569
+ expect(outerCount).toBe(innerCount);
570
+ });
571
+ });
572
+
573
+ describe("MenuThemeProvider", () => {
574
+ it("provides dark mode for light2 parent", () => {
575
+ render(
576
+ <ThemeProvider mode="light2">
577
+ <MenuThemeProvider>
578
+ <ThemeDisplay />
579
+ </MenuThemeProvider>
580
+ </ThemeProvider>
581
+ );
582
+ expect(screen.getByTestId("theme-display")).toHaveAttribute("data-mode", "dark");
583
+ });
584
+
585
+ it("provides light mode for light parent", () => {
586
+ render(
587
+ <ThemeProvider mode="light">
588
+ <MenuThemeProvider>
589
+ <ThemeDisplay />
590
+ </MenuThemeProvider>
591
+ </ThemeProvider>
592
+ );
593
+ expect(screen.getByTestId("theme-display")).toHaveAttribute("data-mode", "light");
594
+ });
595
+
596
+ it("provides dark mode for dark parent", () => {
597
+ render(
598
+ <ThemeProvider mode="dark">
599
+ <MenuThemeProvider>
600
+ <ThemeDisplay />
601
+ </MenuThemeProvider>
602
+ </ThemeProvider>
603
+ );
604
+ expect(screen.getByTestId("theme-display")).toHaveAttribute("data-mode", "dark");
605
+ });
606
+ });
607
+
608
+ describe("portal mode", () => {
609
+ function getStyleCount(el: Element) {
610
+ const style = el.getAttribute("style") || "";
611
+ return style.split(";").filter((s) => s.includes(":")).length;
612
+ }
613
+
614
+ function getThemeProviderDivs(container: HTMLElement) {
615
+ return Array.from(container.querySelectorAll("div")).filter((d) => !d.hasAttribute("data-testid"));
616
+ }
617
+
618
+ function renderWithRoot(children: React.ReactNode, mode: "light" | "dark" = "light") {
619
+ return render(
620
+ <RootThemeProvider initialThemeMode={mode} initialPreference={mode} initialMenuPreference={"auto"}>
621
+ {children}
622
+ </RootThemeProvider>
623
+ );
624
+ }
625
+
626
+ it("diffs against root styles, not parent context", () => {
627
+ const darkTheme = getThemeColors(null, "dark", palette);
628
+
629
+ // Non-portal dark inside dark parent: diffs against parent (same) → 0 styles
630
+ const {container: normalContainer} = renderWithRoot(
631
+ <ThemeProvider theme={darkTheme}>
632
+ <ThemeProvider theme={darkTheme}>
633
+ <span>normal</span>
634
+ </ThemeProvider>
635
+ </ThemeProvider>
636
+ );
637
+
638
+ // Portal dark inside dark parent: diffs against root (light) → many styles
639
+ const {container: portalContainer} = renderWithRoot(
640
+ <ThemeProvider theme={darkTheme}>
641
+ <ThemeProvider theme={darkTheme} portal>
642
+ <span>portal</span>
643
+ </ThemeProvider>
644
+ </ThemeProvider>
645
+ );
646
+
647
+ const normalDivs = getThemeProviderDivs(normalContainer);
648
+ const portalDivs = getThemeProviderDivs(portalContainer);
649
+
650
+ const normalInner = normalDivs[normalDivs.length - 1];
651
+ const portalInner = portalDivs[portalDivs.length - 1];
652
+
653
+ const normalCount = getStyleCount(normalInner);
654
+ const portalCount = getStyleCount(portalInner);
655
+
656
+ // Non-portal: same theme as parent → 0 diff
657
+ expect(normalCount).toBe(0);
658
+ // Portal: dark vs light root → many overrides
659
+ expect(portalCount).toBeGreaterThan(0);
660
+ });
661
+
662
+ it("when theme matches root, produces no inline styles", () => {
663
+ const lightTheme = getThemeColors(null, "light", palette);
664
+
665
+ const {container} = renderWithRoot(
666
+ <ThemeProvider theme={lightTheme} portal>
667
+ <span>portal same as root</span>
668
+ </ThemeProvider>
669
+ );
670
+
671
+ // The portal ThemeProvider div is after the root one
672
+ const divs = getThemeProviderDivs(container);
673
+ const portalDiv = divs[divs.length - 1];
674
+ expect(getStyleCount(portalDiv)).toBe(0);
675
+ });
676
+
677
+ it("when theme differs from root, emits diff against root", () => {
678
+ const lightTheme = getThemeColors(null, "light", palette);
679
+ const darkTheme = getThemeColors(null, "dark", palette);
680
+
681
+ const lightStyles = createInlineStyles(lightTheme);
682
+ const darkStyles = createInlineStyles(darkTheme);
683
+ let expectedDiffCount = 0;
684
+ for (const key in darkStyles) {
685
+ if (darkStyles[key] !== lightStyles[key]) {
686
+ expectedDiffCount++;
687
+ }
688
+ }
689
+
690
+ const {container} = renderWithRoot(
691
+ <ThemeProvider theme={darkTheme} portal>
692
+ <span>portal dark</span>
693
+ </ThemeProvider>
694
+ );
695
+
696
+ const divs = getThemeProviderDivs(container);
697
+ const portalDiv = divs[divs.length - 1];
698
+ expect(getStyleCount(portalDiv)).toBe(expectedDiffCount);
699
+ });
700
+
701
+ it("portal ignores intermediate parent overrides for diff base", () => {
702
+ const lightTheme = getThemeColors(null, "light", palette);
703
+ const darkTheme = getThemeColors(null, "dark", palette);
704
+
705
+ // root(light) > dark > portal(light)
706
+ // Portal diffs against root(light), not dark parent
707
+ // Since portal theme === root theme, diff should be empty
708
+ const {container} = renderWithRoot(
709
+ <ThemeProvider theme={darkTheme}>
710
+ <ThemeProvider theme={lightTheme} portal>
711
+ <span>portal</span>
712
+ </ThemeProvider>
713
+ </ThemeProvider>
714
+ );
715
+
716
+ const divs = getThemeProviderDivs(container);
717
+ const portalDiv = divs[divs.length - 1];
718
+ expect(getStyleCount(portalDiv)).toBe(0);
719
+ });
720
+
721
+ it("portal with dark mode emits overrides vs light root", () => {
722
+ const lightTheme = getThemeColors(null, "light", palette);
723
+ const darkTheme = getThemeColors(null, "dark", palette);
724
+
725
+ const lightStyles = createInlineStyles(lightTheme);
726
+ const darkStyles = createInlineStyles(darkTheme);
727
+ let expectedDiffCount = 0;
728
+ for (const key in darkStyles) {
729
+ if (darkStyles[key] !== lightStyles[key]) {
730
+ expectedDiffCount++;
731
+ }
732
+ }
733
+ expect(expectedDiffCount).toBeGreaterThan(0);
734
+
735
+ const {container} = renderWithRoot(
736
+ <ThemeProvider theme={darkTheme} portal>
737
+ <span>portal dark</span>
738
+ </ThemeProvider>
739
+ );
740
+
741
+ const divs = getThemeProviderDivs(container);
742
+ const portalDiv = divs[divs.length - 1];
743
+ expect(getStyleCount(portalDiv)).toBe(expectedDiffCount);
744
+ });
745
+
746
+ it("non-portal still diffs against parent context", () => {
747
+ const darkTheme = getThemeColors(null, "dark", palette);
748
+
749
+ // Non-portal nested with same theme as parent: empty diff
750
+ const {container} = renderWithRoot(
751
+ <ThemeProvider theme={darkTheme}>
752
+ <ThemeProvider theme={darkTheme}>
753
+ <span>same</span>
754
+ </ThemeProvider>
755
+ </ThemeProvider>
756
+ );
757
+
758
+ const divs = getThemeProviderDivs(container);
759
+ const innerDiv = divs[divs.length - 1];
760
+ expect(getStyleCount(innerDiv)).toBe(0);
761
+ });
762
+
763
+ it("provides correct theme context regardless of portal flag", () => {
764
+ const darkTheme = getThemeColors(null, "dark", palette);
765
+
766
+ renderWithRoot(
767
+ <ThemeProvider theme={darkTheme} portal>
768
+ <ThemeDisplay testId="portal-child" />
769
+ </ThemeProvider>
770
+ );
771
+
772
+ expect(screen.getByTestId("portal-child")).toHaveAttribute("data-mode", "dark");
773
+ });
774
+
775
+ it("provides correct ThemeStylesContext inside portal", () => {
776
+ const darkTheme = getThemeColors(null, "dark", palette);
777
+
778
+ function StylesCheck() {
779
+ const styles = useThemeStyles();
780
+ const expected = createInlineStyles(darkTheme);
781
+ const match = Object.keys(expected).every((k) => styles[k] === expected[k]);
782
+ return <div data-testid="styles-match" data-match={String(match)} />;
783
+ }
784
+
785
+ renderWithRoot(
786
+ <ThemeProvider theme={darkTheme} portal>
787
+ <StylesCheck />
788
+ </ThemeProvider>
789
+ );
790
+
791
+ expect(screen.getByTestId("styles-match")).toHaveAttribute("data-match", "true");
792
+ });
793
+ });
794
+
795
+ describe("error handling", () => {
796
+ const originalError = console.error;
797
+ beforeAll(() => {
798
+ console.error = jest.fn();
799
+ });
800
+ afterAll(() => {
801
+ console.error = originalError;
802
+ });
803
+
804
+ it("useThemePreference throws outside RootThemeProvider", () => {
805
+ function BadComponent() {
806
+ useThemePreference();
807
+ return null;
808
+ }
809
+ expect(() => render(<BadComponent />)).toThrow(/RootThemeProvider/);
810
+ });
811
+
812
+ it("useThemeMenuPreference throws outside RootThemeProvider", () => {
813
+ function BadComponent() {
814
+ useThemeMenuPreference();
815
+ return null;
816
+ }
817
+ expect(() => render(<BadComponent />)).toThrow(/RootThemeProvider/);
818
+ });
819
+ });
820
+ });