@oxyhq/bloom 0.80.0 → 0.81.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.
- package/lib/commonjs/tabs/Tabs.js +153 -15
- package/lib/commonjs/tabs/Tabs.js.map +1 -1
- package/lib/commonjs/theme/color-engine/index.js +6 -0
- package/lib/commonjs/theme/color-engine/index.js.map +1 -1
- package/lib/commonjs/theme/color-engine/scheme-variants.js +29 -1
- package/lib/commonjs/theme/color-engine/scheme-variants.js.map +1 -1
- package/lib/commonjs/theme/color-policy.js +54 -10
- package/lib/commonjs/theme/color-policy.js.map +1 -1
- package/lib/commonjs/theme/color-presets.js +14 -2
- package/lib/commonjs/theme/color-presets.js.map +1 -1
- package/lib/commonjs/theme/color-scope/seed-scope.js +5 -1
- package/lib/commonjs/theme/color-scope/seed-scope.js.map +1 -1
- package/lib/module/tabs/Tabs.js +153 -15
- package/lib/module/tabs/Tabs.js.map +1 -1
- package/lib/module/theme/color-engine/index.js +1 -1
- package/lib/module/theme/color-engine/index.js.map +1 -1
- package/lib/module/theme/color-engine/scheme-variants.js +28 -1
- package/lib/module/theme/color-engine/scheme-variants.js.map +1 -1
- package/lib/module/theme/color-policy.js +53 -10
- package/lib/module/theme/color-policy.js.map +1 -1
- package/lib/module/theme/color-presets.js +14 -2
- package/lib/module/theme/color-presets.js.map +1 -1
- package/lib/module/theme/color-scope/seed-scope.js +6 -2
- package/lib/module/theme/color-scope/seed-scope.js.map +1 -1
- package/lib/typescript/commonjs/tabs/Tabs.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-engine/index.d.ts +1 -1
- package/lib/typescript/commonjs/theme/color-engine/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-engine/scheme-variants.d.ts +13 -1
- package/lib/typescript/commonjs/theme/color-engine/scheme-variants.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-policy.d.ts +6 -0
- package/lib/typescript/commonjs/theme/color-policy.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-presets.d.ts +1 -1
- package/lib/typescript/commonjs/theme/color-presets.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-scope/seed-scope.d.ts.map +1 -1
- package/lib/typescript/module/tabs/Tabs.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-engine/index.d.ts +1 -1
- package/lib/typescript/module/theme/color-engine/index.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-engine/scheme-variants.d.ts +13 -1
- package/lib/typescript/module/theme/color-engine/scheme-variants.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-policy.d.ts +6 -0
- package/lib/typescript/module/theme/color-policy.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-presets.d.ts +1 -1
- package/lib/typescript/module/theme/color-presets.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-scope/seed-scope.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/Tabs.test.tsx +286 -10
- package/src/__tests__/theme.test.ts +2 -1
- package/src/tabs/Tabs.tsx +172 -12
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +120 -0
- package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +70 -0
- package/src/theme/__tests__/policy-legibility.test.ts +12 -4
- package/src/theme/color-engine/index.ts +1 -1
- package/src/theme/color-engine/scheme-variants.ts +38 -1
- package/src/theme/color-policy.ts +68 -14
- package/src/theme/color-presets.ts +11 -2
- package/src/theme/color-scope/seed-scope.ts +6 -2
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import type { ReactTestInstance } from 'react-test-renderer';
|
|
3
|
-
import { fireEvent, render } from '@testing-library/react-native';
|
|
3
|
+
import { act, fireEvent, render } from '@testing-library/react-native';
|
|
4
4
|
|
|
5
5
|
import { BloomThemeProvider } from '../theme/BloomThemeProvider';
|
|
6
6
|
import { Tabs, TabsTrigger } from '../tabs';
|
|
7
|
+
import type { TabsDragController } from '../tabs/Tabs';
|
|
7
8
|
|
|
8
9
|
function renderWithTheme(ui: React.ReactElement) {
|
|
9
10
|
return render(
|
|
@@ -34,6 +35,18 @@ function triggerFor(node: ReactTestInstance): ReactTestInstance {
|
|
|
34
35
|
return current;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The selected state as it actually reaches a platform.
|
|
40
|
+
*
|
|
41
|
+
* `aria-selected` rather than `accessibilityState.selected` because
|
|
42
|
+
* react-native-web's `createDOMProps` reads only the former (the latter appears
|
|
43
|
+
* nowhere in it), while React Native's `Pressable` folds `aria-selected` back
|
|
44
|
+
* into `accessibilityState` — so this is the ONE spelling both platforms honour.
|
|
45
|
+
*/
|
|
46
|
+
function selectedState(node: ReactTestInstance): unknown {
|
|
47
|
+
return node.props['aria-selected'];
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
function Bar({
|
|
38
51
|
value,
|
|
39
52
|
onValueChange = () => {},
|
|
@@ -98,14 +111,36 @@ describe('Tabs', () => {
|
|
|
98
111
|
}
|
|
99
112
|
});
|
|
100
113
|
|
|
114
|
+
/**
|
|
115
|
+
* A tab strip that does not say WHICH tab is current is unusable with a
|
|
116
|
+
* screen reader, and the failure is invisible to everyone else: the underline
|
|
117
|
+
* is drawn, so the strip looks correct while announcing nothing.
|
|
118
|
+
*
|
|
119
|
+
* The assertion is on `aria-selected` specifically. Setting only
|
|
120
|
+
* `accessibilityState={{selected}}` — which reads like the React Native
|
|
121
|
+
* answer and is what this component shipped — produces no `aria-selected` on
|
|
122
|
+
* web at all, because react-native-web stopped consulting
|
|
123
|
+
* `accessibilityState` and now maps `aria-*` props directly.
|
|
124
|
+
*/
|
|
101
125
|
it('marks the active trigger as selected for accessibility', () => {
|
|
102
126
|
const { getByText } = renderWithTheme(<Bar value="b" />);
|
|
103
|
-
expect(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
127
|
+
expect(selectedState(triggerFor(getByText('First')))).toBe(false);
|
|
128
|
+
expect(selectedState(triggerFor(getByText('Second')))).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('leaves the disabled state to the disabled prop, which both platforms map', () => {
|
|
132
|
+
// Neither Pressable needs telling twice: React Native's folds a non-null
|
|
133
|
+
// `disabled` into `accessibilityState`, react-native-web's emits
|
|
134
|
+
// `aria-disabled` from it. A second copy on the trigger could only ever
|
|
135
|
+
// disagree with this one.
|
|
136
|
+
const { getByText } = renderWithTheme(
|
|
137
|
+
<Tabs value="a" onValueChange={() => {}} testID="tabs">
|
|
138
|
+
<TabsTrigger value="a" label="First" />
|
|
139
|
+
<TabsTrigger value="b" label="Second" disabled />
|
|
140
|
+
</Tabs>,
|
|
141
|
+
);
|
|
142
|
+
expect(triggerFor(getByText('Second')).props.disabled).toBe(true);
|
|
143
|
+
expect(triggerFor(getByText('First')).props.disabled).toBe(false);
|
|
109
144
|
});
|
|
110
145
|
|
|
111
146
|
/**
|
|
@@ -134,7 +169,7 @@ describe('Tabs', () => {
|
|
|
134
169
|
);
|
|
135
170
|
|
|
136
171
|
expect(getByTestId('tabs-indicator')).toBeTruthy();
|
|
137
|
-
expect(triggerFor(getByText('Overview'))
|
|
172
|
+
expect(selectedState(triggerFor(getByText('Overview')))).toBe(true);
|
|
138
173
|
fireEvent.press(getByText('Activity'));
|
|
139
174
|
expect(onValueChange).toHaveBeenCalledWith('activity');
|
|
140
175
|
});
|
|
@@ -148,8 +183,8 @@ describe('Tabs', () => {
|
|
|
148
183
|
<TabsTrigger value="b" label="Second" isFocused />
|
|
149
184
|
</Tabs>,
|
|
150
185
|
);
|
|
151
|
-
expect(triggerFor(getByText('First'))
|
|
152
|
-
expect(triggerFor(getByText('Second'))
|
|
186
|
+
expect(selectedState(triggerFor(getByText('First')))).toBe(false);
|
|
187
|
+
expect(selectedState(triggerFor(getByText('Second')))).toBe(true);
|
|
153
188
|
});
|
|
154
189
|
|
|
155
190
|
it('does NOT report a selection on press, because the caller navigates', () => {
|
|
@@ -294,4 +329,245 @@ describe('Tabs', () => {
|
|
|
294
329
|
).toThrow(/TabsTrigger must be used within a Tabs/);
|
|
295
330
|
spy.mockRestore();
|
|
296
331
|
});
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* A trigger set that CHANGES after the strip has already been laid out.
|
|
335
|
+
*
|
|
336
|
+
* This is the case `onLayout` alone cannot serve, and it is worth being
|
|
337
|
+
* precise about why, because the component looks correct without it. On
|
|
338
|
+
* react-native-web `onLayout` is one shared `ResizeObserver`
|
|
339
|
+
* (`react-native-web/dist/modules/useElementLayout`), so it fires for a SIZE
|
|
340
|
+
* change and never for a position-only move. Insert a tab and every trigger
|
|
341
|
+
* after it slides right without one of them re-reporting; the underline stays
|
|
342
|
+
* on the stale numbers, one tab to the left of the tab that is actually
|
|
343
|
+
* showing. Measured on production `mention.earth`, where a profile's lane tab
|
|
344
|
+
* arrives from a separate query after first paint: `/@nate/boosts` underlined
|
|
345
|
+
* "Likes".
|
|
346
|
+
*
|
|
347
|
+
* Every test below therefore reports layout for the changed triggers ONLY —
|
|
348
|
+
* modelling exactly what the platform does — and asserts the underline anyway.
|
|
349
|
+
* Reporting layout for all of them would hide the bug completely.
|
|
350
|
+
*/
|
|
351
|
+
describe('a trigger set that changes after first layout', () => {
|
|
352
|
+
interface Box {
|
|
353
|
+
x: number;
|
|
354
|
+
width: number;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Which tab an element belongs to, or `undefined` if it is not a trigger.
|
|
359
|
+
*
|
|
360
|
+
* The element the strip measures is a trigger's animated wrapper, which is
|
|
361
|
+
* anonymous; the tab it stands for is named by the Pressable inside it.
|
|
362
|
+
*/
|
|
363
|
+
function tabLabelOf(element: unknown): string | undefined {
|
|
364
|
+
// `createNodeMock` is handed a plain `{type, props}` pair rather than a
|
|
365
|
+
// real element — `React.isValidElement` is false for it — so the outer
|
|
366
|
+
// hop is narrowed by hand. The CHILD is a genuine element.
|
|
367
|
+
if (typeof element !== 'object' || element === null || !('props' in element)) {
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
const props = element.props;
|
|
371
|
+
if (typeof props !== 'object' || props === null || !('children' in props)) {
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
const child = props.children;
|
|
375
|
+
if (
|
|
376
|
+
!React.isValidElement<{ accessibilityRole?: string; accessibilityLabel?: string }>(
|
|
377
|
+
child,
|
|
378
|
+
)
|
|
379
|
+
) {
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
if (child.props.accessibilityRole !== 'tab') return undefined;
|
|
383
|
+
return child.props.accessibilityLabel;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Stand in for a host view.
|
|
388
|
+
*
|
|
389
|
+
* react-test-renderer hands `null` to every host ref unless a
|
|
390
|
+
* `createNodeMock` is supplied, so without this a trigger has no node to
|
|
391
|
+
* measure and the strip is back to trusting `onLayout` — which is the
|
|
392
|
+
* behaviour under test, not a background detail. Geometry is read from a
|
|
393
|
+
* live table so the test can move the tabs between renders, exactly as a
|
|
394
|
+
* reflow would.
|
|
395
|
+
*/
|
|
396
|
+
function nodeMockFor(geometry: Map<string, Box>) {
|
|
397
|
+
return (element: React.ReactElement): unknown => {
|
|
398
|
+
const label = tabLabelOf(element);
|
|
399
|
+
if (label === undefined) return {};
|
|
400
|
+
return {
|
|
401
|
+
measure: (
|
|
402
|
+
report: (x: number, y: number, width: number, height: number) => void,
|
|
403
|
+
) => {
|
|
404
|
+
const box = geometry.get(label);
|
|
405
|
+
if (box) report(box.x, 0, box.width, 40);
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function mountStrip(tabs: string[], focused: string, geometry: Map<string, Box>) {
|
|
412
|
+
const dragRef = React.createRef<TabsDragController>();
|
|
413
|
+
const tree = (next: { tabs: string[]; focused: string }) => (
|
|
414
|
+
<BloomThemeProvider mode="light" colorPreset="teal">
|
|
415
|
+
<Tabs testID="tabs" ref={dragRef} hasSelection>
|
|
416
|
+
{next.tabs.map((tab) => (
|
|
417
|
+
<TabsTrigger
|
|
418
|
+
key={tab}
|
|
419
|
+
value={tab}
|
|
420
|
+
label={tab}
|
|
421
|
+
isFocused={tab === next.focused}
|
|
422
|
+
/>
|
|
423
|
+
))}
|
|
424
|
+
</Tabs>
|
|
425
|
+
</BloomThemeProvider>
|
|
426
|
+
);
|
|
427
|
+
const utils = render(tree({ tabs, focused }), {
|
|
428
|
+
createNodeMock: nodeMockFor(geometry),
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
/** Fire the `onLayout` a trigger's own node would fire. */
|
|
432
|
+
const reportLayout = (label: string) => {
|
|
433
|
+
const box = geometry.get(label);
|
|
434
|
+
if (!box) throw new Error(`no geometry for "${label}"`);
|
|
435
|
+
let node: ReactTestInstance | null = triggerFor(utils.getByText(label));
|
|
436
|
+
while (node && node.props?.onLayout === undefined) node = node.parent;
|
|
437
|
+
if (!node) throw new Error(`no onLayout ancestor for "${label}"`);
|
|
438
|
+
fireEvent(node, 'layout', { nativeEvent: { layout: box } });
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Let the measurement pass run and re-render so the mapper is evaluated
|
|
443
|
+
* against the shared values it wrote — the reanimated mock computes
|
|
444
|
+
* `useAnimatedStyle` at render time, so nothing written from a callback is
|
|
445
|
+
* visible in the rendered style until something renders again.
|
|
446
|
+
*/
|
|
447
|
+
const settle = async (next?: { tabs: string[]; focused: string }) => {
|
|
448
|
+
await act(async () => {
|
|
449
|
+
utils.rerender(tree(next ?? { tabs, focused }));
|
|
450
|
+
});
|
|
451
|
+
await act(async () => {
|
|
452
|
+
utils.rerender(tree(next ?? { tabs, focused }));
|
|
453
|
+
});
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const indicator = () => {
|
|
457
|
+
const style = flattenStyle(utils.getByTestId('tabs-indicator').props.style);
|
|
458
|
+
const transform = style.transform as { translateX?: number }[] | undefined;
|
|
459
|
+
return {
|
|
460
|
+
x: transform?.[0]?.translateX,
|
|
461
|
+
width: style.width,
|
|
462
|
+
};
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
return { ...utils, dragRef, reportLayout, settle, indicator };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
it('follows the active tab when one is INSERTED before it', async () => {
|
|
469
|
+
const geometry = new Map<string, Box>([
|
|
470
|
+
['posts', { x: 0, width: 80 }],
|
|
471
|
+
['likes', { x: 80, width: 80 }],
|
|
472
|
+
['boosts', { x: 160, width: 80 }],
|
|
473
|
+
]);
|
|
474
|
+
const bar = mountStrip(['posts', 'likes', 'boosts'], 'boosts', geometry);
|
|
475
|
+
for (const tab of ['posts', 'likes', 'boosts']) bar.reportLayout(tab);
|
|
476
|
+
await bar.settle();
|
|
477
|
+
expect(bar.indicator()).toEqual({ x: 160, width: 80 });
|
|
478
|
+
|
|
479
|
+
// The lane tab lands, and everything after it slides right by its width.
|
|
480
|
+
geometry.set('lane', { x: 0, width: 100 });
|
|
481
|
+
geometry.set('posts', { x: 100, width: 80 });
|
|
482
|
+
geometry.set('likes', { x: 180, width: 80 });
|
|
483
|
+
geometry.set('boosts', { x: 260, width: 80 });
|
|
484
|
+
await bar.settle({ tabs: ['lane', 'posts', 'likes', 'boosts'], focused: 'boosts' });
|
|
485
|
+
// Only the NEW node is newly observed, so only it reports.
|
|
486
|
+
bar.reportLayout('lane');
|
|
487
|
+
await bar.settle({ tabs: ['lane', 'posts', 'likes', 'boosts'], focused: 'boosts' });
|
|
488
|
+
|
|
489
|
+
expect(bar.indicator()).toEqual({ x: 260, width: 80 });
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('follows the active tab when one is REMOVED before it', async () => {
|
|
493
|
+
const geometry = new Map<string, Box>([
|
|
494
|
+
['lane', { x: 0, width: 100 }],
|
|
495
|
+
['posts', { x: 100, width: 80 }],
|
|
496
|
+
['boosts', { x: 180, width: 80 }],
|
|
497
|
+
]);
|
|
498
|
+
const bar = mountStrip(['lane', 'posts', 'boosts'], 'boosts', geometry);
|
|
499
|
+
for (const tab of ['lane', 'posts', 'boosts']) bar.reportLayout(tab);
|
|
500
|
+
await bar.settle();
|
|
501
|
+
expect(bar.indicator()).toEqual({ x: 180, width: 80 });
|
|
502
|
+
|
|
503
|
+
geometry.set('posts', { x: 0, width: 80 });
|
|
504
|
+
geometry.set('boosts', { x: 80, width: 80 });
|
|
505
|
+
// Nothing resized and the removed node is gone, so NOTHING reports here.
|
|
506
|
+
await bar.settle({ tabs: ['posts', 'boosts'], focused: 'boosts' });
|
|
507
|
+
|
|
508
|
+
expect(bar.indicator()).toEqual({ x: 80, width: 80 });
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('follows the active tab when the set is REORDERED', async () => {
|
|
512
|
+
const geometry = new Map<string, Box>([
|
|
513
|
+
['posts', { x: 0, width: 80 }],
|
|
514
|
+
['likes', { x: 80, width: 80 }],
|
|
515
|
+
['lane', { x: 160, width: 100 }],
|
|
516
|
+
]);
|
|
517
|
+
const bar = mountStrip(['posts', 'likes', 'lane'], 'lane', geometry);
|
|
518
|
+
for (const tab of ['posts', 'likes', 'lane']) bar.reportLayout(tab);
|
|
519
|
+
await bar.settle();
|
|
520
|
+
expect(bar.indicator()).toEqual({ x: 160, width: 100 });
|
|
521
|
+
|
|
522
|
+
// Same tabs, same sizes, new order — the case no size-change notification
|
|
523
|
+
// can ever report, on either platform.
|
|
524
|
+
geometry.set('lane', { x: 0, width: 100 });
|
|
525
|
+
geometry.set('posts', { x: 100, width: 80 });
|
|
526
|
+
geometry.set('likes', { x: 180, width: 80 });
|
|
527
|
+
await bar.settle({ tabs: ['lane', 'posts', 'likes'], focused: 'lane' });
|
|
528
|
+
|
|
529
|
+
expect(bar.indicator()).toEqual({ x: 0, width: 100 });
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('forgets a removed tab, so a swipe cannot commit to one that is gone', async () => {
|
|
533
|
+
const geometry = new Map<string, Box>([
|
|
534
|
+
['posts', { x: 0, width: 80 }],
|
|
535
|
+
['likes', { x: 80, width: 80 }],
|
|
536
|
+
['lane', { x: 160, width: 100 }],
|
|
537
|
+
]);
|
|
538
|
+
const bar = mountStrip(['posts', 'likes', 'lane'], 'likes', geometry);
|
|
539
|
+
for (const tab of ['posts', 'likes', 'lane']) bar.reportLayout(tab);
|
|
540
|
+
await bar.settle();
|
|
541
|
+
// Dragging left reveals the tab to the RIGHT, so this commits `lane`.
|
|
542
|
+
expect(bar.dragRef.current?.drag(-1000)).toBe('lane');
|
|
543
|
+
|
|
544
|
+
await bar.settle({ tabs: ['posts', 'likes'], focused: 'likes' });
|
|
545
|
+
|
|
546
|
+
// `likes` is now last. A phantom `lane` left behind in the geometry would
|
|
547
|
+
// still be found here, and releasing would navigate to a tab that is not
|
|
548
|
+
// on screen.
|
|
549
|
+
expect(bar.dragRef.current?.drag(-1000)).toBeNull();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('ignores a zero measurement, which means "not laid out" and not "empty"', async () => {
|
|
553
|
+
const geometry = new Map<string, Box>([
|
|
554
|
+
['posts', { x: 0, width: 80 }],
|
|
555
|
+
['likes', { x: 80, width: 80 }],
|
|
556
|
+
]);
|
|
557
|
+
const bar = mountStrip(['posts', 'likes'], 'likes', geometry);
|
|
558
|
+
for (const tab of ['posts', 'likes']) bar.reportLayout(tab);
|
|
559
|
+
await bar.settle();
|
|
560
|
+
expect(bar.indicator()).toEqual({ x: 80, width: 80 });
|
|
561
|
+
|
|
562
|
+
// What a `display: none` ancestor reports on web. A trigger always
|
|
563
|
+
// carries horizontal padding, so it can never genuinely be zero-wide —
|
|
564
|
+
// taking this at face value would collapse the underline and lose the
|
|
565
|
+
// real geometry it has to come back to.
|
|
566
|
+
geometry.set('posts', { x: 0, width: 0 });
|
|
567
|
+
geometry.set('likes', { x: 0, width: 0 });
|
|
568
|
+
await bar.settle();
|
|
569
|
+
|
|
570
|
+
expect(bar.indicator()).toEqual({ x: 80, width: 80 });
|
|
571
|
+
});
|
|
572
|
+
});
|
|
297
573
|
});
|
|
@@ -90,9 +90,10 @@ describe('Theme system', () => {
|
|
|
90
90
|
});
|
|
91
91
|
|
|
92
92
|
it('defaults to teal for unknown hex values', () => {
|
|
93
|
-
expect(hexToAppColorName('#000000')).toBe('teal');
|
|
94
93
|
expect(hexToAppColorName('#ffffff')).toBe('teal');
|
|
95
94
|
expect(hexToAppColorName('#123456')).toBe('teal');
|
|
95
|
+
// '#000000' is no longer unknown — it is the `mono` preset's seed.
|
|
96
|
+
expect(hexToAppColorName('#000000')).toBe('mono');
|
|
96
97
|
});
|
|
97
98
|
});
|
|
98
99
|
|
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 —
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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
|
-
|
|
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>
|