@microbit/ui 0.1.0-alpha.20 → 0.1.0-alpha.22
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/package.json +1 -1
- package/src/Modal.recipe.ts +7 -1
- package/src/Modal.tsx +6 -0
- package/src/NumberField.recipe.ts +17 -1
- package/src/ProgressBar.tsx +0 -1
- package/src/Toast.tsx +83 -17
- package/src/Tooltip.tsx +43 -3
- package/src/TooltipButton.tsx +230 -0
- package/src/base-preset.ts +43 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@microbit/ui",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.22",
|
|
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.recipe.ts
CHANGED
|
@@ -54,7 +54,13 @@ export const dialog = defineSlotRecipe({
|
|
|
54
54
|
overlay: {
|
|
55
55
|
position: "fixed",
|
|
56
56
|
inset: 0,
|
|
57
|
-
|
|
57
|
+
// 100vw, not 100%: react-aria's scroll lock reserves the root
|
|
58
|
+
// scrollbar gutter (scrollbar-gutter: stable), which narrows the
|
|
59
|
+
// containing block for fixed elements — 100% leaves an uncovered
|
|
60
|
+
// strip where the page scrollbar was. Viewport units span the
|
|
61
|
+
// reserved gutter, so the backdrop (and a full-size dialog) reach
|
|
62
|
+
// the real viewport edge.
|
|
63
|
+
w: "100vw",
|
|
58
64
|
h: "100%",
|
|
59
65
|
bg: "blackAlpha.600",
|
|
60
66
|
zIndex: "modal",
|
package/src/Modal.tsx
CHANGED
|
@@ -195,6 +195,12 @@ export const Modal = ({
|
|
|
195
195
|
}}
|
|
196
196
|
isDismissable={isDismissable}
|
|
197
197
|
isKeyboardDismissDisabled={isKeyboardDismissDisabled}
|
|
198
|
+
// Marker for the html:has() rule in base-preset.ts that releases the
|
|
199
|
+
// scroll lock's reserved scrollbar gutter while a full-size dialog is
|
|
200
|
+
// open: the reserved strip is a hit-testing dead zone (clicks fall
|
|
201
|
+
// through to the root and read as outside-dismissal), and the page
|
|
202
|
+
// reflowing behind an opaque full-screen dialog is invisible.
|
|
203
|
+
data-fullsize={size === "full" || undefined}
|
|
198
204
|
className={cx(
|
|
199
205
|
slots.overlay,
|
|
200
206
|
motionlessClass,
|
|
@@ -54,10 +54,18 @@ export const numberField = defineSlotRecipe({
|
|
|
54
54
|
borderColor: "gray.200",
|
|
55
55
|
transitionProperty: "background",
|
|
56
56
|
transitionDuration: "ultra-fast",
|
|
57
|
+
// Follow the input's corners, less the 1px border the stepper is inset
|
|
58
|
+
// by, so the hover and pressed fills curve away with the border instead
|
|
59
|
+
// of squaring off over its arc. Radii track the input recipe's size
|
|
60
|
+
// scale, so the `sm` variant restates them.
|
|
61
|
+
"&:first-child": {
|
|
62
|
+
borderStartEndRadius: "calc(token(radii.md) - 1px)",
|
|
63
|
+
},
|
|
57
64
|
"&:last-child": {
|
|
58
65
|
borderTop: "1px solid",
|
|
59
66
|
borderTopColor: "gray.200",
|
|
60
67
|
marginTop: "-1px",
|
|
68
|
+
borderEndEndRadius: "calc(token(radii.md) - 1px)",
|
|
61
69
|
},
|
|
62
70
|
"&[data-hovered]": { bg: "gray.100" },
|
|
63
71
|
"&[data-pressed]": { bg: "gray.200" },
|
|
@@ -75,7 +83,15 @@ export const numberField = defineSlotRecipe({
|
|
|
75
83
|
},
|
|
76
84
|
md: {},
|
|
77
85
|
sm: {
|
|
78
|
-
stepperButton: {
|
|
86
|
+
stepperButton: {
|
|
87
|
+
fontSize: "calc(token(fontSizes.sm) * 0.75)",
|
|
88
|
+
"&:first-child": {
|
|
89
|
+
borderStartEndRadius: "calc(token(radii.sm) - 1px)",
|
|
90
|
+
},
|
|
91
|
+
"&:last-child": {
|
|
92
|
+
borderEndEndRadius: "calc(token(radii.sm) - 1px)",
|
|
93
|
+
},
|
|
94
|
+
},
|
|
79
95
|
},
|
|
80
96
|
},
|
|
81
97
|
},
|
package/src/ProgressBar.tsx
CHANGED
package/src/Toast.tsx
CHANGED
|
@@ -28,7 +28,7 @@ import { VisuallyHidden } from "./VisuallyHidden";
|
|
|
28
28
|
export type ToastStatus = "info" | "success" | "warning" | "error";
|
|
29
29
|
|
|
30
30
|
export interface ToastContent {
|
|
31
|
-
/** Dedup key: adding a toast whose id is already
|
|
31
|
+
/** Dedup key: adding a toast whose id is already queued is a no-op. */
|
|
32
32
|
id?: string;
|
|
33
33
|
title?: ReactNode;
|
|
34
34
|
description?: ReactNode;
|
|
@@ -36,13 +36,54 @@ export interface ToastContent {
|
|
|
36
36
|
isClosable?: boolean;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Enter/exit/reflow animation: queue updates run inside a view transition
|
|
40
|
+
// (react-aria's supported mechanism — toasts unmount synchronously, so CSS
|
|
41
|
+
// transitions on the element can't animate the exit). The keyframes and the
|
|
42
|
+
// ::view-transition rules live in base-preset.ts, scoped by this class,
|
|
43
|
+
// which marks the transition as toast-initiated while it runs: the rules
|
|
44
|
+
// select entering/exiting groups with `(*)`, avoiding view-transition-class
|
|
45
|
+
// (needs Safari 18.2/Chrome 125 vs 18.0/111 for the API itself), and the
|
|
46
|
+
// scoping keeps them — and the pointer-events override — away from any view
|
|
47
|
+
// transitions the app runs. Browsers without the API and reduced-motion
|
|
48
|
+
// users get the bare update.
|
|
49
|
+
const TRANSITION_CLASS = "microbit-ui-toast-transition";
|
|
50
|
+
let activeTransitions = 0;
|
|
51
|
+
const wrapUpdate = (fn: () => void) => {
|
|
52
|
+
if (
|
|
53
|
+
typeof document !== "undefined" &&
|
|
54
|
+
document.startViewTransition &&
|
|
55
|
+
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
56
|
+
) {
|
|
57
|
+
activeTransitions++;
|
|
58
|
+
document.documentElement.classList.add(TRANSITION_CLASS);
|
|
59
|
+
const transition = document.startViewTransition(fn);
|
|
60
|
+
transition.finished.finally(() => {
|
|
61
|
+
if (--activeTransitions === 0) {
|
|
62
|
+
document.documentElement.classList.remove(TRANSITION_CLASS);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
} else {
|
|
66
|
+
fn();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
39
70
|
// Module-level queue shared by useToast() and the <ToastProvider/> region.
|
|
40
71
|
// (RAC's Toast API is still flagged UNSTABLE_*; the surface is small and behind
|
|
41
72
|
// this module, so a swap to a custom queue later is contained.)
|
|
42
73
|
export const toastQueue = new RACToastQueue<ToastContent>({
|
|
43
74
|
maxVisibleToasts: 5,
|
|
75
|
+
wrapUpdate,
|
|
44
76
|
});
|
|
45
77
|
|
|
78
|
+
// Index of our ids to the queue's own keys. The queue only exposes its
|
|
79
|
+
// visible slice — the newest `maxVisibleToasts` — so ids can't be resolved by
|
|
80
|
+
// scanning it: once newer toasts arrive an older one is still queued but out
|
|
81
|
+
// of sight, and dedup would let a second copy through while update() added
|
|
82
|
+
// rather than replaced. react-aria's per-toast `onClose` keeps this honest
|
|
83
|
+
// however a toast goes (timeout, close button, or update). clear() doesn't
|
|
84
|
+
// call onClose, so closeAll empties both.
|
|
85
|
+
const keysById = new Map<string, string>();
|
|
86
|
+
|
|
46
87
|
// Status icon matching Chakra's AlertIcon (filled glyphs, coloured by the
|
|
47
88
|
// toast foreground = white here). Warning is a triangle, error a circle, as
|
|
48
89
|
// in Chakra — the glyph must distinguish them because the colours alone
|
|
@@ -83,7 +124,13 @@ export const ToastProvider = () => {
|
|
|
83
124
|
{({ toast }) => {
|
|
84
125
|
const status = toast.content.status ?? "info";
|
|
85
126
|
return (
|
|
86
|
-
<RACToast
|
|
127
|
+
<RACToast
|
|
128
|
+
toast={toast}
|
|
129
|
+
className={toastRecipe({ status }).root}
|
|
130
|
+
// A unique view-transition-name per toast creates its snapshot
|
|
131
|
+
// group and lets old/new pair up across the transition.
|
|
132
|
+
style={{ viewTransitionName: toast.key }}
|
|
133
|
+
>
|
|
87
134
|
<Icon as={statusIcon[status]} className={slots.icon} aria-hidden />
|
|
88
135
|
<RACToastContent>
|
|
89
136
|
{/* Colour and icon are the only visible status signals; say it
|
|
@@ -128,14 +175,18 @@ export interface ToastOptions extends ToastContent {
|
|
|
128
175
|
|
|
129
176
|
export interface ToastFn {
|
|
130
177
|
(options: ToastOptions): void;
|
|
131
|
-
/**
|
|
178
|
+
/**
|
|
179
|
+
* Whether a toast with this id is still queued — displayed, or waiting
|
|
180
|
+
* behind newer toasts for its turn.
|
|
181
|
+
*/
|
|
132
182
|
isActive(id: string): boolean;
|
|
133
183
|
/**
|
|
134
|
-
* Replace a
|
|
135
|
-
* re-added, so unlike Chakra it re-animates
|
|
184
|
+
* Replace a queued toast's content (Chakra's toast.update). The toast is
|
|
185
|
+
* re-added, so unlike Chakra it re-animates, restarts any timeout, and
|
|
186
|
+
* takes its place at the front of the queue.
|
|
136
187
|
*/
|
|
137
188
|
update(id: string, options: ToastOptions): void;
|
|
138
|
-
/** Dismiss
|
|
189
|
+
/** Dismiss every toast, queued as well as displayed (Chakra's toast.closeAll). */
|
|
139
190
|
closeAll(): void;
|
|
140
191
|
}
|
|
141
192
|
|
|
@@ -146,8 +197,7 @@ export interface ToastFn {
|
|
|
146
197
|
*/
|
|
147
198
|
export const useToast = (): ToastFn =>
|
|
148
199
|
useMemo(() => {
|
|
149
|
-
const isActive = (id: string) =>
|
|
150
|
-
toastQueue.visibleToasts.some((t) => t.content.id === id);
|
|
200
|
+
const isActive = (id: string) => keysById.has(id);
|
|
151
201
|
const add = ({
|
|
152
202
|
id,
|
|
153
203
|
title,
|
|
@@ -160,7 +210,7 @@ export const useToast = (): ToastFn =>
|
|
|
160
210
|
if (id && isActive(id)) {
|
|
161
211
|
return;
|
|
162
212
|
}
|
|
163
|
-
toastQueue.add(
|
|
213
|
+
const key = toastQueue.add(
|
|
164
214
|
{
|
|
165
215
|
id,
|
|
166
216
|
title,
|
|
@@ -168,21 +218,37 @@ export const useToast = (): ToastFn =>
|
|
|
168
218
|
status,
|
|
169
219
|
isClosable: isClosable || persistent,
|
|
170
220
|
},
|
|
171
|
-
{
|
|
221
|
+
{
|
|
222
|
+
timeout: persistent ? undefined : duration ?? 5000,
|
|
223
|
+
onClose: id
|
|
224
|
+
? // Only our own entry is ours to drop: an id reused after this
|
|
225
|
+
// toast closed belongs to the later add.
|
|
226
|
+
() => {
|
|
227
|
+
if (keysById.get(id) === key) {
|
|
228
|
+
keysById.delete(id);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
: undefined,
|
|
232
|
+
},
|
|
172
233
|
);
|
|
234
|
+
if (id) {
|
|
235
|
+
keysById.set(id, key);
|
|
236
|
+
}
|
|
173
237
|
};
|
|
174
238
|
const update = (id: string, options: ToastOptions) => {
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (existing) {
|
|
179
|
-
toastQueue.close(existing.key);
|
|
239
|
+
const key = keysById.get(id);
|
|
240
|
+
if (key !== undefined) {
|
|
241
|
+
toastQueue.close(key);
|
|
180
242
|
}
|
|
181
243
|
add({ ...options, id });
|
|
182
244
|
};
|
|
245
|
+
// clear() empties the whole queue, including the toasts held back by
|
|
246
|
+
// maxVisibleToasts. Closing the visible ones one by one would only
|
|
247
|
+
// promote the queued ones into view. It's also a single update, so the
|
|
248
|
+
// whole set exits in one view transition.
|
|
183
249
|
const closeAll = () => {
|
|
184
|
-
|
|
185
|
-
|
|
250
|
+
keysById.clear();
|
|
251
|
+
toastQueue.clear();
|
|
186
252
|
};
|
|
187
253
|
return Object.assign(add, { isActive, update, closeAll });
|
|
188
254
|
}, []);
|
package/src/Tooltip.tsx
CHANGED
|
@@ -40,8 +40,41 @@ 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
|
-
/**
|
|
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. Chakra closed
|
|
64
|
+
* on mouse-out and we matched that at first; a tooltip that vanishes as you
|
|
65
|
+
* reach for it is not worth the parity. Pass 0 where the delay is wrong for a
|
|
66
|
+
* particular control.
|
|
67
|
+
*/
|
|
68
|
+
closeDelay?: number;
|
|
69
|
+
/**
|
|
70
|
+
* Whether pressing the trigger closes the tooltip (RAC default true).
|
|
71
|
+
*
|
|
72
|
+
* react-aria binds this to keydown as well as pointerdown, so with the
|
|
73
|
+
* default *any* key press dismisses the tooltip and only hover or focus
|
|
74
|
+
* brings it back. Pass false where the tooltip's text is the point of the
|
|
75
|
+
* control rather than a hint about an action.
|
|
76
|
+
*/
|
|
77
|
+
shouldCloseOnPress?: boolean;
|
|
45
78
|
css?: SystemStyleObject;
|
|
46
79
|
}
|
|
47
80
|
|
|
@@ -58,10 +91,17 @@ export const Tooltip = ({
|
|
|
58
91
|
hasArrow,
|
|
59
92
|
isOpen,
|
|
60
93
|
triggerRef,
|
|
61
|
-
delay
|
|
94
|
+
delay,
|
|
95
|
+
closeDelay,
|
|
96
|
+
shouldCloseOnPress,
|
|
62
97
|
css: cssProp,
|
|
63
98
|
}: TooltipProps) => (
|
|
64
|
-
<TooltipTrigger
|
|
99
|
+
<TooltipTrigger
|
|
100
|
+
isOpen={isOpen}
|
|
101
|
+
delay={delay}
|
|
102
|
+
closeDelay={closeDelay}
|
|
103
|
+
shouldCloseOnPress={shouldCloseOnPress}
|
|
104
|
+
>
|
|
65
105
|
{children}
|
|
66
106
|
<RACTooltip
|
|
67
107
|
triggerRef={triggerRef}
|
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
* see "Open across the completed migrations" in docs/migration-playbook.md
|
|
98
|
+
* before extending it.
|
|
99
|
+
*/
|
|
100
|
+
export const TooltipButton = ({
|
|
101
|
+
label,
|
|
102
|
+
children,
|
|
103
|
+
"aria-label": ariaLabel,
|
|
104
|
+
placement,
|
|
105
|
+
hasArrow,
|
|
106
|
+
css: cssProp,
|
|
107
|
+
triggerCss,
|
|
108
|
+
}: TooltipButtonProps) => {
|
|
109
|
+
const textId = useId();
|
|
110
|
+
const tooltipBodyId = useId();
|
|
111
|
+
return (
|
|
112
|
+
<Tooltip
|
|
113
|
+
label={
|
|
114
|
+
<div id={tooltipBodyId} aria-hidden={true}>
|
|
115
|
+
{label}
|
|
116
|
+
</div>
|
|
117
|
+
}
|
|
118
|
+
placement={placement}
|
|
119
|
+
hasArrow={hasArrow}
|
|
120
|
+
css={cssProp}
|
|
121
|
+
// The tooltip is this button's whole explanation — an icon with a 1.5s
|
|
122
|
+
// wait before anything appears reads as broken — so opt out of the warmup
|
|
123
|
+
// the labelled controls want.
|
|
124
|
+
delay={0}
|
|
125
|
+
shouldCloseOnPress={false}
|
|
126
|
+
>
|
|
127
|
+
<span className={css({ display: "flex" })}>
|
|
128
|
+
<TooltipButtonTrigger
|
|
129
|
+
aria-label={ariaLabel}
|
|
130
|
+
textId={textId}
|
|
131
|
+
tooltipBodyId={tooltipBodyId}
|
|
132
|
+
css={triggerCss}
|
|
133
|
+
>
|
|
134
|
+
{children}
|
|
135
|
+
</TooltipButtonTrigger>
|
|
136
|
+
<VisuallyHidden as="div" id={textId} aria-hidden={true}>
|
|
137
|
+
{label}
|
|
138
|
+
</VisuallyHidden>
|
|
139
|
+
</span>
|
|
140
|
+
</Tooltip>
|
|
141
|
+
);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
interface TooltipButtonTriggerProps {
|
|
145
|
+
children: ReactNode;
|
|
146
|
+
"aria-label"?: string;
|
|
147
|
+
/** Visually hidden copy of the body, naming or describing the button. */
|
|
148
|
+
textId: string;
|
|
149
|
+
/** The body inside the visible tooltip, used to find it in the document. */
|
|
150
|
+
tooltipBodyId: string;
|
|
151
|
+
css?: SystemStyleObject;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The button itself, split out so it can read the tooltip's state from context.
|
|
156
|
+
* Being a RAC component it registers itself as the tooltip's trigger — hover,
|
|
157
|
+
* focus and positioning all follow from that, even nested inside the span.
|
|
158
|
+
*/
|
|
159
|
+
const TooltipButtonTrigger = ({
|
|
160
|
+
children,
|
|
161
|
+
"aria-label": ariaLabel,
|
|
162
|
+
textId,
|
|
163
|
+
tooltipBodyId,
|
|
164
|
+
css: cssProp,
|
|
165
|
+
}: TooltipButtonTriggerProps) => {
|
|
166
|
+
const state = useContext(TooltipTriggerStateContext);
|
|
167
|
+
const ref = useRef<HTMLButtonElement>(null);
|
|
168
|
+
const handlePress = useCallback(() => {
|
|
169
|
+
if (state?.isOpen) {
|
|
170
|
+
state.close(true);
|
|
171
|
+
} else {
|
|
172
|
+
state?.open(true);
|
|
173
|
+
}
|
|
174
|
+
}, [state]);
|
|
175
|
+
// Hovering the tooltip keeps it open, so it can be read at magnification
|
|
176
|
+
// (WCAG 1.4.13). react-aria does that by re-opening on hover, which fails
|
|
177
|
+
// when the tooltip is portalled into a container a modal has marked inert:
|
|
178
|
+
// it is painted but can never be the target of a mouse event. Pointer
|
|
179
|
+
// geometry works either way — open() clears the pending close.
|
|
180
|
+
//
|
|
181
|
+
// Leaving the tooltip has to close it here too. The trigger's own hover-end
|
|
182
|
+
// fired long ago, when the pointer set off across the gap, so nothing else
|
|
183
|
+
// will. Not while the trigger is hovered or focused, though: those are
|
|
184
|
+
// react-aria's own reasons to be open, and it will close on its own terms.
|
|
185
|
+
const isOpen = state?.isOpen;
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (!isOpen) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const listener = (e: MouseEvent) => {
|
|
191
|
+
const rect = document
|
|
192
|
+
.getElementById(tooltipBodyId)
|
|
193
|
+
?.closest('[role="tooltip"]')
|
|
194
|
+
?.getBoundingClientRect();
|
|
195
|
+
const onTooltip =
|
|
196
|
+
!!rect &&
|
|
197
|
+
e.clientX >= rect.left - pointerMarginPx &&
|
|
198
|
+
e.clientX <= rect.right + pointerMarginPx &&
|
|
199
|
+
e.clientY >= rect.top - pointerMarginPx &&
|
|
200
|
+
e.clientY <= rect.bottom + pointerMarginPx;
|
|
201
|
+
if (onTooltip) {
|
|
202
|
+
state?.open(true);
|
|
203
|
+
} else if (
|
|
204
|
+
ref.current !== document.activeElement &&
|
|
205
|
+
!ref.current?.matches(":hover")
|
|
206
|
+
) {
|
|
207
|
+
state?.close();
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
document.addEventListener("mousemove", listener);
|
|
211
|
+
return () => document.removeEventListener("mousemove", listener);
|
|
212
|
+
}, [isOpen, state, tooltipBodyId]);
|
|
213
|
+
return (
|
|
214
|
+
<Button
|
|
215
|
+
ref={ref}
|
|
216
|
+
variant="unstyled"
|
|
217
|
+
aria-label={ariaLabel}
|
|
218
|
+
// Without a short name the body is the name; with one it is the
|
|
219
|
+
// description. react-aria overwrites aria-describedby with the visible
|
|
220
|
+
// tooltip's id while open, and that copy is aria-hidden so announces
|
|
221
|
+
// nothing; closed — the state a touch screen reader is in — this applies.
|
|
222
|
+
aria-labelledby={ariaLabel ? undefined : textId}
|
|
223
|
+
aria-describedby={ariaLabel ? textId : undefined}
|
|
224
|
+
onPress={handlePress}
|
|
225
|
+
css={{ ...triggerStyle, ...cssProp }}
|
|
226
|
+
>
|
|
227
|
+
{children}
|
|
228
|
+
</Button>
|
|
229
|
+
);
|
|
230
|
+
};
|
package/src/base-preset.ts
CHANGED
|
@@ -125,6 +125,15 @@ export const basePreset = definePreset({
|
|
|
125
125
|
background: "var(--skeleton-end-color)",
|
|
126
126
|
},
|
|
127
127
|
},
|
|
128
|
+
// Toast enter/exit, played on the view-transition snapshots (see the
|
|
129
|
+
// ::view-transition rules in globalCss). Chakra-ballpark timings: fade
|
|
130
|
+
// + short slide in, quicker fade + shrink out.
|
|
131
|
+
toastSlideIn: {
|
|
132
|
+
from: { opacity: 0, transform: "translateY(-24px)" },
|
|
133
|
+
},
|
|
134
|
+
toastSlideOut: {
|
|
135
|
+
to: { opacity: 0, transform: "scale(0.85)" },
|
|
136
|
+
},
|
|
128
137
|
},
|
|
129
138
|
tokens: {
|
|
130
139
|
colors: {
|
|
@@ -314,6 +323,40 @@ export const basePreset = definePreset({
|
|
|
314
323
|
"h1, h2, h3, h4, h5, h6": {
|
|
315
324
|
textWrap: "wrap",
|
|
316
325
|
},
|
|
326
|
+
// While a full-size dialog is open (the Modal stamps data-fullsize on
|
|
327
|
+
// its overlay), release the scrollbar gutter that react-aria's scroll
|
|
328
|
+
// lock reserves on the root. The reserved strip is scrollbar chrome to
|
|
329
|
+
// hit-testing — elementFromPoint returns null there, so clicks fall
|
|
330
|
+
// through to the root and dismiss the dialog, and controls near the
|
|
331
|
+
// right edge lose part of their target. With the page fully covered,
|
|
332
|
+
// the reflow this causes is invisible. !important: react-aria sets the
|
|
333
|
+
// reservation as a non-important inline style.
|
|
334
|
+
"html:has([data-fullsize])": {
|
|
335
|
+
scrollbarGutter: "auto !important",
|
|
336
|
+
},
|
|
337
|
+
// Toast enter/exit (the ToastQueue wraps updates in
|
|
338
|
+
// document.startViewTransition — see Toast.tsx, which also stamps the
|
|
339
|
+
// scoping class on <html> while its transitions run). Timings sit in
|
|
340
|
+
// Chakra's ballpark: 0.4s fade+slide in, 0.2s fade+shrink out; the
|
|
341
|
+
// stack reflow comes from the default group animation. `(*)` +
|
|
342
|
+
// `:only-child` matches exactly the entering/exiting toast groups: the
|
|
343
|
+
// root snapshot always has both old and new children, and toasts are
|
|
344
|
+
// the only named groups during a toast transition. The snapshot
|
|
345
|
+
// overlay must not eat clicks while a toast animates, hence
|
|
346
|
+
// pointer-events, scoped likewise.
|
|
347
|
+
"html.microbit-ui-toast-transition::view-transition": {
|
|
348
|
+
pointerEvents: "none",
|
|
349
|
+
},
|
|
350
|
+
// `both` fill: the snapshots must hold the keyframes' end states for
|
|
351
|
+
// however long the rest of the transition (e.g. the 0.25s default group
|
|
352
|
+
// animation) outlives them, or they snap back to full size/opacity for
|
|
353
|
+
// the remainder.
|
|
354
|
+
"html.microbit-ui-toast-transition::view-transition-new(*):only-child": {
|
|
355
|
+
animation: "toastSlideIn 0.4s cubic-bezier(0.4, 0, 0.2, 1) both",
|
|
356
|
+
},
|
|
357
|
+
"html.microbit-ui-toast-transition::view-transition-old(*):only-child": {
|
|
358
|
+
animation: "toastSlideOut 0.2s cubic-bezier(0.4, 0, 1, 1) both",
|
|
359
|
+
},
|
|
317
360
|
},
|
|
318
361
|
// shared-ui components forward `variant`/`size`/etc. as runtime props to
|
|
319
362
|
// the recipe functions, so Panda's static analysis can't see which variants
|
package/src/index.ts
CHANGED
|
@@ -58,6 +58,7 @@ export * from "./Modal";
|
|
|
58
58
|
export * from "./PopoverArrow";
|
|
59
59
|
export * from "./SharedUIProvider";
|
|
60
60
|
export * from "./Tooltip";
|
|
61
|
+
export * from "./TooltipButton";
|
|
61
62
|
export * from "./Toast";
|
|
62
63
|
export * from "./VisuallyHidden";
|
|
63
64
|
export { useBreakpointValue } from "./hooks/useBreakpointValue";
|