@microbit/ui 0.0.0-gray.ramp.90

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 (102) hide show
  1. package/LICENSE.md +53 -0
  2. package/README.md +222 -0
  3. package/lang/ui.ca.json +22 -0
  4. package/lang/ui.cy.json +22 -0
  5. package/lang/ui.de.json +22 -0
  6. package/lang/ui.en-us.json +22 -0
  7. package/lang/ui.en.json +22 -0
  8. package/lang/ui.es-es.json +22 -0
  9. package/lang/ui.fr.json +22 -0
  10. package/lang/ui.ga-ie.json +22 -0
  11. package/lang/ui.it.json +22 -0
  12. package/lang/ui.ja.json +22 -0
  13. package/lang/ui.ko.json +22 -0
  14. package/lang/ui.lol.json +22 -0
  15. package/lang/ui.nl.json +22 -0
  16. package/lang/ui.pl.json +22 -0
  17. package/lang/ui.pt-br.json +22 -0
  18. package/lang/ui.zh-cn.json +22 -0
  19. package/lang/ui.zh-tw.json +22 -0
  20. package/package.json +66 -0
  21. package/postcss-legacy-safari.cjs +96 -0
  22. package/reset.css +35 -0
  23. package/src/Avatar.recipe.ts +168 -0
  24. package/src/Avatar.tsx +276 -0
  25. package/src/Button.recipe.ts +192 -0
  26. package/src/Button.tsx +69 -0
  27. package/src/ButtonGroup.tsx +70 -0
  28. package/src/Card.recipe.ts +49 -0
  29. package/src/Card.tsx +56 -0
  30. package/src/Checkbox.recipe.ts +111 -0
  31. package/src/Checkbox.tsx +106 -0
  32. package/src/CloseButton.tsx +87 -0
  33. package/src/CloseIcon.tsx +40 -0
  34. package/src/Code.tsx +20 -0
  35. package/src/Collapse.tsx +180 -0
  36. package/src/ComboBox.tsx +192 -0
  37. package/src/Divider.tsx +85 -0
  38. package/src/Drawer.recipe.ts +98 -0
  39. package/src/Drawer.tsx +138 -0
  40. package/src/Fade.tsx +48 -0
  41. package/src/GridList.recipe.ts +58 -0
  42. package/src/GridList.tsx +81 -0
  43. package/src/Heading.recipe.ts +52 -0
  44. package/src/Heading.tsx +14 -0
  45. package/src/Icon.tsx +75 -0
  46. package/src/IconButton.tsx +44 -0
  47. package/src/Image.tsx +11 -0
  48. package/src/Input.recipe.ts +75 -0
  49. package/src/Input.tsx +44 -0
  50. package/src/InputGroup.tsx +62 -0
  51. package/src/Kbd.tsx +26 -0
  52. package/src/Link.tsx +23 -0
  53. package/src/LinkBox.tsx +88 -0
  54. package/src/LinkButton.tsx +80 -0
  55. package/src/List.tsx +31 -0
  56. package/src/ListBox.recipe.ts +43 -0
  57. package/src/ListBox.tsx +88 -0
  58. package/src/Menu.recipe.ts +124 -0
  59. package/src/Menu.tsx +291 -0
  60. package/src/Modal.recipe.ts +163 -0
  61. package/src/Modal.tsx +377 -0
  62. package/src/NativeSelect.tsx +94 -0
  63. package/src/NumberField.recipe.ts +67 -0
  64. package/src/NumberField.tsx +86 -0
  65. package/src/PopoverArrow.tsx +67 -0
  66. package/src/ProgressBar.tsx +61 -0
  67. package/src/Radio.recipe.ts +112 -0
  68. package/src/Radio.tsx +89 -0
  69. package/src/Select.recipe.ts +210 -0
  70. package/src/Select.tsx +153 -0
  71. package/src/SharedUIProvider.tsx +55 -0
  72. package/src/Skeleton.tsx +146 -0
  73. package/src/Slide.tsx +54 -0
  74. package/src/Slider.recipe.ts +102 -0
  75. package/src/Slider.tsx +163 -0
  76. package/src/Spinner.tsx +74 -0
  77. package/src/Svg.tsx +23 -0
  78. package/src/Switch.recipe.ts +107 -0
  79. package/src/Switch.tsx +62 -0
  80. package/src/Text.recipe.ts +29 -0
  81. package/src/Text.tsx +16 -0
  82. package/src/TextField.recipe.ts +54 -0
  83. package/src/TextField.tsx +91 -0
  84. package/src/Toast.recipe.ts +98 -0
  85. package/src/Toast.tsx +173 -0
  86. package/src/Tooltip.recipe.ts +37 -0
  87. package/src/Tooltip.tsx +76 -0
  88. package/src/UnmountCallback.tsx +18 -0
  89. package/src/VisuallyHidden.tsx +14 -0
  90. package/src/base-preset.ts +371 -0
  91. package/src/base-tokens.ts +1017 -0
  92. package/src/button-icon.ts +23 -0
  93. package/src/data-attrs.ts +16 -0
  94. package/src/dense-preset.ts +108 -0
  95. package/src/hooks/useBreakpointValue.ts +63 -0
  96. package/src/hooks/useClipboard.ts +64 -0
  97. package/src/hooks/useDisclosure.ts +32 -0
  98. package/src/hooks/useMediaQuery.ts +28 -0
  99. package/src/hooks/usePrevious.ts +18 -0
  100. package/src/index.ts +63 -0
  101. package/src/messages.ts +17 -0
  102. package/src/system.ts +45 -0
package/src/Switch.tsx ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { ReactNode } from "react";
7
+ import {
8
+ Switch as RACSwitch,
9
+ SwitchProps as RACSwitchProps,
10
+ } from "react-aria-components";
11
+ import { css, cx } from "styled-system/css";
12
+ import { switchRecipe, SwitchRecipeVariantProps } from "styled-system/recipes";
13
+ import { SystemStyleObject } from "styled-system/types";
14
+
15
+ export interface SwitchProps
16
+ extends Omit<RACSwitchProps, "className" | "children" | "style">,
17
+ SwitchRecipeVariantProps {
18
+ /** Per-instance style overrides for the root, merged after the recipe. */
19
+ css?: SystemStyleObject;
20
+ className?: string;
21
+ children?: ReactNode;
22
+ }
23
+
24
+ /**
25
+ * Switch — react-aria-components <Switch> styled like Chakra's switch.
26
+ * Children render as the label; pass `aria-label` for label-less switches.
27
+ */
28
+ export const Switch = ({
29
+ size,
30
+ css: cssProp,
31
+ className,
32
+ children,
33
+ ...rest
34
+ }: SwitchProps) => {
35
+ const slots = switchRecipe({ size });
36
+ return (
37
+ <RACSwitch
38
+ className={cx(slots.root, cssProp ? css(cssProp) : undefined, className)}
39
+ {...rest}
40
+ >
41
+ {({ isSelected, isFocusVisible, isDisabled }) => {
42
+ const state = {
43
+ "data-selected": isSelected || undefined,
44
+ "data-focus-visible": isFocusVisible || undefined,
45
+ "data-disabled": isDisabled || undefined,
46
+ };
47
+ return (
48
+ <>
49
+ <span className={slots.track} {...state} aria-hidden>
50
+ <span className={slots.thumb} {...state} />
51
+ </span>
52
+ {children != null && (
53
+ <span className={slots.label} {...state}>
54
+ {children}
55
+ </span>
56
+ )}
57
+ </>
58
+ );
59
+ }}
60
+ </RACSwitch>
61
+ );
62
+ };
@@ -0,0 +1,29 @@
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
+ * Text recipe — Chakra's <Text> had no styles of its own, but an app theme
10
+ * could give it sizes and a default size via `components.Text`
11
+ * (python-editor's did: sm/md with a default of md, which its shrunken
12
+ * fontSize scale renders at 0.9rem). A config recipe so an app preset can
13
+ * restore that: extend `defaultVariants` to set an app-wide default size.
14
+ * No default here — with no `size`, Text inherits like Chakra's default,
15
+ * which is what the other family apps expect.
16
+ *
17
+ * Registered in the base preset (base-preset.ts).
18
+ */
19
+ export const text = defineRecipe({
20
+ className: "text",
21
+ jsx: ["Text"],
22
+ variants: {
23
+ size: {
24
+ sm: { fontSize: "sm" },
25
+ md: { fontSize: "md" },
26
+ lg: { fontSize: "lg" },
27
+ },
28
+ },
29
+ });
package/src/Text.tsx ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { styled } from "styled-system/jsx";
7
+ import { text } from "styled-system/recipes";
8
+
9
+ /**
10
+ * Text — a paragraph that accepts Panda style props. Use `as` to render a
11
+ * different element (`span`, `div`, `h2`). Replaces Chakra's <Text>.
12
+ * Backed by the `text` config recipe: `size` picks a type-scale size, and an
13
+ * app preset can extend the recipe's `defaultVariants` to give all its Text
14
+ * a default size (Chakra `components.Text` parity — see Text.recipe.ts).
15
+ */
16
+ export const Text = styled("p", text);
@@ -0,0 +1,54 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { defineSlotRecipe } from "@pandacss/dev";
7
+
8
+ /**
9
+ * Field slot recipe — Chakra's FormControl parts (FormLabel/FormHelperText/
10
+ * FormErrorMessage, light mode). Consumed by the shared-ui TextField, which
11
+ * maps the slots onto react-aria-components' TextField/Label/Text/FieldError;
12
+ * the input itself is styled by the `input` recipe (Input.recipe.ts).
13
+ *
14
+ * Registered in the base preset (base-preset.ts). No variants, so it
15
+ * needs no `staticCss` entry.
16
+ */
17
+ export const field = defineSlotRecipe({
18
+ className: "field",
19
+ slots: ["root", "label", "requiredIndicator", "helperText", "errorMessage"],
20
+ base: {
21
+ root: {
22
+ display: "flex",
23
+ flexDirection: "column",
24
+ alignItems: "stretch",
25
+ width: "100%",
26
+ },
27
+ label: {
28
+ display: "block",
29
+ fontSize: "md",
30
+ fontWeight: "medium",
31
+ marginEnd: "3",
32
+ mb: "2",
33
+ "&[data-disabled]": { opacity: 0.4 },
34
+ },
35
+ requiredIndicator: {
36
+ marginStart: "1",
37
+ color: "danger.500",
38
+ },
39
+ helperText: {
40
+ mt: "2",
41
+ fontSize: "sm",
42
+ lineHeight: "normal",
43
+ color: "gray.600",
44
+ },
45
+ errorMessage: {
46
+ display: "flex",
47
+ alignItems: "center",
48
+ mt: "2",
49
+ fontSize: "sm",
50
+ lineHeight: "normal",
51
+ color: "danger.500",
52
+ },
53
+ },
54
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { FocusEvent, forwardRef, ReactNode } from "react";
7
+ import {
8
+ FieldError,
9
+ Input as RACInput,
10
+ Label as RACLabel,
11
+ Text as RACText,
12
+ TextField as RACTextField,
13
+ TextFieldProps as RACTextFieldProps,
14
+ } from "react-aria-components";
15
+ import { css, cx } from "styled-system/css";
16
+ import { field, input, InputVariantProps } from "styled-system/recipes";
17
+ import { SystemStyleObject } from "styled-system/types";
18
+
19
+ export interface TextFieldProps
20
+ extends Omit<
21
+ RACTextFieldProps,
22
+ "className" | "children" | "style" | "onFocus" | "onBlur"
23
+ >,
24
+ InputVariantProps {
25
+ /** Visible label (Chakra's FormLabel; asterisk added when `isRequired`). */
26
+ label: ReactNode;
27
+ /** Help text below the input (Chakra's FormHelperText). */
28
+ helperText?: ReactNode;
29
+ /** Shown below the input when `isInvalid` (Chakra's FormErrorMessage). */
30
+ errorMessage?: ReactNode;
31
+ /** Per-instance style overrides for the helper text. */
32
+ helperTextCss?: SystemStyleObject;
33
+ onFocus?: (e: FocusEvent<HTMLInputElement>) => void;
34
+ /** Input autocapitalize attribute (react-aria's TextField omits it). */
35
+ autoCapitalize?: "off" | "none" | "on" | "sentences" | "words" | "characters";
36
+ }
37
+
38
+ /**
39
+ * TextField — a labelled single-line text input, collapsing Chakra's
40
+ * FormControl/FormLabel/Input/FormHelperText/FormErrorMessage. The ref is
41
+ * forwarded to the input element.
42
+ */
43
+ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
44
+ function TextField(
45
+ {
46
+ label,
47
+ helperText,
48
+ errorMessage,
49
+ helperTextCss,
50
+ onFocus,
51
+ autoCapitalize,
52
+ ...props
53
+ },
54
+ ref,
55
+ ) {
56
+ // As Input: forward every recipe variant group, not just `size`, so a
57
+ // preset that adds one keeps working.
58
+ const [variantProps, rest] = input.splitVariantProps(props);
59
+ const slots = field();
60
+ return (
61
+ <RACTextField {...rest} className={slots.root}>
62
+ <RACLabel className={slots.label}>
63
+ {label}
64
+ {rest.isRequired ? (
65
+ <span aria-hidden className={slots.requiredIndicator}>
66
+ *
67
+ </span>
68
+ ) : null}
69
+ </RACLabel>
70
+ <RACInput
71
+ ref={ref}
72
+ className={input(variantProps)}
73
+ onFocus={onFocus}
74
+ autoCapitalize={autoCapitalize}
75
+ />
76
+ {helperText && (
77
+ <RACText
78
+ slot="description"
79
+ className={cx(
80
+ slots.helperText,
81
+ helperTextCss ? css(helperTextCss) : undefined,
82
+ )}
83
+ >
84
+ {helperText}
85
+ </RACText>
86
+ )}
87
+ <FieldError className={slots.errorMessage}>{errorMessage}</FieldError>
88
+ </RACTextField>
89
+ );
90
+ },
91
+ );
@@ -0,0 +1,98 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { defineSlotRecipe } from "@pandacss/dev";
7
+
8
+ /**
9
+ * Toast slot recipe — the Chakra-era solid Alert restyled as a toast (white
10
+ * text, teal for every status except error). Status colours come from the
11
+ * `toast*Bg` semantic tokens so brand presets can retune them without
12
+ * touching the recipe.
13
+ *
14
+ * Registered in the base preset (base-preset.ts). The status variant
15
+ * is chosen at runtime from the toast content, so it needs the preset's
16
+ * `staticCss` entry.
17
+ */
18
+ export const toast = defineSlotRecipe({
19
+ className: "toast",
20
+ slots: ["region", "root", "icon", "title", "description", "closeButton"],
21
+ base: {
22
+ region: {
23
+ position: "fixed",
24
+ top: "4",
25
+ left: "50%",
26
+ transform: "translateX(-50%)",
27
+ zIndex: "toast",
28
+ display: "flex",
29
+ flexDirection: "column",
30
+ gap: "2",
31
+ pointerEvents: "none",
32
+ },
33
+ root: {
34
+ position: "relative",
35
+ display: "flex",
36
+ alignItems: "flex-start",
37
+ gap: "3",
38
+ p: "4",
39
+ // Leave room for the absolutely-positioned close button plus breathing
40
+ // space so its hover area never overlaps the title text (24px button at
41
+ // 4px inset spans ~4-28px from the right; content stops at 40px for a
42
+ // ~12px gap).
43
+ paddingRight: "10",
44
+ borderRadius: "md",
45
+ boxShadow: "lg",
46
+ color: "white",
47
+ maxW: "sm",
48
+ pointerEvents: "auto",
49
+ },
50
+ icon: {
51
+ fontSize: "1.25rem",
52
+ flexShrink: 0,
53
+ },
54
+ title: {
55
+ fontWeight: "bold",
56
+ },
57
+ description: {
58
+ mt: "1",
59
+ },
60
+ closeButton: {
61
+ // A sized box with the glyph centred (like Chakra's CloseButton size
62
+ // "sm": 24px box, 2xs glyph) gives padding around the X and a hover
63
+ // affordance.
64
+ position: "absolute",
65
+ top: "1",
66
+ insetEnd: "1",
67
+ color: "white",
68
+ display: "inline-flex",
69
+ alignItems: "center",
70
+ justifyContent: "center",
71
+ width: "6",
72
+ height: "6",
73
+ padding: "0",
74
+ fontSize: "2xs",
75
+ borderRadius: "md",
76
+ cursor: "pointer",
77
+ bg: "transparent",
78
+ border: "none",
79
+ outline: "none",
80
+ transitionProperty: "background-color, box-shadow",
81
+ transitionDuration: "normal",
82
+ // Chakra's CloseButton hover is a subtle dark overlay (blackAlpha) in
83
+ // light mode, not a bright highlight.
84
+ _hover: { bg: "blackAlpha.100" },
85
+ _active: { bg: "blackAlpha.200" },
86
+ _focusVisible: { focusShadow: "outline" },
87
+ },
88
+ },
89
+ variants: {
90
+ status: {
91
+ info: { root: { bg: "toastInfoBg" } },
92
+ success: { root: { bg: "toastSuccessBg" } },
93
+ warning: { root: { bg: "toastWarningBg" } },
94
+ error: { root: { bg: "toastErrorBg" } },
95
+ },
96
+ },
97
+ defaultVariants: { status: "info" },
98
+ });
package/src/Toast.tsx ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { ReactNode, useMemo } from "react";
7
+ import {
8
+ Button as RACButton,
9
+ UNSTABLE_Toast as RACToast,
10
+ UNSTABLE_ToastContent as RACToastContent,
11
+ UNSTABLE_ToastQueue as RACToastQueue,
12
+ UNSTABLE_ToastRegion as RACToastRegion,
13
+ } from "react-aria-components";
14
+ import { useIntl } from "react-intl";
15
+ import { IconType } from "react-icons/lib";
16
+ import {
17
+ RiAlertFill,
18
+ RiCheckboxCircleFill,
19
+ RiErrorWarningFill,
20
+ RiInformationFill,
21
+ } from "react-icons/ri";
22
+ import { toast as toastRecipe } from "styled-system/recipes";
23
+ import { CloseIcon } from "./CloseIcon";
24
+ import { Icon } from "./Icon";
25
+ import { uiMessage } from "./messages";
26
+ import { VisuallyHidden } from "./VisuallyHidden";
27
+
28
+ export type ToastStatus = "info" | "success" | "warning" | "error";
29
+
30
+ export interface ToastContent {
31
+ /** Dedup key: adding a toast whose id is already visible is a no-op. */
32
+ id?: string;
33
+ title?: ReactNode;
34
+ description?: ReactNode;
35
+ status?: ToastStatus;
36
+ isClosable?: boolean;
37
+ }
38
+
39
+ // Module-level queue shared by useToast() and the <ToastProvider/> region.
40
+ // (RAC's Toast API is still flagged UNSTABLE_*; the surface is small and behind
41
+ // this module, so a swap to a custom queue later is contained.)
42
+ export const toastQueue = new RACToastQueue<ToastContent>({
43
+ maxVisibleToasts: 5,
44
+ });
45
+
46
+ // Status icon matching Chakra's AlertIcon (filled glyphs, coloured by the
47
+ // toast foreground = white here). Warning is a triangle, error a circle, as
48
+ // in Chakra — the glyph must distinguish them because the colours alone
49
+ // don't reliably. The icon is decorative; the status is announced via the
50
+ // visually hidden status text.
51
+ const statusIcon: Record<ToastStatus, IconType> = {
52
+ info: RiInformationFill,
53
+ success: RiCheckboxCircleFill,
54
+ warning: RiAlertFill,
55
+ error: RiErrorWarningFill,
56
+ };
57
+
58
+ /**
59
+ * Mount once near the app root, inside the IntlProvider (the close button's
60
+ * label and the status announcements are react-intl messages).
61
+ * Renders the live region that announces and displays queued toasts.
62
+ */
63
+ export const ToastProvider = () => {
64
+ const intl = useIntl();
65
+ const slots = toastRecipe();
66
+ return (
67
+ <RACToastRegion queue={toastQueue} className={slots.region}>
68
+ {({ toast }) => {
69
+ const status = toast.content.status ?? "info";
70
+ return (
71
+ <RACToast toast={toast} className={toastRecipe({ status }).root}>
72
+ <Icon as={statusIcon[status]} className={slots.icon} aria-hidden />
73
+ <RACToastContent>
74
+ {/* Colour and icon are the only visible status signals; say it
75
+ for assistive tech too. */}
76
+ <VisuallyHidden>
77
+ {intl.formatMessage(uiMessage(`ui.toast-status-${status}`))}{" "}
78
+ </VisuallyHidden>
79
+ {toast.content.title && (
80
+ <p className={slots.title}>{toast.content.title}</p>
81
+ )}
82
+ {toast.content.description && (
83
+ <div className={slots.description}>
84
+ {toast.content.description}
85
+ </div>
86
+ )}
87
+ </RACToastContent>
88
+ {toast.content.isClosable && (
89
+ <RACButton
90
+ slot="close"
91
+ aria-label={intl.formatMessage(uiMessage("ui.close-action"))}
92
+ className={slots.closeButton}
93
+ >
94
+ <CloseIcon />
95
+ </RACButton>
96
+ )}
97
+ </RACToast>
98
+ );
99
+ }}
100
+ </RACToastRegion>
101
+ );
102
+ };
103
+
104
+ export interface ToastOptions extends ToastContent {
105
+ /** Auto-dismiss after this many ms. Default 5000. Ignored when `persistent`. */
106
+ duration?: number;
107
+ /**
108
+ * Never auto-dismiss. The close button is forced on so the toast is not
109
+ * permanent and unremovable.
110
+ */
111
+ persistent?: boolean;
112
+ }
113
+
114
+ export interface ToastFn {
115
+ (options: ToastOptions): void;
116
+ /** Whether a toast with this id is currently visible. */
117
+ isActive(id: string): boolean;
118
+ /**
119
+ * Replace a visible toast's content (Chakra's toast.update). The toast is
120
+ * re-added, so unlike Chakra it re-animates and restarts any timeout.
121
+ */
122
+ update(id: string, options: ToastOptions): void;
123
+ /** Dismiss all visible toasts (Chakra's toast.closeAll). */
124
+ closeAll(): void;
125
+ }
126
+
127
+ /**
128
+ * useToast — imperative toast trigger in the shape of Chakra's `useToast()`
129
+ * call sites: `toast({ title, description, status, duration })`. Unlike
130
+ * Chakra there is no `duration: null`; use `persistent: true` instead.
131
+ */
132
+ export const useToast = (): ToastFn =>
133
+ useMemo(() => {
134
+ const isActive = (id: string) =>
135
+ toastQueue.visibleToasts.some((t) => t.content.id === id);
136
+ const add = ({
137
+ id,
138
+ title,
139
+ description,
140
+ status,
141
+ isClosable,
142
+ duration,
143
+ persistent,
144
+ }: ToastOptions) => {
145
+ if (id && isActive(id)) {
146
+ return;
147
+ }
148
+ toastQueue.add(
149
+ {
150
+ id,
151
+ title,
152
+ description,
153
+ status,
154
+ isClosable: isClosable || persistent,
155
+ },
156
+ { timeout: persistent ? undefined : duration ?? 5000 },
157
+ );
158
+ };
159
+ const update = (id: string, options: ToastOptions) => {
160
+ const existing = toastQueue.visibleToasts.find(
161
+ (t) => t.content.id === id,
162
+ );
163
+ if (existing) {
164
+ toastQueue.close(existing.key);
165
+ }
166
+ add({ ...options, id });
167
+ };
168
+ const closeAll = () => {
169
+ // Copy first: closing mutates visibleToasts as we iterate.
170
+ [...toastQueue.visibleToasts].forEach((t) => toastQueue.close(t.key));
171
+ };
172
+ return Object.assign(add, { isActive, update, closeAll });
173
+ }, []);
@@ -0,0 +1,37 @@
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 — Chakra's 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'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.
20
+ *
21
+ * Registered in the base preset (base-preset.ts).
22
+ */
23
+ export const tooltip = defineRecipe({
24
+ className: "tooltip",
25
+ base: {
26
+ bg: "gray.700",
27
+ color: "whiteAlpha.900",
28
+ px: "2",
29
+ py: "0.5",
30
+ borderRadius: "sm",
31
+ fontSize: "sm",
32
+ fontWeight: "medium",
33
+ boxShadow: "md",
34
+ maxW: "xs",
35
+ zIndex: "tooltip",
36
+ },
37
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { ReactElement, ReactNode, RefObject } from "react";
7
+ import { Tooltip as RACTooltip, TooltipTrigger } from "react-aria-components";
8
+ import { css, cx } from "styled-system/css";
9
+ import { tooltip } from "styled-system/recipes";
10
+ import { SystemStyleObject } from "styled-system/types";
11
+ import { PopoverArrow } from "./PopoverArrow";
12
+
13
+ export interface TooltipProps {
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.
18
+ */
19
+ label: ReactNode;
20
+ /** A single focusable trigger element (e.g. a Button). */
21
+ children: ReactElement;
22
+ placement?:
23
+ | "top"
24
+ | "bottom"
25
+ | "left"
26
+ | "right"
27
+ | "top start"
28
+ | "top end"
29
+ | "bottom start"
30
+ | "bottom end"
31
+ | "left top"
32
+ | "left bottom"
33
+ | "right top"
34
+ | "right bottom";
35
+ hasArrow?: boolean;
36
+ /** Controlled open state (otherwise hover/focus driven). */
37
+ isOpen?: boolean;
38
+ /**
39
+ * Anchor element for positioning, when the child cannot register itself as
40
+ * the trigger (i.e. it is not a RAC component or `Focusable`).
41
+ */
42
+ triggerRef?: RefObject<HTMLElement | null>;
43
+ /** Hover open delay in ms (RAC default ~1500; pass 0 for instant). */
44
+ delay?: number;
45
+ css?: SystemStyleObject;
46
+ }
47
+
48
+ /**
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.
53
+ */
54
+ export const Tooltip = ({
55
+ label,
56
+ children,
57
+ placement = "top",
58
+ hasArrow,
59
+ isOpen,
60
+ triggerRef,
61
+ delay = 0,
62
+ css: cssProp,
63
+ }: TooltipProps) => (
64
+ <TooltipTrigger isOpen={isOpen} delay={delay} closeDelay={0}>
65
+ {children}
66
+ <RACTooltip
67
+ triggerRef={triggerRef}
68
+ placement={placement}
69
+ offset={hasArrow ? 8 : 4}
70
+ className={cx(tooltip(), cssProp ? css(cssProp) : undefined)}
71
+ >
72
+ {hasArrow && <PopoverArrow css={{ "& svg": { fill: "gray.700" } }} />}
73
+ {label}
74
+ </RACTooltip>
75
+ </TooltipTrigger>
76
+ );
@@ -0,0 +1,18 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { useEffect, useRef } from "react";
7
+
8
+ /**
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.
12
+ */
13
+ export const UnmountCallback = ({ callback }: { callback?: () => void }) => {
14
+ const ref = useRef(callback);
15
+ ref.current = callback;
16
+ useEffect(() => () => ref.current?.(), []);
17
+ return null;
18
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { styled } from "styled-system/jsx";
7
+
8
+ /**
9
+ * VisuallyHidden — screen-reader-only content (Panda's `srOnly` utility).
10
+ * Renders a span like Chakra's; use `as="div"` for block children.
11
+ */
12
+ export const VisuallyHidden = styled("span", {
13
+ base: { srOnly: true },
14
+ });