@microbit/ui 0.1.0-alpha.3 → 0.1.0-alpha.30
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 +40 -0
- package/README.md +256 -26
- package/lang/ui.ca.json +43 -3
- package/lang/ui.cy.json +62 -0
- package/lang/ui.de.json +62 -0
- 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 +62 -0
- 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 +62 -0
- package/lang/ui.zh-tw.json +44 -4
- package/package.json +19 -10
- package/postcss-legacy-safari.cjs +96 -0
- package/reset.css +35 -0
- package/src/Avatar.recipe.ts +166 -0
- package/src/Avatar.tsx +278 -0
- package/src/Breadcrumb.recipe.ts +39 -0
- package/src/Breadcrumb.tsx +114 -0
- package/src/Button.recipe.ts +88 -50
- package/src/Button.tsx +64 -26
- package/src/ButtonGroup.tsx +37 -15
- package/src/Card.recipe.ts +1 -2
- package/src/Card.tsx +2 -1
- package/src/Checkbox.recipe.ts +49 -14
- package/src/Checkbox.tsx +111 -32
- package/src/CheckboxGroup.tsx +70 -0
- package/src/CloseButton.tsx +3 -3
- package/src/CloseIcon.tsx +6 -4
- package/src/Code.tsx +20 -0
- package/src/Collapse.tsx +179 -0
- package/src/ComboBox.tsx +246 -0
- package/src/Divider.tsx +72 -8
- package/src/Drawer.recipe.ts +4 -5
- package/src/Drawer.tsx +3 -4
- package/src/ExternalLink.tsx +43 -0
- package/src/Fade.tsx +62 -0
- 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 +7 -12
- package/src/Image.tsx +1 -1
- package/src/Input.recipe.ts +41 -28
- package/src/Input.tsx +23 -7
- package/src/InputGroup.tsx +21 -8
- package/src/Kbd.tsx +26 -0
- package/src/Link.tsx +3 -3
- package/src/LinkBox.tsx +2 -3
- package/src/LinkButton.tsx +81 -0
- package/src/List.tsx +8 -6
- package/src/ListBox.recipe.ts +43 -0
- package/src/ListBox.tsx +88 -0
- package/src/Menu.recipe.ts +51 -17
- package/src/Menu.tsx +117 -2
- package/src/Modal.recipe.ts +14 -9
- package/src/Modal.tsx +137 -24
- package/src/MoreMenuButton.tsx +63 -0
- package/src/NativeSelect.tsx +39 -12
- package/src/NativeSelectField.tsx +84 -0
- package/src/NumberField.recipe.ts +108 -0
- package/src/NumberField.tsx +138 -0
- package/src/PopoverArrow.tsx +19 -5
- package/src/ProgressBar.tsx +3 -5
- package/src/Radio.recipe.ts +108 -0
- package/src/Radio.tsx +62 -0
- package/src/RadioGroup.tsx +68 -0
- package/src/Select.recipe.ts +211 -0
- package/src/Select.tsx +194 -0
- package/src/SharedUIProvider.tsx +51 -7
- package/src/Skeleton.tsx +146 -0
- package/src/Slide.tsx +2 -2
- package/src/Slider.recipe.ts +24 -15
- package/src/Slider.tsx +65 -5
- package/src/Spinner.tsx +11 -9
- package/src/Svg.tsx +2 -3
- package/src/Switch.recipe.ts +58 -13
- package/src/Switch.tsx +39 -7
- package/src/Text.recipe.ts +26 -0
- package/src/Text.tsx +6 -2
- package/src/TextField.tsx +54 -40
- package/src/Toast.recipe.ts +49 -12
- package/src/Toast.tsx +151 -46
- package/src/Tooltip.recipe.ts +32 -0
- package/src/Tooltip.tsx +51 -28
- package/src/TooltipButton.tsx +229 -0
- package/src/UnmountCallback.tsx +2 -2
- package/src/VisuallyHidden.tsx +1 -1
- package/src/base-preset.ts +336 -84
- package/src/{chakra-tokens.ts → base-tokens.ts} +7 -7
- package/src/button-icon.ts +22 -0
- 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 +63 -0
- package/src/hooks/useDisclosure.ts +31 -0
- package/src/hooks/useMediaQuery.ts +27 -0
- package/src/hooks/usePrevious.ts +15 -0
- package/src/index.ts +29 -3
- package/src/rac-locale.ts +33 -0
- package/src/system.ts +22 -1
- package/src/TextField.recipe.ts +0 -54
package/src/Toast.recipe.ts
CHANGED
|
@@ -6,10 +6,9 @@
|
|
|
6
6
|
import { defineSlotRecipe } from "@pandacss/dev";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Toast slot recipe —
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* touching the recipe.
|
|
9
|
+
* Toast slot recipe — a solid alert card with white text, teal for every
|
|
10
|
+
* status except error. Status colours come from the `toast*Bg` semantic
|
|
11
|
+
* tokens so brand presets can retune them without touching the recipe.
|
|
13
12
|
*
|
|
14
13
|
* Registered in the base preset (base-preset.ts). The status variant
|
|
15
14
|
* is chosen at runtime from the toast content, so it needs the preset's
|
|
@@ -17,13 +16,36 @@ import { defineSlotRecipe } from "@pandacss/dev";
|
|
|
17
16
|
*/
|
|
18
17
|
export const toast = defineSlotRecipe({
|
|
19
18
|
className: "toast",
|
|
20
|
-
slots: [
|
|
19
|
+
slots: [
|
|
20
|
+
"region",
|
|
21
|
+
"root",
|
|
22
|
+
"content",
|
|
23
|
+
"body",
|
|
24
|
+
"icon",
|
|
25
|
+
"title",
|
|
26
|
+
"description",
|
|
27
|
+
"closeButton",
|
|
28
|
+
],
|
|
21
29
|
base: {
|
|
22
30
|
region: {
|
|
23
31
|
position: "fixed",
|
|
24
32
|
top: "4",
|
|
25
33
|
left: "50%",
|
|
26
34
|
transform: "translateX(-50%)",
|
|
35
|
+
// Percentage centring of a text-width region lands the toasts on a
|
|
36
|
+
// fractional pixel. Chrome floors ::view-transition-group's transform to
|
|
37
|
+
// a whole CSS pixel, so the snapshot the enter/exit animation displays
|
|
38
|
+
// sits up to 1px left of the element it stands in for, and every toast
|
|
39
|
+
// jumps right as the transition hands back to the real DOM (visible
|
|
40
|
+
// above 1x, where the two no longer round to the same device pixel).
|
|
41
|
+
// Rounding the region's own offsets keeps it on whole pixels so the two
|
|
42
|
+
// agree. round() postdates view transitions in Chrome (130 vs 111), so
|
|
43
|
+
// the guard keeps the plain centring for the versions in between rather
|
|
44
|
+
// than dropping the declarations and losing it altogether.
|
|
45
|
+
"@supports (left: round(50%, 1px))": {
|
|
46
|
+
left: "round(50%, 1px)",
|
|
47
|
+
transform: "translateX(round(-50%, 1px))",
|
|
48
|
+
},
|
|
27
49
|
zIndex: "toast",
|
|
28
50
|
display: "flex",
|
|
29
51
|
flexDirection: "column",
|
|
@@ -34,7 +56,6 @@ export const toast = defineSlotRecipe({
|
|
|
34
56
|
position: "relative",
|
|
35
57
|
display: "flex",
|
|
36
58
|
alignItems: "flex-start",
|
|
37
|
-
gap: "3",
|
|
38
59
|
p: "4",
|
|
39
60
|
// Leave room for the absolutely-positioned close button plus breathing
|
|
40
61
|
// space so its hover area never overlaps the title text (24px button at
|
|
@@ -46,6 +67,24 @@ export const toast = defineSlotRecipe({
|
|
|
46
67
|
color: "white",
|
|
47
68
|
maxW: "sm",
|
|
48
69
|
pointerEvents: "auto",
|
|
70
|
+
// The card is focusable (RAC gives it tabindex), so it takes the
|
|
71
|
+
// house ring rather than the UA default. Ink, because the ring is
|
|
72
|
+
// drawn on the page, not the card — Toast.tsx tags the close button.
|
|
73
|
+
outline: "none",
|
|
74
|
+
_focusVisible: { focusRing: "outline" },
|
|
75
|
+
},
|
|
76
|
+
// The icon has to be inside the alert region to be announced with the
|
|
77
|
+
// message, so this row — not the root — is what lays the two out.
|
|
78
|
+
content: {
|
|
79
|
+
display: "flex",
|
|
80
|
+
alignItems: "flex-start",
|
|
81
|
+
gap: "3",
|
|
82
|
+
// Let the text shrink below its content width so long words wrap
|
|
83
|
+
// instead of pushing past the toast's maxW.
|
|
84
|
+
minWidth: 0,
|
|
85
|
+
},
|
|
86
|
+
body: {
|
|
87
|
+
minWidth: 0,
|
|
49
88
|
},
|
|
50
89
|
icon: {
|
|
51
90
|
fontSize: "1.25rem",
|
|
@@ -58,9 +97,8 @@ export const toast = defineSlotRecipe({
|
|
|
58
97
|
mt: "1",
|
|
59
98
|
},
|
|
60
99
|
closeButton: {
|
|
61
|
-
// A sized box with the glyph centred (
|
|
62
|
-
//
|
|
63
|
-
// affordance.
|
|
100
|
+
// A sized box with the glyph centred (24px box, 2xs glyph) gives
|
|
101
|
+
// padding around the X and a hover affordance.
|
|
64
102
|
position: "absolute",
|
|
65
103
|
top: "1",
|
|
66
104
|
insetEnd: "1",
|
|
@@ -79,11 +117,10 @@ export const toast = defineSlotRecipe({
|
|
|
79
117
|
outline: "none",
|
|
80
118
|
transitionProperty: "background-color, box-shadow",
|
|
81
119
|
transitionDuration: "normal",
|
|
82
|
-
//
|
|
83
|
-
// light mode, not a bright highlight.
|
|
120
|
+
// Hover is a subtle dark overlay (blackAlpha), not a bright highlight.
|
|
84
121
|
_hover: { bg: "blackAlpha.100" },
|
|
85
122
|
_active: { bg: "blackAlpha.200" },
|
|
86
|
-
_focusVisible: {
|
|
123
|
+
_focusVisible: { focusRing: "outline" },
|
|
87
124
|
},
|
|
88
125
|
},
|
|
89
126
|
variants: {
|
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,32 +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;
|
|
205
|
+
/** Dismiss every toast, queued as well as displayed. */
|
|
206
|
+
closeAll(): void;
|
|
121
207
|
}
|
|
122
208
|
|
|
123
209
|
/**
|
|
124
|
-
* useToast — imperative toast trigger
|
|
125
|
-
* `
|
|
210
|
+
* useToast — imperative toast trigger:
|
|
211
|
+
* `toast({ title, description, status, duration })`. There is no
|
|
212
|
+
* `duration: null`; use `persistent: true` instead.
|
|
126
213
|
*/
|
|
127
214
|
export const useToast = (): ToastFn =>
|
|
128
215
|
useMemo(() => {
|
|
129
|
-
const isActive = (id: string) =>
|
|
130
|
-
toastQueue.visibleToasts.some((t) => t.content.id === id);
|
|
216
|
+
const isActive = (id: string) => keysById.has(id);
|
|
131
217
|
const add = ({
|
|
132
218
|
id,
|
|
133
219
|
title,
|
|
@@ -135,31 +221,50 @@ export const useToast = (): ToastFn =>
|
|
|
135
221
|
status,
|
|
136
222
|
isClosable,
|
|
137
223
|
duration,
|
|
224
|
+
persistent,
|
|
138
225
|
}: ToastOptions) => {
|
|
139
226
|
if (id && isActive(id)) {
|
|
140
227
|
return;
|
|
141
228
|
}
|
|
142
|
-
toastQueue.add(
|
|
229
|
+
const key = toastQueue.add(
|
|
143
230
|
{
|
|
144
231
|
id,
|
|
145
232
|
title,
|
|
146
233
|
description,
|
|
147
234
|
status,
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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,
|
|
151
248
|
},
|
|
152
|
-
{ timeout: duration ?? undefined },
|
|
153
249
|
);
|
|
250
|
+
if (id) {
|
|
251
|
+
keysById.set(id, key);
|
|
252
|
+
}
|
|
154
253
|
};
|
|
155
254
|
const update = (id: string, options: ToastOptions) => {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (existing) {
|
|
160
|
-
toastQueue.close(existing.key);
|
|
255
|
+
const key = keysById.get(id);
|
|
256
|
+
if (key !== undefined) {
|
|
257
|
+
toastQueue.close(key);
|
|
161
258
|
}
|
|
162
259
|
add({ ...options, id });
|
|
163
260
|
};
|
|
164
|
-
|
|
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.
|
|
265
|
+
const closeAll = () => {
|
|
266
|
+
keysById.clear();
|
|
267
|
+
toastQueue.clear();
|
|
268
|
+
};
|
|
269
|
+
return Object.assign(add, { isActive, update, closeAll });
|
|
165
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,31 +5,16 @@
|
|
|
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). */
|
|
@@ -55,16 +40,47 @@ export interface TooltipProps {
|
|
|
55
40
|
* the trigger (i.e. it is not a RAC component or `Focusable`).
|
|
56
41
|
*/
|
|
57
42
|
triggerRef?: RefObject<HTMLElement | null>;
|
|
58
|
-
/**
|
|
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
|
+
*/
|
|
59
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;
|
|
60
76
|
css?: SystemStyleObject;
|
|
61
77
|
}
|
|
62
78
|
|
|
63
79
|
/**
|
|
64
|
-
* Tooltip — react-aria-components TooltipTrigger + Tooltip
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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.
|
|
68
84
|
*/
|
|
69
85
|
export const Tooltip = ({
|
|
70
86
|
label,
|
|
@@ -73,16 +89,23 @@ export const Tooltip = ({
|
|
|
73
89
|
hasArrow,
|
|
74
90
|
isOpen,
|
|
75
91
|
triggerRef,
|
|
76
|
-
delay
|
|
92
|
+
delay,
|
|
93
|
+
closeDelay,
|
|
94
|
+
shouldCloseOnPress,
|
|
77
95
|
css: cssProp,
|
|
78
96
|
}: TooltipProps) => (
|
|
79
|
-
<TooltipTrigger
|
|
97
|
+
<TooltipTrigger
|
|
98
|
+
isOpen={isOpen}
|
|
99
|
+
delay={delay}
|
|
100
|
+
closeDelay={closeDelay}
|
|
101
|
+
shouldCloseOnPress={shouldCloseOnPress}
|
|
102
|
+
>
|
|
80
103
|
{children}
|
|
81
104
|
<RACTooltip
|
|
82
105
|
triggerRef={triggerRef}
|
|
83
106
|
placement={placement}
|
|
84
107
|
offset={hasArrow ? 8 : 4}
|
|
85
|
-
className={
|
|
108
|
+
className={cx(tooltip(), cssProp ? css(cssProp) : undefined)}
|
|
86
109
|
>
|
|
87
110
|
{hasArrow && <PopoverArrow css={{ "& svg": { fill: "gray.700" } }} />}
|
|
88
111
|
{label}
|