@oxy.so/bloom 4.22.2 → 4.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/adoption-matrix.mdx +10 -10
- package/docs/ai-chat.mdx +7 -3
- package/docs/bottom-sheet.mdx +2 -0
- package/docs/composer-panel.mdx +10 -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-sheet/BottomSheetBase.js +10 -3
- package/lib/commonjs/bottom-sheet/BottomSheetBase.js.map +1 -1
- package/lib/commonjs/chat-composer/ChatComposer.js +10 -12
- package/lib/commonjs/chat-composer/ChatComposer.js.map +1 -1
- package/lib/commonjs/composer-panel/ComposerPanelBase.js +9 -11
- package/lib/commonjs/composer-panel/ComposerPanelBase.js.map +1 -1
- package/lib/commonjs/composer-panel/ComposerPillBase.js +24 -2
- package/lib/commonjs/composer-panel/ComposerPillBase.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/hooks/use-textarea-autosize.js +103 -0
- package/lib/commonjs/hooks/use-textarea-autosize.js.map +1 -0
- 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 +12 -4
- 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-sheet/BottomSheetBase.js +10 -3
- package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
- package/lib/module/chat-composer/ChatComposer.js +11 -13
- package/lib/module/chat-composer/ChatComposer.js.map +1 -1
- package/lib/module/composer-panel/ComposerPanelBase.js +10 -12
- package/lib/module/composer-panel/ComposerPanelBase.js.map +1 -1
- package/lib/module/composer-panel/ComposerPillBase.js +24 -2
- package/lib/module/composer-panel/ComposerPillBase.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/hooks/use-textarea-autosize.js +98 -0
- package/lib/module/hooks/use-textarea-autosize.js.map +1 -0
- 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 +12 -4
- 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-sheet/BottomSheetBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/chat-composer/ChatComposer.d.ts.map +1 -1
- package/lib/typescript/commonjs/composer-panel/ComposerPanelBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/composer-panel/ComposerPillBase.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/hooks/use-textarea-autosize.d.ts +61 -0
- package/lib/typescript/commonjs/hooks/use-textarea-autosize.d.ts.map +1 -0
- 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-sheet/BottomSheetBase.d.ts.map +1 -1
- package/lib/typescript/module/chat-composer/ChatComposer.d.ts.map +1 -1
- package/lib/typescript/module/composer-panel/ComposerPanelBase.d.ts.map +1 -1
- package/lib/typescript/module/composer-panel/ComposerPillBase.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/hooks/use-textarea-autosize.d.ts +61 -0
- package/lib/typescript/module/hooks/use-textarea-autosize.d.ts.map +1 -0
- 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-sheet/BottomSheetBase.tsx +8 -0
- package/src/chat-composer/ChatComposer.tsx +11 -13
- package/src/composer-panel/ComposerPanelBase.tsx +10 -12
- package/src/composer-panel/ComposerPillBase.tsx +28 -2
- package/src/dialog/Dialog.web.tsx +22 -18
- package/src/dialog/DialogBottomSheet.tsx +9 -4
- package/src/hooks/use-textarea-autosize.ts +106 -0
- 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 +7 -2
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, type RefObject } from 'react';
|
|
2
|
+
import type { TextInput } from 'react-native';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The web half of a composer's autosize: how tall its draft is, measured from
|
|
6
|
+
* the `<textarea>` react-native-web renders. Native reads
|
|
7
|
+
* `onContentSizeChange` and never calls this.
|
|
8
|
+
*
|
|
9
|
+
* `scrollHeight` never falls below the height already applied, so it reports
|
|
10
|
+
* growth exactly but cannot report shrinking. The old measurement collapsed the
|
|
11
|
+
* live field to `0px` before EVERY read and restored it after — and a
|
|
12
|
+
* collapsed field is a different page: each keystroke paid for laying the
|
|
13
|
+
* whole document out three times (collapse, read, restore), 23–27 ms a key
|
|
14
|
+
* beside a 1,000-message conversation, against 6–8 ms without the collapse.
|
|
15
|
+
*
|
|
16
|
+
* So the field is collapsed only when the draft could have got SHORTER:
|
|
17
|
+
*
|
|
18
|
+
* - an edit that only INSERTED characters (typing, pasting, an IME
|
|
19
|
+
* committing) cannot remove a line, so `scrollHeight` is read as it
|
|
20
|
+
* stands: one layout, the same one the frame was going to do anyway.
|
|
21
|
+
* - a field already at its floor cannot shrink, so it is never collapsed.
|
|
22
|
+
* - anything else — a deletion, a replacement, a programmatic `value` such as
|
|
23
|
+
* the clear after send, a change of floor — collapses, reads and restores
|
|
24
|
+
* in the same task, so nothing is painted in between.
|
|
25
|
+
*
|
|
26
|
+
* The one thing insertion cannot promise is soft-wrap: inserting a character
|
|
27
|
+
* could in principle reflow a wrapped word onto fewer lines. The field then
|
|
28
|
+
* keeps one line too many until the next edit that deletes, which is the
|
|
29
|
+
* failure the old measurement would have fixed a keystroke earlier — not a
|
|
30
|
+
* clipped draft.
|
|
31
|
+
*/
|
|
32
|
+
export interface TextareaAutosizeOptions {
|
|
33
|
+
/** Measure at all — `IS_WEB`. Native measures through `onContentSizeChange`. */
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
/** The height the field has now, as last applied by the caller. */
|
|
36
|
+
height: number;
|
|
37
|
+
/** The smallest height the field is drawn at; a field there cannot shrink. */
|
|
38
|
+
minHeight: number;
|
|
39
|
+
/** Receives the draft's content height, in px, whenever it is measured. */
|
|
40
|
+
onMeasure: (contentHeight: number) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The DOM surface this reads and writes — `HTMLTextAreaElement`, narrowed. */
|
|
44
|
+
export interface AutosizeNode {
|
|
45
|
+
style: { height: string };
|
|
46
|
+
scrollHeight: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether `next` is `previous` with characters only INSERTED — at one place,
|
|
51
|
+
* nothing removed or replaced. A common prefix plus a common suffix that
|
|
52
|
+
* together cover all of `previous` means every old character survived.
|
|
53
|
+
*/
|
|
54
|
+
export function isPureInsertion(previous: string, next: string): boolean {
|
|
55
|
+
const length = previous.length;
|
|
56
|
+
if (next.length < length) return false;
|
|
57
|
+
let prefix = 0;
|
|
58
|
+
while (prefix < length && previous.charCodeAt(prefix) === next.charCodeAt(prefix)) prefix++;
|
|
59
|
+
if (prefix === length) return true;
|
|
60
|
+
let suffix = 0;
|
|
61
|
+
while (
|
|
62
|
+
suffix < length - prefix &&
|
|
63
|
+
previous.charCodeAt(length - 1 - suffix) === next.charCodeAt(next.length - 1 - suffix)
|
|
64
|
+
) {
|
|
65
|
+
suffix++;
|
|
66
|
+
}
|
|
67
|
+
return prefix + suffix >= length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The content height of `node`. With `collapse` it zeroes the height first so
|
|
72
|
+
* `scrollHeight` can report LESS than the field's current height, and puts the
|
|
73
|
+
* height back before returning.
|
|
74
|
+
*/
|
|
75
|
+
export function measureTextarea(node: AutosizeNode, collapse: boolean): number {
|
|
76
|
+
if (!collapse) return node.scrollHeight;
|
|
77
|
+
const previous = node.style.height;
|
|
78
|
+
node.style.height = '0px';
|
|
79
|
+
const measured = node.scrollHeight;
|
|
80
|
+
node.style.height = previous;
|
|
81
|
+
return measured;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function useTextareaAutosize(
|
|
85
|
+
ref: RefObject<TextInput | null>,
|
|
86
|
+
text: string,
|
|
87
|
+
{ enabled, height, minHeight, onMeasure }: TextareaAutosizeOptions,
|
|
88
|
+
): void {
|
|
89
|
+
// What the field held at the last measurement, and the floor it was held to.
|
|
90
|
+
const last = useRef<{ text: string; minHeight: number } | null>(null);
|
|
91
|
+
const latest = useRef({ height, onMeasure });
|
|
92
|
+
latest.current = { height, onMeasure };
|
|
93
|
+
|
|
94
|
+
useLayoutEffect(() => {
|
|
95
|
+
if (!enabled) return;
|
|
96
|
+
const node = ref.current as unknown as AutosizeNode | null;
|
|
97
|
+
if (!node?.style || typeof node.scrollHeight !== 'number') return;
|
|
98
|
+
const previous = last.current;
|
|
99
|
+
last.current = { text, minHeight };
|
|
100
|
+
const onlyGrew =
|
|
101
|
+
previous !== null && previous.minHeight === minHeight && isPureInsertion(previous.text, text);
|
|
102
|
+
const collapse = !onlyGrew && latest.current.height > minHeight;
|
|
103
|
+
const measured = measureTextarea(node, collapse);
|
|
104
|
+
if (measured > 0) latest.current.onMeasure(measured);
|
|
105
|
+
}, [enabled, ref, text, minHeight]);
|
|
106
|
+
}
|
|
@@ -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
|
@@ -412,6 +412,10 @@ const SidebarPanel: React.FC<SidebarProps> = ({
|
|
|
412
412
|
const plain = surface === 'plain';
|
|
413
413
|
const flatMobile = mobile && plain;
|
|
414
414
|
const searchRing = `inset 0 0 0 2px ${palette.searchRing}`;
|
|
415
|
+
// The glyph-only header buttons: the web ring rule paints
|
|
416
|
+
// `var(--bloom-sidebar-ring)`, which resolves to nothing — no ring at all —
|
|
417
|
+
// on a node that does not set it.
|
|
418
|
+
const iconButtonRing = { borderRadius: 6, '--bloom-sidebar-ring': palette.ring } as WebCssStyle;
|
|
415
419
|
const placeholder = searchPlaceholder ?? (flatMobile ? 'Search...' : 'Search navigation…');
|
|
416
420
|
|
|
417
421
|
const toggleCollapse = () => {
|
|
@@ -442,7 +446,7 @@ const SidebarPanel: React.FC<SidebarProps> = ({
|
|
|
442
446
|
role="button"
|
|
443
447
|
accessibilityLabel={searchButtonLabel}
|
|
444
448
|
onPress={activateSearch}
|
|
445
|
-
style={{ width: 20, height: 20, alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}
|
|
449
|
+
style={[{ width: 20, height: 20, alignItems: 'center', justifyContent: 'center', flexShrink: 0 }, iconButtonRing]}
|
|
446
450
|
>
|
|
447
451
|
<RiSearchLine width={20} height={20} fill={palette.textSecondary} />
|
|
448
452
|
</Pressable>
|
|
@@ -466,6 +470,7 @@ const SidebarPanel: React.FC<SidebarProps> = ({
|
|
|
466
470
|
role="button"
|
|
467
471
|
accessibilityLabel={closeLabel}
|
|
468
472
|
onPress={onClose}
|
|
473
|
+
style={iconButtonRing}
|
|
469
474
|
testID="sidebar-close"
|
|
470
475
|
>
|
|
471
476
|
<RiCloseLine width={20} height={20} fill={palette.textSecondary} />
|
|
@@ -477,7 +482,7 @@ const SidebarPanel: React.FC<SidebarProps> = ({
|
|
|
477
482
|
accessibilityLabel={collapsed ? expandLabel : collapseLabel}
|
|
478
483
|
aria-expanded={!collapsed}
|
|
479
484
|
onPress={toggleCollapse}
|
|
480
|
-
style={{ width: '100%', alignItems: 'center', justifyContent: 'center' }}
|
|
485
|
+
style={[{ width: '100%', alignItems: 'center', justifyContent: 'center' }, iconButtonRing]}
|
|
481
486
|
testID="sidebar-collapse"
|
|
482
487
|
>
|
|
483
488
|
<Animated.View style={sideBarIconStyle}>
|