@oxyhq/bloom 1.0.4 → 1.0.6
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/dropdown-menu.mdx +1 -1
- package/lib/commonjs/floating/menu-sub-flyout.js +115 -26
- package/lib/commonjs/floating/menu-sub-flyout.js.map +1 -1
- package/lib/module/floating/menu-sub-flyout.js +115 -26
- package/lib/module/floating/menu-sub-flyout.js.map +1 -1
- package/lib/typescript/commonjs/floating/menu-sub-flyout.d.ts.map +1 -1
- package/lib/typescript/module/floating/menu-sub-flyout.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/floating/menu-sub-flyout.tsx +135 -27
- package/src/__tests__/support/collision-fixture-barrel.ts +0 -20
- package/src/__tests__/support/constructed-style-sheets.ts +0 -68
- package/src/__tests__/support/press-host.ts +0 -30
- package/src/__tests__/support/rendered-style.ts +0 -99
- package/src/__tests__/support/unread-hook-fixture.ts +0 -33
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +0 -7682
- package/src/theme/__tests__/fixtures/color-engine-golden.json +0 -1
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
* - **Stack rank.** Taken by the `OverlayRoot` inside `FloatingPanel`, on
|
|
17
17
|
* mount, like every other surface. The sub-panel mounts AFTER the root panel,
|
|
18
18
|
* so it paints above it by construction and carries no `zIndex`.
|
|
19
|
-
* - **Hover intent.**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* - **Hover intent.** A real mouse/pen move over the row opens it; a layout
|
|
20
|
+
* transition placing the row under a parked pointer does not. Leaving either
|
|
21
|
+
* surface SCHEDULES a close, moving over either CANCELS it, and the delay is
|
|
22
|
+
* long enough (`CLOSE_DELAY_MS`) to cross the gap.
|
|
23
23
|
* - **Keyboard.** Right opens and moves into the panel, Left and Escape leave
|
|
24
24
|
* it. Escape is handled here in the CAPTURE phase with
|
|
25
25
|
* `stopImmediatePropagation`, which is the only way the INNERMOST surface
|
|
@@ -111,9 +111,61 @@ function domNode(node: View | null): DomNode | null {
|
|
|
111
111
|
return element as DomNode;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/** Touch movement is scrolling/dragging, not hover intent. */
|
|
115
|
+
function isHoverPointer(event: Event): boolean {
|
|
116
|
+
const pointerType = (event as PointerEvent).pointerType;
|
|
117
|
+
return pointerType === 'mouse' || pointerType === 'pen';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Every role a menu ROW can carry — the rows a pointer can actually land on. */
|
|
121
|
+
const MENU_ITEM_SELECTOR =
|
|
122
|
+
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="checkbox"], [role="radio"]';
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Did the pointer leave for another ROW, rather than for the gap this sub's
|
|
126
|
+
* grace delay exists to cover?
|
|
127
|
+
*
|
|
128
|
+
* {@link CLOSE_DELAY_MS} buys time for ONE journey: the diagonal from a trigger
|
|
129
|
+
* row to its own flyout, across a strip where neither surface is hovered. Every
|
|
130
|
+
* other departure is unambiguous the moment it happens, and the most visible one
|
|
131
|
+
* is a SIBLING sub-trigger: two subs in a panel each own their state and their
|
|
132
|
+
* own timer, so the one being left served out its full delay while the one being
|
|
133
|
+
* entered had already opened — 300ms of two overlapping flyouts, which is not a
|
|
134
|
+
* grace period but a bug.
|
|
135
|
+
*
|
|
136
|
+
* "Another row" means the pointer landed on an actual menu ITEM that belongs to
|
|
137
|
+
* neither this sub's trigger nor its flyout. Testing for an ITEM rather than for
|
|
138
|
+
* a `[role="menu"]` ancestor is the whole subtlety: the diagonal to the flyout
|
|
139
|
+
* crosses the parent panel's own padding, which IS inside a `[role="menu"]` but
|
|
140
|
+
* is not a row — measured, a panel-ancestor test dismissed the flyout mid-journey
|
|
141
|
+
* and broke the very case the delay exists for.
|
|
142
|
+
*
|
|
143
|
+
* Both exclusions are load-bearing. Without the flyout check, arriving at the
|
|
144
|
+
* panel's first row would close the panel the move was aimed at. Without the
|
|
145
|
+
* trigger check, moving from the panel back onto its own trigger would close and
|
|
146
|
+
* immediately re-open, which flickers.
|
|
147
|
+
*
|
|
148
|
+
* A departure with no `relatedTarget` — off the window, or the node under the
|
|
149
|
+
* pointer being torn down — is NOT another row, so it keeps the delay and
|
|
150
|
+
* behaviour there is what it was.
|
|
151
|
+
*/
|
|
152
|
+
function leavesForAnotherRow(
|
|
153
|
+
event: Event,
|
|
154
|
+
ownFlyout: DomNode | null,
|
|
155
|
+
ownTrigger: DomNode | null,
|
|
156
|
+
): boolean {
|
|
157
|
+
const next = (event as PointerEvent).relatedTarget;
|
|
158
|
+
if (!(next instanceof Element)) return false;
|
|
159
|
+
const row = next.closest(MENU_ITEM_SELECTOR);
|
|
160
|
+
if (!row) return false;
|
|
161
|
+
if (ownFlyout?.contains(row as HTMLElement)) return false;
|
|
162
|
+
if (ownTrigger?.contains(row as HTMLElement)) return false;
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
114
166
|
/** The first focusable descendant, so Right lands ON the panel's first row. */
|
|
115
167
|
function focusFirstItem(node: DomNode | null): void {
|
|
116
|
-
const first = node?.querySelector<HTMLElement>(
|
|
168
|
+
const first = node?.querySelector<HTMLElement>(MENU_ITEM_SELECTOR);
|
|
117
169
|
first?.focus?.();
|
|
118
170
|
}
|
|
119
171
|
|
|
@@ -229,10 +281,22 @@ interface SubFlyoutContextValue {
|
|
|
229
281
|
setOpen: (next: boolean) => void;
|
|
230
282
|
/** The trigger row's wrapper, measured to anchor the panel. */
|
|
231
283
|
triggerRef: React.RefObject<View | null>;
|
|
284
|
+
/**
|
|
285
|
+
* The flyout's scroller, so the TRIGGER can tell a pointer heading for its own
|
|
286
|
+
* panel from one heading for a sibling row. Written by `MenuSubContent`; null
|
|
287
|
+
* whenever the panel is closed, which is exactly when there is nothing to
|
|
288
|
+
* cross to.
|
|
289
|
+
*/
|
|
290
|
+
contentRef: React.RefObject<View | null>;
|
|
232
291
|
/** Cancel a scheduled close — the pointer arrived on one of the two surfaces. */
|
|
233
292
|
keepOpen: () => void;
|
|
234
293
|
/** Schedule a close — the pointer left one of them, and may be crossing to the other. */
|
|
235
294
|
closeSoon: () => void;
|
|
295
|
+
/**
|
|
296
|
+
* Close immediately, WITHOUT moving focus — the pointer has already landed on
|
|
297
|
+
* another row, and refocusing this trigger would take focus off it.
|
|
298
|
+
*/
|
|
299
|
+
closeNow: () => void;
|
|
236
300
|
/** Close now and put focus back on the trigger row (Left, Escape). */
|
|
237
301
|
closeAndRefocus: () => void;
|
|
238
302
|
}
|
|
@@ -257,6 +321,7 @@ export function createFlyoutMenuSub(prefix: string): MenuSubParts {
|
|
|
257
321
|
onChange: onOpenChange,
|
|
258
322
|
});
|
|
259
323
|
const triggerRef = useRef<View | null>(null);
|
|
324
|
+
const contentRef = useRef<View | null>(null);
|
|
260
325
|
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
261
326
|
|
|
262
327
|
const cancelTimer = useCallback(() => {
|
|
@@ -272,8 +337,10 @@ export function createFlyoutMenuSub(prefix: string): MenuSubParts {
|
|
|
272
337
|
|
|
273
338
|
const keepOpen = useCallback(() => {
|
|
274
339
|
cancelTimer();
|
|
275
|
-
|
|
276
|
-
|
|
340
|
+
// `pointermove` is intentionally continuous. Once open, cancel the close
|
|
341
|
+
// timer without re-notifying a controlled consumer on every coordinate.
|
|
342
|
+
if (!isOpen) setOpen(true);
|
|
343
|
+
}, [cancelTimer, isOpen, setOpen]);
|
|
277
344
|
|
|
278
345
|
const closeSoon = useCallback(() => {
|
|
279
346
|
cancelTimer();
|
|
@@ -283,6 +350,11 @@ export function createFlyoutMenuSub(prefix: string): MenuSubParts {
|
|
|
283
350
|
}, CLOSE_DELAY_MS);
|
|
284
351
|
}, [cancelTimer, setOpen]);
|
|
285
352
|
|
|
353
|
+
const closeNow = useCallback(() => {
|
|
354
|
+
cancelTimer();
|
|
355
|
+
setOpen(false);
|
|
356
|
+
}, [cancelTimer, setOpen]);
|
|
357
|
+
|
|
286
358
|
const closeAndRefocus = useCallback(() => {
|
|
287
359
|
cancelTimer();
|
|
288
360
|
setOpen(false);
|
|
@@ -291,8 +363,8 @@ export function createFlyoutMenuSub(prefix: string): MenuSubParts {
|
|
|
291
363
|
}, [cancelTimer, setOpen]);
|
|
292
364
|
|
|
293
365
|
const context = useMemo<SubFlyoutContextValue>(
|
|
294
|
-
() => ({ open: isOpen, setOpen, triggerRef, keepOpen, closeSoon, closeAndRefocus }),
|
|
295
|
-
[isOpen, setOpen, keepOpen, closeSoon, closeAndRefocus],
|
|
366
|
+
() => ({ open: isOpen, setOpen, triggerRef, contentRef, keepOpen, closeSoon, closeNow, closeAndRefocus }),
|
|
367
|
+
[isOpen, setOpen, keepOpen, closeSoon, closeNow, closeAndRefocus],
|
|
296
368
|
);
|
|
297
369
|
|
|
298
370
|
return <SubFlyoutContext.Provider value={context}>{children}</SubFlyoutContext.Provider>;
|
|
@@ -322,17 +394,29 @@ export function createFlyoutMenuSub(prefix: string): MenuSubParts {
|
|
|
322
394
|
[sub.triggerRef],
|
|
323
395
|
);
|
|
324
396
|
|
|
325
|
-
//
|
|
326
|
-
// props: `Item` renders its own `Pressable` and publishes neither
|
|
327
|
-
//
|
|
328
|
-
// web-only caller is the wrong trade. `
|
|
329
|
-
//
|
|
397
|
+
// Pointer intent and keyboard through DOM listeners on the wrapper rather
|
|
398
|
+
// than RN props: `Item` renders its own `Pressable` and publishes neither
|
|
399
|
+
// pointer movement nor `onKeyDown`, and widening its prop surface for one
|
|
400
|
+
// web-only caller is the wrong trade. Crucially, `pointermove` needs actual
|
|
401
|
+
// coordinates to change: unlike enter, layout motion cannot synthesize it
|
|
402
|
+
// when a transition puts this row under a parked pointer.
|
|
330
403
|
useEffect(() => {
|
|
331
404
|
const element = domNode(node);
|
|
332
405
|
if (!element || disabled) return;
|
|
333
406
|
|
|
334
|
-
const
|
|
335
|
-
|
|
407
|
+
const onMove = (event: Event) => {
|
|
408
|
+
if (isHoverPointer(event)) sub.keepOpen();
|
|
409
|
+
};
|
|
410
|
+
const onLeave = (event: Event) => {
|
|
411
|
+
if (!isHoverPointer(event)) return;
|
|
412
|
+
// Straight onto another row of the parent panel — a sibling trigger, or
|
|
413
|
+
// any plain item — is not the diagonal the delay exists for.
|
|
414
|
+
if (leavesForAnotherRow(event, domNode(sub.contentRef.current), element)) {
|
|
415
|
+
sub.closeNow();
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
sub.closeSoon();
|
|
419
|
+
};
|
|
336
420
|
const onKeyDown = (event: Event) => {
|
|
337
421
|
const key = (event as KeyboardEvent).key;
|
|
338
422
|
if (key !== 'ArrowRight' && key !== 'Enter' && key !== ' ') return;
|
|
@@ -340,12 +424,12 @@ export function createFlyoutMenuSub(prefix: string): MenuSubParts {
|
|
|
340
424
|
sub.keepOpen();
|
|
341
425
|
};
|
|
342
426
|
|
|
343
|
-
element.addEventListener('
|
|
344
|
-
element.addEventListener('
|
|
427
|
+
element.addEventListener('pointermove', onMove);
|
|
428
|
+
element.addEventListener('pointerleave', onLeave);
|
|
345
429
|
element.addEventListener('keydown', onKeyDown);
|
|
346
430
|
return () => {
|
|
347
|
-
element.removeEventListener('
|
|
348
|
-
element.removeEventListener('
|
|
431
|
+
element.removeEventListener('pointermove', onMove);
|
|
432
|
+
element.removeEventListener('pointerleave', onLeave);
|
|
349
433
|
element.removeEventListener('keydown', onKeyDown);
|
|
350
434
|
};
|
|
351
435
|
}, [node, disabled, sub]);
|
|
@@ -405,12 +489,36 @@ export function createFlyoutMenuSub(prefix: string): MenuSubParts {
|
|
|
405
489
|
const anchor = useFlyoutAnchor(sub.triggerRef, sub.open);
|
|
406
490
|
const [node, setNode] = useState<View | null>(null);
|
|
407
491
|
|
|
492
|
+
// State for this component's own effects, a ref for the TRIGGER's leave
|
|
493
|
+
// handler: that handler runs from a DOM listener, outside React's render,
|
|
494
|
+
// so it needs a value it can read at event time rather than one captured
|
|
495
|
+
// when the listener was attached.
|
|
496
|
+
const attachContent = useCallback(
|
|
497
|
+
(value: View | null) => {
|
|
498
|
+
setNode(value);
|
|
499
|
+
sub.contentRef.current = value;
|
|
500
|
+
},
|
|
501
|
+
[sub.contentRef],
|
|
502
|
+
);
|
|
503
|
+
|
|
408
504
|
useEffect(() => {
|
|
409
505
|
const element = domNode(node);
|
|
410
506
|
if (!element || !sub.open) return;
|
|
411
507
|
|
|
412
|
-
const
|
|
413
|
-
|
|
508
|
+
const onMove = (event: Event) => {
|
|
509
|
+
if (isHoverPointer(event)) sub.keepOpen();
|
|
510
|
+
};
|
|
511
|
+
const onLeave = (event: Event) => {
|
|
512
|
+
if (!isHoverPointer(event)) return;
|
|
513
|
+
// Leaving the flyout for a row of the parent panel is just as
|
|
514
|
+
// unambiguous as leaving the trigger for one: the pointer has arrived
|
|
515
|
+
// somewhere, and it is not this sub.
|
|
516
|
+
if (leavesForAnotherRow(event, element, domNode(sub.triggerRef.current))) {
|
|
517
|
+
sub.closeNow();
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
sub.closeSoon();
|
|
521
|
+
};
|
|
414
522
|
const onKeyDown = (event: Event) => {
|
|
415
523
|
const key = (event as KeyboardEvent).key;
|
|
416
524
|
if (key !== 'ArrowLeft') return;
|
|
@@ -418,12 +526,12 @@ export function createFlyoutMenuSub(prefix: string): MenuSubParts {
|
|
|
418
526
|
sub.closeAndRefocus();
|
|
419
527
|
};
|
|
420
528
|
|
|
421
|
-
element.addEventListener('
|
|
422
|
-
element.addEventListener('
|
|
529
|
+
element.addEventListener('pointermove', onMove);
|
|
530
|
+
element.addEventListener('pointerleave', onLeave);
|
|
423
531
|
element.addEventListener('keydown', onKeyDown);
|
|
424
532
|
return () => {
|
|
425
|
-
element.removeEventListener('
|
|
426
|
-
element.removeEventListener('
|
|
533
|
+
element.removeEventListener('pointermove', onMove);
|
|
534
|
+
element.removeEventListener('pointerleave', onLeave);
|
|
427
535
|
element.removeEventListener('keydown', onKeyDown);
|
|
428
536
|
};
|
|
429
537
|
}, [node, sub]);
|
|
@@ -481,7 +589,7 @@ export function createFlyoutMenuSub(prefix: string): MenuSubParts {
|
|
|
481
589
|
flyout's pointer hit box — without it that 4px ring is a place where
|
|
482
590
|
the pointer is over the panel but over nothing listening, and a
|
|
483
591
|
pointer resting there would schedule a close. */}
|
|
484
|
-
<StyledView ref={
|
|
592
|
+
<StyledView ref={attachContent} className={MENU_SUB_SCROLL_CLASS}>
|
|
485
593
|
{children}
|
|
486
594
|
</StyledView>
|
|
487
595
|
</FloatingPanel>
|
|
@@ -1,20 +0,0 @@
|
|
|
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';
|
|
@@ -1,68 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
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 `ProfileCard`: with the handler deleted from the component the
|
|
10
|
-
* 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
|
-
}
|
|
@@ -1,99 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
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
|
-
}
|