@oxy.so/bloom 4.23.0 → 4.24.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 +10 -10
- package/docs/ai-chat.mdx +7 -3
- package/docs/bottom-sheet.mdx +2 -0
- package/docs/dialog.mdx +28 -0
- package/docs/sidebar.mdx +2 -0
- package/lib/commonjs/ai-chat/AiChatContainer.js +22 -11
- package/lib/commonjs/ai-chat/AiChatContainer.js.map +1 -1
- package/lib/commonjs/ai-chat/AiChatShell.js +8 -2
- package/lib/commonjs/ai-chat/AiChatShell.js.map +1 -1
- package/lib/commonjs/ai-chat/use-panel-interaction.js +20 -33
- package/lib/commonjs/ai-chat/use-panel-interaction.js.map +1 -1
- package/lib/commonjs/bottom-bar/BottomBarBase.js +5 -2
- package/lib/commonjs/bottom-bar/BottomBarBase.js.map +1 -1
- package/lib/commonjs/bottom-sheet/BottomSheetBase.js +10 -3
- package/lib/commonjs/bottom-sheet/BottomSheetBase.js.map +1 -1
- package/lib/commonjs/dialog/Dialog.web.js +28 -22
- package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
- package/lib/commonjs/dialog/DialogBottomSheet.js +9 -4
- package/lib/commonjs/dialog/DialogBottomSheet.js.map +1 -1
- package/lib/commonjs/overlay/ModalKeyboard.js +88 -0
- package/lib/commonjs/overlay/ModalKeyboard.js.map +1 -0
- package/lib/commonjs/overlay/modal-keyboard.js +131 -0
- package/lib/commonjs/overlay/modal-keyboard.js.map +1 -0
- package/lib/commonjs/overlay/stack.js +16 -0
- package/lib/commonjs/overlay/stack.js.map +1 -1
- package/lib/commonjs/sidebar/Sidebar.js +18 -6
- package/lib/commonjs/sidebar/Sidebar.js.map +1 -1
- package/lib/module/ai-chat/AiChatContainer.js +22 -11
- package/lib/module/ai-chat/AiChatContainer.js.map +1 -1
- package/lib/module/ai-chat/AiChatShell.js +8 -2
- package/lib/module/ai-chat/AiChatShell.js.map +1 -1
- package/lib/module/ai-chat/use-panel-interaction.js +20 -32
- package/lib/module/ai-chat/use-panel-interaction.js.map +1 -1
- package/lib/module/bottom-bar/BottomBarBase.js +5 -2
- package/lib/module/bottom-bar/BottomBarBase.js.map +1 -1
- package/lib/module/bottom-sheet/BottomSheetBase.js +10 -3
- package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
- package/lib/module/dialog/Dialog.web.js +28 -22
- package/lib/module/dialog/Dialog.web.js.map +1 -1
- package/lib/module/dialog/DialogBottomSheet.js +9 -4
- package/lib/module/dialog/DialogBottomSheet.js.map +1 -1
- package/lib/module/overlay/ModalKeyboard.js +84 -0
- package/lib/module/overlay/ModalKeyboard.js.map +1 -0
- package/lib/module/overlay/modal-keyboard.js +124 -0
- package/lib/module/overlay/modal-keyboard.js.map +1 -0
- package/lib/module/overlay/stack.js +15 -0
- package/lib/module/overlay/stack.js.map +1 -1
- package/lib/module/sidebar/Sidebar.js +18 -6
- package/lib/module/sidebar/Sidebar.js.map +1 -1
- package/lib/typescript/commonjs/ai-chat/AiChatContainer.d.ts +5 -4
- package/lib/typescript/commonjs/ai-chat/AiChatContainer.d.ts.map +1 -1
- package/lib/typescript/commonjs/ai-chat/AiChatShell.d.ts.map +1 -1
- package/lib/typescript/commonjs/ai-chat/use-panel-interaction.d.ts +10 -1
- package/lib/typescript/commonjs/ai-chat/use-panel-interaction.d.ts.map +1 -1
- package/lib/typescript/commonjs/bottom-bar/BottomBarBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts.map +1 -1
- package/lib/typescript/commonjs/overlay/ModalKeyboard.d.ts +32 -0
- package/lib/typescript/commonjs/overlay/ModalKeyboard.d.ts.map +1 -0
- package/lib/typescript/commonjs/overlay/modal-keyboard.d.ts +17 -0
- package/lib/typescript/commonjs/overlay/modal-keyboard.d.ts.map +1 -0
- package/lib/typescript/commonjs/overlay/stack.d.ts +6 -0
- package/lib/typescript/commonjs/overlay/stack.d.ts.map +1 -1
- package/lib/typescript/commonjs/sidebar/Sidebar.d.ts.map +1 -1
- package/lib/typescript/module/ai-chat/AiChatContainer.d.ts +5 -4
- package/lib/typescript/module/ai-chat/AiChatContainer.d.ts.map +1 -1
- package/lib/typescript/module/ai-chat/AiChatShell.d.ts.map +1 -1
- package/lib/typescript/module/ai-chat/use-panel-interaction.d.ts +10 -1
- package/lib/typescript/module/ai-chat/use-panel-interaction.d.ts.map +1 -1
- package/lib/typescript/module/bottom-bar/BottomBarBase.d.ts.map +1 -1
- package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
- package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
- package/lib/typescript/module/dialog/DialogBottomSheet.d.ts.map +1 -1
- package/lib/typescript/module/overlay/ModalKeyboard.d.ts +32 -0
- package/lib/typescript/module/overlay/ModalKeyboard.d.ts.map +1 -0
- package/lib/typescript/module/overlay/modal-keyboard.d.ts +17 -0
- package/lib/typescript/module/overlay/modal-keyboard.d.ts.map +1 -0
- package/lib/typescript/module/overlay/stack.d.ts +6 -0
- package/lib/typescript/module/overlay/stack.d.ts.map +1 -1
- package/lib/typescript/module/sidebar/Sidebar.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/ai-chat/AiChatContainer.tsx +18 -8
- package/src/ai-chat/AiChatShell.tsx +12 -2
- package/src/ai-chat/use-panel-interaction.ts +20 -34
- package/src/bottom-bar/BottomBarBase.tsx +5 -2
- package/src/bottom-sheet/BottomSheetBase.tsx +8 -0
- package/src/dialog/Dialog.web.tsx +22 -18
- package/src/dialog/DialogBottomSheet.tsx +9 -4
- package/src/overlay/ModalKeyboard.tsx +88 -0
- package/src/overlay/modal-keyboard.ts +133 -0
- package/src/overlay/stack.ts +13 -0
- package/src/sidebar/Sidebar.tsx +13 -4
|
@@ -37,6 +37,7 @@ import Animated, {
|
|
|
37
37
|
import { RemoveScrollBar } from 'react-remove-scroll-bar';
|
|
38
38
|
|
|
39
39
|
import { Backdrop, OverlayRoot } from '../overlay';
|
|
40
|
+
import { ModalKeyboard } from '../overlay/ModalKeyboard';
|
|
40
41
|
import { StyledView } from '../styles/styled-primitives';
|
|
41
42
|
import { Portal } from '../portal/index.web';
|
|
42
43
|
import { adoptStyleSheet } from '../styles/adopt-style-sheet';
|
|
@@ -219,6 +220,7 @@ function CenterOrSideDialog({
|
|
|
219
220
|
// the imperative `control.close()` still drives the exit + post-exit onClose.
|
|
220
221
|
const [isOpen, setIsOpen] = useState(() => controlledOpen ?? startOpen ?? false);
|
|
221
222
|
const [isClosing, setIsClosing] = useState(false);
|
|
223
|
+
const panelRef = useRef<View>(null);
|
|
222
224
|
const closeCallbacksRef = useRef<(() => void)[]>([]);
|
|
223
225
|
// Read the latest controlled flag inside stable callbacks without re-binding.
|
|
224
226
|
const isControlledRef = useRef(isControlled);
|
|
@@ -314,24 +316,6 @@ function CenterOrSideDialog({
|
|
|
314
316
|
: withTiming(1, { duration: BACKDROP_FADE_IN_DURATION, easing: Easing.out(Easing.ease) });
|
|
315
317
|
}, [backdropFade, isOpen, isClosing]);
|
|
316
318
|
|
|
317
|
-
// Escape-to-close while open. The listener is intentionally scoped to the
|
|
318
|
-
// open lifetime so stacked dialogs don't fight for the keydown — the
|
|
319
|
-
// top-most one wins via document-level event order. Escape honors
|
|
320
|
-
// `dismissOnBackdrop`: a blocking dialog (e.g. an unanswered confirm) is not
|
|
321
|
-
// dismissible by Escape, matching the backdrop's behavior. The default
|
|
322
|
-
// (`dismissOnBackdrop` true) is unchanged — Escape still closes.
|
|
323
|
-
useEffect(() => {
|
|
324
|
-
if (!isOpen || !dismissOnBackdrop || typeof document === 'undefined') return;
|
|
325
|
-
const handler = (e: KeyboardEvent) => {
|
|
326
|
-
if (e.key === 'Escape') {
|
|
327
|
-
e.stopPropagation();
|
|
328
|
-
close();
|
|
329
|
-
}
|
|
330
|
-
};
|
|
331
|
-
document.addEventListener('keydown', handler);
|
|
332
|
-
return () => document.removeEventListener('keydown', handler);
|
|
333
|
-
}, [close, dismissOnBackdrop, isOpen]);
|
|
334
|
-
|
|
335
319
|
useImperativeHandle(
|
|
336
320
|
control?.ref,
|
|
337
321
|
() => ({ open, close }),
|
|
@@ -359,6 +343,12 @@ function CenterOrSideDialog({
|
|
|
359
343
|
`OverlayInertBoundary` take the page out of the tab order and
|
|
360
344
|
the accessibility tree while it is open. */}
|
|
361
345
|
<OverlayRoot modal>
|
|
346
|
+
<ModalKeyboard
|
|
347
|
+
panelRef={panelRef}
|
|
348
|
+
closing={isClosing}
|
|
349
|
+
dismissible={dismissOnBackdrop}
|
|
350
|
+
dismiss={close}
|
|
351
|
+
/>
|
|
362
352
|
{/* The press target IS the full-viewport box, so it uses
|
|
363
353
|
`Backdrop` (which opts back in from the Portal root's
|
|
364
354
|
`pointer-events: none` via the `pointerEvents` PROP — the style
|
|
@@ -380,6 +370,7 @@ function CenterOrSideDialog({
|
|
|
380
370
|
}}
|
|
381
371
|
>
|
|
382
372
|
<DialogPanel
|
|
373
|
+
panelRef={panelRef}
|
|
383
374
|
testID={testID}
|
|
384
375
|
label={label}
|
|
385
376
|
title={title}
|
|
@@ -410,6 +401,7 @@ function CenterOrSideDialog({
|
|
|
410
401
|
<ClosingContext.Provider value={isClosing}>
|
|
411
402
|
<RemoveScrollBar />
|
|
412
403
|
<SheetSurface
|
|
404
|
+
panelRef={panelRef}
|
|
413
405
|
testID={testID}
|
|
414
406
|
label={label}
|
|
415
407
|
title={title}
|
|
@@ -439,6 +431,7 @@ function CenterOrSideDialog({
|
|
|
439
431
|
}
|
|
440
432
|
|
|
441
433
|
function DialogPanel({
|
|
434
|
+
panelRef,
|
|
442
435
|
testID,
|
|
443
436
|
label,
|
|
444
437
|
title,
|
|
@@ -454,6 +447,7 @@ function DialogPanel({
|
|
|
454
447
|
isClosing,
|
|
455
448
|
children,
|
|
456
449
|
}: {
|
|
450
|
+
panelRef: React.RefObject<View | null>;
|
|
457
451
|
testID?: string;
|
|
458
452
|
label?: string;
|
|
459
453
|
title?: string;
|
|
@@ -508,7 +502,11 @@ function DialogPanel({
|
|
|
508
502
|
|
|
509
503
|
return (
|
|
510
504
|
<Animated.View
|
|
505
|
+
ref={panelRef}
|
|
511
506
|
role="dialog"
|
|
507
|
+
aria-modal
|
|
508
|
+
// Focus lands on the panel itself when it holds nothing tabbable.
|
|
509
|
+
tabIndex={-1}
|
|
512
510
|
aria-label={label}
|
|
513
511
|
aria-labelledby={title ? titleId : undefined}
|
|
514
512
|
aria-describedby={description ? descriptionId : undefined}
|
|
@@ -625,6 +623,7 @@ function DialogPanel({
|
|
|
625
623
|
* implementation with native and supports drag-to-dismiss on web.
|
|
626
624
|
*/
|
|
627
625
|
function SheetSurface({
|
|
626
|
+
panelRef,
|
|
628
627
|
testID,
|
|
629
628
|
label,
|
|
630
629
|
title,
|
|
@@ -646,6 +645,7 @@ function SheetSurface({
|
|
|
646
645
|
style,
|
|
647
646
|
children,
|
|
648
647
|
}: {
|
|
648
|
+
panelRef: React.RefObject<View | null>;
|
|
649
649
|
testID?: string;
|
|
650
650
|
label?: string;
|
|
651
651
|
title?: string;
|
|
@@ -750,6 +750,7 @@ function SheetSurface({
|
|
|
750
750
|
|
|
751
751
|
return (
|
|
752
752
|
<OverlayRoot style={[sheetStyles.root, containerStyle]} className={containerClassName} modal>
|
|
753
|
+
<ModalKeyboard panelRef={panelRef} closing={!shown} dismissible={dismissOnBackdrop} dismiss={onDismiss} />
|
|
753
754
|
<Backdrop
|
|
754
755
|
testID={testID ? `${testID}-backdrop` : DIALOG_SHEET_BACKDROP_TESTID}
|
|
755
756
|
accessibilityLabel={label ? `Dismiss ${label}` : 'Dismiss dialog'}
|
|
@@ -763,7 +764,10 @@ function SheetSurface({
|
|
|
763
764
|
/>
|
|
764
765
|
|
|
765
766
|
<StyledView
|
|
767
|
+
ref={panelRef}
|
|
766
768
|
role="dialog"
|
|
769
|
+
aria-modal
|
|
770
|
+
tabIndex={-1}
|
|
767
771
|
aria-label={label}
|
|
768
772
|
aria-labelledby={title ? titleId : undefined}
|
|
769
773
|
aria-describedby={description ? descriptionId : undefined}
|
|
@@ -100,6 +100,8 @@ export function DialogBottomSheet({
|
|
|
100
100
|
isControlledRef.current = isControlled;
|
|
101
101
|
const onCloseRef = useRef(onClose);
|
|
102
102
|
onCloseRef.current = onClose;
|
|
103
|
+
const controlledOpenRef = useRef(controlledOpen);
|
|
104
|
+
controlledOpenRef.current = controlledOpen;
|
|
103
105
|
|
|
104
106
|
// Drain queued close callbacks atomically — capturing the list and resetting
|
|
105
107
|
// it before invocation ensures a callback that synchronously re-opens the
|
|
@@ -139,12 +141,15 @@ export function DialogBottomSheet({
|
|
|
139
141
|
}, []);
|
|
140
142
|
|
|
141
143
|
// Fired when the sheet has finished closing. Drains queued callbacks, then
|
|
142
|
-
// fires `onClose`
|
|
143
|
-
//
|
|
144
|
-
// again
|
|
144
|
+
// fires `onClose` in imperative mode. In controlled mode a close the HOST
|
|
145
|
+
// made (it flipped `open` to false, usually after `close()` asked it to) was
|
|
146
|
+
// already requested through `onClose`, so firing it again would double-call
|
|
147
|
+
// it — but a close the SHEET made on its own (the backdrop, a drag, Escape)
|
|
148
|
+
// arrives with `open` still true, and the host has to hear about it or its
|
|
149
|
+
// state says open over a closed sheet.
|
|
145
150
|
const handleDismiss = useCallback(() => {
|
|
146
151
|
callQueuedCallbacks();
|
|
147
|
-
if (!isControlledRef.current) onCloseRef.current?.();
|
|
152
|
+
if (!isControlledRef.current || controlledOpenRef.current) onCloseRef.current?.();
|
|
148
153
|
}, [callQueuedCallbacks]);
|
|
149
154
|
|
|
150
155
|
// In controlled mode, mirror the `open` prop onto the underlying sheet so both
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import type { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { listenForEscape, tabbablesWithin, wrapTab } from './modal-keyboard';
|
|
5
|
+
import { useOverlayLayerContext } from './Overlay';
|
|
6
|
+
import { isTopmostOverlayLayer } from './stack';
|
|
7
|
+
|
|
8
|
+
export interface ModalKeyboardProps {
|
|
9
|
+
/** The surface's panel: where focus goes, and what Tab stays inside. */
|
|
10
|
+
panelRef: React.RefObject<View | null>;
|
|
11
|
+
/** The surface has started its exit: focus goes back now, Escape is spent. */
|
|
12
|
+
closing?: boolean;
|
|
13
|
+
/** Whether Escape may dismiss it (a dialog's `dismissOnBackdrop`). */
|
|
14
|
+
dismissible: boolean;
|
|
15
|
+
dismiss: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* An open modal surface's keyboard and focus, WEB. Renders nothing; it must
|
|
20
|
+
* sit INSIDE the surface's `OverlayRoot`, which is how it knows the surface's
|
|
21
|
+
* place in the overlay stack. Used by the web `Dialog` (card and side drawers)
|
|
22
|
+
* and the web `BottomSheet`.
|
|
23
|
+
*
|
|
24
|
+
* - Focus moves INTO the panel when it opens — to whatever the content
|
|
25
|
+
* focused itself (an `autoFocus` field), else the first tabbable, else the
|
|
26
|
+
* panel — and goes BACK to the element that had it once the surface starts
|
|
27
|
+
* closing (or unmounts), unless the host has already put it somewhere else.
|
|
28
|
+
* - Tab and Shift+Tab wrap at the panel's edges.
|
|
29
|
+
* - Escape dismisses (when `dismissible`), from anywhere — a text field of the
|
|
30
|
+
* surface included, which react-native-web keeps every keydown from
|
|
31
|
+
* bubbling out of (`./modal-keyboard.ts`).
|
|
32
|
+
*
|
|
33
|
+
* All three belong to the TOP-MOST surface only: a menu or a second dialog
|
|
34
|
+
* opened from this one takes them until it closes, so Escape closes one layer
|
|
35
|
+
* at a time. Inert without a DOM, so native never runs any of it.
|
|
36
|
+
*/
|
|
37
|
+
export function ModalKeyboard({ panelRef, closing = false, dismissible, dismiss }: ModalKeyboardProps) {
|
|
38
|
+
const layer = useOverlayLayerContext();
|
|
39
|
+
const state = useRef({ closing, dismissible, dismiss });
|
|
40
|
+
state.current = { closing, dismissible, dismiss };
|
|
41
|
+
const opener = useRef<HTMLElement | null>(null);
|
|
42
|
+
|
|
43
|
+
const restoreFocus = useCallback(() => {
|
|
44
|
+
const target = opener.current;
|
|
45
|
+
opener.current = null;
|
|
46
|
+
if (!target?.isConnected || typeof document === 'undefined') return;
|
|
47
|
+
const panel = panelRef.current as unknown as HTMLElement | null;
|
|
48
|
+
const active = document.activeElement;
|
|
49
|
+
// Only focus the surface still holds (or lost with its nodes): a host that
|
|
50
|
+
// moved focus on purpose while closing keeps its choice.
|
|
51
|
+
if (!active || active === document.body || panel?.contains(active)) target.focus();
|
|
52
|
+
}, [panelRef]);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
|
56
|
+
const active = document.activeElement;
|
|
57
|
+
opener.current = active instanceof HTMLElement && active !== document.body ? active : null;
|
|
58
|
+
const panel = () => panelRef.current as unknown as HTMLElement | null;
|
|
59
|
+
// A frame later, so content that focuses itself on mount has done so.
|
|
60
|
+
const frame = requestAnimationFrame(() => {
|
|
61
|
+
const node = panel();
|
|
62
|
+
if (!node || node.contains(document.activeElement) || !isTopmostOverlayLayer(layer)) return;
|
|
63
|
+
(tabbablesWithin(node)[0] ?? node).focus();
|
|
64
|
+
});
|
|
65
|
+
const onTab = (event: KeyboardEvent) => {
|
|
66
|
+
if (event.defaultPrevented || !isTopmostOverlayLayer(layer)) return;
|
|
67
|
+
wrapTab(event, panel());
|
|
68
|
+
};
|
|
69
|
+
// Capture: a text field in the panel stops its own keydowns.
|
|
70
|
+
window.addEventListener('keydown', onTab, true);
|
|
71
|
+
const stopEscape = listenForEscape(
|
|
72
|
+
() => state.current.dismissible && !state.current.closing && isTopmostOverlayLayer(layer),
|
|
73
|
+
() => state.current.dismiss(),
|
|
74
|
+
);
|
|
75
|
+
return () => {
|
|
76
|
+
cancelAnimationFrame(frame);
|
|
77
|
+
window.removeEventListener('keydown', onTab, true);
|
|
78
|
+
stopEscape();
|
|
79
|
+
restoreFocus();
|
|
80
|
+
};
|
|
81
|
+
}, [layer, panelRef, restoreFocus]);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (closing) restoreFocus();
|
|
85
|
+
}, [closing, restoreFocus]);
|
|
86
|
+
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard plumbing for a web surface that takes over the screen — a dialog, a
|
|
3
|
+
* drawer: which elements Tab may land on, wrapping Tab at the surface's edges,
|
|
4
|
+
* and Escape. WEB ONLY; every export is inert without a DOM.
|
|
5
|
+
*
|
|
6
|
+
* ── WHY ESCAPE IS NOT A PLAIN `keydown` LISTENER ────────────────────────────
|
|
7
|
+
*
|
|
8
|
+
* react-native-web's `TextInput` calls `stopPropagation()` on EVERY keydown it
|
|
9
|
+
* receives (its source cites RNW #612). React delegates events at the root or
|
|
10
|
+
* portal container, so the stop happens there — below `document` and `window`.
|
|
11
|
+
* A surface that listened for Escape on either never heard it while focus was
|
|
12
|
+
* in one of its own text fields: a command palette, whose focus lives in its
|
|
13
|
+
* search box, could not be dismissed from the keyboard at all.
|
|
14
|
+
*
|
|
15
|
+
* Listening in the CAPTURE phase alone is not the fix either: it runs before
|
|
16
|
+
* the focused control, so a control that owns Escape (a tag field clearing its
|
|
17
|
+
* highlight, a composer closing its suggestion list) would lose it to the
|
|
18
|
+
* surface. So `listenForEscape` takes the key in three steps:
|
|
19
|
+
*
|
|
20
|
+
* 1. CAPTURE on `window`: the surface decides whether the key is its to take
|
|
21
|
+
* (`claim`). This is the only point where "an anchored menu is open above
|
|
22
|
+
* me" is still true — the floating Escape stack closes that menu during the
|
|
23
|
+
* bubble phase. A claimed key is remembered, nothing else happens yet.
|
|
24
|
+
* 2. BUBBLE on `window`: the key arrived untouched — dismiss, unless something
|
|
25
|
+
* called `preventDefault()`.
|
|
26
|
+
* 3. After dispatch, for a key that never reached the bubble listener: it is
|
|
27
|
+
* still dismissed when its target is an editable text field and nothing
|
|
28
|
+
* prevented its default, because that stop is react-native-web's, not a
|
|
29
|
+
* decision any control made. Any other stopped key was consumed on purpose
|
|
30
|
+
* (an inner popup) and is left alone. A control inside a text field that
|
|
31
|
+
* wants to KEEP Escape calls `preventDefault()` — the tag field and the
|
|
32
|
+
* composer already do.
|
|
33
|
+
*/
|
|
34
|
+
import { hasOpenFloatingSurface } from '../floating/escape-stack';
|
|
35
|
+
|
|
36
|
+
const FOCUSABLE = 'button, a[href], input, select, textarea, [tabindex]';
|
|
37
|
+
|
|
38
|
+
function hasDom(): boolean {
|
|
39
|
+
return typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function visible(element: HTMLElement): boolean {
|
|
43
|
+
for (let current: HTMLElement | null = element; current; current = current.parentElement) {
|
|
44
|
+
const style = getComputedStyle(current);
|
|
45
|
+
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The elements inside `node` that Tab can land on, in document order. */
|
|
51
|
+
export function tabbablesWithin(node: HTMLElement | null): HTMLElement[] {
|
|
52
|
+
if (!node) return [];
|
|
53
|
+
return Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
|
54
|
+
(element) =>
|
|
55
|
+
element.tabIndex >= 0 &&
|
|
56
|
+
!element.matches(':disabled, [aria-disabled="true"]') &&
|
|
57
|
+
!element.closest('[hidden], [aria-hidden="true"], [inert]') &&
|
|
58
|
+
visible(element),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Keep Tab inside `node`: from the last tabbable (or from outside) forward to
|
|
64
|
+
* the first, from the first (or from outside) backward to the last. Returns
|
|
65
|
+
* whether it moved focus.
|
|
66
|
+
*/
|
|
67
|
+
export function wrapTab(event: KeyboardEvent, node: HTMLElement | null): boolean {
|
|
68
|
+
if (event.key !== 'Tab' || !node) return false;
|
|
69
|
+
const items = tabbablesWithin(node);
|
|
70
|
+
const first = items[0];
|
|
71
|
+
const last = items[items.length - 1];
|
|
72
|
+
const active = document.activeElement;
|
|
73
|
+
const outside = !node.contains(active);
|
|
74
|
+
if (event.shiftKey ? outside || active === first : outside || active === last) {
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
(event.shiftKey ? last : first)?.focus();
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isTextEntry(target: EventTarget | null): boolean {
|
|
83
|
+
if (!target || typeof (target as Element).closest !== 'function') return false;
|
|
84
|
+
const element = target as HTMLElement;
|
|
85
|
+
if (element.isContentEditable) return true;
|
|
86
|
+
if (element.tagName === 'TEXTAREA') return true;
|
|
87
|
+
if (element.tagName !== 'INPUT') return false;
|
|
88
|
+
const type = (element as HTMLInputElement).type;
|
|
89
|
+
return !['button', 'checkbox', 'radio', 'range', 'submit', 'reset', 'file', 'color', 'image'].includes(type);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Dismiss on Escape — see the file comment for the three steps. `claim` runs
|
|
94
|
+
* in the capture phase and says whether this surface is the one the key is
|
|
95
|
+
* for (the top-most, nothing anchored open above it). An open anchored
|
|
96
|
+
* surface (a menu, a select's list) always keeps the key: it is the top layer
|
|
97
|
+
* whatever the caller's own bookkeeping says.
|
|
98
|
+
*/
|
|
99
|
+
export function listenForEscape(claim: () => boolean, dismiss: () => void): () => void {
|
|
100
|
+
if (!hasDom()) return () => undefined;
|
|
101
|
+
let pending: KeyboardEvent | null = null;
|
|
102
|
+
const timers = new Set<ReturnType<typeof setTimeout>>();
|
|
103
|
+
|
|
104
|
+
const onCapture = (event: KeyboardEvent) => {
|
|
105
|
+
if (event.key !== 'Escape' || event.isComposing || event.defaultPrevented) return;
|
|
106
|
+
if (hasOpenFloatingSurface() || !claim()) return;
|
|
107
|
+
pending = event;
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
timers.delete(timer);
|
|
110
|
+
if (pending !== event) return;
|
|
111
|
+
pending = null;
|
|
112
|
+
if (!event.defaultPrevented && isTextEntry(event.target)) dismiss();
|
|
113
|
+
}, 0);
|
|
114
|
+
timers.add(timer);
|
|
115
|
+
};
|
|
116
|
+
const onBubble = (event: KeyboardEvent) => {
|
|
117
|
+
if (pending !== event) return;
|
|
118
|
+
pending = null;
|
|
119
|
+
if (event.defaultPrevented) return;
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
dismiss();
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
window.addEventListener('keydown', onCapture, true);
|
|
125
|
+
window.addEventListener('keydown', onBubble);
|
|
126
|
+
return () => {
|
|
127
|
+
window.removeEventListener('keydown', onCapture, true);
|
|
128
|
+
window.removeEventListener('keydown', onBubble);
|
|
129
|
+
for (const timer of timers) clearTimeout(timer);
|
|
130
|
+
timers.clear();
|
|
131
|
+
pending = null;
|
|
132
|
+
};
|
|
133
|
+
}
|
package/src/overlay/stack.ts
CHANGED
|
@@ -169,3 +169,16 @@ export function resetOverlayStack(): void {
|
|
|
169
169
|
export function hasActiveOverlays(): boolean {
|
|
170
170
|
return registry().live.size > 0;
|
|
171
171
|
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Whether the surface painting at `layer` is the top-most one open — the one
|
|
175
|
+
* Escape and Tab belong to. A menu opened from inside a dialog takes a higher
|
|
176
|
+
* rank than the dialog, so the dialog stands aside until it closes.
|
|
177
|
+
*/
|
|
178
|
+
export function isTopmostOverlayLayer(layer: OverlayLayer): boolean {
|
|
179
|
+
const { live } = registry();
|
|
180
|
+
if (live.size === 0) return false;
|
|
181
|
+
let top = 0;
|
|
182
|
+
for (const rank of live) if (rank > top) top = rank;
|
|
183
|
+
return layerForRank(top).root === layer.root;
|
|
184
|
+
}
|
package/src/sidebar/Sidebar.tsx
CHANGED
|
@@ -254,11 +254,15 @@ const SidebarPanel: React.FC<SidebarProps> = ({
|
|
|
254
254
|
};
|
|
255
255
|
}, [progress, expandedWidth, fluid, collapsedWidth, expandedPadding, collapsedPadding]);
|
|
256
256
|
const [contentHeight, setContentHeight] = useState(0);
|
|
257
|
+
// A worklet copies what it reads to the UI runtime, and `tree` carries the
|
|
258
|
+
// app's rows, elements and data — a `Date` among them crashes native on the
|
|
259
|
+
// copy. The mapper reads only whether there is one (`worklet-captures.test.ts`).
|
|
260
|
+
const treeShown = Boolean(tree);
|
|
257
261
|
const contentMorph = useAnimatedStyle(() => ({
|
|
258
262
|
height: contentHeight > 0 ? contentHeight * (1 - progress.value) : collapsed ? 0 : undefined,
|
|
259
263
|
opacity: 1 - progress.value,
|
|
260
|
-
marginTop:
|
|
261
|
-
}), [contentHeight, collapsed, progress,
|
|
264
|
+
marginTop: treeShown ? 0 : 24 * (1 - progress.value),
|
|
265
|
+
}), [contentHeight, collapsed, progress, treeShown]);
|
|
262
266
|
const contentWidth = useAnimatedStyle(() => ({ width: expandedWidth.value - expandedPadding * 2 }), [expandedWidth, expandedPadding]);
|
|
263
267
|
const navInset = useAnimatedStyle(() => {
|
|
264
268
|
const inset = expandedInset * (1 - progress.value);
|
|
@@ -412,6 +416,10 @@ const SidebarPanel: React.FC<SidebarProps> = ({
|
|
|
412
416
|
const plain = surface === 'plain';
|
|
413
417
|
const flatMobile = mobile && plain;
|
|
414
418
|
const searchRing = `inset 0 0 0 2px ${palette.searchRing}`;
|
|
419
|
+
// The glyph-only header buttons: the web ring rule paints
|
|
420
|
+
// `var(--bloom-sidebar-ring)`, which resolves to nothing — no ring at all —
|
|
421
|
+
// on a node that does not set it.
|
|
422
|
+
const iconButtonRing = { borderRadius: 6, '--bloom-sidebar-ring': palette.ring } as WebCssStyle;
|
|
415
423
|
const placeholder = searchPlaceholder ?? (flatMobile ? 'Search...' : 'Search navigation…');
|
|
416
424
|
|
|
417
425
|
const toggleCollapse = () => {
|
|
@@ -442,7 +450,7 @@ const SidebarPanel: React.FC<SidebarProps> = ({
|
|
|
442
450
|
role="button"
|
|
443
451
|
accessibilityLabel={searchButtonLabel}
|
|
444
452
|
onPress={activateSearch}
|
|
445
|
-
style={{ width: 20, height: 20, alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}
|
|
453
|
+
style={[{ width: 20, height: 20, alignItems: 'center', justifyContent: 'center', flexShrink: 0 }, iconButtonRing]}
|
|
446
454
|
>
|
|
447
455
|
<RiSearchLine width={20} height={20} fill={palette.textSecondary} />
|
|
448
456
|
</Pressable>
|
|
@@ -466,6 +474,7 @@ const SidebarPanel: React.FC<SidebarProps> = ({
|
|
|
466
474
|
role="button"
|
|
467
475
|
accessibilityLabel={closeLabel}
|
|
468
476
|
onPress={onClose}
|
|
477
|
+
style={iconButtonRing}
|
|
469
478
|
testID="sidebar-close"
|
|
470
479
|
>
|
|
471
480
|
<RiCloseLine width={20} height={20} fill={palette.textSecondary} />
|
|
@@ -477,7 +486,7 @@ const SidebarPanel: React.FC<SidebarProps> = ({
|
|
|
477
486
|
accessibilityLabel={collapsed ? expandLabel : collapseLabel}
|
|
478
487
|
aria-expanded={!collapsed}
|
|
479
488
|
onPress={toggleCollapse}
|
|
480
|
-
style={{ width: '100%', alignItems: 'center', justifyContent: 'center' }}
|
|
489
|
+
style={[{ width: '100%', alignItems: 'center', justifyContent: 'center' }, iconButtonRing]}
|
|
481
490
|
testID="sidebar-collapse"
|
|
482
491
|
>
|
|
483
492
|
<Animated.View style={sideBarIconStyle}>
|