@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/Modal.tsx ADDED
@@ -0,0 +1,377 @@
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
+ DialogTrigger as RACDialogTrigger,
17
+ Heading as RACHeading,
18
+ Modal as RACModal,
19
+ ModalOverlay,
20
+ OverlayTriggerStateContext,
21
+ } from "react-aria-components";
22
+ import { css, cx } from "styled-system/css";
23
+ import { dialog } from "styled-system/recipes";
24
+ import { ConditionalValue, SystemStyleObject } from "styled-system/types";
25
+ import { useIntl } from "react-intl";
26
+ import { CloseIcon } from "./CloseIcon";
27
+ import { dataAttrs } from "./data-attrs";
28
+ import { uiMessage } from "./messages";
29
+ import { UnmountCallback } from "./UnmountCallback";
30
+
31
+ type DialogSlots = ReturnType<typeof dialog>;
32
+
33
+ const SlotContext = createContext<{ slots: DialogSlots; onClose: () => void }>({
34
+ slots: dialog({}),
35
+ onClose: () => undefined,
36
+ });
37
+
38
+ const useDialog = () => useContext(SlotContext);
39
+
40
+ export type ModalSize = ConditionalValue<
41
+ | "xs"
42
+ | "sm"
43
+ | "md"
44
+ | "lg"
45
+ | "xl"
46
+ | "2xl"
47
+ | "3xl"
48
+ | "4xl"
49
+ | "5xl"
50
+ | "6xl"
51
+ | "full"
52
+ >;
53
+
54
+ export interface ModalOwnProps {
55
+ size?: ModalSize;
56
+ /** Allow closing by clicking the backdrop (default true; Escape always closes). */
57
+ isDismissable?: boolean;
58
+ /** Disable the enter/exit transitions (Chakra's motionPreset="none"). */
59
+ motionless?: boolean;
60
+ /** Prevent Escape closing the dialog (Chakra's closeOnEsc={false}). */
61
+ isKeyboardDismissDisabled?: boolean;
62
+ /** Style overrides for the dialog box (Chakra's ModalContent props). */
63
+ contentCss?: SystemStyleObject;
64
+ /**
65
+ * Inline styles for the dialog box, for runtime-computed positioning that
66
+ * Panda can't statically extract (e.g. a dialog aligned to a measured
67
+ * element). Prefer `contentCss` for static styles.
68
+ */
69
+ contentStyle?: CSSProperties;
70
+ /**
71
+ * Style overrides for the backdrop (Chakra's ModalOverlay props), e.g. a
72
+ * transparent backdrop when something else provides the dimming.
73
+ */
74
+ overlayCss?: SystemStyleObject;
75
+ /**
76
+ * Use "alertdialog" for confirmations that interrupt the user (Chakra's
77
+ * AlertDialog).
78
+ */
79
+ role?: "dialog" | "alertdialog";
80
+ /** Vertically centre the dialog (Chakra's `isCentered`). */
81
+ isCentered?: boolean;
82
+ /**
83
+ * Called after the dialog has fully closed (exit transition done and the
84
+ * dialog removed). Matches Chakra's `onCloseComplete`.
85
+ */
86
+ onCloseComplete?: () => void;
87
+ /**
88
+ * Element to focus when the dialog closes, instead of the element that was
89
+ * focused when it opened. Matches Chakra's `finalFocusRef`.
90
+ */
91
+ finalFocusRef?: RefObject<HTMLElement>;
92
+ /**
93
+ * Accessible name for dialogs without a ModalHeader (which otherwise
94
+ * provides the label). One of the two is required — react-aria warns in
95
+ * dev builds when a dialog has neither. An explicit `aria-label` wins
96
+ * over a ModalHeader.
97
+ */
98
+ "aria-label"?: string;
99
+ children: ReactNode;
100
+ /**
101
+ * `data-*` attributes land on the dialog box (where Chakra's went, on
102
+ * ModalContent), so end-to-end tests can address a dialog. Shells that
103
+ * forward their caller's data attributes can spread them straight in.
104
+ */
105
+ [key: `data-${string}`]: unknown;
106
+ }
107
+
108
+ /**
109
+ * A Modal you drive yourself. Also the type for a component that *forwards*
110
+ * modal props — `Omit<ControlledModalProps, "children">` — because a spread
111
+ * cannot be matched against the union `ModalProps` is: TypeScript has no way
112
+ * to know which half of it an object with `isOpen?: boolean` satisfies.
113
+ */
114
+ export type ControlledModalProps = ModalOwnProps & {
115
+ /** Whether the dialog is showing. */
116
+ isOpen: boolean;
117
+ /** Called when the dialog asks to close. */
118
+ onClose: () => void;
119
+ };
120
+
121
+ /**
122
+ * The props of a `Modal`: its own, plus an open state that is either entirely
123
+ * yours or entirely a `DialogTrigger`'s. Never half of each — `isOpen`
124
+ * without `onClose` leaves the close button and Escape with nothing to call,
125
+ * so the pair is enforced rather than merely documented.
126
+ */
127
+ export type ModalProps =
128
+ | ControlledModalProps
129
+ | (ModalOwnProps & { isOpen?: never; onClose?: never });
130
+
131
+ /**
132
+ * Modal — a focus-trapping dialog. Collapses Chakra's
133
+ * Modal/ModalOverlay/ModalContent into a single shell; place ModalHeader,
134
+ * ModalBody and ModalFooter inside.
135
+ *
136
+ * Two ways to drive it:
137
+ *
138
+ * - **Controlled** (`isOpen` + `onClose`), which is what a Chakra app ports
139
+ * to, and what any dialog with more than one opener needs — a menu item and
140
+ * a toolbar button opening the same dialog, or one opened from a handler
141
+ * after an async result.
142
+ * - **Inside a `DialogTrigger`**, with neither prop: react-aria holds the
143
+ * open state, the trigger opens it, and `ModalCloseButton` and the footer's
144
+ * `useDialogClose()` still close it. Prefer this where a dialog has exactly
145
+ * one trigger sitting next to it — there is no state to hold, and none to
146
+ * get out of step.
147
+ */
148
+ export const Modal = ({
149
+ isOpen,
150
+ onClose,
151
+ size,
152
+ isDismissable = true,
153
+ motionless,
154
+ isKeyboardDismissDisabled,
155
+ contentCss,
156
+ contentStyle,
157
+ overlayCss,
158
+ role,
159
+ isCentered,
160
+ onCloseComplete,
161
+ finalFocusRef,
162
+ "aria-label": ariaLabel,
163
+ children,
164
+ ...rest
165
+ }: ModalProps) => {
166
+ // Set by a DialogTrigger (or any react-aria overlay trigger) above us. When
167
+ // `isOpen` is given it is ignored: RAC's ModalOverlay prefers an explicit
168
+ // prop over the context, and so do we for the close function.
169
+ const triggerState = useContext(OverlayTriggerStateContext);
170
+ const close = onClose ?? (() => triggerState?.close());
171
+ const dataProps = dataAttrs(rest);
172
+ const slots = dialog({ size, centered: isCentered });
173
+ const motionlessClass = motionless
174
+ ? css({
175
+ transition: "none",
176
+ "&[data-entering]": { opacity: 1, transform: "none" },
177
+ "&[data-exiting]": { opacity: 1, transform: "none" },
178
+ })
179
+ : undefined;
180
+ const handleUnmount = () => {
181
+ onCloseComplete?.();
182
+ const el = finalFocusRef?.current;
183
+ if (el) {
184
+ // After RAC's own focus restoration, which also runs on unmount.
185
+ requestAnimationFrame(() => el.focus());
186
+ }
187
+ };
188
+ return (
189
+ <ModalOverlay
190
+ isOpen={isOpen}
191
+ onOpenChange={(open) => {
192
+ if (!open) {
193
+ close();
194
+ }
195
+ }}
196
+ isDismissable={isDismissable}
197
+ isKeyboardDismissDisabled={isKeyboardDismissDisabled}
198
+ className={cx(
199
+ slots.overlay,
200
+ motionlessClass,
201
+ overlayCss ? css(overlayCss) : undefined,
202
+ )}
203
+ >
204
+ <UnmountCallback callback={handleUnmount} />
205
+ <RACModal
206
+ {...dataProps}
207
+ style={contentStyle}
208
+ className={cx(
209
+ slots.content,
210
+ motionlessClass,
211
+ contentCss ? css(contentCss) : undefined,
212
+ )}
213
+ >
214
+ <Dialog role={role} aria-label={ariaLabel} className={slots.inner}>
215
+ <SlotContext.Provider value={{ slots, onClose: close }}>
216
+ {children}
217
+ </SlotContext.Provider>
218
+ </Dialog>
219
+ </RACModal>
220
+ </ModalOverlay>
221
+ );
222
+ };
223
+
224
+ /**
225
+ * DialogTrigger — react-aria-components' <DialogTrigger>: wrap a trigger
226
+ * element and a `Modal`, and the open state is theirs rather than yours.
227
+ *
228
+ * ```tsx
229
+ * <DialogTrigger>
230
+ * <Button>Settings</Button>
231
+ * <Modal size="lg">
232
+ * <ModalHeader>Settings</ModalHeader>
233
+ * …
234
+ * </Modal>
235
+ * </DialogTrigger>
236
+ * ```
237
+ *
238
+ * Only for a dialog with a single trigger beside it. A dialog opened from
239
+ * more than one place, from a menu item (which cannot hold a dialog — a
240
+ * non-collection child truncates the menu), or from a handler, wants the
241
+ * controlled `Modal` instead.
242
+ */
243
+ export const DialogTrigger = RACDialogTrigger;
244
+
245
+ /**
246
+ * The current dialog's close function — the same one `ModalCloseButton` uses,
247
+ * for a footer's own Cancel/Done buttons. Works in both modes, so a dialog's
248
+ * content need not know which is driving it.
249
+ */
250
+ export const useDialogClose = () => useDialog().onClose;
251
+
252
+ interface SlotProps {
253
+ children?: ReactNode;
254
+ css?: SystemStyleObject;
255
+ className?: string;
256
+ /** `data-*` attributes land on the slot element, as they did on Chakra's. */
257
+ [key: `data-${string}`]: unknown;
258
+ }
259
+
260
+ /** Modal title. Rendered as RAC's labelling heading for the dialog. */
261
+ export const ModalHeader = ({
262
+ children,
263
+ css: cssProp,
264
+ className,
265
+ level,
266
+ ...rest
267
+ }: SlotProps & {
268
+ /**
269
+ * Heading element level. Defaults to 2: RAC's Dialog supplies that through
270
+ * HeadingContext for the `title` slot, not the bare Heading default of 3.
271
+ */
272
+ level?: number;
273
+ }) => {
274
+ const { slots } = useDialog();
275
+ return (
276
+ <RACHeading
277
+ {...dataAttrs(rest)}
278
+ slot="title"
279
+ level={level}
280
+ className={cx(
281
+ slots.header,
282
+ cssProp ? css(cssProp) : undefined,
283
+ className,
284
+ )}
285
+ >
286
+ {children}
287
+ </RACHeading>
288
+ );
289
+ };
290
+
291
+ export const ModalBody = ({
292
+ children,
293
+ css: cssProp,
294
+ className,
295
+ ...rest
296
+ }: SlotProps) => {
297
+ const { slots } = useDialog();
298
+ return (
299
+ <div
300
+ {...dataAttrs(rest)}
301
+ className={cx(slots.body, cssProp ? css(cssProp) : undefined, className)}
302
+ >
303
+ {children}
304
+ </div>
305
+ );
306
+ };
307
+
308
+ export const ModalFooter = ({
309
+ children,
310
+ css: cssProp,
311
+ className,
312
+ ...rest
313
+ }: SlotProps) => {
314
+ const { slots } = useDialog();
315
+ return (
316
+ <div
317
+ {...dataAttrs(rest)}
318
+ className={cx(
319
+ slots.footer,
320
+ cssProp ? css(cssProp) : undefined,
321
+ className,
322
+ )}
323
+ >
324
+ {children}
325
+ </div>
326
+ );
327
+ };
328
+
329
+ export interface ModalCloseButtonProps {
330
+ /** Accessible name; defaults to the localized close label. */
331
+ "aria-label"?: string;
332
+ /** `data-*` attributes land on the button. */
333
+ [key: `data-${string}`]: unknown;
334
+ }
335
+
336
+ /**
337
+ * ModalCloseButton — the X in the dialog's top corner (Chakra's
338
+ * ModalCloseButton at its default md size). Closes via the Modal's onClose.
339
+ */
340
+ export const ModalCloseButton = ({
341
+ "aria-label": ariaLabel,
342
+ ...rest
343
+ }: ModalCloseButtonProps) => {
344
+ const intl = useIntl();
345
+ const { slots, onClose } = useDialog();
346
+ return (
347
+ <RACButton
348
+ {...dataAttrs(rest)}
349
+ aria-label={ariaLabel ?? intl.formatMessage(uiMessage("ui.close-action"))}
350
+ onPress={onClose}
351
+ className={cx(
352
+ slots.closeTrigger,
353
+ css({
354
+ display: "inline-flex",
355
+ alignItems: "center",
356
+ justifyContent: "center",
357
+ width: "8",
358
+ height: "8",
359
+ fontSize: "xs",
360
+ borderRadius: "md",
361
+ cursor: "pointer",
362
+ bg: "transparent",
363
+ border: "none",
364
+ color: "inherit",
365
+ outline: "none",
366
+ transitionProperty: "background-color, box-shadow",
367
+ transitionDuration: "normal",
368
+ _hover: { bg: "blackAlpha.100" },
369
+ _active: { bg: "blackAlpha.200" },
370
+ _focusVisible: { focusShadow: "outline" },
371
+ }),
372
+ )}
373
+ >
374
+ <CloseIcon />
375
+ </RACButton>
376
+ );
377
+ };
@@ -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
+ );