@microbit/ui 0.1.0-alpha.4 → 0.1.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Menu.tsx CHANGED
@@ -5,17 +5,21 @@
5
5
  */
6
6
  import { ReactNode, useCallback, useState } from "react";
7
7
  import {
8
+ Header as RACHeader,
8
9
  Menu as RACMenu,
9
10
  MenuItem as RACMenuItem,
10
11
  MenuItemProps as RACMenuItemProps,
12
+ MenuSection as RACMenuSection,
11
13
  MenuTrigger as RACMenuTrigger,
12
14
  Popover,
13
15
  PopoverProps,
14
16
  Separator,
15
17
  } from "react-aria-components";
18
+ import { RiCheckLine } from "react-icons/ri";
16
19
  import { css, cx } from "styled-system/css";
17
20
  import { menu } from "styled-system/recipes";
18
21
  import { SystemStyleObject } from "styled-system/types";
22
+ import { Icon } from "./Icon";
19
23
  import { useOverlayCloseRegistrar } from "./SharedUIProvider";
20
24
 
21
25
  export interface MenuTriggerProps {
@@ -152,6 +156,91 @@ export const MenuItem = ({
152
156
  );
153
157
  };
154
158
 
159
+ export interface MenuOptionGroupProps {
160
+ /** Group heading shown above the options (Chakra's `title`). */
161
+ title?: ReactNode;
162
+ /** The selected `MenuItemOption`'s value (radio semantics). */
163
+ value?: string;
164
+ /** Called with the newly selected option's value. */
165
+ onChange?: (value: string) => void;
166
+ /** `MenuItemOption` children. */
167
+ children: ReactNode;
168
+ css?: SystemStyleObject;
169
+ className?: string;
170
+ }
171
+
172
+ /**
173
+ * MenuOptionGroup — a single-select (radio) group of `MenuItemOption`s within
174
+ * a menu, replacing Chakra's `MenuOptionGroup type="radio"`. Selection is
175
+ * section-scoped (RAC MenuSection), so a menu can mix action items and option
176
+ * groups.
177
+ */
178
+ export const MenuOptionGroup = ({
179
+ title,
180
+ value,
181
+ onChange,
182
+ children,
183
+ css: cssProp,
184
+ className,
185
+ }: MenuOptionGroupProps) => {
186
+ const slots = menu();
187
+ return (
188
+ <RACMenuSection
189
+ className={cx(slots.group, cssProp ? css(cssProp) : undefined, className)}
190
+ selectionMode="single"
191
+ selectedKeys={value != null ? [value] : []}
192
+ onSelectionChange={(keys) => {
193
+ if (keys !== "all") {
194
+ const key = keys.values().next().value;
195
+ if (key != null) {
196
+ onChange?.(String(key));
197
+ }
198
+ }
199
+ }}
200
+ >
201
+ {title != null && (
202
+ <RACHeader className={slots.groupTitle}>{title}</RACHeader>
203
+ )}
204
+ {children}
205
+ </RACMenuSection>
206
+ );
207
+ };
208
+
209
+ export interface MenuItemOptionProps
210
+ extends Omit<RACMenuItemProps, "className" | "children" | "id" | "value"> {
211
+ /** This option's value within its `MenuOptionGroup`. */
212
+ value: string;
213
+ css?: SystemStyleObject;
214
+ className?: string;
215
+ children?: ReactNode;
216
+ }
217
+
218
+ /**
219
+ * MenuItemOption — a selectable option inside a `MenuOptionGroup`, with a
220
+ * check indicator on the selected item (Chakra's MenuItemOption).
221
+ */
222
+ export const MenuItemOption = ({
223
+ value,
224
+ css: cssProp,
225
+ className,
226
+ children,
227
+ ...rest
228
+ }: MenuItemOptionProps) => {
229
+ const slots = menu();
230
+ return (
231
+ <RACMenuItem
232
+ id={value}
233
+ className={cx(slots.item, cssProp ? css(cssProp) : undefined, className)}
234
+ {...rest}
235
+ >
236
+ <span className={slots.itemIndicator} aria-hidden>
237
+ <Icon as={RiCheckLine} />
238
+ </span>
239
+ <span className={slots.label}>{children}</span>
240
+ </RACMenuItem>
241
+ );
242
+ };
243
+
155
244
  export interface MenuDividerProps {
156
245
  css?: SystemStyleObject;
157
246
  className?: string;
package/src/Modal.tsx CHANGED
@@ -3,7 +3,13 @@
3
3
  *
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
- import { createContext, ReactNode, RefObject, useContext } from "react";
6
+ import {
7
+ createContext,
8
+ CSSProperties,
9
+ ReactNode,
10
+ RefObject,
11
+ useContext,
12
+ } from "react";
7
13
  import {
8
14
  Button as RACButton,
9
15
  Dialog,
@@ -54,6 +60,12 @@ export interface ModalProps {
54
60
  isKeyboardDismissDisabled?: boolean;
55
61
  /** Style overrides for the dialog box (Chakra's ModalContent props). */
56
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;
57
69
  /**
58
70
  * Style overrides for the backdrop (Chakra's ModalOverlay props), e.g. a
59
71
  * transparent backdrop when something else provides the dimming.
@@ -99,6 +111,7 @@ export const Modal = ({
99
111
  motionless,
100
112
  isKeyboardDismissDisabled,
101
113
  contentCss,
114
+ contentStyle,
102
115
  overlayCss,
103
116
  role,
104
117
  isCentered,
@@ -141,6 +154,7 @@ export const Modal = ({
141
154
  >
142
155
  <UnmountCallback callback={handleUnmount} />
143
156
  <RACModal
157
+ style={contentStyle}
144
158
  className={cx(
145
159
  slots.content,
146
160
  motionlessClass,
@@ -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
+ );
@@ -13,11 +13,26 @@ import { SystemStyleObject } from "styled-system/types";
13
13
  // rotating about the centre keeps it flush against the overlay on every side
14
14
  // — no translate fix-ups, whose signs depend on rotation direction and are
15
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).
16
21
  const arrowBase = css({
17
22
  "& svg": { display: "block" },
18
- "&[data-placement='bottom'] svg": { transform: "rotate(180deg)" },
19
- "&[data-placement='right'] svg": { transform: "rotate(90deg)" },
20
- "&[data-placement='left'] svg": { transform: "rotate(-90deg)" },
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
+ },
21
36
  });
22
37
 
23
38
  export interface PopoverArrowProps {
package/src/Slider.tsx CHANGED
@@ -37,6 +37,23 @@ export interface SliderProps {
37
37
  */
38
38
  mark?: ReactNode;
39
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;
40
57
  }
41
58
 
42
59
  /**
@@ -57,6 +74,10 @@ export const Slider = ({
57
74
  thumbCss,
58
75
  mark,
59
76
  markCss,
77
+ children,
78
+ thumbTooltip,
79
+ isThumbTooltipOpen,
80
+ onThumbFocusChange,
60
81
  }: SliderProps) => {
61
82
  const slots = slider();
62
83
  const percent = ((value - minValue) / (maxValue - minValue)) * 100;
@@ -91,9 +112,52 @@ export const Slider = ({
91
112
  style={{ width: `${percent}%` }}
92
113
  />
93
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% + 14px)",
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
+ )}
94
156
  <SliderThumb
157
+ onFocusChange={onThumbFocusChange}
95
158
  className={cx(slots.thumb, thumbCss ? css(thumbCss) : undefined)}
96
159
  />
160
+ {children}
97
161
  </RACSlider>
98
162
  );
99
163
  };
package/src/TextField.tsx CHANGED
@@ -30,6 +30,8 @@ export interface TextFieldProps
30
30
  /** Per-instance style overrides for the helper text. */
31
31
  helperTextCss?: SystemStyleObject;
32
32
  onFocus?: (e: FocusEvent<HTMLInputElement>) => void;
33
+ /** Input autocapitalize attribute (react-aria's TextField omits it). */
34
+ autoCapitalize?: "off" | "none" | "on" | "sentences" | "words" | "characters";
33
35
  }
34
36
 
35
37
  /**
@@ -39,7 +41,15 @@ export interface TextFieldProps
39
41
  */
40
42
  export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
41
43
  function TextField(
42
- { label, helperText, errorMessage, helperTextCss, onFocus, ...rest },
44
+ {
45
+ label,
46
+ helperText,
47
+ errorMessage,
48
+ helperTextCss,
49
+ onFocus,
50
+ autoCapitalize,
51
+ ...rest
52
+ },
43
53
  ref,
44
54
  ) {
45
55
  const slots = field();
@@ -53,7 +63,12 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
53
63
  </span>
54
64
  ) : null}
55
65
  </RACLabel>
56
- <RACInput ref={ref} className={input()} onFocus={onFocus} />
66
+ <RACInput
67
+ ref={ref}
68
+ className={input()}
69
+ onFocus={onFocus}
70
+ autoCapitalize={autoCapitalize}
71
+ />
57
72
  {helperText && (
58
73
  <RACText
59
74
  slot="description"
package/src/Toast.tsx CHANGED
@@ -118,6 +118,8 @@ export interface ToastFn {
118
118
  * re-added, so unlike Chakra it re-animates and restarts any timeout.
119
119
  */
120
120
  update(id: string, options: ToastOptions): void;
121
+ /** Dismiss all visible toasts (Chakra's toast.closeAll). */
122
+ closeAll(): void;
121
123
  }
122
124
 
123
125
  /**
@@ -161,5 +163,9 @@ export const useToast = (): ToastFn =>
161
163
  }
162
164
  add({ ...options, id });
163
165
  };
164
- return Object.assign(add, { isActive, update });
166
+ const closeAll = () => {
167
+ // Copy first: closing mutates visibleToasts as we iterate.
168
+ [...toastQueue.visibleToasts].forEach((t) => toastQueue.close(t.key));
169
+ };
170
+ return Object.assign(add, { isActive, update, closeAll });
165
171
  }, []);
@@ -20,7 +20,7 @@ import {
20
20
  sizes,
21
21
  spacing,
22
22
  zIndex,
23
- } from "./chakra-tokens";
23
+ } from "./base-tokens";
24
24
  // Config recipes are colocated with the shared-ui components they style; this
25
25
  // preset registers them so Panda merges them at codegen time.
26
26
  import { button } from "./Button.recipe";
@@ -29,6 +29,7 @@ import { checkbox } from "./Checkbox.recipe";
29
29
  import { drawer } from "./Drawer.recipe";
30
30
  import { heading } from "./Heading.recipe";
31
31
  import { input } from "./Input.recipe";
32
+ import { numberField } from "./NumberField.recipe";
32
33
  import { menu } from "./Menu.recipe";
33
34
  import { slider } from "./Slider.recipe";
34
35
  import { switchRecipe } from "./Switch.recipe";
@@ -37,8 +38,8 @@ import { field } from "./TextField.recipe";
37
38
  import { toast } from "./Toast.recipe";
38
39
 
39
40
  /**
40
- * The base preset: the complete, working micro:bit design system. Chakra v2's
41
- * default token scales (chakra-tokens.ts snapshot), the micro:bit house style
41
+ * The base preset: the complete, working micro:bit design system. The base
42
+ * token scales (base-tokens.ts), the micro:bit house style
42
43
  * (pill `radii.button`, `outline*` focus shadows, Helvetica fonts, the
43
44
  * `language`/`toolbar` button variants in Button.recipe.ts, the
44
45
  * `languageText`/`toast*Bg`/`statusBarBg` semantic tokens), the shared-ui
@@ -145,13 +146,16 @@ export const basePreset = definePreset({
145
146
  600: { value: "{colors.red.600}" },
146
147
  700: { value: "{colors.red.700}" },
147
148
  },
148
- // The `language` button variant's text colour is the one place the
149
- // brands diverge structurally (OSS uses the grey brand2 ramp, the
150
- // CreateAI brand its blue brand ramp with no hover change). Driven by
151
- // these semantic tokens so the recipe stays shared and a brand preset
152
- // overrides only the values.
153
- languageText: { value: "{colors.brand2.500}" },
154
- languageTextHover: { value: "{colors.brand2.600}" },
149
+ // The `language` button variant's text colour follows the primary
150
+ // interactive brand: every consumer resolves it to its `brand` ramp
151
+ // (CreateAI privately to brand.600 with no hover change,
152
+ // python-editor to brand.500/600 the default). Semantic tokens so
153
+ // the recipe stays shared and a brand preset overrides only values.
154
+ // (Was brand2.* — the grey ml-trainer OSS Chakra look — but both
155
+ // apps' final values sit on their primary brand, so the default
156
+ // follows; OSS language buttons are brand blue.)
157
+ languageText: { value: "{colors.brand.500}" },
158
+ languageTextHover: { value: "{colors.brand.600}" },
155
159
  // Toast status colours: the Chakra-era toast Alert restyle (teal for
156
160
  // every status except error) shared across the app family.
157
161
  toastInfoBg: { value: "{colors.teal.800}" },
@@ -175,6 +179,7 @@ export const basePreset = definePreset({
175
179
  drawer,
176
180
  field,
177
181
  menu,
182
+ numberField,
178
183
  slider,
179
184
  switchRecipe,
180
185
  toast,
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * (c) 2026, Micro:bit Educational Foundation and contributors
3
3
  *
4
+ * Token values (c) 2019, Segun Adebayo — Chakra UI's @chakra-ui/theme
5
+ * defaults, used under the MIT License (see LICENSE.md third-party notices).
6
+ *
4
7
  * SPDX-License-Identifier: MIT
5
8
  */
6
- /* eslint-disable */
7
- // GENERATED by bin/gen-chakra-tokens.mjs (snapshot of @chakra-ui/theme defaults).
8
- // Do not edit by hand. Canonical Chakra v2 foundation values in Panda token
9
- // format so the OSS preset matches Chakra exactly.
9
+ // The base token scales, in Panda token format. Began as a mechanical
10
+ // snapshot of Chakra UI v2's @chakra-ui/theme defaults; hand-maintained.
11
+ // Deliberately absent: `fonts` (foundation-owned, defined in base-preset.ts)
12
+ // and `transition.property` (no Panda token category; inlined at use sites).
10
13
 
11
14
  export const colors = {
12
15
  transparent: {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { cva } from "styled-system/css";
7
+
8
+ // Chakra's ButtonIcon: keeps the glyph centred and spaced from the label
9
+ // (iconSpacing 0.5rem). Shared by Button and LinkButton; deliberately not
10
+ // exported from the package index.
11
+ export const buttonIcon = cva({
12
+ base: {
13
+ display: "inline-flex",
14
+ alignSelf: "center",
15
+ flexShrink: 0,
16
+ },
17
+ variants: {
18
+ side: {
19
+ left: { marginEnd: "2" },
20
+ right: { marginStart: "2" },
21
+ },
22
+ },
23
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { useCallback, useEffect, useRef, useState } from "react";
7
+
8
+ /**
9
+ * Copy a value to the clipboard with a transient `hasCopied` flag for
10
+ * "Copied!" feedback. Replaces Chakra's `useClipboard`.
11
+ */
12
+ export function useClipboard(
13
+ value: string,
14
+ timeoutMs = 1500,
15
+ ): { onCopy: () => void; hasCopied: boolean } {
16
+ const [hasCopied, setHasCopied] = useState(false);
17
+ const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
18
+ const onCopy = useCallback(() => {
19
+ navigator.clipboard.writeText(value).then(() => {
20
+ setHasCopied(true);
21
+ clearTimeout(timeoutRef.current);
22
+ timeoutRef.current = setTimeout(() => setHasCopied(false), timeoutMs);
23
+ });
24
+ }, [value, timeoutMs]);
25
+ useEffect(() => () => clearTimeout(timeoutRef.current), []);
26
+ return { onCopy, hasCopied };
27
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { useCallback, useSyncExternalStore } from "react";
7
+
8
+ /**
9
+ * Tracks a raw CSS media query, replacing Chakra's `useMediaQuery` for
10
+ * queries that aren't breakpoint-based (custom widths, height-based
11
+ * queries). For the preset's breakpoint scale prefer `useBreakpointValue`.
12
+ * Returns false during SSR.
13
+ */
14
+ export function useMediaQuery(query: string): boolean {
15
+ const subscribe = useCallback(
16
+ (onChange: () => void) => {
17
+ const list = window.matchMedia(query);
18
+ list.addEventListener("change", onChange);
19
+ return () => list.removeEventListener("change", onChange);
20
+ },
21
+ [query],
22
+ );
23
+ return useSyncExternalStore(
24
+ subscribe,
25
+ () => window.matchMedia(query).matches,
26
+ () => false,
27
+ );
28
+ }
@@ -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
+ * The value from the previous render (undefined on the first render).
10
+ * Replaces Chakra's `usePrevious`.
11
+ */
12
+ export function usePrevious<T>(value: T): T | undefined {
13
+ const ref = useRef<T>();
14
+ useEffect(() => {
15
+ ref.current = value;
16
+ });
17
+ return ref.current;
18
+ }