@oxy.so/bloom 4.23.0 → 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.
Files changed (86) hide show
  1. package/docs/adoption-matrix.mdx +10 -10
  2. package/docs/ai-chat.mdx +7 -3
  3. package/docs/bottom-sheet.mdx +2 -0
  4. package/docs/dialog.mdx +28 -0
  5. package/docs/sidebar.mdx +2 -0
  6. package/lib/commonjs/ai-chat/AiChatContainer.js +22 -11
  7. package/lib/commonjs/ai-chat/AiChatContainer.js.map +1 -1
  8. package/lib/commonjs/ai-chat/AiChatShell.js +8 -2
  9. package/lib/commonjs/ai-chat/AiChatShell.js.map +1 -1
  10. package/lib/commonjs/ai-chat/use-panel-interaction.js +20 -33
  11. package/lib/commonjs/ai-chat/use-panel-interaction.js.map +1 -1
  12. package/lib/commonjs/bottom-sheet/BottomSheetBase.js +10 -3
  13. package/lib/commonjs/bottom-sheet/BottomSheetBase.js.map +1 -1
  14. package/lib/commonjs/dialog/Dialog.web.js +28 -22
  15. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  16. package/lib/commonjs/dialog/DialogBottomSheet.js +9 -4
  17. package/lib/commonjs/dialog/DialogBottomSheet.js.map +1 -1
  18. package/lib/commonjs/overlay/ModalKeyboard.js +88 -0
  19. package/lib/commonjs/overlay/ModalKeyboard.js.map +1 -0
  20. package/lib/commonjs/overlay/modal-keyboard.js +131 -0
  21. package/lib/commonjs/overlay/modal-keyboard.js.map +1 -0
  22. package/lib/commonjs/overlay/stack.js +16 -0
  23. package/lib/commonjs/overlay/stack.js.map +1 -1
  24. package/lib/commonjs/sidebar/Sidebar.js +12 -4
  25. package/lib/commonjs/sidebar/Sidebar.js.map +1 -1
  26. package/lib/module/ai-chat/AiChatContainer.js +22 -11
  27. package/lib/module/ai-chat/AiChatContainer.js.map +1 -1
  28. package/lib/module/ai-chat/AiChatShell.js +8 -2
  29. package/lib/module/ai-chat/AiChatShell.js.map +1 -1
  30. package/lib/module/ai-chat/use-panel-interaction.js +20 -32
  31. package/lib/module/ai-chat/use-panel-interaction.js.map +1 -1
  32. package/lib/module/bottom-sheet/BottomSheetBase.js +10 -3
  33. package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
  34. package/lib/module/dialog/Dialog.web.js +28 -22
  35. package/lib/module/dialog/Dialog.web.js.map +1 -1
  36. package/lib/module/dialog/DialogBottomSheet.js +9 -4
  37. package/lib/module/dialog/DialogBottomSheet.js.map +1 -1
  38. package/lib/module/overlay/ModalKeyboard.js +84 -0
  39. package/lib/module/overlay/ModalKeyboard.js.map +1 -0
  40. package/lib/module/overlay/modal-keyboard.js +124 -0
  41. package/lib/module/overlay/modal-keyboard.js.map +1 -0
  42. package/lib/module/overlay/stack.js +15 -0
  43. package/lib/module/overlay/stack.js.map +1 -1
  44. package/lib/module/sidebar/Sidebar.js +12 -4
  45. package/lib/module/sidebar/Sidebar.js.map +1 -1
  46. package/lib/typescript/commonjs/ai-chat/AiChatContainer.d.ts +5 -4
  47. package/lib/typescript/commonjs/ai-chat/AiChatContainer.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/ai-chat/AiChatShell.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/ai-chat/use-panel-interaction.d.ts +10 -1
  50. package/lib/typescript/commonjs/ai-chat/use-panel-interaction.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/overlay/ModalKeyboard.d.ts +32 -0
  55. package/lib/typescript/commonjs/overlay/ModalKeyboard.d.ts.map +1 -0
  56. package/lib/typescript/commonjs/overlay/modal-keyboard.d.ts +17 -0
  57. package/lib/typescript/commonjs/overlay/modal-keyboard.d.ts.map +1 -0
  58. package/lib/typescript/commonjs/overlay/stack.d.ts +6 -0
  59. package/lib/typescript/commonjs/overlay/stack.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/sidebar/Sidebar.d.ts.map +1 -1
  61. package/lib/typescript/module/ai-chat/AiChatContainer.d.ts +5 -4
  62. package/lib/typescript/module/ai-chat/AiChatContainer.d.ts.map +1 -1
  63. package/lib/typescript/module/ai-chat/AiChatShell.d.ts.map +1 -1
  64. package/lib/typescript/module/ai-chat/use-panel-interaction.d.ts +10 -1
  65. package/lib/typescript/module/ai-chat/use-panel-interaction.d.ts.map +1 -1
  66. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  67. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  68. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts.map +1 -1
  69. package/lib/typescript/module/overlay/ModalKeyboard.d.ts +32 -0
  70. package/lib/typescript/module/overlay/ModalKeyboard.d.ts.map +1 -0
  71. package/lib/typescript/module/overlay/modal-keyboard.d.ts +17 -0
  72. package/lib/typescript/module/overlay/modal-keyboard.d.ts.map +1 -0
  73. package/lib/typescript/module/overlay/stack.d.ts +6 -0
  74. package/lib/typescript/module/overlay/stack.d.ts.map +1 -1
  75. package/lib/typescript/module/sidebar/Sidebar.d.ts.map +1 -1
  76. package/package.json +1 -1
  77. package/src/ai-chat/AiChatContainer.tsx +18 -8
  78. package/src/ai-chat/AiChatShell.tsx +12 -2
  79. package/src/ai-chat/use-panel-interaction.ts +20 -34
  80. package/src/bottom-sheet/BottomSheetBase.tsx +8 -0
  81. package/src/dialog/Dialog.web.tsx +22 -18
  82. package/src/dialog/DialogBottomSheet.tsx +9 -4
  83. package/src/overlay/ModalKeyboard.tsx +88 -0
  84. package/src/overlay/modal-keyboard.ts +133 -0
  85. package/src/overlay/stack.ts +13 -0
  86. package/src/sidebar/Sidebar.tsx +7 -2
@@ -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
+ }
@@ -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
+ }
@@ -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}>