@microbit/ui 0.1.0-alpha.9 → 0.1.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/LICENSE.md +8 -0
- package/README.md +171 -20
- package/lang/ui.ar.json +62 -0
- package/lang/ui.ca.json +43 -3
- package/lang/ui.cy.json +62 -0
- package/lang/ui.de.json +44 -4
- package/lang/ui.en-us.json +40 -0
- package/lang/ui.en.json +40 -0
- package/lang/ui.es-es.json +43 -3
- package/lang/ui.fr.json +44 -4
- package/lang/ui.ga-ie.json +43 -3
- package/lang/ui.it.json +62 -0
- package/lang/ui.ja.json +44 -4
- package/lang/ui.ko.json +44 -4
- package/lang/ui.lo.json +62 -0
- package/lang/ui.lol.json +40 -0
- package/lang/ui.nl.json +44 -4
- package/lang/ui.pl.json +44 -4
- package/lang/ui.pt-br.json +44 -4
- package/lang/ui.vi.json +62 -0
- package/lang/ui.zh-cn.json +44 -4
- package/lang/ui.zh-tw.json +44 -4
- package/package.json +12 -10
- package/src/Avatar.recipe.ts +191 -0
- package/src/Avatar.tsx +278 -0
- package/src/Breadcrumb.recipe.ts +45 -0
- package/src/Breadcrumb.tsx +114 -0
- package/src/Button.recipe.ts +88 -50
- package/src/Button.tsx +67 -14
- package/src/ButtonGroup.tsx +37 -15
- package/src/Card.recipe.ts +1 -2
- package/src/Card.tsx +2 -1
- package/src/Checkbox.recipe.ts +13 -12
- package/src/Checkbox.tsx +103 -34
- package/src/CheckboxGroup.tsx +70 -0
- package/src/CloseButton.tsx +3 -3
- package/src/CloseIcon.tsx +6 -4
- package/src/Code.tsx +1 -1
- package/src/Collapse.tsx +13 -14
- package/src/ComboBox.tsx +246 -0
- package/src/Divider.tsx +40 -7
- package/src/Drawer.recipe.ts +21 -10
- package/src/Drawer.tsx +3 -4
- package/src/ExternalLink.tsx +43 -0
- package/src/Fade.tsx +18 -4
- package/src/Field.recipe.ts +114 -0
- package/src/Field.tsx +187 -0
- package/src/GridList.recipe.ts +57 -0
- package/src/GridList.tsx +81 -0
- package/src/Heading.recipe.ts +20 -1
- package/src/Heading.tsx +3 -3
- package/src/Icon.tsx +23 -7
- package/src/IconButton.tsx +8 -18
- package/src/Image.tsx +1 -1
- package/src/Input.recipe.ts +30 -26
- package/src/Input.tsx +16 -7
- package/src/InputGroup.tsx +9 -9
- package/src/Kbd.tsx +1 -1
- package/src/Link.tsx +3 -3
- package/src/LinkBox.tsx +2 -3
- package/src/LinkButton.tsx +14 -13
- package/src/List.tsx +5 -7
- package/src/ListBox.recipe.ts +43 -0
- package/src/ListBox.tsx +88 -0
- package/src/Menu.recipe.ts +22 -19
- package/src/Menu.tsx +54 -28
- package/src/Modal.recipe.ts +17 -9
- package/src/Modal.tsx +123 -24
- package/src/MoreMenuButton.tsx +63 -0
- package/src/NativeSelect.tsx +37 -14
- package/src/NativeSelectField.tsx +84 -0
- package/src/NumberField.recipe.ts +56 -15
- package/src/NumberField.tsx +74 -22
- package/src/PopoverArrow.tsx +1 -2
- package/src/ProgressBar.tsx +3 -5
- package/src/Radio.recipe.ts +13 -12
- package/src/Radio.tsx +2 -29
- package/src/RadioGroup.tsx +68 -0
- package/src/Select.recipe.ts +211 -0
- package/src/Select.tsx +194 -0
- package/src/SharedUIProvider.tsx +63 -7
- package/src/Skeleton.tsx +146 -0
- package/src/Slide.tsx +2 -2
- package/src/Slider.recipe.ts +27 -16
- package/src/Slider.tsx +20 -12
- package/src/Spinner.tsx +11 -9
- package/src/Svg.tsx +2 -3
- package/src/Switch.recipe.ts +40 -21
- package/src/Switch.tsx +35 -5
- package/src/Text.recipe.ts +26 -0
- package/src/Text.tsx +6 -2
- package/src/TextField.tsx +35 -38
- package/src/Toast.recipe.ts +52 -15
- package/src/Toast.tsx +147 -48
- package/src/Tooltip.recipe.ts +32 -0
- package/src/Tooltip.tsx +68 -28
- package/src/TooltipButton.tsx +229 -0
- package/src/UnmountCallback.tsx +2 -2
- package/src/VisuallyHidden.tsx +1 -1
- package/src/base-preset.ts +323 -84
- package/src/base-tokens.ts +0 -3
- package/src/button-icon.ts +4 -5
- package/src/data-attrs.ts +16 -0
- package/src/dense-preset.ts +107 -0
- package/src/hooks/useBreakpointValue.ts +4 -4
- package/src/hooks/useClipboard.ts +5 -6
- package/src/hooks/useDisclosure.ts +31 -0
- package/src/hooks/useMediaQuery.ts +3 -4
- package/src/hooks/usePrevious.ts +1 -4
- package/src/index.ts +19 -3
- package/src/rac-locale.ts +33 -0
- package/src/system.ts +22 -1
- package/src/TextField.recipe.ts +0 -54
package/src/Toast.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* SPDX-License-Identifier: MIT
|
|
5
5
|
*/
|
|
6
|
-
import { ReactNode, useMemo } from "react";
|
|
6
|
+
import { ReactNode, useMemo, useSyncExternalStore } from "react";
|
|
7
7
|
import {
|
|
8
8
|
Button as RACButton,
|
|
9
9
|
UNSTABLE_Toast as RACToast,
|
|
@@ -23,12 +23,12 @@ 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 {
|
|
26
|
+
import { darkSurface } from "./system";
|
|
27
27
|
|
|
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,18 +36,68 @@ 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
|
+
// A transition is routinely skipped — another update starts one while
|
|
61
|
+
// this is still animating, or the tab is hidden — and the browser then
|
|
62
|
+
// rejects its promises with "AbortError: Transition was skipped". The
|
|
63
|
+
// DOM update has still run; only the animation is dropped. Settle on
|
|
64
|
+
// rejection too (`.finally()` would re-throw, surfacing routine skips
|
|
65
|
+
// as unhandled rejections in error reporting) and swallow `ready`,
|
|
66
|
+
// which some engines reject without marking handled.
|
|
67
|
+
transition.ready.catch(() => {});
|
|
68
|
+
const settle = () => {
|
|
69
|
+
if (--activeTransitions === 0) {
|
|
70
|
+
document.documentElement.classList.remove(TRANSITION_CLASS);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
transition.finished.then(settle, settle);
|
|
74
|
+
} else {
|
|
75
|
+
fn();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
39
79
|
// Module-level queue shared by useToast() and the <ToastProvider/> region.
|
|
40
80
|
// (RAC's Toast API is still flagged UNSTABLE_*; the surface is small and behind
|
|
41
81
|
// this module, so a swap to a custom queue later is contained.)
|
|
42
82
|
export const toastQueue = new RACToastQueue<ToastContent>({
|
|
43
83
|
maxVisibleToasts: 5,
|
|
84
|
+
wrapUpdate,
|
|
44
85
|
});
|
|
45
86
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
87
|
+
// Index of our ids to the queue's own keys. The queue only exposes its
|
|
88
|
+
// visible slice — the newest `maxVisibleToasts` — so ids can't be resolved by
|
|
89
|
+
// scanning it: once newer toasts arrive an older one is still queued but out
|
|
90
|
+
// of sight, and dedup would let a second copy through while update() added
|
|
91
|
+
// rather than replaced. react-aria's per-toast `onClose` keeps this honest
|
|
92
|
+
// however a toast goes (timeout, close button, or update). clear() doesn't
|
|
93
|
+
// call onClose, so closeAll empties both.
|
|
94
|
+
const keysById = new Map<string, string>();
|
|
95
|
+
|
|
96
|
+
// Status icon (filled glyphs, coloured by the toast foreground = white
|
|
97
|
+
// here). Warning is a triangle, error a circle — the glyph must distinguish
|
|
98
|
+
// them because the colours alone don't reliably. The glyph is also what
|
|
99
|
+
// carries the status for assistive tech, via its accessible name (see
|
|
100
|
+
// ToastProvider).
|
|
51
101
|
const statusIcon: Record<ToastStatus, IconType> = {
|
|
52
102
|
info: RiInformationFill,
|
|
53
103
|
success: RiCheckboxCircleFill,
|
|
@@ -55,6 +105,10 @@ const statusIcon: Record<ToastStatus, IconType> = {
|
|
|
55
105
|
error: RiErrorWarningFill,
|
|
56
106
|
};
|
|
57
107
|
|
|
108
|
+
// Module scope so useSyncExternalStore doesn't resubscribe every render.
|
|
109
|
+
const subscribeToQueue = (fn: () => void) => toastQueue.subscribe(fn);
|
|
110
|
+
const getVisibleCount = () => toastQueue.visibleToasts.length;
|
|
111
|
+
|
|
58
112
|
/**
|
|
59
113
|
* Mount once near the app root, inside the IntlProvider (the close button's
|
|
60
114
|
* label and the status announcements are react-intl messages).
|
|
@@ -63,32 +117,57 @@ const statusIcon: Record<ToastStatus, IconType> = {
|
|
|
63
117
|
export const ToastProvider = () => {
|
|
64
118
|
const intl = useIntl();
|
|
65
119
|
const slots = toastRecipe();
|
|
120
|
+
// The region's landmark label counts the visible toasts, so it has to
|
|
121
|
+
// track the queue.
|
|
122
|
+
const count = useSyncExternalStore(
|
|
123
|
+
subscribeToQueue,
|
|
124
|
+
getVisibleCount,
|
|
125
|
+
getVisibleCount,
|
|
126
|
+
);
|
|
66
127
|
return (
|
|
67
|
-
<RACToastRegion
|
|
128
|
+
<RACToastRegion
|
|
129
|
+
queue={toastQueue}
|
|
130
|
+
aria-label={intl.formatMessage(uiMessage("ui.toast-region"), { count })}
|
|
131
|
+
className={slots.region}
|
|
132
|
+
>
|
|
68
133
|
{({ toast }) => {
|
|
69
134
|
const status = toast.content.status ?? "info";
|
|
70
135
|
return (
|
|
71
|
-
<RACToast
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
136
|
+
<RACToast
|
|
137
|
+
toast={toast}
|
|
138
|
+
className={toastRecipe({ status }).root}
|
|
139
|
+
// A unique view-transition-name per toast creates its snapshot
|
|
140
|
+
// group and lets old/new pair up across the transition.
|
|
141
|
+
style={{ viewTransitionName: toast.key }}
|
|
142
|
+
>
|
|
143
|
+
<RACToastContent className={slots.content}>
|
|
144
|
+
<Icon
|
|
145
|
+
as={statusIcon[status]}
|
|
146
|
+
className={slots.icon}
|
|
147
|
+
aria-label={intl.formatMessage(
|
|
148
|
+
uiMessage(`ui.toast-status-${status}`),
|
|
149
|
+
)}
|
|
150
|
+
/>
|
|
151
|
+
<div className={slots.body}>
|
|
152
|
+
{toast.content.title && (
|
|
153
|
+
<p className={slots.title}>{toast.content.title}</p>
|
|
154
|
+
)}
|
|
155
|
+
{toast.content.description && (
|
|
156
|
+
<div className={slots.description}>
|
|
157
|
+
{toast.content.description}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
87
161
|
</RACToastContent>
|
|
88
162
|
{toast.content.isClosable && (
|
|
89
163
|
<RACButton
|
|
90
164
|
slot="close"
|
|
91
165
|
aria-label={intl.formatMessage(uiMessage("ui.close-action"))}
|
|
166
|
+
// The card is dark at every status, and no app can tag a
|
|
167
|
+
// surface the package ships. Tagged here, not on the card:
|
|
168
|
+
// the tag covers the element it sits on, whose own ring is
|
|
169
|
+
// drawn outside it, on the page.
|
|
170
|
+
{...darkSurface}
|
|
92
171
|
className={slots.closeButton}
|
|
93
172
|
>
|
|
94
173
|
<CloseIcon />
|
|
@@ -102,34 +181,39 @@ export const ToastProvider = () => {
|
|
|
102
181
|
};
|
|
103
182
|
|
|
104
183
|
export interface ToastOptions extends ToastContent {
|
|
184
|
+
/** Auto-dismiss after this many ms. Default 5000. Ignored when `persistent`. */
|
|
185
|
+
duration?: number;
|
|
105
186
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
187
|
+
* Never auto-dismiss. The close button is forced on so the toast is not
|
|
188
|
+
* permanent and unremovable.
|
|
108
189
|
*/
|
|
109
|
-
|
|
190
|
+
persistent?: boolean;
|
|
110
191
|
}
|
|
111
192
|
|
|
112
193
|
export interface ToastFn {
|
|
113
194
|
(options: ToastOptions): void;
|
|
114
|
-
/**
|
|
195
|
+
/**
|
|
196
|
+
* Whether a toast with this id is still queued — displayed, or waiting
|
|
197
|
+
* behind newer toasts for its turn.
|
|
198
|
+
*/
|
|
115
199
|
isActive(id: string): boolean;
|
|
116
200
|
/**
|
|
117
|
-
* Replace a
|
|
118
|
-
*
|
|
201
|
+
* Replace a queued toast's content. The toast is re-added: it re-animates,
|
|
202
|
+
* restarts any timeout, and takes its place at the front of the queue.
|
|
119
203
|
*/
|
|
120
204
|
update(id: string, options: ToastOptions): void;
|
|
121
|
-
/** Dismiss
|
|
205
|
+
/** Dismiss every toast, queued as well as displayed. */
|
|
122
206
|
closeAll(): void;
|
|
123
207
|
}
|
|
124
208
|
|
|
125
209
|
/**
|
|
126
|
-
* useToast — imperative toast trigger
|
|
127
|
-
* `
|
|
210
|
+
* useToast — imperative toast trigger:
|
|
211
|
+
* `toast({ title, description, status, duration })`. There is no
|
|
212
|
+
* `duration: null`; use `persistent: true` instead.
|
|
128
213
|
*/
|
|
129
214
|
export const useToast = (): ToastFn =>
|
|
130
215
|
useMemo(() => {
|
|
131
|
-
const isActive = (id: string) =>
|
|
132
|
-
toastQueue.visibleToasts.some((t) => t.content.id === id);
|
|
216
|
+
const isActive = (id: string) => keysById.has(id);
|
|
133
217
|
const add = ({
|
|
134
218
|
id,
|
|
135
219
|
title,
|
|
@@ -137,35 +221,50 @@ export const useToast = (): ToastFn =>
|
|
|
137
221
|
status,
|
|
138
222
|
isClosable,
|
|
139
223
|
duration,
|
|
224
|
+
persistent,
|
|
140
225
|
}: ToastOptions) => {
|
|
141
226
|
if (id && isActive(id)) {
|
|
142
227
|
return;
|
|
143
228
|
}
|
|
144
|
-
toastQueue.add(
|
|
229
|
+
const key = toastQueue.add(
|
|
145
230
|
{
|
|
146
231
|
id,
|
|
147
232
|
title,
|
|
148
233
|
description,
|
|
149
234
|
status,
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
235
|
+
isClosable: isClosable || persistent,
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
timeout: persistent ? undefined : duration ?? 5000,
|
|
239
|
+
onClose: id
|
|
240
|
+
? // Only our own entry is ours to drop: an id reused after this
|
|
241
|
+
// toast closed belongs to the later add.
|
|
242
|
+
() => {
|
|
243
|
+
if (keysById.get(id) === key) {
|
|
244
|
+
keysById.delete(id);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
: undefined,
|
|
153
248
|
},
|
|
154
|
-
{ timeout: duration ?? undefined },
|
|
155
249
|
);
|
|
250
|
+
if (id) {
|
|
251
|
+
keysById.set(id, key);
|
|
252
|
+
}
|
|
156
253
|
};
|
|
157
254
|
const update = (id: string, options: ToastOptions) => {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (existing) {
|
|
162
|
-
toastQueue.close(existing.key);
|
|
255
|
+
const key = keysById.get(id);
|
|
256
|
+
if (key !== undefined) {
|
|
257
|
+
toastQueue.close(key);
|
|
163
258
|
}
|
|
164
259
|
add({ ...options, id });
|
|
165
260
|
};
|
|
261
|
+
// clear() empties the whole queue, including the toasts held back by
|
|
262
|
+
// maxVisibleToasts. Closing the visible ones one by one would only
|
|
263
|
+
// promote the queued ones into view. It's also a single update, so the
|
|
264
|
+
// whole set exits in one view transition.
|
|
166
265
|
const closeAll = () => {
|
|
167
|
-
|
|
168
|
-
|
|
266
|
+
keysById.clear();
|
|
267
|
+
toastQueue.clear();
|
|
169
268
|
};
|
|
170
269
|
return Object.assign(add, { isActive, update, closeAll });
|
|
171
270
|
}, []);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import { defineRecipe } from "@pandacss/dev";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Tooltip recipe — the dark tooltip.
|
|
10
|
+
*
|
|
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 does,
|
|
13
|
+
* `fontSize: md`), and a `css` override at today's call sites would quietly
|
|
14
|
+
* not apply to tomorrow's.
|
|
15
|
+
*
|
|
16
|
+
* Registered in the base preset (base-preset.ts).
|
|
17
|
+
*/
|
|
18
|
+
export const tooltip = defineRecipe({
|
|
19
|
+
className: "tooltip",
|
|
20
|
+
base: {
|
|
21
|
+
bg: "gray.700",
|
|
22
|
+
color: "whiteAlpha.900",
|
|
23
|
+
px: "2",
|
|
24
|
+
py: "0.5",
|
|
25
|
+
borderRadius: "sm",
|
|
26
|
+
fontSize: "sm",
|
|
27
|
+
fontWeight: "medium",
|
|
28
|
+
boxShadow: "md",
|
|
29
|
+
maxW: "xs",
|
|
30
|
+
zIndex: "tooltip",
|
|
31
|
+
},
|
|
32
|
+
});
|
package/src/Tooltip.tsx
CHANGED
|
@@ -5,44 +5,46 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { ReactElement, ReactNode, RefObject } from "react";
|
|
7
7
|
import { Tooltip as RACTooltip, TooltipTrigger } from "react-aria-components";
|
|
8
|
-
import { css } from "styled-system/css";
|
|
8
|
+
import { css, cx } from "styled-system/css";
|
|
9
|
+
import { tooltip } from "styled-system/recipes";
|
|
9
10
|
import { SystemStyleObject } from "styled-system/types";
|
|
10
11
|
import { PopoverArrow } from "./PopoverArrow";
|
|
11
12
|
|
|
12
|
-
// Base as an object (not a precomputed class) so a caller's `css` override is
|
|
13
|
-
// merged into a single css() call — Panda then dedupes conflicting utilities
|
|
14
|
-
// (e.g. px/py) so overrides actually win.
|
|
15
|
-
const tooltipBase: SystemStyleObject = {
|
|
16
|
-
bg: "gray.700",
|
|
17
|
-
color: "white",
|
|
18
|
-
px: "2",
|
|
19
|
-
py: "1",
|
|
20
|
-
borderRadius: "md",
|
|
21
|
-
fontSize: "sm",
|
|
22
|
-
fontWeight: "medium",
|
|
23
|
-
boxShadow: "md",
|
|
24
|
-
maxW: "xs",
|
|
25
|
-
zIndex: "tooltip",
|
|
26
|
-
};
|
|
27
|
-
|
|
28
13
|
export interface TooltipProps {
|
|
29
14
|
/**
|
|
30
|
-
* Tooltip body
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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.
|
|
33
18
|
*/
|
|
34
19
|
label: ReactNode;
|
|
35
20
|
/** A single focusable trigger element (e.g. a Button). */
|
|
36
21
|
children: ReactElement;
|
|
22
|
+
/**
|
|
23
|
+
* Which side of the trigger the tooltip prefers. A preference, not a
|
|
24
|
+
* promise: react-aria flips it at runtime when the tooltip would leave the
|
|
25
|
+
* viewport, so pick the side that reads right rather than one that dodges
|
|
26
|
+
* an edge.
|
|
27
|
+
*
|
|
28
|
+
* `top`/`bottom` are direction-neutral and suit most tooltips.
|
|
29
|
+
* `start`/`end` follow reading order and mirror in RTL. `left`/`right` are
|
|
30
|
+
* screen sides and never mirror — reach for them only when the trigger is
|
|
31
|
+
* pinned to a physical edge.
|
|
32
|
+
*/
|
|
37
33
|
placement?:
|
|
38
34
|
| "top"
|
|
39
35
|
| "bottom"
|
|
36
|
+
| "start"
|
|
37
|
+
| "end"
|
|
40
38
|
| "left"
|
|
41
39
|
| "right"
|
|
42
40
|
| "top start"
|
|
43
41
|
| "top end"
|
|
44
42
|
| "bottom start"
|
|
45
43
|
| "bottom end"
|
|
44
|
+
| "start top"
|
|
45
|
+
| "start bottom"
|
|
46
|
+
| "end top"
|
|
47
|
+
| "end bottom"
|
|
46
48
|
| "left top"
|
|
47
49
|
| "left bottom"
|
|
48
50
|
| "right top"
|
|
@@ -55,16 +57,47 @@ export interface TooltipProps {
|
|
|
55
57
|
* the trigger (i.e. it is not a RAC component or `Focusable`).
|
|
56
58
|
*/
|
|
57
59
|
triggerRef?: RefObject<HTMLElement | null>;
|
|
58
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Hover open delay in ms, defaulting to react-aria's 1500.
|
|
62
|
+
*
|
|
63
|
+
* The delay is per bout of interest, not per control: react-aria keeps a
|
|
64
|
+
* global "warm" flag, so the first tooltip waits and every one after it opens
|
|
65
|
+
* instantly until half a second or so after the last one closes. That is what
|
|
66
|
+
* keeps a row of buttons from firing tooltips at a pointer merely crossing
|
|
67
|
+
* them.
|
|
68
|
+
*
|
|
69
|
+
* **Pass 0 where the tooltip is the label** — an icon-only button, where the
|
|
70
|
+
* text is the only explanation of the glyph and waiting for it reads as
|
|
71
|
+
* broken. Leave it alone where the control already says what it is and the
|
|
72
|
+
* tooltip adds detail.
|
|
73
|
+
*/
|
|
59
74
|
delay?: number;
|
|
75
|
+
/**
|
|
76
|
+
* Close delay in ms, defaulting to react-aria's 500.
|
|
77
|
+
*
|
|
78
|
+
* This is what makes a tooltip hoverable, as WCAG 1.4.13 asks: react-aria
|
|
79
|
+
* puts hover handlers on the tooltip that re-open it, but with an immediate
|
|
80
|
+
* close it has unmounted before the pointer can cross the gap. Pass 0 where
|
|
81
|
+
* the delay is wrong for a particular control.
|
|
82
|
+
*/
|
|
83
|
+
closeDelay?: number;
|
|
84
|
+
/**
|
|
85
|
+
* Whether pressing the trigger closes the tooltip (RAC default true).
|
|
86
|
+
*
|
|
87
|
+
* react-aria binds this to keydown as well as pointerdown, so with the
|
|
88
|
+
* default *any* key press dismisses the tooltip and only hover or focus
|
|
89
|
+
* brings it back. Pass false where the tooltip's text is the point of the
|
|
90
|
+
* control rather than a hint about an action.
|
|
91
|
+
*/
|
|
92
|
+
shouldCloseOnPress?: boolean;
|
|
60
93
|
css?: SystemStyleObject;
|
|
61
94
|
}
|
|
62
95
|
|
|
63
96
|
/**
|
|
64
|
-
* Tooltip — react-aria-components TooltipTrigger + Tooltip
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
97
|
+
* Tooltip — react-aria-components TooltipTrigger + Tooltip in the dark
|
|
98
|
+
* tooltip style. The child must be a focusable element so the tooltip is
|
|
99
|
+
* reachable by keyboard (RAC requirement), unless `triggerRef` provides the
|
|
100
|
+
* anchor and the caller manages open state and keyboard access itself.
|
|
68
101
|
*/
|
|
69
102
|
export const Tooltip = ({
|
|
70
103
|
label,
|
|
@@ -73,16 +106,23 @@ export const Tooltip = ({
|
|
|
73
106
|
hasArrow,
|
|
74
107
|
isOpen,
|
|
75
108
|
triggerRef,
|
|
76
|
-
delay
|
|
109
|
+
delay,
|
|
110
|
+
closeDelay,
|
|
111
|
+
shouldCloseOnPress,
|
|
77
112
|
css: cssProp,
|
|
78
113
|
}: TooltipProps) => (
|
|
79
|
-
<TooltipTrigger
|
|
114
|
+
<TooltipTrigger
|
|
115
|
+
isOpen={isOpen}
|
|
116
|
+
delay={delay}
|
|
117
|
+
closeDelay={closeDelay}
|
|
118
|
+
shouldCloseOnPress={shouldCloseOnPress}
|
|
119
|
+
>
|
|
80
120
|
{children}
|
|
81
121
|
<RACTooltip
|
|
82
122
|
triggerRef={triggerRef}
|
|
83
123
|
placement={placement}
|
|
84
124
|
offset={hasArrow ? 8 : 4}
|
|
85
|
-
className={
|
|
125
|
+
className={cx(tooltip(), cssProp ? css(cssProp) : undefined)}
|
|
86
126
|
>
|
|
87
127
|
{hasArrow && <PopoverArrow css={{ "& svg": { fill: "gray.700" } }} />}
|
|
88
128
|
{label}
|