@microbit/ui 0.2.1 → 0.2.2

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/Modal.tsx +91 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microbit/ui",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "micro:bit design-system primitives: react-aria-components + Panda CSS with a design language ported from Chakra UI v2. Ships as source; see README for the consumption setup.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/Modal.tsx CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  ReactNode,
10
10
  RefObject,
11
11
  useContext,
12
+ useEffect,
13
+ useRef,
12
14
  } from "react";
13
15
  import {
14
16
  Button as RACButton,
@@ -28,6 +30,92 @@ import { dataAttrs } from "./data-attrs";
28
30
  import { uiMessage } from "./messages";
29
31
  import { UnmountCallback } from "./UnmountCallback";
30
32
 
33
+ // react-aria's tabbable selector, which it doesn't export. It special-cases
34
+ // radio groups (only the checked radio is tabbable) where this doesn't — an
35
+ // acceptable divergence for the shim below, whose worst case on a miss is
36
+ // standing aside and leaving the keystroke to FocusScope, as without it.
37
+ const TABBABLE_SELECTOR = [
38
+ "input:not([disabled]):not([type=hidden])",
39
+ "select:not([disabled])",
40
+ "textarea:not([disabled])",
41
+ "button:not([disabled])",
42
+ "a[href]",
43
+ "area[href]",
44
+ "summary",
45
+ "iframe",
46
+ "object",
47
+ "embed",
48
+ "audio[controls]",
49
+ "video[controls]",
50
+ "[contenteditable]:not([contenteditable^='false'])",
51
+ "[tabindex]:not([disabled])",
52
+ ]
53
+ .map((s) => `${s}:not([hidden]):not([tabindex='-1'])`)
54
+ .join(",");
55
+
56
+ const isTabbable = (element: Element) =>
57
+ !element.closest("[inert]") &&
58
+ (element.checkVisibility?.({ visibilityProperty: true }) ?? true);
59
+
60
+ /**
61
+ * Tab handling for iframes in the dialog, which react-aria's focus
62
+ * containment gets wrong in Firefox: FocusScope re-implements Tab as a
63
+ * synchronous programmatic focus of the next tabbable element, and Firefox
64
+ * silently drops a synchronous focus() of a cross-origin iframe made during
65
+ * key event dispatch — the keystroke appears to do nothing. The same call
66
+ * deferred to the next frame works, so this capture-phase listener (which
67
+ * runs before FocusScope's bubble-phase one) claims the keystroke whenever
68
+ * the element Tab would move to is an iframe, and performs the move
69
+ * deferred. Every other Tab is left to FocusScope. Remove if react-aria
70
+ * defers iframe focus itself.
71
+ */
72
+ const IframeTabShim = ({
73
+ containerRef,
74
+ }: {
75
+ containerRef: RefObject<HTMLElement | null>;
76
+ }) => {
77
+ useEffect(() => {
78
+ const onKeyDown = (e: KeyboardEvent) => {
79
+ if (
80
+ e.key !== "Tab" ||
81
+ e.altKey ||
82
+ e.ctrlKey ||
83
+ e.metaKey ||
84
+ e.isComposing
85
+ ) {
86
+ return;
87
+ }
88
+ const container = containerRef.current;
89
+ const active = container?.ownerDocument.activeElement;
90
+ if (!container || !active || !container.contains(active)) {
91
+ return;
92
+ }
93
+ const tabbables = Array.from(
94
+ container.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR),
95
+ ).filter(isTabbable);
96
+ const index = tabbables.indexOf(active as HTMLElement);
97
+ if (index === -1) {
98
+ return;
99
+ }
100
+ const target =
101
+ tabbables[
102
+ (index + (e.shiftKey ? -1 : 1) + tabbables.length) % tabbables.length
103
+ ];
104
+ if (!(target instanceof HTMLIFrameElement)) {
105
+ return;
106
+ }
107
+ e.preventDefault();
108
+ // Keep FocusScope's own document-level handler from also acting.
109
+ e.stopImmediatePropagation();
110
+ requestAnimationFrame(() => target.focus());
111
+ };
112
+ const doc = containerRef.current?.ownerDocument ?? document;
113
+ doc.addEventListener("keydown", onKeyDown, true);
114
+ return () => doc.removeEventListener("keydown", onKeyDown, true);
115
+ }, [containerRef]);
116
+ return null;
117
+ };
118
+
31
119
  type DialogSlots = ReturnType<typeof dialog>;
32
120
 
33
121
  const SlotContext = createContext<{ slots: DialogSlots; onClose: () => void }>({
@@ -163,6 +251,7 @@ export const Modal = ({
163
251
  // prop over the context, and so do we for the close function.
164
252
  const triggerState = useContext(OverlayTriggerStateContext);
165
253
  const close = onClose ?? (() => triggerState?.close());
254
+ const contentRef = useRef<HTMLDivElement>(null);
166
255
  const dataProps = dataAttrs(rest);
167
256
  const slots = dialog({ size, centered: isCentered });
168
257
  const motionlessClass = motionless
@@ -205,6 +294,7 @@ export const Modal = ({
205
294
  <UnmountCallback callback={handleUnmount} />
206
295
  <RACModal
207
296
  {...dataProps}
297
+ ref={contentRef}
208
298
  style={contentStyle}
209
299
  className={cx(
210
300
  slots.content,
@@ -213,6 +303,7 @@ export const Modal = ({
213
303
  )}
214
304
  >
215
305
  <Dialog role={role} aria-label={ariaLabel} className={slots.inner}>
306
+ <IframeTabShim containerRef={contentRef} />
216
307
  <SlotContext.Provider value={{ slots, onClose: close }}>
217
308
  {children}
218
309
  </SlotContext.Provider>