@oxy.so/bloom 6.2.0 → 6.2.1
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/docs/adoption-matrix.mdx +2 -2
- package/docs/bottom-sheet.mdx +1 -1
- package/lib/commonjs/bottom-sheet/BottomSheetBase.js +33 -49
- package/lib/commonjs/bottom-sheet/BottomSheetBase.js.map +1 -1
- package/lib/commonjs/dialog/DialogBottomSheet.js +1 -1
- package/lib/commonjs/dialog/DialogBottomSheet.js.map +1 -1
- package/lib/commonjs/surface/SurfacePaint.js +17 -4
- package/lib/commonjs/surface/SurfacePaint.js.map +1 -1
- package/lib/module/bottom-sheet/BottomSheetBase.js +33 -49
- package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
- package/lib/module/dialog/DialogBottomSheet.js +1 -1
- package/lib/module/dialog/DialogBottomSheet.js.map +1 -1
- package/lib/module/surface/SurfacePaint.js +18 -5
- package/lib/module/surface/SurfacePaint.js.map +1 -1
- package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/bottom-sheet/types.d.ts +1 -0
- package/lib/typescript/commonjs/bottom-sheet/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/surface/SurfacePaint.d.ts.map +1 -1
- package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
- package/lib/typescript/module/bottom-sheet/types.d.ts +1 -0
- package/lib/typescript/module/bottom-sheet/types.d.ts.map +1 -1
- package/lib/typescript/module/surface/SurfacePaint.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/support/adoption-matrix.ts +683 -0
- package/src/__tests__/support/card-surface.ts +25 -0
- package/src/__tests__/support/collision-fixture-barrel.ts +20 -0
- package/src/__tests__/support/commerce-harness.tsx +97 -0
- package/src/__tests__/support/composite.ts +68 -0
- package/src/__tests__/support/constructed-style-sheets.ts +68 -0
- package/src/__tests__/support/messages-in.ts +10 -0
- package/src/__tests__/support/press-host.ts +30 -0
- package/src/__tests__/support/rendered-style.ts +99 -0
- package/src/__tests__/support/unread-hook-fixture.ts +33 -0
- package/src/__tests__/support/worklet-capture-fixture.tsx +28 -0
- package/src/bottom-sheet/BottomSheetBase.tsx +24 -34
- package/src/bottom-sheet/types.ts +1 -0
- package/src/dialog/DialogBottomSheet.tsx +1 -1
- package/src/surface/SurfacePaint.tsx +14 -4
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +9346 -0
- package/src/theme/__tests__/__fixtures__/tonal-glass-primary-failures.json +69 -0
- package/src/theme/__tests__/fixtures/color-engine-golden.json +1 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { resolveSurfaceMaterial } from '../../surface/resolve-surface-material';
|
|
2
|
+
import { resolveChartCardPalette } from '../../chart-cards/palette';
|
|
3
|
+
import type { ReactTestInstance } from 'react-test-renderer';
|
|
4
|
+
import { SurfacePaint } from '../../surface/SurfacePaint';
|
|
5
|
+
import { resolvedStyle } from './rendered-style';
|
|
6
|
+
|
|
7
|
+
/** Card may opt into a content clip; inspect its layout without replacing host geometry. */
|
|
8
|
+
export function cardLayout(root: ReactTestInstance) {
|
|
9
|
+
const id = root.props.testID;
|
|
10
|
+
const content = root.findAll(node => node.props.testID === `${id}-clip`)[0];
|
|
11
|
+
return { ...(content ? resolvedStyle(content.props.style) : {}), ...resolvedStyle(root.props.style) };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The material's fill contract, independent of its transparent host. Not a pixel test. */
|
|
15
|
+
export function cardFill(root: ReactTestInstance) {
|
|
16
|
+
const component = (SurfacePaint as unknown as { type: unknown }).type;
|
|
17
|
+
// The host's own paint precedes any nested cards or buttons.
|
|
18
|
+
return root.findAll(node => node.type === component)[0]!.props.fill;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Chart content derives colours from the composited card, not its unpainted token. */
|
|
22
|
+
export function materialChartPalette(theme: import('../../theme/types').Theme) {
|
|
23
|
+
const fill = resolveSurfaceMaterial({ fill: theme.colors.card, parentFill: theme.colors.background }).publishedFill;
|
|
24
|
+
return resolveChartCardPalette(theme, fill);
|
|
25
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A barrel that DELIBERATELY offers one name from two declarations, so that
|
|
3
|
+
* `barrel-name-collisions.test.ts` can prove its detector fires. It reproduces
|
|
4
|
+
* the shipped `Item` shape: a star export carrying `Item` from `item/`, and an
|
|
5
|
+
* explicit re-export binding that same name to a different component.
|
|
6
|
+
*
|
|
7
|
+
* Nothing imports this at runtime. bob's `exclude` keeps it out of `lib/`, and
|
|
8
|
+
* `files: ["src", …]` does ship it inside the tarball's `src/` like the other
|
|
9
|
+
* test files there — but no entry point reaches it, so no bundler links it, and
|
|
10
|
+
* `package.json#exports` never names it.
|
|
11
|
+
*
|
|
12
|
+
* The `Card` lines below are the other half of the
|
|
13
|
+
* control: one name offered twice from ONE declaration, which the detector must
|
|
14
|
+
* stay quiet about — otherwise its cheapest fix would be deleting a legitimate
|
|
15
|
+
* redundant re-export.
|
|
16
|
+
*/
|
|
17
|
+
export * from '../../item';
|
|
18
|
+
export { Card as Item } from '../../card';
|
|
19
|
+
export { Card } from '../../card';
|
|
20
|
+
export * from '../../card';
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mount harness the four commerce suites share.
|
|
3
|
+
*
|
|
4
|
+
* Every one of them asserts EMITTED DOM — an `aria-*` attribute, a computed
|
|
5
|
+
* colour, whether a node exists at all — rather than the props it just passed,
|
|
6
|
+
* so each renders through the real react-native-web. The mock has to be
|
|
7
|
+
* installed by the SUITE (a `jest.mock` call is hoisted to the top of the file
|
|
8
|
+
* it is written in and does not travel through an import), which is why this
|
|
9
|
+
* module exports the harness and not the mock.
|
|
10
|
+
*/
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import { act } from 'react';
|
|
13
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
14
|
+
|
|
15
|
+
import { BloomThemeProvider } from '../../theme/BloomThemeProvider';
|
|
16
|
+
import type { Theme } from '../../theme/types';
|
|
17
|
+
import { useTheme } from '../../theme/use-theme';
|
|
18
|
+
|
|
19
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
20
|
+
|
|
21
|
+
let container: HTMLDivElement;
|
|
22
|
+
let root: Root;
|
|
23
|
+
let lastTheme: Theme | null = null;
|
|
24
|
+
|
|
25
|
+
function ThemeProbe() {
|
|
26
|
+
lastTheme = useTheme();
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function setupHarness(): void {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
container = document.createElement('div');
|
|
33
|
+
document.body.appendChild(container);
|
|
34
|
+
root = createRoot(container);
|
|
35
|
+
});
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
act(() => root.unmount());
|
|
38
|
+
container.remove();
|
|
39
|
+
lastTheme = null;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function mount(ui: React.ReactElement, mode: 'light' | 'dark' = 'light'): HTMLElement {
|
|
44
|
+
act(() => {
|
|
45
|
+
root.render(
|
|
46
|
+
<BloomThemeProvider mode={mode} colorPreset="teal">
|
|
47
|
+
<ThemeProbe />
|
|
48
|
+
{ui}
|
|
49
|
+
</BloomThemeProvider>,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
return container;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function theme(): Theme {
|
|
56
|
+
if (!lastTheme) throw new Error('theme not captured');
|
|
57
|
+
return lastTheme;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function root$(): HTMLElement {
|
|
61
|
+
return container;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function byTestId(id: string): HTMLElement {
|
|
65
|
+
const el = document.querySelector(`[data-testid="${id}"]`);
|
|
66
|
+
if (!(el instanceof HTMLElement)) throw new Error(`No element for testID "${id}"`);
|
|
67
|
+
return el;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function queryTestId(id: string): HTMLElement | null {
|
|
71
|
+
return document.querySelector(`[data-testid="${id}"]`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function byLabel(label: string): HTMLElement {
|
|
75
|
+
const el = document.querySelector(`[aria-label="${label}"]`);
|
|
76
|
+
if (!(el instanceof HTMLElement)) throw new Error(`No element labelled "${label}"`);
|
|
77
|
+
return el;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function allByRole(role: string): HTMLElement[] {
|
|
81
|
+
return Array.from(document.querySelectorAll(`[role="${role}"]`)).filter(
|
|
82
|
+
(el): el is HTMLElement => el instanceof HTMLElement,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function click(el: HTMLElement): void {
|
|
87
|
+
act(() => {
|
|
88
|
+
el.click();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A colour as the DOM serialises it, for comparing against a computed style. */
|
|
93
|
+
export function css(color: string): string {
|
|
94
|
+
const probe = document.createElement('div');
|
|
95
|
+
probe.style.color = color;
|
|
96
|
+
return probe.style.color;
|
|
97
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compositing and WCAG maths for suites that measure a TRANSLUCENT surface.
|
|
3
|
+
*
|
|
4
|
+
* A pane you can see through has no colour of its own to read off a style prop:
|
|
5
|
+
* what a label lands on is the fill flattened onto whatever is behind it. Every
|
|
6
|
+
* gate on a glass material therefore needs the same four functions, and they
|
|
7
|
+
* are here so a new one does not start by copying them — which is how two
|
|
8
|
+
* copies of a luminance formula end up disagreeing about the sRGB knee.
|
|
9
|
+
*
|
|
10
|
+
* `theme/__tests__/glass-colors.test.ts` keeps its OWN copies, deliberately and
|
|
11
|
+
* with its own reasons written down; this is for everything since.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface Rgba {
|
|
15
|
+
r: number;
|
|
16
|
+
g: number;
|
|
17
|
+
b: number;
|
|
18
|
+
a: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Parse `#rgb`, `#rrggbb`, `rgb(...)` or `rgba(...)`. Throws on anything else. */
|
|
22
|
+
export function parseColor(value: string): Rgba {
|
|
23
|
+
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value.trim());
|
|
24
|
+
if (hex?.[1] !== undefined) {
|
|
25
|
+
const h = hex[1].length === 3 ? hex[1].replace(/./g, (c) => c + c) : hex[1];
|
|
26
|
+
return {
|
|
27
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
28
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
29
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
30
|
+
a: 1,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const fn = /^rgba?\(([^)]*)\)$/.exec(value.trim());
|
|
34
|
+
if (!fn?.[1]) throw new Error(`unparseable colour: ${JSON.stringify(value)}`);
|
|
35
|
+
const parts = fn[1].split(/[\s,/]+/).filter(Boolean).map(Number);
|
|
36
|
+
if (parts.length < 3 || parts.some((n) => Number.isNaN(n))) {
|
|
37
|
+
throw new Error(`unparseable colour: ${JSON.stringify(value)}`);
|
|
38
|
+
}
|
|
39
|
+
return { r: parts[0] ?? 0, g: parts[1] ?? 0, b: parts[2] ?? 0, a: parts[3] ?? 1 };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Flatten a translucent colour onto an opaque one — source-over. */
|
|
43
|
+
export function over(top: Rgba, bottom: Rgba): Rgba {
|
|
44
|
+
return {
|
|
45
|
+
r: top.r * top.a + bottom.r * (1 - top.a),
|
|
46
|
+
g: top.g * top.a + bottom.g * (1 - top.a),
|
|
47
|
+
b: top.b * top.a + bottom.b * (1 - top.a),
|
|
48
|
+
a: 1,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** WCAG relative luminance. */
|
|
53
|
+
export function luminance({ r, g, b }: Rgba): number {
|
|
54
|
+
const channel = (v: number): number => {
|
|
55
|
+
const c = v / 255;
|
|
56
|
+
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
57
|
+
};
|
|
58
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** WCAG contrast ratio, order-independent. */
|
|
62
|
+
export function contrastRatio(a: Rgba, b: Rgba): number {
|
|
63
|
+
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
|
|
64
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const WHITE: Rgba = { r: 255, g: 255, b: 255, a: 1 };
|
|
68
|
+
export const BLACK: Rgba = { r: 0, g: 0, b: 0, a: 1 };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A spec-shaped stand-in for constructed stylesheets, for the jsdom suites.
|
|
3
|
+
*
|
|
4
|
+
* jsdom 26 ships the `CSSStyleSheet` CONSTRUCTOR but neither `replaceSync` nor
|
|
5
|
+
* `document.adoptedStyleSheets`, so every jsdom test takes Bloom's `<style>`
|
|
6
|
+
* fallback. That makes the whole CSP-safe path — the reason
|
|
7
|
+
* `styles/adopt-style-sheet.ts` exists — invisible to jest unless a test
|
|
8
|
+
* installs the API itself. Without this, a mutation that deleted the adoption
|
|
9
|
+
* branch entirely would leave the suite green.
|
|
10
|
+
*
|
|
11
|
+
* Not collected as a suite: jest's `testMatch` wants `*.test.ts` / `*.spec.ts`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface FakeStyleSheet {
|
|
15
|
+
/** The CSS most recently handed to `replaceSync`. */
|
|
16
|
+
cssText: string;
|
|
17
|
+
/** How many times the sheet has been re-parsed, so a test can prove it wasn't. */
|
|
18
|
+
replaceSyncCalls: number;
|
|
19
|
+
replaceSync(css: string): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ConstructedStyleSheetsHarness {
|
|
23
|
+
/** The document's adopted sheets, read live (the code reassigns the array). */
|
|
24
|
+
adopted(): readonly FakeStyleSheet[];
|
|
25
|
+
/** Drop the API again, restoring jsdom's own `CSSStyleSheet`. */
|
|
26
|
+
uninstall(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function installConstructedStyleSheets(): ConstructedStyleSheetsHarness {
|
|
30
|
+
class FakeCSSStyleSheet implements FakeStyleSheet {
|
|
31
|
+
cssText = '';
|
|
32
|
+
replaceSyncCalls = 0;
|
|
33
|
+
|
|
34
|
+
replaceSync(css: string): void {
|
|
35
|
+
this.cssText = css;
|
|
36
|
+
this.replaceSyncCalls += 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const previousConstructor = Object.getOwnPropertyDescriptor(
|
|
41
|
+
globalThis,
|
|
42
|
+
'CSSStyleSheet',
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
Object.defineProperty(globalThis, 'CSSStyleSheet', {
|
|
46
|
+
value: FakeCSSStyleSheet,
|
|
47
|
+
writable: true,
|
|
48
|
+
configurable: true,
|
|
49
|
+
});
|
|
50
|
+
Object.defineProperty(document, 'adoptedStyleSheets', {
|
|
51
|
+
value: [],
|
|
52
|
+
writable: true,
|
|
53
|
+
configurable: true,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
adopted: () =>
|
|
58
|
+
document.adoptedStyleSheets as unknown as readonly FakeStyleSheet[],
|
|
59
|
+
uninstall: () => {
|
|
60
|
+
Reflect.deleteProperty(document, 'adoptedStyleSheets');
|
|
61
|
+
if (previousConstructor) {
|
|
62
|
+
Object.defineProperty(globalThis, 'CSSStyleSheet', previousConstructor);
|
|
63
|
+
} else {
|
|
64
|
+
Reflect.deleteProperty(globalThis, 'CSSStyleSheet');
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { pickMessages, type MessageCatalog } from '../../locale/messages';
|
|
2
|
+
import type { BloomLanguage } from '../../locale/languages';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A catalog's strings in `language`. Catalogs hold English only; the other
|
|
6
|
+
* languages are registered for every suite by `__mocks__/setup.ts`.
|
|
7
|
+
*/
|
|
8
|
+
export function messagesIn<M>(catalog: MessageCatalog<M>, language: BloomLanguage): M {
|
|
9
|
+
return pickMessages(catalog, language);
|
|
10
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pressing a component's OWN host node, and proving the component is what
|
|
3
|
+
* installed the handler being measured.
|
|
4
|
+
*
|
|
5
|
+
* `fireEvent.press` walks UP from the element it is handed to the nearest
|
|
6
|
+
* ancestor carrying an `onPress` prop, and it does not stop at host nodes — a
|
|
7
|
+
* COMPOSITE counts, because `getEventHandler` only reads `element.props`. So
|
|
8
|
+
* `<Thing onPress={fn}>` written in a test's own JSX catches the press itself.
|
|
9
|
+
* Measured on a (since removed) card component: with the handler deleted from the
|
|
10
|
+
* component the card rendered as an inert `View`, and the test still reported exactly one
|
|
11
|
+
* call. Green, and measuring nothing. Nine suites had the same shape.
|
|
12
|
+
*
|
|
13
|
+
* Asserting the host node's own `onPress` FIRST closes both halves:
|
|
14
|
+
*
|
|
15
|
+
* - a component that installs no handler at all fails here, and
|
|
16
|
+
* - a component that installs one which drops the caller's fails on the call
|
|
17
|
+
* count, because `fireEvent` now finds a handler AT the node and never walks
|
|
18
|
+
* past it to the test's own JSX.
|
|
19
|
+
*
|
|
20
|
+
* `host` must therefore be the node the component itself made pressable — the
|
|
21
|
+
* one carrying its `testID` / `accessibilityLabel` — not a `Text` deep inside
|
|
22
|
+
* it, or the walk-up is back and so is the hole.
|
|
23
|
+
*/
|
|
24
|
+
import { fireEvent } from '@testing-library/react-native';
|
|
25
|
+
import type { ReactTestInstance } from 'react-test-renderer';
|
|
26
|
+
|
|
27
|
+
export function pressHost(host: ReactTestInstance): void {
|
|
28
|
+
expect(typeof host.props.onPress).toBe('function');
|
|
29
|
+
fireEvent.press(host);
|
|
30
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading what actually landed on a rendered node — shared by every suite that
|
|
3
|
+
* asserts the "className lands on the node the parent lays out" rule.
|
|
4
|
+
*
|
|
5
|
+
* Three components had the same two-node defect (`Button`, then `Fab` and
|
|
6
|
+
* `FrostedIconButton`), and asserting it needs the same two things each time:
|
|
7
|
+
* the HOST tree (composites do not appear in `toJSON()`, so a wrapper is only
|
|
8
|
+
* visible there) and a deep flatten of the `style` prop, because react-native-css
|
|
9
|
+
* merges a caller's class in as a `{ $$css: true, className }` descriptor
|
|
10
|
+
* ALONGSIDE the style objects rather than in a prop of its own.
|
|
11
|
+
*/
|
|
12
|
+
import type { ReactTestRendererJSON } from 'react-test-renderer';
|
|
13
|
+
|
|
14
|
+
export type StyleEntry = Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
export interface HostNode {
|
|
17
|
+
type: string;
|
|
18
|
+
props: Record<string, unknown>;
|
|
19
|
+
children: Array<HostNode | string> | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Deep-flatten an RN `style` prop (nested arrays, `false`/`null` holes) into the
|
|
24
|
+
* list of style objects that actually reached the node.
|
|
25
|
+
*/
|
|
26
|
+
export function styleEntries(style: unknown, out: StyleEntry[] = []): StyleEntry[] {
|
|
27
|
+
if (!style || typeof style !== 'object') return out;
|
|
28
|
+
if (Array.isArray(style)) {
|
|
29
|
+
for (const entry of style) styleEntries(entry, out);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
out.push(style as StyleEntry);
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Every style key/value that landed on a node, later entries winning. */
|
|
37
|
+
export function resolvedStyle(style: unknown): StyleEntry {
|
|
38
|
+
return Object.assign({}, ...styleEntries(style)) as StyleEntry;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The class tokens react-native-css accepted for a node, in arrival order. */
|
|
42
|
+
export function classNamesOn(style: unknown): string[] {
|
|
43
|
+
return styleEntries(style)
|
|
44
|
+
.filter((entry) => entry.$$css === true && typeof entry.className === 'string')
|
|
45
|
+
.map((entry) => String(entry.className));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isHostNode(value: unknown): value is HostNode {
|
|
49
|
+
return (
|
|
50
|
+
typeof value === 'object' && value !== null && typeof (value as HostNode).type === 'string'
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Walk the HOST tree (composites are absent from `toJSON()`) for a testID. */
|
|
55
|
+
export function findHost(
|
|
56
|
+
node: ReactTestRendererJSON | ReactTestRendererJSON[] | unknown,
|
|
57
|
+
testID: string,
|
|
58
|
+
): HostNode | null {
|
|
59
|
+
if (Array.isArray(node)) {
|
|
60
|
+
for (const child of node) {
|
|
61
|
+
const hit = findHost(child, testID);
|
|
62
|
+
if (hit) return hit;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
if (!isHostNode(node)) return null;
|
|
67
|
+
if (node.props.testID === testID) return node;
|
|
68
|
+
return findHost(node.children, testID);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every host node in a rendered tree, in document order.
|
|
73
|
+
*
|
|
74
|
+
* The reason to walk `toJSON()` rather than `UNSAFE_root.findAll` is that the
|
|
75
|
+
* latter yields COMPOSITE instances too, so a `memo(fn)` component contributes
|
|
76
|
+
* two matches for one rendered element and every count is silently doubled —
|
|
77
|
+
* which reads as "the component rendered the thing twice", not as an artefact
|
|
78
|
+
* of the query.
|
|
79
|
+
*/
|
|
80
|
+
export function hostNodes(tree: unknown, out: HostNode[] = []): HostNode[] {
|
|
81
|
+
if (Array.isArray(tree)) {
|
|
82
|
+
for (const child of tree) hostNodes(child, out);
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
if (!isHostNode(tree)) return out;
|
|
86
|
+
out.push(tree);
|
|
87
|
+
return hostNodes(tree.children, out);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The host nodes a parent laid out. Callers assert the LENGTH themselves —
|
|
92
|
+
* "exactly one" is the property, and a helper that returned only the first would
|
|
93
|
+
* hide the second.
|
|
94
|
+
*/
|
|
95
|
+
export function renderedChildren(tree: unknown, hostTestID: string): HostNode[] {
|
|
96
|
+
const host = findHost(tree, hostTestID);
|
|
97
|
+
if (host === null) throw new Error(`no host rendered for testID "${hostTestID}"`);
|
|
98
|
+
return (host.children ?? []).filter(isHostNode);
|
|
99
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A module that DELIBERATELY throws away one of the values a hook hands back, so
|
|
3
|
+
* `hook-subscriptions-are-read.test.ts` can prove its detector fires. It carries
|
|
4
|
+
* both halves of the control: `unreadFlag` is bound and never read, which the
|
|
5
|
+
* detector must report, and `readFlag` is bound and read, which it must stay
|
|
6
|
+
* quiet about — otherwise the cheapest way to green the gate would be deleting a
|
|
7
|
+
* binding that is doing its job.
|
|
8
|
+
*
|
|
9
|
+
* Nothing imports this at runtime. bob's `exclude` keeps it out of `lib/`, and
|
|
10
|
+
* `files: ["src", …]` does ship it inside the tarball's `src/` like the other
|
|
11
|
+
* 137 test files there — but no entry point reaches it, so no bundler links it.
|
|
12
|
+
* It is a plain `.ts` with no React import because the
|
|
13
|
+
* detector reads source, never types: a call named `use…` bound to a name is all
|
|
14
|
+
* the shape there is.
|
|
15
|
+
*
|
|
16
|
+
* The destructuring RENAMES, which is not decoration — it reproduces the shipped
|
|
17
|
+
* shape (`const { state: pressed, onIn: onPressIn } = useInteractionState()`) and
|
|
18
|
+
* it is what keeps the control honest. A hook declared in the same file as its
|
|
19
|
+
* caller mentions its own property names in its return type and its return
|
|
20
|
+
* object, and the detector counts every occurrence of a spelling as a possible
|
|
21
|
+
* read, so an unrenamed fixture would be silently unreportable for a reason no
|
|
22
|
+
* real call site has.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Stands in for a real subscription — a hook by name, with two return values. */
|
|
26
|
+
function useFixtureInteractionState(): { first: boolean; second: boolean } {
|
|
27
|
+
return { first: false, second: false };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function fixtureComponentBody(): boolean {
|
|
31
|
+
const { first: readFlag, second: unreadFlag } = useFixtureInteractionState();
|
|
32
|
+
return readFlag;
|
|
33
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Positive control for `worklet-captures.test.ts`. Never rendered: the gate
|
|
3
|
+
* builds a program over this file and reads its worklets.
|
|
4
|
+
*
|
|
5
|
+
* `history` carries a `Date` and `slot` a React element — the two shapes the
|
|
6
|
+
* native worklet runtime cannot copy — and each is captured whole by a mapper.
|
|
7
|
+
* `hasHistory` is the fix: the primitive derived OUTSIDE the worklet, which the
|
|
8
|
+
* gate must leave alone.
|
|
9
|
+
*/
|
|
10
|
+
import type { ReactNode } from 'react';
|
|
11
|
+
import { Gesture } from 'react-native-gesture-handler';
|
|
12
|
+
import { useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
|
|
13
|
+
|
|
14
|
+
interface Conversation {
|
|
15
|
+
title: string;
|
|
16
|
+
createdAt: Date;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useWorkletCaptureFixture(history: Conversation[] | undefined, slot: ReactNode) {
|
|
20
|
+
const progress = useSharedValue(0);
|
|
21
|
+
const hasHistory = Boolean(history);
|
|
22
|
+
const whole = useAnimatedStyle(() => ({ opacity: history ? progress.value : 1 }), [progress, history]);
|
|
23
|
+
const derived = useAnimatedStyle(() => ({ opacity: hasHistory ? progress.value : 1 }), [progress, hasHistory]);
|
|
24
|
+
const pan = Gesture.Pan().onUpdate(() => {
|
|
25
|
+
progress.value = slot ? 1 : 0;
|
|
26
|
+
});
|
|
27
|
+
return { whole, derived, pan };
|
|
28
|
+
}
|
|
@@ -133,14 +133,11 @@ export const BottomSheetBase = forwardRef((props: BottomSheetBaseProps, ref: Rea
|
|
|
133
133
|
const bodyPanRef = useRef<GestureType | undefined>(undefined);
|
|
134
134
|
const handlePanRef = useRef<GestureType | undefined>(undefined);
|
|
135
135
|
|
|
136
|
-
//
|
|
136
|
+
// Completion is not a user request: once closed, every owner must hear it.
|
|
137
|
+
// Vetoes are checked before a user-triggered close starts, never after it.
|
|
137
138
|
const safeClose = useCallback(() => {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
} else if (!onDismissAttempt) {
|
|
141
|
-
onDismiss?.();
|
|
142
|
-
}
|
|
143
|
-
}, [onDismissAttempt, onDismiss]);
|
|
139
|
+
onDismiss?.();
|
|
140
|
+
}, [onDismiss]);
|
|
144
141
|
|
|
145
142
|
// Mirror `safeClose` and `rendered` into refs so the unmount cleanup can
|
|
146
143
|
// fire the latest dismiss callback when needed, without re-binding the
|
|
@@ -251,6 +248,20 @@ export const BottomSheetBase = forwardRef((props: BottomSheetBaseProps, ref: Rea
|
|
|
251
248
|
};
|
|
252
249
|
}, [colors.border]);
|
|
253
250
|
|
|
251
|
+
// Gesture callbacks cross to JS before consulting the consumer's veto.
|
|
252
|
+
// A refused drag springs back; it never hides the Modal or strands its owner.
|
|
253
|
+
const requestPanDismiss = useCallback((generation: number, velocity: number) => {
|
|
254
|
+
if (closeGenerationRef.current !== generation || hasClosedRef.current) return;
|
|
255
|
+
if (onDismissAttempt && !onDismissAttempt()) {
|
|
256
|
+
translateY.value = withSpring(0, { ...SPRING_CONFIG, velocity });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
translateY.value = withSpring(screenHeightSV.value, { ...SPRING_CONFIG, velocity });
|
|
260
|
+
opacity.value = withTiming(0, { ...MOTION_RECIPES.dismiss, duration: 250 }, (finished) => {
|
|
261
|
+
if (finished) runOnJS(finishClose)(generation);
|
|
262
|
+
});
|
|
263
|
+
}, [onDismissAttempt, translateY, screenHeightSV, opacity, finishClose]);
|
|
264
|
+
|
|
254
265
|
const present = useCallback(() => {
|
|
255
266
|
setRendered(true);
|
|
256
267
|
setVisible(true);
|
|
@@ -336,15 +347,7 @@ export const BottomSheetBase = forwardRef((props: BottomSheetBaseProps, ref: Rea
|
|
|
336
347
|
(distance > closeThreshold && velocity > -300);
|
|
337
348
|
|
|
338
349
|
if (shouldClose) {
|
|
339
|
-
|
|
340
|
-
// moment the close gesture commits. The completion
|
|
341
|
-
// callback only fires `finishClose` if no reopen
|
|
342
|
-
// bumped the generation in between.
|
|
343
|
-
const generation = closeGeneration.value;
|
|
344
|
-
translateY.value = withSpring(screenHeightSV.value, { ...SPRING_CONFIG, velocity });
|
|
345
|
-
opacity.value = withTiming(0, { ...MOTION_RECIPES.dismiss, duration: 250 }, (finished) => {
|
|
346
|
-
if (finished) runOnJS(finishClose)(generation);
|
|
347
|
-
});
|
|
350
|
+
runOnJS(requestPanDismiss)(closeGeneration.value, velocity);
|
|
348
351
|
} else {
|
|
349
352
|
translateY.value = withSpring(0, { ...SPRING_CONFIG, velocity });
|
|
350
353
|
}
|
|
@@ -396,16 +399,7 @@ export const BottomSheetBase = forwardRef((props: BottomSheetBaseProps, ref: Rea
|
|
|
396
399
|
(distance > closeThreshold && velocity > -300);
|
|
397
400
|
|
|
398
401
|
if (shouldClose) {
|
|
399
|
-
|
|
400
|
-
translateY.value = withSpring(screenHeightSV.value, {
|
|
401
|
-
...SPRING_CONFIG,
|
|
402
|
-
velocity: velocity,
|
|
403
|
-
});
|
|
404
|
-
opacity.value = withTiming(0, { ...MOTION_RECIPES.dismiss, duration: 250 }, (finished) => {
|
|
405
|
-
if (finished) {
|
|
406
|
-
runOnJS(finishClose)(generation);
|
|
407
|
-
}
|
|
408
|
-
});
|
|
402
|
+
runOnJS(requestPanDismiss)(closeGeneration.value, velocity);
|
|
409
403
|
} else {
|
|
410
404
|
translateY.value = withSpring(0, {
|
|
411
405
|
...SPRING_CONFIG,
|
|
@@ -417,7 +411,7 @@ export const BottomSheetBase = forwardRef((props: BottomSheetBaseProps, ref: Rea
|
|
|
417
411
|
// values that change the gesture's behavior. `finishClose` is stable
|
|
418
412
|
// (useCallback with stable deps).
|
|
419
413
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
420
|
-
}, [enablePanDownToClose, detached, manualActivation, nativeGesture,
|
|
414
|
+
}, [enablePanDownToClose, detached, manualActivation, nativeGesture, requestPanDismiss]);
|
|
421
415
|
|
|
422
416
|
// Dedicated handle pan — only built in `manualActivation` mode. Always
|
|
423
417
|
// active so users can drag the handle even while content is mid-scroll.
|
|
@@ -456,17 +450,13 @@ export const BottomSheetBase = forwardRef((props: BottomSheetBaseProps, ref: Rea
|
|
|
456
450
|
(distance > closeThreshold && velocity > -300);
|
|
457
451
|
|
|
458
452
|
if (shouldClose) {
|
|
459
|
-
|
|
460
|
-
translateY.value = withSpring(screenHeightSV.value, { ...SPRING_CONFIG, velocity });
|
|
461
|
-
opacity.value = withTiming(0, { ...MOTION_RECIPES.dismiss, duration: 250 }, (finished) => {
|
|
462
|
-
if (finished) runOnJS(finishClose)(generation);
|
|
463
|
-
});
|
|
453
|
+
runOnJS(requestPanDismiss)(closeGeneration.value, velocity);
|
|
464
454
|
} else {
|
|
465
455
|
translateY.value = withSpring(0, { ...SPRING_CONFIG, velocity });
|
|
466
456
|
}
|
|
467
457
|
});
|
|
468
458
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
469
|
-
}, [manualActivation, enablePanDownToClose, enableHandlePanningGesture, detached,
|
|
459
|
+
}, [manualActivation, enablePanDownToClose, enableHandlePanningGesture, detached, requestPanDismiss]);
|
|
470
460
|
|
|
471
461
|
// CRITICAL — the shared values each `useAnimatedStyle` READS (translateY,
|
|
472
462
|
// opacity, screenHeightSV, keyboardHeight) MUST be listed in its dependency
|
|
@@ -698,7 +688,7 @@ export const BottomSheetBase = forwardRef((props: BottomSheetBaseProps, ref: Rea
|
|
|
698
688
|
: <View style={[styles.nonScrollableContent, surfaceCorners, { overflow: 'hidden' }]}>{surfaceChildren}</View>;
|
|
699
689
|
|
|
700
690
|
return (
|
|
701
|
-
<Shell visible={rendered} onRequestClose={
|
|
691
|
+
<Shell visible={rendered} onRequestClose={handleBackdropPress} keyboardHeight={keyboardHeight}>
|
|
702
692
|
{/* Web keyboard and focus: in on open, back on close, Tab kept in
|
|
703
693
|
the sheet, Escape a USER dismissal — through `onDismissAttempt`,
|
|
704
694
|
exactly like the backdrop. The sheet had no keyboard path at
|
|
@@ -38,6 +38,7 @@ export interface BottomSheetProps {
|
|
|
38
38
|
*/
|
|
39
39
|
style?: StyleProp<AnimatedStyle<ViewStyle>>;
|
|
40
40
|
enableHandlePanningGesture?: boolean;
|
|
41
|
+
/** Veto user Back/backdrop/Escape/pan requests before closing; imperative dismiss bypasses it. */
|
|
41
42
|
onDismissAttempt?: () => boolean;
|
|
42
43
|
detached?: boolean; // If true, shows with margins and rounded corners. If false, full width with rounded top only.
|
|
43
44
|
/**
|
|
@@ -197,7 +197,7 @@ export function DialogBottomSheet({
|
|
|
197
197
|
const maxHeightPercent: `${number}%` = `${Math.round(maxHeightRatio * 100)}%`;
|
|
198
198
|
return [
|
|
199
199
|
{
|
|
200
|
-
backgroundColor:
|
|
200
|
+
backgroundColor: theme.colors.background,
|
|
201
201
|
...surfaceStyle(SURFACE_SHAPES.sheet),
|
|
202
202
|
maxHeight: maxHeightPercent,
|
|
203
203
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { surfaceStyle } from '../shapes/surface-style';
|
|
2
2
|
import type { SurfaceShape } from '../shapes/corner-types';
|
|
3
|
-
import React, { memo, useEffect } from 'react';
|
|
4
|
-
import { Platform, StyleSheet, View, type ViewStyle } from 'react-native';
|
|
3
|
+
import React, { memo, useCallback, useEffect, useState } from 'react';
|
|
4
|
+
import { Platform, StyleSheet, View, type LayoutChangeEvent, type ViewStyle } from 'react-native';
|
|
5
5
|
import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg';
|
|
6
6
|
import { useSvgIdPrefix } from '../styles/svg-id';
|
|
7
7
|
import { StyledView } from '../styles/styled-primitives';
|
|
@@ -47,6 +47,13 @@ export const SurfacePaint = memo(function SurfacePaint({
|
|
|
47
47
|
const resolvedFill = resolveSurfaceTint(fill ?? 'transparent');
|
|
48
48
|
const id = useSvgIdPrefix('bloom-surface');
|
|
49
49
|
const isWeb = Platform.OS === 'web';
|
|
50
|
+
const [nativeSize, setNativeSize] = useState<{ width: number; height: number } | null>(null);
|
|
51
|
+
const onNativeLayout = useCallback((event: LayoutChangeEvent) => {
|
|
52
|
+
const { width, height } = event.nativeEvent.layout;
|
|
53
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return;
|
|
54
|
+
setNativeSize(previous => previous?.width === width && previous.height === height
|
|
55
|
+
? previous : { width, height });
|
|
56
|
+
}, []);
|
|
50
57
|
useSurfaceRefraction(isWeb);
|
|
51
58
|
useEffect(() => {
|
|
52
59
|
if (isWeb) adoptStyleSheet('bloom-surface-paint-web-css', WEB_PAINT_CSS);
|
|
@@ -67,17 +74,20 @@ export const SurfacePaint = memo(function SurfacePaint({
|
|
|
67
74
|
return (
|
|
68
75
|
<View
|
|
69
76
|
testID={testID}
|
|
77
|
+
onLayout={onNativeLayout}
|
|
70
78
|
pointerEvents="none"
|
|
71
79
|
style={[StyleSheet.absoluteFill, geometry, { overflow: 'hidden' }]}
|
|
72
80
|
>
|
|
81
|
+
{/* Android SVG percentage paths cache canvas dimensions after first paint.
|
|
82
|
+
Update the actual Rect geometry when this backing View changes size. */}
|
|
73
83
|
<Svg width="100%" height="100%" style={StyleSheet.absoluteFill}>
|
|
74
84
|
<Defs>
|
|
75
85
|
{sheen ? <LinearGradient id={`${id}-sheen`} x1="0" y1="0" x2="0" y2="1">
|
|
76
86
|
{optics.sheen.map(stop => <Stop key={stop.offset} offset={String(stop.offset)} stopColor={stop.color} stopOpacity={stop.opacity} />)}
|
|
77
87
|
</LinearGradient> : null}
|
|
78
88
|
</Defs>
|
|
79
|
-
{fill !== undefined ? <Rect x="0" y="0" width=
|
|
80
|
-
{sheen ? <Rect x="0" y="0" width=
|
|
89
|
+
{fill !== undefined ? <Rect x="0" y="0" width={nativeSize?.width ?? '100%'} height={nativeSize?.height ?? '100%'} fill={base.color} fillOpacity={base.opacity} /> : null}
|
|
90
|
+
{sheen ? <Rect x="0" y="0" width={nativeSize?.width ?? '100%'} height={nativeSize?.height ?? '100%'} fill={`url(#${id}-sheen)`} /> : null}
|
|
81
91
|
</Svg>
|
|
82
92
|
<View pointerEvents="none" style={[StyleSheet.absoluteFill, geometry, { boxShadow: optics.rim }]} />
|
|
83
93
|
</View>
|