@oxyhq/bloom 0.80.0 → 0.82.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 (57) hide show
  1. package/lib/commonjs/tabs/Tabs.js +153 -15
  2. package/lib/commonjs/tabs/Tabs.js.map +1 -1
  3. package/lib/commonjs/theme/color-engine/index.js +6 -0
  4. package/lib/commonjs/theme/color-engine/index.js.map +1 -1
  5. package/lib/commonjs/theme/color-engine/scheme-variants.js +29 -1
  6. package/lib/commonjs/theme/color-engine/scheme-variants.js.map +1 -1
  7. package/lib/commonjs/theme/color-policy.js +130 -20
  8. package/lib/commonjs/theme/color-policy.js.map +1 -1
  9. package/lib/commonjs/theme/color-presets.js +62 -20
  10. package/lib/commonjs/theme/color-presets.js.map +1 -1
  11. package/lib/commonjs/theme/color-scope/seed-scope.js +5 -1
  12. package/lib/commonjs/theme/color-scope/seed-scope.js.map +1 -1
  13. package/lib/module/tabs/Tabs.js +153 -15
  14. package/lib/module/tabs/Tabs.js.map +1 -1
  15. package/lib/module/theme/color-engine/index.js +1 -1
  16. package/lib/module/theme/color-engine/index.js.map +1 -1
  17. package/lib/module/theme/color-engine/scheme-variants.js +28 -1
  18. package/lib/module/theme/color-engine/scheme-variants.js.map +1 -1
  19. package/lib/module/theme/color-policy.js +129 -20
  20. package/lib/module/theme/color-policy.js.map +1 -1
  21. package/lib/module/theme/color-presets.js +62 -20
  22. package/lib/module/theme/color-presets.js.map +1 -1
  23. package/lib/module/theme/color-scope/seed-scope.js +6 -2
  24. package/lib/module/theme/color-scope/seed-scope.js.map +1 -1
  25. package/lib/typescript/commonjs/tabs/Tabs.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/theme/color-engine/index.d.ts +1 -1
  27. package/lib/typescript/commonjs/theme/color-engine/index.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/theme/color-engine/scheme-variants.d.ts +13 -1
  29. package/lib/typescript/commonjs/theme/color-engine/scheme-variants.d.ts.map +1 -1
  30. package/lib/typescript/commonjs/theme/color-policy.d.ts +6 -0
  31. package/lib/typescript/commonjs/theme/color-policy.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/theme/color-presets.d.ts +1 -1
  33. package/lib/typescript/commonjs/theme/color-presets.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/theme/color-scope/seed-scope.d.ts.map +1 -1
  35. package/lib/typescript/module/tabs/Tabs.d.ts.map +1 -1
  36. package/lib/typescript/module/theme/color-engine/index.d.ts +1 -1
  37. package/lib/typescript/module/theme/color-engine/index.d.ts.map +1 -1
  38. package/lib/typescript/module/theme/color-engine/scheme-variants.d.ts +13 -1
  39. package/lib/typescript/module/theme/color-engine/scheme-variants.d.ts.map +1 -1
  40. package/lib/typescript/module/theme/color-policy.d.ts +6 -0
  41. package/lib/typescript/module/theme/color-policy.d.ts.map +1 -1
  42. package/lib/typescript/module/theme/color-presets.d.ts +1 -1
  43. package/lib/typescript/module/theme/color-presets.d.ts.map +1 -1
  44. package/lib/typescript/module/theme/color-scope/seed-scope.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/__tests__/BloomThemeProvider.test.tsx +1 -1
  47. package/src/__tests__/Tabs.test.tsx +286 -10
  48. package/src/__tests__/theme.test.ts +4 -3
  49. package/src/tabs/Tabs.tsx +172 -12
  50. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +1273 -673
  51. package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +775 -425
  52. package/src/theme/__tests__/policy-legibility.test.ts +55 -13
  53. package/src/theme/color-engine/index.ts +1 -1
  54. package/src/theme/color-engine/scheme-variants.ts +38 -1
  55. package/src/theme/color-policy.ts +147 -24
  56. package/src/theme/color-presets.ts +44 -17
  57. package/src/theme/color-scope/seed-scope.ts +6 -2
package/src/tabs/Tabs.tsx CHANGED
@@ -40,6 +40,18 @@ import type { TabsProps, TabsTriggerProps, TabsContentProps, TabsVariant } from
40
40
 
41
41
  type TriggerLayout = Pick<LayoutRectangle, 'x' | 'width'>;
42
42
 
43
+ /**
44
+ * Re-read ONE trigger's geometry from the platform and report it back.
45
+ *
46
+ * A callback rather than a return value because measuring is asynchronous on
47
+ * both platforms: react-native-web defers to a `setTimeout(0)` inside
48
+ * `UIManager.measure`, and native hops to the UI thread. Each trigger keeps its
49
+ * own host ref and hands the strip this instead, so the registry says exactly
50
+ * what it needs — a position on demand — and nothing about what a trigger
51
+ * renders.
52
+ */
53
+ type TriggerMeasure = (report: (layout: TriggerLayout) => void) => void;
54
+
43
55
  /**
44
56
  * Underline travel. The same spring the floating `TabBar` highlight uses, so the
45
57
  * two strips read as one motion language; stated here rather than imported
@@ -48,7 +60,7 @@ type TriggerLayout = Pick<LayoutRectangle, 'x' | 'width'>;
48
60
  */
49
61
  const SLIDE_SPRING = { duration: 420, dampingRatio: 0.82 };
50
62
 
51
- /** Visibility, not travel — see {@link TabsContextValue.indicatorOpacity}. */
63
+ /** Visibility, not travel — the underline fades, it does not slide, in and out. */
52
64
  const HIGHLIGHT_FADE = { duration: 160 };
53
65
 
54
66
  /**
@@ -84,7 +96,13 @@ interface TabsContextValue {
84
96
  onValueChange: ((value: string) => void) | undefined;
85
97
  variant: TabsVariant;
86
98
  fullWidth: boolean;
87
- /** A trigger reports its measured position so the shared underline can track it. */
99
+ /**
100
+ * A trigger hands the strip a way to RE-READ its own geometry, and takes it
101
+ * back on unmount. See `remeasureTriggers` for why the strip cannot simply
102
+ * keep whatever `onLayout` last reported.
103
+ */
104
+ registerTrigger: (value: string, measure: TriggerMeasure) => () => void;
105
+ /** A trigger reports the geometry its own `onLayout` just handed it. */
88
106
  reportTriggerLayout: (value: string, layout: TriggerLayout) => void;
89
107
  /** A trigger reports that the ROUTER considers it focused. */
90
108
  reportFocused: (value: string) => void;
@@ -173,6 +191,11 @@ const TabsBarComponent = forwardRef<TabsDragController, TabsProps>(function Tabs
173
191
  const dragWidthDelta = useSharedValue(0);
174
192
 
175
193
  const triggerLayoutsRef = useRef<Record<string, TriggerLayout>>({});
194
+ // How to ask each trigger where it is NOW, keyed by value. Registration order
195
+ // carries no meaning — everything that consumes the geometry orders itself by
196
+ // measured `x`, which is the only ordering that survives a reorder.
197
+ const triggerMeasuresRef = useRef(new Map<string, TriggerMeasure>());
198
+ const remeasureScheduledRef = useRef(false);
176
199
  const indicatorPlacedRef = useRef(false);
177
200
  const scrollRef = useRef<ScrollView>(null);
178
201
  const viewportWidthRef = useRef(0);
@@ -190,9 +213,16 @@ const TabsBarComponent = forwardRef<TabsDragController, TabsProps>(function Tabs
190
213
  indicatorX.value = target.x;
191
214
  indicatorWidth.value = target.width;
192
215
  }
216
+ // Reveal only while something IS selected. Geometry keeps arriving while
217
+ // a non-tab sibling route is showing — the measurement pass below is
218
+ // precisely what makes it arrive — and fading the underline back in there
219
+ // would re-assert a tab the reader has left. Read from the closure rather
220
+ // than a ref: a ref written in an effect is still stale at this point,
221
+ // because a child's effect runs before its parent's.
222
+ if (!hasSelection) return;
193
223
  indicatorOpacity.value = withTiming(1, HIGHLIGHT_FADE);
194
224
  },
195
- [indicatorX, indicatorWidth, indicatorOpacity],
225
+ [indicatorX, indicatorWidth, indicatorOpacity, hasSelection],
196
226
  );
197
227
 
198
228
  // Keep the active tab in view when the strip overflows its viewport. Centring
@@ -222,18 +252,99 @@ const TabsBarComponent = forwardRef<TabsDragController, TabsProps>(function Tabs
222
252
  [moveIndicator, revealTrigger],
223
253
  );
224
254
 
225
- const reportTriggerLayout = useCallback(
255
+ /**
256
+ * Record where a trigger is, from whichever source measured it, and keep the
257
+ * underline glued to it.
258
+ *
259
+ * Placement here never animates: the tab did not become SELECTED, it MOVED,
260
+ * and it moved instantly because nothing animates a strip's reflow. Sliding
261
+ * the underline across would read as a selection change that never happened.
262
+ */
263
+ const applyTriggerLayout = useCallback(
226
264
  (tabValue: string, layout: TriggerLayout) => {
265
+ const previous = triggerLayoutsRef.current[tabValue];
266
+ // Unchanged geometry must be a no-op, not a re-place: a measurement pass
267
+ // runs after every render, and re-placing would cancel a selection slide
268
+ // mid-flight for no reason.
269
+ if (previous !== undefined && previous.x === layout.x && previous.width === layout.width) {
270
+ return;
271
+ }
227
272
  triggerLayoutsRef.current[tabValue] = layout;
273
+ if (tabValue !== selectedValueRef.current) return;
274
+ moveIndicator(layout, false);
275
+ revealTrigger(layout, false);
276
+ indicatorPlacedRef.current = true;
277
+ },
278
+ [moveIndicator, revealTrigger],
279
+ );
280
+
281
+ /**
282
+ * Re-read EVERY trigger's position from the platform.
283
+ *
284
+ * This exists because `onLayout` cannot be trusted to report a MOVE. On
285
+ * react-native-web it is backed by a single `ResizeObserver`
286
+ * (`modules/useElementLayout`), which fires for a SIZE change and never for a
287
+ * position-only one — so a trigger inserted, removed or reordered after first
288
+ * layout shifts every trigger after it while not one of them re-reports, and
289
+ * the underline stays where the stale numbers put it: silently, one tab off,
290
+ * only on the first paint after an async tab list lands. A tab whose own
291
+ * width changes has the same effect on its neighbours.
292
+ *
293
+ * So `onLayout` is demoted to a change SIGNAL and the geometry is read back
294
+ * explicitly, through the `measure` both platforms put on a host view ref. It
295
+ * answers in the view's PARENT coordinate space, which is the space the
296
+ * underline is positioned in and the same one `onLayout` reports — the two
297
+ * sources cannot disagree about what they mean.
298
+ */
299
+ const remeasureTriggers = useCallback(() => {
300
+ for (const [tabValue, measure] of triggerMeasuresRef.current) {
301
+ measure((layout) => {
302
+ // A strip that is mounted but not laid out — a `display: none` ancestor
303
+ // on web, an unmeasured subtree on native — measures as zero, and a
304
+ // trigger cannot genuinely be zero-wide (it always carries horizontal
305
+ // padding). Recording that would collapse the underline and throw away
306
+ // the real geometry, so the last known position stands until it is on
307
+ // screen again.
308
+ if (layout.width <= 0) return;
309
+ applyTriggerLayout(tabValue, layout);
310
+ });
311
+ }
312
+ }, [applyTriggerLayout]);
313
+
314
+ const scheduleRemeasure = useCallback(() => {
315
+ // One pass per turn, however many signals arrive: a mount registers N
316
+ // triggers and reports N layouts, which is N+1 reasons to measure the same
317
+ // frame. A microtask, so the pass is queued before the browser paints the
318
+ // frame the change landed in.
319
+ if (remeasureScheduledRef.current) return;
320
+ remeasureScheduledRef.current = true;
321
+ queueMicrotask(() => {
322
+ remeasureScheduledRef.current = false;
323
+ remeasureTriggers();
324
+ });
325
+ }, [remeasureTriggers]);
326
+
327
+ const registerTrigger = useCallback((tabValue: string, measure: TriggerMeasure) => {
328
+ triggerMeasuresRef.current.set(tabValue, measure);
329
+ return () => {
330
+ triggerMeasuresRef.current.delete(tabValue);
331
+ // A tab that is gone must not keep a position in the geometry the drag
332
+ // controller orders itself by, or a swipe can commit to a trigger that is
333
+ // no longer on screen.
334
+ delete triggerLayoutsRef.current[tabValue];
335
+ };
336
+ }, []);
337
+
338
+ const reportTriggerLayout = useCallback(
339
+ (tabValue: string, layout: TriggerLayout) => {
228
340
  // Snap onto the active trigger the moment it is first measured (mount), or
229
341
  // when its size changes — never slide in from the origin.
230
- if (tabValue === selectedValueRef.current) {
231
- moveIndicator(layout, false);
232
- revealTrigger(layout, false);
233
- indicatorPlacedRef.current = true;
234
- }
342
+ applyTriggerLayout(tabValue, layout);
343
+ // One trigger changing size moves every trigger after it, and not one of
344
+ // them will say so.
345
+ scheduleRemeasure();
235
346
  },
236
- [moveIndicator, revealTrigger],
347
+ [applyTriggerLayout, scheduleRemeasure],
237
348
  );
238
349
 
239
350
  const reportFocused = useCallback(
@@ -263,6 +374,16 @@ const TabsBarComponent = forwardRef<TabsDragController, TabsProps>(function Tabs
263
374
  indicatorOpacity.value = withTiming(0, HIGHLIGHT_FADE);
264
375
  }, [hasSelection, indicatorOpacity]);
265
376
 
377
+ // Every render of the strip is a render in which its contents may have MOVED:
378
+ // a trigger inserted, removed or reordered, or one re-rendered at a new size.
379
+ // Unconditional on purpose, rather than keyed on a signature of `children` —
380
+ // any such signature has to be built from React keys or child props, both of
381
+ // which the caller controls and neither of which is obliged to change on a
382
+ // reorder. A pass writes nothing when nothing moved, so being wrong in this
383
+ // direction costs a handful of reads and being wrong in the other direction
384
+ // is the bug.
385
+ useEffect(scheduleRemeasure);
386
+
266
387
  useImperativeHandle(
267
388
  dragRef,
268
389
  (): TabsDragController => ({
@@ -327,10 +448,19 @@ const TabsBarComponent = forwardRef<TabsDragController, TabsProps>(function Tabs
327
448
  onValueChange,
328
449
  variant,
329
450
  fullWidth,
451
+ registerTrigger,
330
452
  reportTriggerLayout,
331
453
  reportFocused,
332
454
  }),
333
- [value, onValueChange, variant, fullWidth, reportTriggerLayout, reportFocused],
455
+ [
456
+ value,
457
+ onValueChange,
458
+ variant,
459
+ fullWidth,
460
+ registerTrigger,
461
+ reportTriggerLayout,
462
+ reportFocused,
463
+ ],
334
464
  );
335
465
 
336
466
  const containerStyle = useMemo((): ViewStyle => {
@@ -446,6 +576,7 @@ const TabComponent: React.FC<TabsTriggerProps> = ({
446
576
  onValueChange,
447
577
  variant,
448
578
  fullWidth,
579
+ registerTrigger,
449
580
  reportTriggerLayout,
450
581
  reportFocused,
451
582
  } = useTabsContext('TabsTrigger');
@@ -456,6 +587,24 @@ const TabComponent: React.FC<TabsTriggerProps> = ({
456
587
  const resolvedCount = count ?? 0;
457
588
  const showCount = resolvedCount > 0;
458
589
 
590
+ // The trigger's own host view. It stays here rather than in the strip because
591
+ // the strip has no way to reach a child it did not create — it receives them
592
+ // as `children` — and because a trigger is the only thing that knows its own
593
+ // value, which is what the geometry has to be keyed by.
594
+ const nodeRef = useRef<View | null>(null);
595
+ const measureSelf = useCallback<TriggerMeasure>((report) => {
596
+ nodeRef.current?.measure((x, _y, width) => {
597
+ report({ x, width });
598
+ });
599
+ }, []);
600
+
601
+ // Registration IS the insertion/removal signal the strip acts on, so it must
602
+ // outlive nothing: the cleanup drops both the measure hook and the geometry.
603
+ useEffect(
604
+ () => registerTrigger(value, measureSelf),
605
+ [registerTrigger, value, measureSelf],
606
+ );
607
+
459
608
  // FOCUS-DRIVEN path only. This covers programmatic navigation too — a deep
460
609
  // link, a browser Back, a back gesture — because nothing here asks HOW the
461
610
  // change happened: the trigger simply re-renders focused and the underline
@@ -528,6 +677,7 @@ const TabComponent: React.FC<TabsTriggerProps> = ({
528
677
 
529
678
  return (
530
679
  <RNAnimated.View
680
+ ref={nodeRef}
531
681
  onLayout={(e) => {
532
682
  const { x, width } = e.nativeEvent.layout;
533
683
  reportTriggerLayout(value, { x, width });
@@ -542,7 +692,17 @@ const TabComponent: React.FC<TabsTriggerProps> = ({
542
692
  disabled={disabled}
543
693
  accessibilityRole="tab"
544
694
  accessibilityLabel={showCount ? `${label}, ${resolvedCount}` : label}
545
- accessibilityState={{ selected: isSelected, disabled }}
695
+ // `aria-selected`, and NOT a web-only spelling of `accessibilityState`.
696
+ // react-native-web's `createDOMProps` reads `aria-selected` (or the
697
+ // deprecated `accessibilitySelected`) and does not look at
698
+ // `accessibilityState` at all, so a strip that set only the latter
699
+ // announced no selection on web at all — every tab equally current.
700
+ // React Native's own `Pressable` folds `aria-selected` back into
701
+ // `accessibilityState.selected`, so this one prop serves both platforms
702
+ // and there is no second place for the answer to disagree. `disabled`
703
+ // needs no counterpart: both Pressables already derive that state from
704
+ // the `disabled` prop above.
705
+ aria-selected={isSelected}
546
706
  >
547
707
  {icon}
548
708
  <Text style={[labelStyle, textStyle]}>{label}</Text>