@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
@@ -0,0 +1,107 @@
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
+ * Radio slot recipe — Chakra's radio with the default blue colorScheme
10
+ * (light mode) and its sm/md/lg size scale: the checkbox control rounded
11
+ * fully, with a 50% `currentColor` dot when selected instead of the check
12
+ * glyph. As with the checkbox, `borderColor: inherit` picks up a
13
+ * `borderColor` set on the root.
14
+ *
15
+ * State styling keys off data attributes stamped by the shared-ui Radio
16
+ * (react-aria provides the state via render props).
17
+ *
18
+ * Registered in the base preset (base-preset.ts), which also has the
19
+ * `staticCss` entry that keeps the runtime-prop size variants generated.
20
+ */
21
+ export const radio = defineSlotRecipe({
22
+ className: "radio",
23
+ slots: ["root", "control", "label"],
24
+ base: {
25
+ root: {
26
+ display: "inline-flex",
27
+ alignItems: "center",
28
+ verticalAlign: "top",
29
+ cursor: "pointer",
30
+ position: "relative",
31
+ "&[data-disabled]": { cursor: "not-allowed" },
32
+ },
33
+ control: {
34
+ display: "inline-flex",
35
+ alignItems: "center",
36
+ justifyContent: "center",
37
+ flexShrink: 0,
38
+ transitionProperty: "box-shadow",
39
+ transitionDuration: "normal",
40
+ borderWidth: "2px",
41
+ borderStyle: "solid",
42
+ borderRadius: "full",
43
+ borderColor: "inherit",
44
+ color: "white",
45
+ bg: "white",
46
+ "&[data-selected]": {
47
+ bg: "controlCheckedBg",
48
+ borderColor: "controlCheckedBg",
49
+ color: "white",
50
+ _hover: {
51
+ bg: "controlCheckedHoverBg",
52
+ borderColor: "controlCheckedHoverBg",
53
+ },
54
+ // Chakra's radio dot.
55
+ _before: {
56
+ content: '""',
57
+ display: "inline-block",
58
+ position: "relative",
59
+ width: "50%",
60
+ height: "50%",
61
+ borderRadius: "50%",
62
+ bg: "currentColor",
63
+ },
64
+ },
65
+ // Chakra's disabled greys; the selected block restates _hover so the
66
+ // widened native-:hover condition can't re-tint a disabled control.
67
+ "&[data-disabled]": {
68
+ bg: "gray.100",
69
+ borderColor: "gray.100",
70
+ },
71
+ "&[data-selected][data-disabled]": {
72
+ bg: "gray.200",
73
+ borderColor: "gray.200",
74
+ color: "gray.500",
75
+ _hover: { bg: "gray.200", borderColor: "gray.200" },
76
+ },
77
+ "&[data-focus-visible]": {
78
+ focusShadow: "outline",
79
+ },
80
+ },
81
+ label: {
82
+ userSelect: "none",
83
+ marginStart: "2",
84
+ "&[data-disabled]": { opacity: 0.4 },
85
+ },
86
+ },
87
+ variants: {
88
+ // Chakra's Radio size scale (the Checkbox control scale).
89
+ size: {
90
+ sm: {
91
+ control: { width: "3", height: "3" },
92
+ label: { fontSize: "sm" },
93
+ },
94
+ md: {
95
+ control: { width: "4", height: "4" },
96
+ label: { fontSize: "md" },
97
+ },
98
+ lg: {
99
+ control: { width: "5", height: "5" },
100
+ label: { fontSize: "lg" },
101
+ },
102
+ },
103
+ },
104
+ defaultVariants: {
105
+ size: "md",
106
+ },
107
+ });
package/src/Radio.tsx ADDED
@@ -0,0 +1,89 @@
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
+ Radio as RACRadio,
9
+ RadioProps as RACRadioProps,
10
+ RadioGroup as RACRadioGroup,
11
+ RadioGroupProps as RACRadioGroupProps,
12
+ } from "react-aria-components";
13
+ import { css, cx } from "styled-system/css";
14
+ import { radio, RadioVariantProps } from "styled-system/recipes";
15
+ import { SystemStyleObject } from "styled-system/types";
16
+
17
+ export interface RadioGroupProps
18
+ extends Omit<RACRadioGroupProps, "className" | "style"> {
19
+ /** Per-instance style overrides, merged after the recipe. */
20
+ css?: SystemStyleObject;
21
+ className?: string;
22
+ }
23
+
24
+ /**
25
+ * RadioGroup — react-aria-components <RadioGroup> for a set of Radios. Carries
26
+ * no styling of its own: compose with Stack for layout, as Chakra call sites
27
+ * did.
28
+ */
29
+ export const RadioGroup = ({
30
+ css: cssProp,
31
+ className,
32
+ ...rest
33
+ }: RadioGroupProps) => {
34
+ return (
35
+ <RACRadioGroup
36
+ className={cx(cssProp ? css(cssProp) : undefined, className)}
37
+ {...rest}
38
+ />
39
+ );
40
+ };
41
+
42
+ export interface RadioProps
43
+ extends Omit<RACRadioProps, "className" | "children" | "style">,
44
+ RadioVariantProps {
45
+ /** Per-instance style overrides for the root, merged after the recipe. */
46
+ css?: SystemStyleObject;
47
+ className?: string;
48
+ children?: ReactNode;
49
+ }
50
+
51
+ /**
52
+ * Radio — react-aria-components <Radio> styled like Chakra's radio. Must be
53
+ * rendered inside a RadioGroup. Children render as the label.
54
+ */
55
+ export const Radio = ({
56
+ size,
57
+ css: cssProp,
58
+ className,
59
+ children,
60
+ ...rest
61
+ }: RadioProps) => {
62
+ const slots = radio({ size });
63
+ return (
64
+ <RACRadio
65
+ className={cx(slots.root, cssProp ? css(cssProp) : undefined, className)}
66
+ {...rest}
67
+ >
68
+ {({ isSelected, isFocusVisible, isDisabled }) => (
69
+ <>
70
+ <span
71
+ className={slots.control}
72
+ data-selected={isSelected || undefined}
73
+ data-focus-visible={isFocusVisible || undefined}
74
+ data-disabled={isDisabled || undefined}
75
+ aria-hidden
76
+ />
77
+ {children != null && (
78
+ <span
79
+ className={slots.label}
80
+ data-disabled={isDisabled || undefined}
81
+ >
82
+ {children}
83
+ </span>
84
+ )}
85
+ </>
86
+ )}
87
+ </RACRadio>
88
+ );
89
+ };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { createContext, ReactNode, useContext, useMemo } from "react";
7
+
8
+ /**
9
+ * Registers the close function of the currently open dismissable overlay, or
10
+ * clears it (null) when the overlay closes. Installed by apps that need to
11
+ * dismiss overlays from outside the component tree — e.g. the Android
12
+ * hardware back button. Only one overlay is open at a time.
13
+ */
14
+ export type OverlayCloseRegistrar = (close: (() => void) | null) => void;
15
+
16
+ interface SharedUIContextValue {
17
+ overlayCloseRegistrar?: OverlayCloseRegistrar;
18
+ }
19
+
20
+ const SharedUIContext = createContext<SharedUIContextValue | null>(null);
21
+
22
+ export interface SharedUIProviderProps {
23
+ overlayCloseRegistrar?: OverlayCloseRegistrar;
24
+ children: ReactNode;
25
+ }
26
+
27
+ /**
28
+ * SharedUIProvider — the app-side installation point for optional shared-ui
29
+ * integrations. Currently that is only the overlay-close registrar, so apps
30
+ * without one can omit the provider entirely. Localized strings come from
31
+ * react-intl: an IntlProvider must be mounted above shared-ui components
32
+ * (see the package README for merging this package's message catalogs).
33
+ */
34
+ export const SharedUIProvider = ({
35
+ overlayCloseRegistrar,
36
+ children,
37
+ }: SharedUIProviderProps) => {
38
+ const value = useMemo(
39
+ () => ({ overlayCloseRegistrar }),
40
+ [overlayCloseRegistrar],
41
+ );
42
+ return (
43
+ <SharedUIContext.Provider value={value}>
44
+ {children}
45
+ </SharedUIContext.Provider>
46
+ );
47
+ };
48
+
49
+ /**
50
+ * The app-installed overlay-close registrar, if any. Overlay components run
51
+ * controlled and register their close function while open so the app can
52
+ * dismiss them; without a registrar they manage open state internally.
53
+ */
54
+ export const useOverlayCloseRegistrar = (): OverlayCloseRegistrar | undefined =>
55
+ useContext(SharedUIContext)?.overlayCloseRegistrar;
package/src/Slide.tsx ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { ReactNode } from "react";
7
+ import { css, cx } from "styled-system/css";
8
+ import { SystemStyleObject } from "styled-system/types";
9
+
10
+ export interface SlideProps {
11
+ /** Slides in when true; sits translated offscreen when false. */
12
+ isOpen: boolean;
13
+ /** Per-instance style overrides (e.g. zIndex), merged after the base. */
14
+ css?: SystemStyleObject;
15
+ className?: string;
16
+ children: ReactNode;
17
+ }
18
+
19
+ /**
20
+ * Slide — Chakra's Slide transition, bottom edge only (add other directions
21
+ * when a use appears): a full-width panel pinned to the viewport bottom,
22
+ * translated offscreen when closed. Content stays mounted throughout, so
23
+ * closing doesn't drop in-flight state and the exit animates.
24
+ */
25
+ export const Slide = ({
26
+ isOpen,
27
+ css: cssProp,
28
+ className,
29
+ children,
30
+ }: SlideProps) => (
31
+ <div
32
+ data-open={isOpen ? "" : undefined}
33
+ className={cx(
34
+ css(
35
+ {
36
+ position: "fixed",
37
+ left: 0,
38
+ right: 0,
39
+ bottom: 0,
40
+ transform: "translateY(100%)",
41
+ "&[data-open]": { transform: "translateY(0)" },
42
+ transitionProperty: "transform",
43
+ transitionDuration: "0.25s",
44
+ transitionTimingFunction: "cubic-bezier(0, 0, 0.2, 1)",
45
+ _motionReduce: { transition: "none" },
46
+ },
47
+ cssProp,
48
+ ),
49
+ className,
50
+ )}
51
+ >
52
+ {children}
53
+ </div>
54
+ );
@@ -0,0 +1,102 @@
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
+ * Slider slot recipe — Chakra's horizontal md slider (3.5-token thumb, 1-token
10
+ * gray.200 track, blue filled track; light mode). Consumed by the shared-ui
11
+ * Slider, which maps the slots onto react-aria-components'
12
+ * Slider/SliderTrack/SliderThumb.
13
+ *
14
+ * Geometry matches Chakra's DOM: the root is thumb-height with NO horizontal
15
+ * padding, the track is absolutely centred inside it at full width, and the
16
+ * thumb overhangs the track ends at 0%/100%. Call sites position overlays
17
+ * (marks, value labels) against that root box, so Chakra-era offsets work
18
+ * unchanged. Sizes are tokens, not px, so apps with resized scales (e.g.
19
+ * python-editor's ×0.88) get their Chakra-equivalent geometry.
20
+ *
21
+ * The `mark` slot is hidden until the slider has focus (Chakra call sites did
22
+ * this with `_focusWithin` + a class); the reveal lives here so call sites
23
+ * only style the mark's look.
24
+ *
25
+ * Registered in the base preset (base-preset.ts). No variants, so it
26
+ * needs no `staticCss` entry.
27
+ */
28
+ export const slider = defineSlotRecipe({
29
+ className: "slider",
30
+ slots: ["root", "track", "filledTrack", "thumb", "mark"],
31
+ base: {
32
+ root: {
33
+ position: "relative",
34
+ display: "block",
35
+ width: "100%",
36
+ // Thumb-height, like Chakra's container; the track centres inside.
37
+ height: "3.5",
38
+ touchAction: "none",
39
+ "&:focus-within [data-part='mark']": {
40
+ display: "block",
41
+ },
42
+ "&[data-disabled]": {
43
+ opacity: 0.4,
44
+ cursor: "not-allowed",
45
+ },
46
+ },
47
+ track: {
48
+ position: "absolute",
49
+ top: "50%",
50
+ transform: "translateY(-50%)",
51
+ left: 0,
52
+ width: "100%",
53
+ height: "1",
54
+ overflow: "hidden",
55
+ borderRadius: "sm",
56
+ bg: "gray.200",
57
+ cursor: "pointer",
58
+ "[data-disabled] &": {
59
+ bg: "gray.300",
60
+ cursor: "default",
61
+ },
62
+ },
63
+ filledTrack: {
64
+ position: "absolute",
65
+ top: 0,
66
+ left: 0,
67
+ height: "100%",
68
+ bg: "sliderFilledTrack",
69
+ },
70
+ thumb: {
71
+ position: "absolute",
72
+ top: "50%",
73
+ width: "3.5",
74
+ height: "3.5",
75
+ display: "flex",
76
+ alignItems: "center",
77
+ justifyContent: "center",
78
+ outline: "none",
79
+ zIndex: 1,
80
+ borderRadius: "full",
81
+ bg: "white",
82
+ boxShadow: "base",
83
+ transitionProperty: "transform",
84
+ transitionDuration: "normal",
85
+ "&[data-dragging]": {
86
+ transform: "translate(-50%, -50%) scale(1.15)",
87
+ },
88
+ transform: "translate(-50%, -50%)",
89
+ "&[data-focus-visible]": {
90
+ focusShadow: "outline",
91
+ },
92
+ "&[data-disabled]": {
93
+ bg: "gray.300",
94
+ },
95
+ },
96
+ mark: {
97
+ display: "none",
98
+ position: "absolute",
99
+ whiteSpace: "nowrap",
100
+ },
101
+ },
102
+ });
package/src/Slider.tsx ADDED
@@ -0,0 +1,163 @@
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
+ Slider as RACSlider,
9
+ SliderThumb,
10
+ SliderTrack,
11
+ } from "react-aria-components";
12
+ import { css, cx } from "styled-system/css";
13
+ import { slider } from "styled-system/recipes";
14
+ import { SystemStyleObject } from "styled-system/types";
15
+
16
+ export interface SliderProps {
17
+ value: number;
18
+ onChange: (value: number) => void;
19
+ minValue?: number;
20
+ maxValue?: number;
21
+ "aria-label": string;
22
+ isDisabled?: boolean;
23
+ /**
24
+ * Number format for the value announced to assistive tech (e.g.
25
+ * `{ style: "unit", unit: "percent" }` so units are heard, not bare
26
+ * numbers).
27
+ */
28
+ formatOptions?: Intl.NumberFormatOptions;
29
+ /** Per-instance style overrides, merged after the recipe. */
30
+ css?: SystemStyleObject;
31
+ trackCss?: SystemStyleObject;
32
+ filledTrackCss?: SystemStyleObject;
33
+ thumbCss?: SystemStyleObject;
34
+ /**
35
+ * Positioned at the current value along the track and shown while the
36
+ * slider has focus (Chakra's SliderMark usage).
37
+ */
38
+ mark?: ReactNode;
39
+ markCss?: SystemStyleObject;
40
+ /**
41
+ * Additional positioned overlays rendered inside the slider root
42
+ * (always-visible value labels, threshold markers, ...). The root is
43
+ * position: relative; position children absolutely, typically with a
44
+ * percentage `left` for the track position.
45
+ */
46
+ children?: ReactNode;
47
+ /**
48
+ * Tooltip-styled bubble anchored above the thumb (Chakra's
49
+ * Tooltip-around-SliderThumb pattern). Rendered only while
50
+ * `isThumbTooltipOpen`; drive it from hover/focus, e.g. via
51
+ * `onThumbFocusChange` and mouse handlers on an enclosing element.
52
+ */
53
+ thumbTooltip?: ReactNode;
54
+ isThumbTooltipOpen?: boolean;
55
+ /** Thumb focus tracking (react-aria focus events). */
56
+ onThumbFocusChange?: (isFocused: boolean) => void;
57
+ }
58
+
59
+ /**
60
+ * Slider — react-aria-components <Slider> styled like Chakra's horizontal md
61
+ * slider.
62
+ */
63
+ export const Slider = ({
64
+ value,
65
+ onChange,
66
+ minValue = 0,
67
+ maxValue = 100,
68
+ "aria-label": ariaLabel,
69
+ isDisabled,
70
+ formatOptions,
71
+ css: cssProp,
72
+ trackCss,
73
+ filledTrackCss,
74
+ thumbCss,
75
+ mark,
76
+ markCss,
77
+ children,
78
+ thumbTooltip,
79
+ isThumbTooltipOpen,
80
+ onThumbFocusChange,
81
+ }: SliderProps) => {
82
+ const slots = slider();
83
+ const percent = ((value - minValue) / (maxValue - minValue)) * 100;
84
+ return (
85
+ <RACSlider
86
+ value={value}
87
+ onChange={onChange}
88
+ minValue={minValue}
89
+ maxValue={maxValue}
90
+ aria-label={ariaLabel}
91
+ isDisabled={isDisabled}
92
+ formatOptions={formatOptions}
93
+ className={cx(slots.root, cssProp ? css(cssProp) : undefined)}
94
+ >
95
+ {mark && (
96
+ <div
97
+ data-part="mark"
98
+ className={cx(slots.mark, markCss ? css(markCss) : undefined)}
99
+ style={{ left: `${percent}%` }}
100
+ >
101
+ {mark}
102
+ </div>
103
+ )}
104
+ <SliderTrack
105
+ className={cx(slots.track, trackCss ? css(trackCss) : undefined)}
106
+ >
107
+ <div
108
+ className={cx(
109
+ slots.filledTrack,
110
+ filledTrackCss ? css(filledTrackCss) : undefined,
111
+ )}
112
+ style={{ width: `${percent}%` }}
113
+ />
114
+ </SliderTrack>
115
+ {thumbTooltip && isThumbTooltipOpen && (
116
+ // Matches the shared Tooltip's look (tooltipBase) with a bottom
117
+ // arrow, anchored above the thumb.
118
+ <div
119
+ role="presentation"
120
+ className={css({
121
+ position: "absolute",
122
+ bottom: "calc(50% + token(sizes.3.5))",
123
+ transform: "translateX(-50%)",
124
+ bg: "gray.700",
125
+ color: "white",
126
+ px: "2",
127
+ py: "1",
128
+ borderRadius: "md",
129
+ fontSize: "sm",
130
+ fontWeight: "medium",
131
+ boxShadow: "md",
132
+ zIndex: "tooltip",
133
+ whiteSpace: "nowrap",
134
+ _after: {
135
+ content: '""',
136
+ position: "absolute",
137
+ // 1px into the box so subpixel edges can't antialias into a
138
+ // hairline seam (see PopoverArrow).
139
+ top: "calc(100% - 1px)",
140
+ left: "50%",
141
+ transform: "translateX(-50%)",
142
+ borderWidth: "4px",
143
+ borderStyle: "solid",
144
+ // Per-side: tokens don't resolve inside multi-value
145
+ // shorthands (they emit verbatim and the browser drops the
146
+ // invalid declaration).
147
+ borderColor: "transparent",
148
+ borderTopColor: "gray.700",
149
+ },
150
+ })}
151
+ style={{ left: `${percent}%` }}
152
+ >
153
+ {thumbTooltip}
154
+ </div>
155
+ )}
156
+ <SliderThumb
157
+ onFocusChange={onThumbFocusChange}
158
+ className={cx(slots.thumb, thumbCss ? css(thumbCss) : undefined)}
159
+ />
160
+ {children}
161
+ </RACSlider>
162
+ );
163
+ };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { CSSProperties } from "react";
7
+ import { css, cx } from "styled-system/css";
8
+ import { SystemStyleObject } from "styled-system/types";
9
+
10
+ export interface SpinnerProps {
11
+ /** Chakra Spinner sizes: sm 1rem, md 1.5rem (default). */
12
+ size?: "sm" | "md";
13
+ /**
14
+ * Revolution time (Chakra's `speed`, default 0.45s). Under
15
+ * prefers-reduced-motion the spin is slowed to ~3x this value.
16
+ */
17
+ speed?: string;
18
+ /** Per-instance style overrides, merged after the base. */
19
+ css?: SystemStyleObject;
20
+ className?: string;
21
+ /**
22
+ * Accessible name. Required: Chakra's Spinner announced a visually hidden
23
+ * "Loading..." by default, so a nameless spinner would regress on it.
24
+ */
25
+ "aria-label": string;
26
+ }
27
+
28
+ /**
29
+ * Spinner — Chakra's border-based spinner (currentColor with transparent
30
+ * bottom/left quadrants).
31
+ */
32
+ export const Spinner = ({
33
+ size = "md",
34
+ speed,
35
+ css: cssProp,
36
+ className,
37
+ "aria-label": ariaLabel,
38
+ }: SpinnerProps) => (
39
+ <span
40
+ role="status"
41
+ aria-label={ariaLabel}
42
+ style={speed ? ({ "--spinner-speed": speed } as CSSProperties) : undefined}
43
+ className={cx(
44
+ // Single css() call so caller overrides of base properties (e.g. width)
45
+ // are deduped at merge time rather than racing on stylesheet order.
46
+ css(
47
+ {
48
+ display: "inline-block",
49
+ borderWidth: "2px",
50
+ borderStyle: "solid",
51
+ borderColor: "currentColor",
52
+ borderBottomColor: "transparent",
53
+ borderLeftColor: "transparent",
54
+ borderRadius: "full",
55
+ // The duration rides a variable so the reduced-motion slow-down
56
+ // scales with a per-instance `speed` rather than fighting it.
57
+ animation: "spin var(--spinner-speed, 0.45s) linear infinite",
58
+ _motionReduce: {
59
+ animationDuration: "calc(var(--spinner-speed, 0.45s) * 3.33)",
60
+ },
61
+ width: size === "sm" ? "1rem" : "1.5rem",
62
+ height: size === "sm" ? "1rem" : "1.5rem",
63
+ },
64
+ cssProp,
65
+ ),
66
+ className,
67
+ )}
68
+ />
69
+ );
package/src/Svg.tsx ADDED
@@ -0,0 +1,23 @@
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
+ * Svg — a Panda-styled svg element with Chakra Icon's base sizing (1em,
10
+ * inline-block). For custom-path icons; react-icons glyphs use `Icon`.
11
+ * As a styled-factory component its call-site style props are extracted
12
+ * (unlike style props forwarded through a plain wrapper — gotcha #9).
13
+ */
14
+ export const Svg = styled("svg", {
15
+ base: {
16
+ width: "1em",
17
+ height: "1em",
18
+ display: "inline-block",
19
+ lineHeight: "1em",
20
+ flexShrink: 0,
21
+ verticalAlign: "middle",
22
+ },
23
+ });