@microbit/ui 0.1.0-alpha.10

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 (86) hide show
  1. package/LICENSE.md +53 -0
  2. package/README.md +220 -0
  3. package/lang/ui.ca.json +22 -0
  4. package/lang/ui.de.json +22 -0
  5. package/lang/ui.en-us.json +22 -0
  6. package/lang/ui.en.json +22 -0
  7. package/lang/ui.es-es.json +22 -0
  8. package/lang/ui.fr.json +22 -0
  9. package/lang/ui.ga-ie.json +22 -0
  10. package/lang/ui.ja.json +22 -0
  11. package/lang/ui.ko.json +22 -0
  12. package/lang/ui.lol.json +22 -0
  13. package/lang/ui.nl.json +22 -0
  14. package/lang/ui.pl.json +22 -0
  15. package/lang/ui.pt-br.json +22 -0
  16. package/lang/ui.zh-cn.json +22 -0
  17. package/lang/ui.zh-tw.json +22 -0
  18. package/package.json +58 -0
  19. package/postcss-legacy-safari.cjs +96 -0
  20. package/reset.css +35 -0
  21. package/src/Button.recipe.ts +162 -0
  22. package/src/Button.tsx +69 -0
  23. package/src/ButtonGroup.tsx +63 -0
  24. package/src/Card.recipe.ts +49 -0
  25. package/src/Card.tsx +56 -0
  26. package/src/Checkbox.recipe.ts +105 -0
  27. package/src/Checkbox.tsx +76 -0
  28. package/src/CloseButton.tsx +87 -0
  29. package/src/CloseIcon.tsx +40 -0
  30. package/src/Code.tsx +20 -0
  31. package/src/Collapse.tsx +180 -0
  32. package/src/Divider.tsx +52 -0
  33. package/src/Drawer.recipe.ts +98 -0
  34. package/src/Drawer.tsx +138 -0
  35. package/src/Fade.tsx +48 -0
  36. package/src/Heading.recipe.ts +52 -0
  37. package/src/Heading.tsx +14 -0
  38. package/src/Icon.tsx +55 -0
  39. package/src/IconButton.tsx +44 -0
  40. package/src/Image.tsx +11 -0
  41. package/src/Input.recipe.ts +72 -0
  42. package/src/Input.tsx +40 -0
  43. package/src/InputGroup.tsx +62 -0
  44. package/src/Kbd.tsx +26 -0
  45. package/src/Link.tsx +23 -0
  46. package/src/LinkBox.tsx +88 -0
  47. package/src/LinkButton.tsx +80 -0
  48. package/src/List.tsx +31 -0
  49. package/src/Menu.recipe.ts +119 -0
  50. package/src/Menu.tsx +261 -0
  51. package/src/Modal.recipe.ts +163 -0
  52. package/src/Modal.tsx +279 -0
  53. package/src/NativeSelect.tsx +94 -0
  54. package/src/NumberField.recipe.ts +67 -0
  55. package/src/NumberField.tsx +86 -0
  56. package/src/PopoverArrow.tsx +67 -0
  57. package/src/ProgressBar.tsx +61 -0
  58. package/src/Radio.recipe.ts +107 -0
  59. package/src/Radio.tsx +89 -0
  60. package/src/SharedUIProvider.tsx +55 -0
  61. package/src/Slide.tsx +54 -0
  62. package/src/Slider.recipe.ts +102 -0
  63. package/src/Slider.tsx +163 -0
  64. package/src/Spinner.tsx +69 -0
  65. package/src/Svg.tsx +23 -0
  66. package/src/Switch.recipe.ts +107 -0
  67. package/src/Switch.tsx +62 -0
  68. package/src/Text.recipe.ts +29 -0
  69. package/src/Text.tsx +16 -0
  70. package/src/TextField.recipe.ts +54 -0
  71. package/src/TextField.tsx +89 -0
  72. package/src/Toast.recipe.ts +98 -0
  73. package/src/Toast.tsx +171 -0
  74. package/src/Tooltip.tsx +91 -0
  75. package/src/UnmountCallback.tsx +18 -0
  76. package/src/VisuallyHidden.tsx +14 -0
  77. package/src/base-preset.ts +294 -0
  78. package/src/base-tokens.ts +1017 -0
  79. package/src/button-icon.ts +23 -0
  80. package/src/hooks/useBreakpointValue.ts +63 -0
  81. package/src/hooks/useClipboard.ts +64 -0
  82. package/src/hooks/useMediaQuery.ts +28 -0
  83. package/src/hooks/usePrevious.ts +18 -0
  84. package/src/index.ts +55 -0
  85. package/src/messages.ts +17 -0
  86. package/src/system.ts +36 -0
package/src/Modal.tsx ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import {
7
+ createContext,
8
+ CSSProperties,
9
+ ReactNode,
10
+ RefObject,
11
+ useContext,
12
+ } from "react";
13
+ import {
14
+ Button as RACButton,
15
+ Dialog,
16
+ Heading as RACHeading,
17
+ Modal as RACModal,
18
+ ModalOverlay,
19
+ } from "react-aria-components";
20
+ import { css, cx } from "styled-system/css";
21
+ import { dialog } from "styled-system/recipes";
22
+ import { ConditionalValue, SystemStyleObject } from "styled-system/types";
23
+ import { useIntl } from "react-intl";
24
+ import { CloseIcon } from "./CloseIcon";
25
+ import { uiMessage } from "./messages";
26
+ import { UnmountCallback } from "./UnmountCallback";
27
+
28
+ type DialogSlots = ReturnType<typeof dialog>;
29
+
30
+ const SlotContext = createContext<{ slots: DialogSlots; onClose: () => void }>({
31
+ slots: dialog({}),
32
+ onClose: () => undefined,
33
+ });
34
+
35
+ const useDialog = () => useContext(SlotContext);
36
+
37
+ export type ModalSize = ConditionalValue<
38
+ | "xs"
39
+ | "sm"
40
+ | "md"
41
+ | "lg"
42
+ | "xl"
43
+ | "2xl"
44
+ | "3xl"
45
+ | "4xl"
46
+ | "5xl"
47
+ | "6xl"
48
+ | "full"
49
+ >;
50
+
51
+ export interface ModalProps {
52
+ isOpen: boolean;
53
+ onClose: () => void;
54
+ size?: ModalSize;
55
+ /** Allow closing by clicking the backdrop (default true; Escape always closes). */
56
+ isDismissable?: boolean;
57
+ /** Disable the enter/exit transitions (Chakra's motionPreset="none"). */
58
+ motionless?: boolean;
59
+ /** Prevent Escape closing the dialog (Chakra's closeOnEsc={false}). */
60
+ isKeyboardDismissDisabled?: boolean;
61
+ /** Style overrides for the dialog box (Chakra's ModalContent props). */
62
+ contentCss?: SystemStyleObject;
63
+ /**
64
+ * Inline styles for the dialog box, for runtime-computed positioning that
65
+ * Panda can't statically extract (e.g. a dialog aligned to a measured
66
+ * element). Prefer `contentCss` for static styles.
67
+ */
68
+ contentStyle?: CSSProperties;
69
+ /**
70
+ * Style overrides for the backdrop (Chakra's ModalOverlay props), e.g. a
71
+ * transparent backdrop when something else provides the dimming.
72
+ */
73
+ overlayCss?: SystemStyleObject;
74
+ /**
75
+ * Use "alertdialog" for confirmations that interrupt the user (Chakra's
76
+ * AlertDialog).
77
+ */
78
+ role?: "dialog" | "alertdialog";
79
+ /** Vertically centre the dialog (Chakra's `isCentered`). */
80
+ isCentered?: boolean;
81
+ /**
82
+ * Called after the dialog has fully closed (exit transition done and the
83
+ * dialog removed). Matches Chakra's `onCloseComplete`.
84
+ */
85
+ onCloseComplete?: () => void;
86
+ /**
87
+ * Element to focus when the dialog closes, instead of the element that was
88
+ * focused when it opened. Matches Chakra's `finalFocusRef`.
89
+ */
90
+ finalFocusRef?: RefObject<HTMLElement>;
91
+ /**
92
+ * Accessible name for dialogs without a ModalHeader (which otherwise
93
+ * provides the label). One of the two is required — react-aria warns in
94
+ * dev builds when a dialog has neither. An explicit `aria-label` wins
95
+ * over a ModalHeader.
96
+ */
97
+ "aria-label"?: string;
98
+ children: ReactNode;
99
+ }
100
+
101
+ /**
102
+ * Modal — a focus-trapping dialog. Collapses Chakra's
103
+ * Modal/ModalOverlay/ModalContent into a single shell; place ModalHeader,
104
+ * ModalBody and ModalFooter inside.
105
+ */
106
+ export const Modal = ({
107
+ isOpen,
108
+ onClose,
109
+ size,
110
+ isDismissable = true,
111
+ motionless,
112
+ isKeyboardDismissDisabled,
113
+ contentCss,
114
+ contentStyle,
115
+ overlayCss,
116
+ role,
117
+ isCentered,
118
+ onCloseComplete,
119
+ finalFocusRef,
120
+ "aria-label": ariaLabel,
121
+ children,
122
+ }: ModalProps) => {
123
+ const slots = dialog({ size, centered: isCentered });
124
+ const motionlessClass = motionless
125
+ ? css({
126
+ transition: "none",
127
+ "&[data-entering]": { opacity: 1, transform: "none" },
128
+ "&[data-exiting]": { opacity: 1, transform: "none" },
129
+ })
130
+ : undefined;
131
+ const handleUnmount = () => {
132
+ onCloseComplete?.();
133
+ const el = finalFocusRef?.current;
134
+ if (el) {
135
+ // After RAC's own focus restoration, which also runs on unmount.
136
+ requestAnimationFrame(() => el.focus());
137
+ }
138
+ };
139
+ return (
140
+ <ModalOverlay
141
+ isOpen={isOpen}
142
+ onOpenChange={(open) => {
143
+ if (!open) {
144
+ onClose();
145
+ }
146
+ }}
147
+ isDismissable={isDismissable}
148
+ isKeyboardDismissDisabled={isKeyboardDismissDisabled}
149
+ className={cx(
150
+ slots.overlay,
151
+ motionlessClass,
152
+ overlayCss ? css(overlayCss) : undefined,
153
+ )}
154
+ >
155
+ <UnmountCallback callback={handleUnmount} />
156
+ <RACModal
157
+ style={contentStyle}
158
+ className={cx(
159
+ slots.content,
160
+ motionlessClass,
161
+ contentCss ? css(contentCss) : undefined,
162
+ )}
163
+ >
164
+ <Dialog role={role} aria-label={ariaLabel} className={slots.inner}>
165
+ <SlotContext.Provider value={{ slots, onClose }}>
166
+ {children}
167
+ </SlotContext.Provider>
168
+ </Dialog>
169
+ </RACModal>
170
+ </ModalOverlay>
171
+ );
172
+ };
173
+
174
+ interface SlotProps {
175
+ children?: ReactNode;
176
+ css?: SystemStyleObject;
177
+ className?: string;
178
+ }
179
+
180
+ /** Modal title. Rendered as RAC's labelling heading for the dialog. */
181
+ export const ModalHeader = ({
182
+ children,
183
+ css: cssProp,
184
+ className,
185
+ level,
186
+ }: SlotProps & {
187
+ /** Heading element level (default 3, like RAC). */ level?: number;
188
+ }) => {
189
+ const { slots } = useDialog();
190
+ return (
191
+ <RACHeading
192
+ slot="title"
193
+ level={level}
194
+ className={cx(
195
+ slots.header,
196
+ cssProp ? css(cssProp) : undefined,
197
+ className,
198
+ )}
199
+ >
200
+ {children}
201
+ </RACHeading>
202
+ );
203
+ };
204
+
205
+ export const ModalBody = ({ children, css: cssProp, className }: SlotProps) => {
206
+ const { slots } = useDialog();
207
+ return (
208
+ <div
209
+ className={cx(slots.body, cssProp ? css(cssProp) : undefined, className)}
210
+ >
211
+ {children}
212
+ </div>
213
+ );
214
+ };
215
+
216
+ export const ModalFooter = ({
217
+ children,
218
+ css: cssProp,
219
+ className,
220
+ }: SlotProps) => {
221
+ const { slots } = useDialog();
222
+ return (
223
+ <div
224
+ className={cx(
225
+ slots.footer,
226
+ cssProp ? css(cssProp) : undefined,
227
+ className,
228
+ )}
229
+ >
230
+ {children}
231
+ </div>
232
+ );
233
+ };
234
+
235
+ export interface ModalCloseButtonProps {
236
+ /** Accessible name; defaults to the localized close label. */
237
+ "aria-label"?: string;
238
+ }
239
+
240
+ /**
241
+ * ModalCloseButton — the X in the dialog's top corner (Chakra's
242
+ * ModalCloseButton at its default md size). Closes via the Modal's onClose.
243
+ */
244
+ export const ModalCloseButton = ({
245
+ "aria-label": ariaLabel,
246
+ }: ModalCloseButtonProps) => {
247
+ const intl = useIntl();
248
+ const { slots, onClose } = useDialog();
249
+ return (
250
+ <RACButton
251
+ aria-label={ariaLabel ?? intl.formatMessage(uiMessage("ui.close-action"))}
252
+ onPress={onClose}
253
+ className={cx(
254
+ slots.closeTrigger,
255
+ css({
256
+ display: "inline-flex",
257
+ alignItems: "center",
258
+ justifyContent: "center",
259
+ width: "8",
260
+ height: "8",
261
+ fontSize: "xs",
262
+ borderRadius: "md",
263
+ cursor: "pointer",
264
+ bg: "transparent",
265
+ border: "none",
266
+ color: "inherit",
267
+ outline: "none",
268
+ transitionProperty: "background-color, box-shadow",
269
+ transitionDuration: "normal",
270
+ _hover: { bg: "blackAlpha.100" },
271
+ _active: { bg: "blackAlpha.200" },
272
+ _focusVisible: { focusShadow: "outline" },
273
+ }),
274
+ )}
275
+ >
276
+ <CloseIcon />
277
+ </RACButton>
278
+ );
279
+ };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { forwardRef, SelectHTMLAttributes } from "react";
7
+ import { css, cx } from "styled-system/css";
8
+ import { input, InputVariantProps } from "styled-system/recipes";
9
+ import { SystemStyleObject } from "styled-system/types";
10
+
11
+ export interface NativeSelectProps
12
+ // `size` is the recipe's size scale, as in Chakra; the native visible-rows
13
+ // attribute it shadows was unused.
14
+ extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "className" | "size">,
15
+ InputVariantProps {
16
+ /**
17
+ * Suppress the dropdown chevron, e.g. when an adjacent control provides the
18
+ * affordance. Without the chevron a bare <select> element is rendered (no
19
+ * wrapper), so it can participate directly in e.g. an attached ButtonGroup.
20
+ */
21
+ hideChevron?: boolean;
22
+ /** Per-instance style overrides for the select, merged after the recipe. */
23
+ css?: SystemStyleObject;
24
+ /**
25
+ * Style overrides for the chevron wrapper, which is where width constraints
26
+ * belong (the select fills it). Chakra's Select worked the same way: layout
27
+ * props were split onto `.chakra-select__wrapper`, `width: 100%` otherwise.
28
+ * No wrapper is rendered with `hideChevron`; constrain the select directly.
29
+ */
30
+ wrapperCss?: SystemStyleObject;
31
+ className?: string;
32
+ }
33
+
34
+ /**
35
+ * NativeSelect — a native select styled like Chakra's Select field (outline).
36
+ * The recipe's `appearance: none` removes the platform chevron, so one is
37
+ * drawn back in by default (Chakra Select's glyph, `currentColor`).
38
+ */
39
+ export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
40
+ function NativeSelect(
41
+ { hideChevron = false, size, css: cssProp, wrapperCss, className, ...rest },
42
+ ref,
43
+ ) {
44
+ const select = (
45
+ <select
46
+ ref={ref}
47
+ className={cx(
48
+ input({ size }),
49
+ css(
50
+ { cursor: "pointer" },
51
+ // Room for the chevron overlay (Chakra Select's icon spacing,
52
+ // constant across sizes).
53
+ hideChevron ? undefined : { paddingRight: "8" },
54
+ cssProp,
55
+ ),
56
+ className,
57
+ )}
58
+ {...rest}
59
+ />
60
+ );
61
+ if (hideChevron) {
62
+ return select;
63
+ }
64
+ return (
65
+ <span
66
+ className={css(
67
+ // Full-width like every other field (and Chakra's select wrapper);
68
+ // the select's own recipe width fills it. Constrain via wrapperCss.
69
+ { position: "relative", display: "inline-flex", width: "100%" },
70
+ wrapperCss,
71
+ )}
72
+ >
73
+ {select}
74
+ {/* Chakra Select's chevron, fixed-size across field sizes. */}
75
+ <svg
76
+ viewBox="0 0 24 24"
77
+ aria-hidden
78
+ className={css({
79
+ position: "absolute",
80
+ right: "2",
81
+ top: "50%",
82
+ transform: "translateY(-50%)",
83
+ width: "5",
84
+ height: "5",
85
+ pointerEvents: "none",
86
+ fill: "currentColor",
87
+ })}
88
+ >
89
+ <path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" />
90
+ </svg>
91
+ </span>
92
+ );
93
+ },
94
+ );
@@ -0,0 +1,67 @@
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
+ * NumberField slot recipe — Chakra's NumberInput look: an outline input (the
10
+ * `input` recipe styles the input itself) with a right-hand stepper column of
11
+ * two stacked buttons. Consumed by the shared-ui NumberField
12
+ * (react-aria-components NumberField).
13
+ *
14
+ * Registered in the base preset (base-preset.ts). No variants, so it needs
15
+ * no `staticCss` entry.
16
+ */
17
+ export const numberField = defineSlotRecipe({
18
+ className: "numberField",
19
+ slots: ["root", "group", "stepper", "stepperButton"],
20
+ base: {
21
+ root: {
22
+ display: "flex",
23
+ flexDirection: "column",
24
+ alignItems: "stretch",
25
+ },
26
+ group: {
27
+ position: "relative",
28
+ zIndex: 0,
29
+ },
30
+ // Chakra's NumberInputStepper: a column overlaying the input's right
31
+ // edge, inset by the input border.
32
+ stepper: {
33
+ display: "flex",
34
+ flexDirection: "column",
35
+ position: "absolute",
36
+ insetEnd: "0",
37
+ top: "0",
38
+ height: "calc(100% - 2px)",
39
+ margin: "1px",
40
+ width: "6",
41
+ zIndex: 1,
42
+ },
43
+ stepperButton: {
44
+ display: "flex",
45
+ alignItems: "center",
46
+ justifyContent: "center",
47
+ flex: 1,
48
+ cursor: "pointer",
49
+ lineHeight: "normal",
50
+ fontSize: "xs",
51
+ color: "inherit",
52
+ bg: "transparent",
53
+ borderStart: "1px solid",
54
+ borderColor: "gray.200",
55
+ transitionProperty: "background",
56
+ transitionDuration: "ultra-fast",
57
+ "&:last-child": {
58
+ borderTop: "1px solid",
59
+ borderTopColor: "gray.200",
60
+ marginTop: "-1px",
61
+ },
62
+ "&[data-hovered]": { bg: "gray.100" },
63
+ "&[data-pressed]": { bg: "gray.200" },
64
+ "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
65
+ },
66
+ },
67
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { forwardRef, ReactNode } from "react";
7
+ import {
8
+ Button as RACButton,
9
+ Group as RACGroup,
10
+ Input as RACInput,
11
+ Label as RACLabel,
12
+ NumberField as RACNumberField,
13
+ NumberFieldProps as RACNumberFieldProps,
14
+ } from "react-aria-components";
15
+ import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
16
+ import { css, cx } from "styled-system/css";
17
+ import { field, input, numberField } from "styled-system/recipes";
18
+ import { SystemStyleObject } from "styled-system/types";
19
+ import { Icon } from "./Icon";
20
+
21
+ export interface NumberFieldProps
22
+ extends Omit<RACNumberFieldProps, "className" | "children" | "style"> {
23
+ /** Visible label (optional; otherwise pass `aria-label`). */
24
+ label?: ReactNode;
25
+ /** Root style overrides (e.g. row layout for label-beside-field forms). */
26
+ css?: SystemStyleObject;
27
+ /** Label style overrides. */
28
+ labelCss?: SystemStyleObject;
29
+ /** Group (input + steppers) style overrides — set `width` here. */
30
+ groupCss?: SystemStyleObject;
31
+ /** Input style overrides (e.g. a smaller size than the recipe's md). */
32
+ inputCss?: SystemStyleObject;
33
+ }
34
+
35
+ /**
36
+ * NumberField — react-aria-components <NumberField> styled like Chakra's
37
+ * NumberInput (outline input + right-hand stepper column). The ref is
38
+ * forwarded to the input element. Value clamping to min/maxValue is
39
+ * handled by react-aria; onChange receives NaN when the field is emptied.
40
+ */
41
+ export const NumberField = forwardRef<HTMLInputElement, NumberFieldProps>(
42
+ function NumberField(
43
+ { label, css: cssProp, labelCss, groupCss, inputCss, ...rest },
44
+ ref,
45
+ ) {
46
+ const slots = numberField();
47
+ const fieldSlots = field();
48
+ return (
49
+ <RACNumberField
50
+ {...rest}
51
+ className={cx(slots.root, cssProp ? css(cssProp) : undefined)}
52
+ >
53
+ {label != null && (
54
+ <RACLabel
55
+ className={cx(
56
+ fieldSlots.label,
57
+ labelCss ? css(labelCss) : undefined,
58
+ )}
59
+ >
60
+ {label}
61
+ </RACLabel>
62
+ )}
63
+ <RACGroup
64
+ className={cx(slots.group, groupCss ? css(groupCss) : undefined)}
65
+ >
66
+ <RACInput
67
+ ref={ref}
68
+ className={cx(
69
+ input(),
70
+ // Room for the stepper column.
71
+ css({ paddingEnd: "6" }, inputCss),
72
+ )}
73
+ />
74
+ <div className={slots.stepper}>
75
+ <RACButton slot="increment" className={slots.stepperButton}>
76
+ <Icon as={RiArrowUpSFill} />
77
+ </RACButton>
78
+ <RACButton slot="decrement" className={slots.stepperButton}>
79
+ <Icon as={RiArrowDownSFill} />
80
+ </RACButton>
81
+ </div>
82
+ </RACGroup>
83
+ </RACNumberField>
84
+ );
85
+ },
86
+ );
@@ -0,0 +1,67 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { OverlayArrow } from "react-aria-components";
7
+ import { css, cx } from "styled-system/css";
8
+ import { SystemStyleObject } from "styled-system/types";
9
+
10
+ // react-aria positions the arrow and stamps the resolved side on the wrapper
11
+ // as data-placement, but leaves orienting the glyph to us. The svg is square
12
+ // with the triangle in the half nearest the overlay (tip at centre), so
13
+ // rotating about the centre keeps it flush against the overlay on every side
14
+ // — no translate fix-ups, whose signs depend on rotation direction and are
15
+ // easy to get wrong (they detached the tooltip arrow from its box).
16
+ //
17
+ // "Flush" still antialiases to a hairline seam when the overlay's edge lands
18
+ // on a subpixel boundary, so each placement also pulls the svg 1px into the
19
+ // overlay with a negative margin on the overlay-facing side (margins move the
20
+ // box before the rotate, so the bleed direction is unaffected by it).
21
+ const arrowBase = css({
22
+ "& svg": { display: "block" },
23
+ "&[data-placement='top'] svg": { marginTop: "-1px" },
24
+ "&[data-placement='bottom'] svg": {
25
+ transform: "rotate(180deg)",
26
+ marginBottom: "-1px",
27
+ },
28
+ "&[data-placement='right'] svg": {
29
+ transform: "rotate(90deg)",
30
+ marginRight: "-1px",
31
+ },
32
+ "&[data-placement='left'] svg": {
33
+ transform: "rotate(-90deg)",
34
+ marginLeft: "-1px",
35
+ },
36
+ });
37
+
38
+ export interface PopoverArrowProps {
39
+ /**
40
+ * Base width of the visible triangle in px; it protrudes half this into
41
+ * the gap. Defaults to Chakra's tooltip arrow proportions (an 8px square
42
+ * rotated 45°).
43
+ */
44
+ size?: number;
45
+ /** Styles merged after the base, e.g. `{ "& svg": { fill: "white" } }`. */
46
+ css?: SystemStyleObject;
47
+ className?: string;
48
+ }
49
+
50
+ /**
51
+ * PopoverArrow — the notch pointing from a popover or tooltip at its
52
+ * trigger, oriented automatically for react-aria's resolved placement.
53
+ * Render inside a RAC Popover/Tooltip; set the fill via the css prop.
54
+ */
55
+ export const PopoverArrow = ({
56
+ size = 11.314,
57
+ css: cssProp,
58
+ className,
59
+ }: PopoverArrowProps) => (
60
+ <OverlayArrow
61
+ className={cx(arrowBase, cssProp ? css(cssProp) : undefined, className)}
62
+ >
63
+ <svg width={size} height={size} viewBox="0 0 12 12">
64
+ <path d="M0 0 L6 6 L12 0" />
65
+ </svg>
66
+ </OverlayArrow>
67
+ );
@@ -0,0 +1,61 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { ProgressBar as RACProgressBar } from "react-aria-components";
7
+ import { css } from "styled-system/css";
8
+ import { SystemStyleObject } from "styled-system/types";
9
+
10
+ export interface ProgressBarProps {
11
+ /** 0–100. */
12
+ value: number;
13
+ "aria-label": string;
14
+ /** Track overrides (size, radius, width), merged after the base. */
15
+ css?: SystemStyleObject;
16
+ /** Fill overrides (colour), merged after the base. */
17
+ barCss?: SystemStyleObject;
18
+ }
19
+
20
+ /**
21
+ * ProgressBar — react-aria-components <ProgressBar> styled like Chakra's md
22
+ * Progress (12px gray.100 track). Set the fill colour per call site via
23
+ * `barCss` (Chakra's colorScheme).
24
+ */
25
+ export const ProgressBar = ({
26
+ value,
27
+ "aria-label": ariaLabel,
28
+ css: cssProp,
29
+ barCss,
30
+ }: ProgressBarProps) => (
31
+ <RACProgressBar
32
+ value={value}
33
+ aria-label={ariaLabel}
34
+ // Single css() calls so caller overrides of base properties (radius, fill
35
+ // colour) are deduped at merge time rather than racing on stylesheet order.
36
+ className={css(
37
+ {
38
+ width: "100%",
39
+ height: 3,
40
+ bg: "gray.100",
41
+ overflow: "hidden",
42
+ borderRadius: "sm",
43
+ },
44
+ cssProp,
45
+ )}
46
+ >
47
+ {({ percentage }) => (
48
+ <div
49
+ className={css(
50
+ {
51
+ height: "100%",
52
+ bg: "progressFilledTrack",
53
+ transition: "width 0.2s",
54
+ },
55
+ barCss,
56
+ )}
57
+ style={{ width: `${percentage ?? 0}%` }}
58
+ />
59
+ )}
60
+ </RACProgressBar>
61
+ );