@microbit/ui 0.1.0-alpha.21 → 0.1.0-alpha.23

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 (97) hide show
  1. package/LICENSE.md +5 -5
  2. package/README.md +29 -13
  3. package/lang/ui.ca.json +12 -12
  4. package/lang/ui.cy.json +4 -4
  5. package/lang/ui.de.json +6 -6
  6. package/lang/ui.es-es.json +5 -5
  7. package/lang/ui.fr.json +6 -6
  8. package/lang/ui.ga-ie.json +6 -6
  9. package/lang/ui.it.json +4 -4
  10. package/lang/ui.ja.json +7 -7
  11. package/lang/ui.ko.json +6 -6
  12. package/lang/ui.lo.json +58 -0
  13. package/lang/ui.nl.json +9 -9
  14. package/lang/ui.pl.json +8 -8
  15. package/lang/ui.pt-br.json +7 -7
  16. package/lang/ui.vi.json +58 -0
  17. package/lang/ui.zh-cn.json +6 -6
  18. package/lang/ui.zh-tw.json +7 -7
  19. package/package.json +1 -1
  20. package/src/Avatar.recipe.ts +16 -20
  21. package/src/Avatar.tsx +31 -29
  22. package/src/Breadcrumb.recipe.ts +3 -7
  23. package/src/Breadcrumb.tsx +6 -8
  24. package/src/Button.recipe.ts +22 -33
  25. package/src/Button.tsx +10 -12
  26. package/src/ButtonGroup.tsx +10 -9
  27. package/src/Card.recipe.ts +1 -2
  28. package/src/Card.tsx +2 -1
  29. package/src/Checkbox.recipe.ts +7 -9
  30. package/src/Checkbox.tsx +2 -3
  31. package/src/CheckboxGroup.tsx +5 -7
  32. package/src/CloseButton.tsx +2 -2
  33. package/src/CloseIcon.tsx +6 -4
  34. package/src/Code.tsx +1 -1
  35. package/src/Collapse.tsx +13 -14
  36. package/src/Divider.tsx +3 -3
  37. package/src/Drawer.recipe.ts +4 -5
  38. package/src/Drawer.tsx +3 -4
  39. package/src/Fade.tsx +6 -10
  40. package/src/Field.recipe.ts +9 -12
  41. package/src/Field.tsx +16 -18
  42. package/src/GridList.recipe.ts +1 -2
  43. package/src/Heading.recipe.ts +5 -5
  44. package/src/Heading.tsx +3 -3
  45. package/src/Icon.tsx +7 -11
  46. package/src/IconButton.tsx +1 -1
  47. package/src/Image.tsx +1 -1
  48. package/src/Input.recipe.ts +5 -6
  49. package/src/Input.tsx +4 -4
  50. package/src/InputGroup.tsx +4 -5
  51. package/src/Kbd.tsx +1 -1
  52. package/src/Link.tsx +2 -2
  53. package/src/LinkBox.tsx +2 -3
  54. package/src/LinkButton.tsx +4 -4
  55. package/src/List.tsx +5 -7
  56. package/src/Menu.recipe.ts +15 -16
  57. package/src/Menu.tsx +9 -13
  58. package/src/Modal.recipe.ts +7 -8
  59. package/src/Modal.tsx +19 -24
  60. package/src/NativeSelect.tsx +16 -16
  61. package/src/NumberField.recipe.ts +25 -11
  62. package/src/NumberField.tsx +4 -4
  63. package/src/PopoverArrow.tsx +1 -2
  64. package/src/ProgressBar.tsx +2 -4
  65. package/src/Radio.recipe.ts +8 -9
  66. package/src/Radio.tsx +2 -2
  67. package/src/RadioGroup.tsx +4 -5
  68. package/src/Select.recipe.ts +9 -9
  69. package/src/Select.tsx +8 -9
  70. package/src/Skeleton.tsx +13 -15
  71. package/src/Slide.tsx +2 -2
  72. package/src/Slider.recipe.ts +11 -14
  73. package/src/Slider.tsx +3 -7
  74. package/src/Spinner.tsx +6 -9
  75. package/src/Svg.tsx +2 -3
  76. package/src/Switch.recipe.ts +4 -5
  77. package/src/Switch.tsx +3 -4
  78. package/src/Text.recipe.ts +4 -7
  79. package/src/Text.tsx +2 -2
  80. package/src/TextField.tsx +3 -4
  81. package/src/Toast.recipe.ts +43 -11
  82. package/src/Toast.tsx +69 -40
  83. package/src/Tooltip.recipe.ts +4 -9
  84. package/src/Tooltip.tsx +48 -10
  85. package/src/TooltipButton.tsx +229 -0
  86. package/src/UnmountCallback.tsx +2 -2
  87. package/src/VisuallyHidden.tsx +1 -1
  88. package/src/base-preset.ts +38 -43
  89. package/src/button-icon.ts +2 -3
  90. package/src/dense-preset.ts +6 -7
  91. package/src/hooks/useBreakpointValue.ts +4 -4
  92. package/src/hooks/useClipboard.ts +5 -6
  93. package/src/hooks/useDisclosure.ts +4 -5
  94. package/src/hooks/useMediaQuery.ts +3 -4
  95. package/src/hooks/usePrevious.ts +1 -4
  96. package/src/index.ts +4 -3
  97. package/src/system.ts +2 -2
package/src/Toast.tsx CHANGED
@@ -23,12 +23,11 @@ import { toast as toastRecipe } from "styled-system/recipes";
23
23
  import { CloseIcon } from "./CloseIcon";
24
24
  import { Icon } from "./Icon";
25
25
  import { uiMessage } from "./messages";
26
- import { VisuallyHidden } from "./VisuallyHidden";
27
26
 
28
27
  export type ToastStatus = "info" | "success" | "warning" | "error";
29
28
 
30
29
  export interface ToastContent {
31
- /** Dedup key: adding a toast whose id is already visible is a no-op. */
30
+ /** Dedup key: adding a toast whose id is already queued is a no-op. */
32
31
  id?: string;
33
32
  title?: ReactNode;
34
33
  description?: ReactNode;
@@ -75,11 +74,20 @@ export const toastQueue = new RACToastQueue<ToastContent>({
75
74
  wrapUpdate,
76
75
  });
77
76
 
78
- // Status icon matching Chakra's AlertIcon (filled glyphs, coloured by the
79
- // toast foreground = white here). Warning is a triangle, error a circle, as
80
- // in Chakra the glyph must distinguish them because the colours alone
81
- // don't reliably. The icon is decorative; the status is announced via the
82
- // visually hidden status text.
77
+ // Index of our ids to the queue's own keys. The queue only exposes its
78
+ // visible slice the newest `maxVisibleToasts` so ids can't be resolved by
79
+ // scanning it: once newer toasts arrive an older one is still queued but out
80
+ // of sight, and dedup would let a second copy through while update() added
81
+ // rather than replaced. react-aria's per-toast `onClose` keeps this honest
82
+ // however a toast goes (timeout, close button, or update). clear() doesn't
83
+ // call onClose, so closeAll empties both.
84
+ const keysById = new Map<string, string>();
85
+
86
+ // Status icon (filled glyphs, coloured by the toast foreground = white
87
+ // here). Warning is a triangle, error a circle — the glyph must distinguish
88
+ // them because the colours alone don't reliably. The glyph is also what
89
+ // carries the status for assistive tech, via its accessible name (see
90
+ // ToastProvider).
83
91
  const statusIcon: Record<ToastStatus, IconType> = {
84
92
  info: RiInformationFill,
85
93
  success: RiCheckboxCircleFill,
@@ -122,21 +130,24 @@ export const ToastProvider = () => {
122
130
  // group and lets old/new pair up across the transition.
123
131
  style={{ viewTransitionName: toast.key }}
124
132
  >
125
- <Icon as={statusIcon[status]} className={slots.icon} aria-hidden />
126
- <RACToastContent>
127
- {/* Colour and icon are the only visible status signals; say it
128
- for assistive tech too. */}
129
- <VisuallyHidden>
130
- {intl.formatMessage(uiMessage(`ui.toast-status-${status}`))}{" "}
131
- </VisuallyHidden>
132
- {toast.content.title && (
133
- <p className={slots.title}>{toast.content.title}</p>
134
- )}
135
- {toast.content.description && (
136
- <div className={slots.description}>
137
- {toast.content.description}
138
- </div>
139
- )}
133
+ <RACToastContent className={slots.content}>
134
+ <Icon
135
+ as={statusIcon[status]}
136
+ className={slots.icon}
137
+ aria-label={intl.formatMessage(
138
+ uiMessage(`ui.toast-status-${status}`),
139
+ )}
140
+ />
141
+ <div className={slots.body}>
142
+ {toast.content.title && (
143
+ <p className={slots.title}>{toast.content.title}</p>
144
+ )}
145
+ {toast.content.description && (
146
+ <div className={slots.description}>
147
+ {toast.content.description}
148
+ </div>
149
+ )}
150
+ </div>
140
151
  </RACToastContent>
141
152
  {toast.content.isClosable && (
142
153
  <RACButton
@@ -166,26 +177,28 @@ export interface ToastOptions extends ToastContent {
166
177
 
167
178
  export interface ToastFn {
168
179
  (options: ToastOptions): void;
169
- /** Whether a toast with this id is currently visible. */
180
+ /**
181
+ * Whether a toast with this id is still queued — displayed, or waiting
182
+ * behind newer toasts for its turn.
183
+ */
170
184
  isActive(id: string): boolean;
171
185
  /**
172
- * Replace a visible toast's content (Chakra's toast.update). The toast is
173
- * re-added, so unlike Chakra it re-animates and restarts any timeout.
186
+ * Replace a queued toast's content. The toast is re-added: it re-animates,
187
+ * restarts any timeout, and takes its place at the front of the queue.
174
188
  */
175
189
  update(id: string, options: ToastOptions): void;
176
- /** Dismiss all visible toasts (Chakra's toast.closeAll). */
190
+ /** Dismiss every toast, queued as well as displayed. */
177
191
  closeAll(): void;
178
192
  }
179
193
 
180
194
  /**
181
- * useToast — imperative toast trigger in the shape of Chakra's `useToast()`
182
- * call sites: `toast({ title, description, status, duration })`. Unlike
183
- * Chakra there is no `duration: null`; use `persistent: true` instead.
195
+ * useToast — imperative toast trigger:
196
+ * `toast({ title, description, status, duration })`. There is no
197
+ * `duration: null`; use `persistent: true` instead.
184
198
  */
185
199
  export const useToast = (): ToastFn =>
186
200
  useMemo(() => {
187
- const isActive = (id: string) =>
188
- toastQueue.visibleToasts.some((t) => t.content.id === id);
201
+ const isActive = (id: string) => keysById.has(id);
189
202
  const add = ({
190
203
  id,
191
204
  title,
@@ -198,7 +211,7 @@ export const useToast = (): ToastFn =>
198
211
  if (id && isActive(id)) {
199
212
  return;
200
213
  }
201
- toastQueue.add(
214
+ const key = toastQueue.add(
202
215
  {
203
216
  id,
204
217
  title,
@@ -206,21 +219,37 @@ export const useToast = (): ToastFn =>
206
219
  status,
207
220
  isClosable: isClosable || persistent,
208
221
  },
209
- { timeout: persistent ? undefined : duration ?? 5000 },
222
+ {
223
+ timeout: persistent ? undefined : duration ?? 5000,
224
+ onClose: id
225
+ ? // Only our own entry is ours to drop: an id reused after this
226
+ // toast closed belongs to the later add.
227
+ () => {
228
+ if (keysById.get(id) === key) {
229
+ keysById.delete(id);
230
+ }
231
+ }
232
+ : undefined,
233
+ },
210
234
  );
235
+ if (id) {
236
+ keysById.set(id, key);
237
+ }
211
238
  };
212
239
  const update = (id: string, options: ToastOptions) => {
213
- const existing = toastQueue.visibleToasts.find(
214
- (t) => t.content.id === id,
215
- );
216
- if (existing) {
217
- toastQueue.close(existing.key);
240
+ const key = keysById.get(id);
241
+ if (key !== undefined) {
242
+ toastQueue.close(key);
218
243
  }
219
244
  add({ ...options, id });
220
245
  };
246
+ // clear() empties the whole queue, including the toasts held back by
247
+ // maxVisibleToasts. Closing the visible ones one by one would only
248
+ // promote the queued ones into view. It's also a single update, so the
249
+ // whole set exits in one view transition.
221
250
  const closeAll = () => {
222
- // Copy first: closing mutates visibleToasts as we iterate.
223
- [...toastQueue.visibleToasts].forEach((t) => toastQueue.close(t.key));
251
+ keysById.clear();
252
+ toastQueue.clear();
224
253
  };
225
254
  return Object.assign(add, { isActive, update, closeAll });
226
255
  }, []);
@@ -6,17 +6,12 @@
6
6
  import { defineRecipe } from "@pandacss/dev";
7
7
 
8
8
  /**
9
- * Tooltip recipe — Chakra's dark tooltip.
9
+ * Tooltip recipe — the dark tooltip.
10
10
  *
11
11
  * A recipe rather than styles inside the component because tooltip typography
12
- * is the kind of thing an app sets once for all of them: classroom's Chakra
13
- * theme did exactly that (`fontSize: md`), and a `css` override at today's
14
- * call sites would quietly not apply to tomorrow's.
15
- *
16
- * The colour, vertical padding and radius are Chakra's exactly. They had
17
- * drifted (white, `py: 1`, `borderRadius: md`) while this lived inside the
18
- * component, which classroom's port measured: a 6px radius where Chakra drew
19
- * 2px. ml-trainer and python-editor pick the correction up too.
12
+ * is the kind of thing an app sets once for all of them (classroom does,
13
+ * `fontSize: md`), and a `css` override at today's call sites would quietly
14
+ * not apply to tomorrow's.
20
15
  *
21
16
  * Registered in the base preset (base-preset.ts).
22
17
  */
package/src/Tooltip.tsx CHANGED
@@ -12,9 +12,9 @@ import { PopoverArrow } from "./PopoverArrow";
12
12
 
13
13
  export interface TooltipProps {
14
14
  /**
15
- * Tooltip body (Chakra's `label`). Not named `content`: Panda extracts
16
- * utility-named props with literal values from any JSX component, so a
17
- * `content` prop taking a string would emit a broken CSS `content` rule.
15
+ * Tooltip body. Not named `content`: Panda extracts utility-named props
16
+ * with literal values from any JSX component, so a `content` prop taking a
17
+ * string would emit a broken CSS `content` rule.
18
18
  */
19
19
  label: ReactNode;
20
20
  /** A single focusable trigger element (e.g. a Button). */
@@ -40,16 +40,47 @@ export interface TooltipProps {
40
40
  * the trigger (i.e. it is not a RAC component or `Focusable`).
41
41
  */
42
42
  triggerRef?: RefObject<HTMLElement | null>;
43
- /** Hover open delay in ms (RAC default ~1500; pass 0 for instant). */
43
+ /**
44
+ * Hover open delay in ms, defaulting to react-aria's 1500.
45
+ *
46
+ * The delay is per bout of interest, not per control: react-aria keeps a
47
+ * global "warm" flag, so the first tooltip waits and every one after it opens
48
+ * instantly until half a second or so after the last one closes. That is what
49
+ * keeps a row of buttons from firing tooltips at a pointer merely crossing
50
+ * them.
51
+ *
52
+ * **Pass 0 where the tooltip is the label** — an icon-only button, where the
53
+ * text is the only explanation of the glyph and waiting for it reads as
54
+ * broken. Leave it alone where the control already says what it is and the
55
+ * tooltip adds detail.
56
+ */
44
57
  delay?: number;
58
+ /**
59
+ * Close delay in ms, defaulting to react-aria's 500.
60
+ *
61
+ * This is what makes a tooltip hoverable, as WCAG 1.4.13 asks: react-aria
62
+ * puts hover handlers on the tooltip that re-open it, but with an immediate
63
+ * close it has unmounted before the pointer can cross the gap. Pass 0 where
64
+ * the delay is wrong for a particular control.
65
+ */
66
+ closeDelay?: number;
67
+ /**
68
+ * Whether pressing the trigger closes the tooltip (RAC default true).
69
+ *
70
+ * react-aria binds this to keydown as well as pointerdown, so with the
71
+ * default *any* key press dismisses the tooltip and only hover or focus
72
+ * brings it back. Pass false where the tooltip's text is the point of the
73
+ * control rather than a hint about an action.
74
+ */
75
+ shouldCloseOnPress?: boolean;
45
76
  css?: SystemStyleObject;
46
77
  }
47
78
 
48
79
  /**
49
- * Tooltip — react-aria-components TooltipTrigger + Tooltip, styled to match
50
- * Chakra's dark tooltip. The child must be a focusable element so the tooltip
51
- * is reachable by keyboard (RAC requirement), unless `triggerRef` provides
52
- * the anchor and the caller manages open state and keyboard access itself.
80
+ * Tooltip — react-aria-components TooltipTrigger + Tooltip in the dark
81
+ * tooltip style. The child must be a focusable element so the tooltip is
82
+ * reachable by keyboard (RAC requirement), unless `triggerRef` provides the
83
+ * anchor and the caller manages open state and keyboard access itself.
53
84
  */
54
85
  export const Tooltip = ({
55
86
  label,
@@ -58,10 +89,17 @@ export const Tooltip = ({
58
89
  hasArrow,
59
90
  isOpen,
60
91
  triggerRef,
61
- delay = 0,
92
+ delay,
93
+ closeDelay,
94
+ shouldCloseOnPress,
62
95
  css: cssProp,
63
96
  }: TooltipProps) => (
64
- <TooltipTrigger isOpen={isOpen} delay={delay} closeDelay={0}>
97
+ <TooltipTrigger
98
+ isOpen={isOpen}
99
+ delay={delay}
100
+ closeDelay={closeDelay}
101
+ shouldCloseOnPress={shouldCloseOnPress}
102
+ >
65
103
  {children}
66
104
  <RACTooltip
67
105
  triggerRef={triggerRef}
@@ -0,0 +1,229 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import {
7
+ ReactNode,
8
+ useCallback,
9
+ useContext,
10
+ useEffect,
11
+ useId,
12
+ useRef,
13
+ } from "react";
14
+ import { TooltipTriggerStateContext } from "react-aria-components";
15
+ import { css } from "styled-system/css";
16
+ import { SystemStyleObject } from "styled-system/types";
17
+ import { Button } from "./Button";
18
+ import { Tooltip, TooltipProps } from "./Tooltip";
19
+ import { VisuallyHidden } from "./VisuallyHidden";
20
+
21
+ // A tooltip whose text *is* the point of the control — an information affordance
22
+ // beside a heading, say — rather than a hint about what a button does. That
23
+ // difference drives everything here, because react-aria's tooltips are built for
24
+ // the second case:
25
+ //
26
+ // - They never open on press, since a tooltip isn't a touch pattern. Sighted
27
+ // touch users would have no way in, so this toggles on press.
28
+ // - Their text is associated with the trigger only while open, so touch screen
29
+ // readers (iPadOS VoiceOver, TalkBack) never reach it. The same text is
30
+ // therefore always present on a visually hidden node, named or described from
31
+ // the button. The visible tooltip is aria-hidden to avoid double announcement.
32
+ // - Any key press dismisses them (see Tooltip's shouldCloseOnPress), which for
33
+ // this pattern means a keyboard user can dismiss but never re-open.
34
+ //
35
+ // Everything else is left to react-aria: it opens on hover and on keyboard
36
+ // focus, closes on Escape without disturbing a surrounding dialog, and keeps
37
+ // only one tooltip open at a time across the whole document. Hovering the
38
+ // tooltip to keep it open relies on Tooltip's non-zero close delay, so don't
39
+ // pass `closeDelay={0}` through to it.
40
+
41
+ // How far outside the tooltip the pointer still counts as on it, covering the
42
+ // trigger/tooltip gap and the arrow.
43
+ const pointerMarginPx = 12;
44
+
45
+ const triggerStyle: SystemStyleObject = {
46
+ // The button recipe's size variants set a height and horizontal padding for
47
+ // text buttons; shrink to the glyph so the focus ring is an even circle
48
+ // around it and the control doesn't stretch its row.
49
+ display: "inline-flex",
50
+ alignItems: "center",
51
+ justifyContent: "center",
52
+ height: "auto",
53
+ minHeight: "0",
54
+ minWidth: "0",
55
+ padding: "0",
56
+ lineHeight: "1",
57
+ cursor: "pointer",
58
+ borderRadius: "50%",
59
+ _focusVisible: { focusShadow: "outline" },
60
+ };
61
+
62
+ export interface TooltipButtonProps {
63
+ /**
64
+ * Tooltip body. Also the button's accessible name, or its description when
65
+ * `aria-label` is given.
66
+ */
67
+ label: ReactNode;
68
+ /** Button content, typically an `Icon`. */
69
+ children: ReactNode;
70
+ /**
71
+ * Short accessible name for the button, e.g. "Live graph". Recommended when
72
+ * `label` runs to more than a few words: without it the whole body becomes
73
+ * the button's name, which a screen reader reads out in full.
74
+ */
75
+ "aria-label"?: string;
76
+ placement?: TooltipProps["placement"];
77
+ hasArrow?: boolean;
78
+ /** Style overrides for the tooltip, e.g. padding for a multi-line body. */
79
+ css?: SystemStyleObject;
80
+ /** Style overrides for the button. */
81
+ triggerCss?: SystemStyleObject;
82
+ }
83
+
84
+ /**
85
+ * TooltipButton — a small button, usually an icon, whose tooltip carries
86
+ * information the user needs rather than a hint about an action.
87
+ *
88
+ * Unlike a bare `Tooltip` it works by pointer, keyboard and touch, and its text
89
+ * reaches screen readers on every platform. Use it for an information icon
90
+ * beside a heading or a "partially supported" marker; use `Tooltip` for a hint
91
+ * on a button that does something else.
92
+ *
93
+ * Open question: react-spectrum makes this pattern a popover
94
+ * (`ContextualHelp`), not a tooltip, which would remove the hidden copy of the
95
+ * body and the pointer-geometry keep-alive below rather than work around them.
96
+ * Tracked as microbit-foundation/ui#63, which would deprecate this component;
97
+ * prefer that direction over extending it.
98
+ */
99
+ export const TooltipButton = ({
100
+ label,
101
+ children,
102
+ "aria-label": ariaLabel,
103
+ placement,
104
+ hasArrow,
105
+ css: cssProp,
106
+ triggerCss,
107
+ }: TooltipButtonProps) => {
108
+ const textId = useId();
109
+ const tooltipBodyId = useId();
110
+ return (
111
+ <Tooltip
112
+ label={
113
+ <div id={tooltipBodyId} aria-hidden={true}>
114
+ {label}
115
+ </div>
116
+ }
117
+ placement={placement}
118
+ hasArrow={hasArrow}
119
+ css={cssProp}
120
+ // The tooltip is this button's whole explanation — an icon with a 1.5s
121
+ // wait before anything appears reads as broken — so opt out of the warmup
122
+ // the labelled controls want.
123
+ delay={0}
124
+ shouldCloseOnPress={false}
125
+ >
126
+ <span className={css({ display: "flex" })}>
127
+ <TooltipButtonTrigger
128
+ aria-label={ariaLabel}
129
+ textId={textId}
130
+ tooltipBodyId={tooltipBodyId}
131
+ css={triggerCss}
132
+ >
133
+ {children}
134
+ </TooltipButtonTrigger>
135
+ <VisuallyHidden as="div" id={textId} aria-hidden={true}>
136
+ {label}
137
+ </VisuallyHidden>
138
+ </span>
139
+ </Tooltip>
140
+ );
141
+ };
142
+
143
+ interface TooltipButtonTriggerProps {
144
+ children: ReactNode;
145
+ "aria-label"?: string;
146
+ /** Visually hidden copy of the body, naming or describing the button. */
147
+ textId: string;
148
+ /** The body inside the visible tooltip, used to find it in the document. */
149
+ tooltipBodyId: string;
150
+ css?: SystemStyleObject;
151
+ }
152
+
153
+ /**
154
+ * The button itself, split out so it can read the tooltip's state from context.
155
+ * Being a RAC component it registers itself as the tooltip's trigger — hover,
156
+ * focus and positioning all follow from that, even nested inside the span.
157
+ */
158
+ const TooltipButtonTrigger = ({
159
+ children,
160
+ "aria-label": ariaLabel,
161
+ textId,
162
+ tooltipBodyId,
163
+ css: cssProp,
164
+ }: TooltipButtonTriggerProps) => {
165
+ const state = useContext(TooltipTriggerStateContext);
166
+ const ref = useRef<HTMLButtonElement>(null);
167
+ const handlePress = useCallback(() => {
168
+ if (state?.isOpen) {
169
+ state.close(true);
170
+ } else {
171
+ state?.open(true);
172
+ }
173
+ }, [state]);
174
+ // Hovering the tooltip keeps it open, so it can be read at magnification
175
+ // (WCAG 1.4.13). react-aria does that by re-opening on hover, which fails
176
+ // when the tooltip is portalled into a container a modal has marked inert:
177
+ // it is painted but can never be the target of a mouse event. Pointer
178
+ // geometry works either way — open() clears the pending close.
179
+ //
180
+ // Leaving the tooltip has to close it here too. The trigger's own hover-end
181
+ // fired long ago, when the pointer set off across the gap, so nothing else
182
+ // will. Not while the trigger is hovered or focused, though: those are
183
+ // react-aria's own reasons to be open, and it will close on its own terms.
184
+ const isOpen = state?.isOpen;
185
+ useEffect(() => {
186
+ if (!isOpen) {
187
+ return;
188
+ }
189
+ const listener = (e: MouseEvent) => {
190
+ const rect = document
191
+ .getElementById(tooltipBodyId)
192
+ ?.closest('[role="tooltip"]')
193
+ ?.getBoundingClientRect();
194
+ const onTooltip =
195
+ !!rect &&
196
+ e.clientX >= rect.left - pointerMarginPx &&
197
+ e.clientX <= rect.right + pointerMarginPx &&
198
+ e.clientY >= rect.top - pointerMarginPx &&
199
+ e.clientY <= rect.bottom + pointerMarginPx;
200
+ if (onTooltip) {
201
+ state?.open(true);
202
+ } else if (
203
+ ref.current !== document.activeElement &&
204
+ !ref.current?.matches(":hover")
205
+ ) {
206
+ state?.close();
207
+ }
208
+ };
209
+ document.addEventListener("mousemove", listener);
210
+ return () => document.removeEventListener("mousemove", listener);
211
+ }, [isOpen, state, tooltipBodyId]);
212
+ return (
213
+ <Button
214
+ ref={ref}
215
+ variant="unstyled"
216
+ aria-label={ariaLabel}
217
+ // Without a short name the body is the name; with one it is the
218
+ // description. react-aria overwrites aria-describedby with the visible
219
+ // tooltip's id while open, and that copy is aria-hidden so announces
220
+ // nothing; closed — the state a touch screen reader is in — this applies.
221
+ aria-labelledby={ariaLabel ? undefined : textId}
222
+ aria-describedby={ariaLabel ? textId : undefined}
223
+ onPress={handlePress}
224
+ css={{ ...triggerStyle, ...cssProp }}
225
+ >
226
+ {children}
227
+ </Button>
228
+ );
229
+ };
@@ -7,8 +7,8 @@ import { useEffect, useRef } from "react";
7
7
 
8
8
  /**
9
9
  * Runs its callback when unmounted. RAC keeps an overlay tree mounted until
10
- * the exit transition finishes, so mounting this inside a Modal/Drawer gives
11
- * the equivalent of Chakra's `onCloseComplete`. Internal to shared-ui.
10
+ * the exit transition finishes, so mounting this inside the overlay
11
+ * implements Modal/Drawer's `onCloseComplete`. Internal to shared-ui.
12
12
  */
13
13
  export const UnmountCallback = ({ callback }: { callback?: () => void }) => {
14
14
  const ref = useRef(callback);
@@ -7,7 +7,7 @@ import { styled } from "styled-system/jsx";
7
7
 
8
8
  /**
9
9
  * VisuallyHidden — screen-reader-only content (Panda's `srOnly` utility).
10
- * Renders a span like Chakra's; use `as="div"` for block children.
10
+ * Renders a span; use `as="div"` for block children.
11
11
  */
12
12
  export const VisuallyHidden = styled("span", {
13
13
  base: { srOnly: true },