@microbit/ui 0.1.0-alpha.16 → 0.1.0-alpha.17

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/README.md CHANGED
@@ -84,10 +84,13 @@ node_modules/@microbit/ui/lang/ui.fr.json --ast --out-file ...` (multiple
84
84
  input files merge; ids are `ui.`-namespaced so they can't collide). This
85
85
  keeps the strings in the app's lazily loaded locale chunks rather than
86
86
  an eagerly bundled catalog-of-all-locales.
87
- 6. **`ToastProvider`** once near the root, inside the `IntlProvider`.
88
- 7. Optionally **`SharedUIProvider`** with an overlay-close registrar so the
89
- app can dismiss open menus from outside the tree (e.g. the Android
90
- hardware back button). Apps without one can omit the provider.
87
+ 6. **`SharedUIProvider`** inside the `IntlProvider`, wrapping the app. It
88
+ passes the locale on to react-aria, which translates its own built-in
89
+ strings (see [Strings](#strings)); without it those follow the browser
90
+ rather than the app's language setting. Also takes an optional
91
+ overlay-close registrar, so the app can dismiss open menus from outside
92
+ the tree (e.g. the Android hardware back button).
93
+ 7. **`ToastProvider`** once near the root, inside the two providers above.
91
94
 
92
95
  ## Legacy browser support (Safari < 15) — temporary
93
96
 
@@ -207,6 +210,20 @@ Crowdin ZIP>` (config-driven over packages in
207
210
  `bin/update-translations.cjs`), after which you run `npm run i18n:tidy`
208
211
  from the root.
209
212
 
213
+ ### react-aria's own strings
214
+
215
+ Separately from all of the above, react-aria has built-in strings of its own —
216
+ stepper button labels, the toast region's landmark label, hidden dismiss
217
+ buttons, listbox and selection announcements — and translates them from
218
+ catalogs it bundles, nothing to do with react-intl. `SharedUIProvider` hands it
219
+ the app's locale so the two agree.
220
+
221
+ It covers 32 locales. Of ours, ca, cy and ga-IE (and the `lol` pseudo-locale)
222
+ are not among them and fall back to English; the rest resolve, including where
223
+ our id is less specific than react-aria's (`fr` finds its fr-FR). There is no
224
+ supported way to supply translations for the locales it misses, so where one of
225
+ its strings matters we pass our own text in as a label instead.
226
+
210
227
  ## Chakra UI heritage and license
211
228
 
212
229
  This package's design language began as a faithful port of
package/lang/ui.en.json CHANGED
@@ -1,8 +1,16 @@
1
1
  {
2
+ "ui.breadcrumb": {
3
+ "defaultMessage": "Breadcrumb",
4
+ "description": "Accessible label for the breadcrumb navigation trail (the WAI-ARIA APG's conventional name)"
5
+ },
2
6
  "ui.close-action": {
3
7
  "defaultMessage": "Close",
4
8
  "description": "Close button text or label"
5
9
  },
10
+ "ui.loading": {
11
+ "defaultMessage": "Loading",
12
+ "description": "Announced by screen readers for a button showing a loading spinner"
13
+ },
6
14
  "ui.toast-status-error": {
7
15
  "defaultMessage": "Error",
8
16
  "description": "Announced by screen readers before an error notification"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microbit/ui",
3
- "version": "0.1.0-alpha.16",
3
+ "version": "0.1.0-alpha.17",
4
4
  "description": "micro:bit design-system primitives: react-aria-components + Panda CSS with a design language ported from Chakra UI v2. Ships as source; see README for the consumption setup.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,43 @@
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
+ * Breadcrumb slot recipe — Chakra's Breadcrumb layout: a flex list with a
10
+ * separator between items. The links themselves are the shared `Link`
11
+ * (Chakra's BreadcrumbLink base was identical to its Link base), so there is
12
+ * no link slot; the current page renders as a plain span.
13
+ *
14
+ * Registered in the base preset (base-preset.ts). No variants, so it needs
15
+ * no `staticCss` entry.
16
+ */
17
+ export const breadcrumb = defineSlotRecipe({
18
+ className: "breadcrumb",
19
+ slots: ["root", "list", "item", "separator"],
20
+ base: {
21
+ root: {},
22
+ list: {
23
+ display: "flex",
24
+ alignItems: "center",
25
+ listStyle: "none",
26
+ margin: 0,
27
+ padding: 0,
28
+ },
29
+ item: {
30
+ display: "inline-flex",
31
+ alignItems: "center",
32
+ // The separator renders inside every item (no children introspection);
33
+ // the last item's simply doesn't show.
34
+ "&:last-of-type [data-separator]": { display: "none" },
35
+ },
36
+ separator: {
37
+ // Chakra's default `spacing` was a literal 0.5rem; the token follows
38
+ // the library's existing lean (the button icon gap made the same
39
+ // call — see the playbook's open token-vs-literal spacing decision).
40
+ mx: "2",
41
+ },
42
+ },
43
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { createContext, ReactNode, useContext } from "react";
7
+ import { useIntl } from "react-intl";
8
+ import { css, cx } from "styled-system/css";
9
+ import { styled, type HTMLStyledProps } from "styled-system/jsx";
10
+ import { breadcrumb } from "styled-system/recipes";
11
+ import { SystemStyleObject } from "styled-system/types";
12
+ import { Link } from "./Link";
13
+ import { uiMessage } from "./messages";
14
+
15
+ // The separator is declared once on the Breadcrumb and rendered by every
16
+ // item; the current-page flag hops from BreadcrumbItem to the BreadcrumbLink
17
+ // inside it (Chakra did both with cloneElement).
18
+ const SeparatorContext = createContext<ReactNode>("/");
19
+ const CurrentPageContext = createContext(false);
20
+
21
+ export interface BreadcrumbProps {
22
+ /** Between items; an element or string. Chakra's default "/". */
23
+ separator?: ReactNode;
24
+ /** Per-instance style overrides for the nav (e.g. fontSize). */
25
+ css?: SystemStyleObject;
26
+ className?: string;
27
+ /** BreadcrumbItems. */
28
+ children: ReactNode;
29
+ }
30
+
31
+ /**
32
+ * Breadcrumb — navigation trail matching Chakra's: nav > ol > li with a
33
+ * separator between items and the current page as plain text with
34
+ * `aria-current="page"`.
35
+ */
36
+ export const Breadcrumb = ({
37
+ separator = "/",
38
+ css: cssProp,
39
+ className,
40
+ children,
41
+ }: BreadcrumbProps) => {
42
+ const intl = useIntl();
43
+ const slots = breadcrumb();
44
+ return (
45
+ <nav
46
+ aria-label={intl.formatMessage(uiMessage("ui.breadcrumb"))}
47
+ className={cx(slots.root, cssProp ? css(cssProp) : undefined, className)}
48
+ >
49
+ <SeparatorContext.Provider value={separator}>
50
+ <ol className={slots.list}>{children}</ol>
51
+ </SeparatorContext.Provider>
52
+ </nav>
53
+ );
54
+ };
55
+
56
+ export interface BreadcrumbItemProps {
57
+ /**
58
+ * Marks this item as the current page: its BreadcrumbLink renders as a
59
+ * plain span with `aria-current="page"` rather than a link.
60
+ */
61
+ isCurrentPage?: boolean;
62
+ css?: SystemStyleObject;
63
+ className?: string;
64
+ children: ReactNode;
65
+ }
66
+
67
+ export const BreadcrumbItem = ({
68
+ isCurrentPage = false,
69
+ css: cssProp,
70
+ className,
71
+ children,
72
+ }: BreadcrumbItemProps) => {
73
+ const separator = useContext(SeparatorContext);
74
+ const slots = breadcrumb();
75
+ return (
76
+ <li
77
+ className={cx(slots.item, cssProp ? css(cssProp) : undefined, className)}
78
+ >
79
+ <CurrentPageContext.Provider value={isCurrentPage}>
80
+ {children}
81
+ </CurrentPageContext.Provider>
82
+ <span
83
+ data-separator
84
+ role="presentation"
85
+ aria-hidden
86
+ className={slots.separator}
87
+ >
88
+ {separator}
89
+ </span>
90
+ </li>
91
+ );
92
+ };
93
+
94
+ export type BreadcrumbLinkProps = HTMLStyledProps<"a">;
95
+
96
+ // The current page's text: same element shape as the link (so call-site
97
+ // style props keep working) minus the interactivity.
98
+ const CurrentPageText = styled("span");
99
+
100
+ /**
101
+ * The trail's link: the shared `Link` (Chakra's BreadcrumbLink base was its
102
+ * Link base), or a plain span with `aria-current="page"` inside an item
103
+ * marked `isCurrentPage`.
104
+ */
105
+ export const BreadcrumbLink = (props: BreadcrumbLinkProps) => {
106
+ const isCurrentPage = useContext(CurrentPageContext);
107
+ if (isCurrentPage) {
108
+ const { href: _href, children, ...rest } = props;
109
+ return (
110
+ <CurrentPageText aria-current="page" {...rest}>
111
+ {children}
112
+ </CurrentPageText>
113
+ );
114
+ }
115
+ return <Link {...props} />;
116
+ };
package/src/Button.tsx CHANGED
@@ -4,6 +4,7 @@
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
6
  import { forwardRef, ReactNode } from "react";
7
+ import { useIntl } from "react-intl";
7
8
  import {
8
9
  Button as RACButton,
9
10
  ButtonProps as RACButtonProps,
@@ -12,6 +13,33 @@ import { css, cx } from "styled-system/css";
12
13
  import { button, ButtonVariantProps } from "styled-system/recipes";
13
14
  import { SystemStyleObject } from "styled-system/types";
14
15
  import { buttonIcon } from "./button-icon";
16
+ import { uiMessage } from "./messages";
17
+ import { Spinner } from "./Spinner";
18
+
19
+ // Chakra's ButtonSpinner: a 1em spinner centred over the hidden label. Out of
20
+ // flow, so the label alone sets the button's size; the recipe's base
21
+ // `position: relative` is what it anchors to. Its own component so useIntl runs
22
+ // only while a button is actually loading — a bare Button must keep working
23
+ // without an IntlProvider (test renders commonly lack one).
24
+ const ButtonSpinner = () => {
25
+ const intl = useIntl();
26
+ return (
27
+ <span
28
+ className={css({
29
+ position: "absolute",
30
+ inset: 0,
31
+ display: "flex",
32
+ alignItems: "center",
33
+ justifyContent: "center",
34
+ })}
35
+ >
36
+ <Spinner
37
+ aria-label={intl.formatMessage(uiMessage("ui.loading"))}
38
+ css={{ width: "1em", height: "1em" }}
39
+ />
40
+ </span>
41
+ );
42
+ };
15
43
 
16
44
  export interface ButtonProps
17
45
  extends Omit<RACButtonProps, "className" | "children">,
@@ -23,6 +51,14 @@ export interface ButtonProps
23
51
  leftIcon?: ReactNode;
24
52
  /** Icon rendered after the label, matching Chakra's `rightIcon`. */
25
53
  rightIcon?: ReactNode;
54
+ /**
55
+ * Show a spinner in place of the label and disable interaction, matching
56
+ * Chakra's `isLoading`: the label stays in the layout but invisible, so the
57
+ * button keeps its size, and the dimmed disabled look applies. Chakra's
58
+ * `loadingText`/`spinnerPlacement` (a visible label beside the spinner) are
59
+ * unported — no app in the family used them.
60
+ */
61
+ isLoading?: boolean;
26
62
  children?: ReactNode;
27
63
  }
28
64
 
@@ -41,11 +77,23 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
41
77
  className,
42
78
  leftIcon,
43
79
  rightIcon,
80
+ isLoading,
44
81
  children,
45
82
  ...rest
46
83
  },
47
84
  ref,
48
85
  ) {
86
+ const label = (
87
+ <>
88
+ {leftIcon ? (
89
+ <span className={buttonIcon({ side: "left" })}>{leftIcon}</span>
90
+ ) : null}
91
+ {children}
92
+ {rightIcon ? (
93
+ <span className={buttonIcon({ side: "right" })}>{rightIcon}</span>
94
+ ) : null}
95
+ </>
96
+ );
49
97
  return (
50
98
  <RACButton
51
99
  ref={ref}
@@ -54,15 +102,21 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
54
102
  cssProp ? css(cssProp) : undefined,
55
103
  className,
56
104
  )}
105
+ data-loading={isLoading ? "" : undefined}
57
106
  {...rest}
107
+ isDisabled={isLoading || rest.isDisabled}
58
108
  >
59
- {leftIcon ? (
60
- <span className={buttonIcon({ side: "left" })}>{leftIcon}</span>
61
- ) : null}
62
- {children}
63
- {rightIcon ? (
64
- <span className={buttonIcon({ side: "right" })}>{rightIcon}</span>
65
- ) : null}
109
+ {isLoading ? (
110
+ <>
111
+ <ButtonSpinner />
112
+ {/* Hidden with opacity, not removed: it keeps the button the size
113
+ it is when idle, so a row of buttons doesn't reflow. Still in
114
+ the accessibility tree, so the button keeps its name. */}
115
+ <span className={css({ opacity: 0 })}>{label}</span>
116
+ </>
117
+ ) : (
118
+ label
119
+ )}
66
120
  </RACButton>
67
121
  );
68
122
  },
@@ -0,0 +1,76 @@
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
+ CheckboxGroup as RACCheckboxGroup,
9
+ CheckboxGroupProps as RACCheckboxGroupProps,
10
+ Label as RACLabel,
11
+ } from "react-aria-components";
12
+ import { css, cx } from "styled-system/css";
13
+ import { field } from "styled-system/recipes";
14
+ import { SystemStyleObject } from "styled-system/types";
15
+ import {
16
+ FieldRequiredIndicator,
17
+ FieldSupport,
18
+ FieldSupportProps,
19
+ } from "./FieldSupport";
20
+
21
+ export interface CheckboxGroupProps
22
+ extends Omit<RACCheckboxGroupProps, "className" | "style">,
23
+ FieldSupportProps {
24
+ /**
25
+ * Visible label for the group (Chakra's FormLabel above it). Use
26
+ * `aria-label` instead where the design has none.
27
+ */
28
+ label?: ReactNode;
29
+ /** Per-instance style overrides. */
30
+ css?: SystemStyleObject;
31
+ className?: string;
32
+ }
33
+
34
+ /**
35
+ * CheckboxGroup — react-aria-components <CheckboxGroup> for a set of
36
+ * Checkboxes sharing one value array (each Checkbox's `value` marks its
37
+ * entry). Beyond the optional field chrome (label/helperText/errorMessage —
38
+ * Chakra's FormControl parts) it carries no styling of its own: compose with
39
+ * Stack for layout, as RadioGroup does. Chakra's CheckboxGroup was a bare
40
+ * context provider, so ported call sites gain the chrome rather than
41
+ * restating it around the group.
42
+ */
43
+ export const CheckboxGroup = ({
44
+ label,
45
+ helperText,
46
+ errorMessage,
47
+ helperTextCss,
48
+ css: cssProp,
49
+ className,
50
+ children,
51
+ ...rest
52
+ }: CheckboxGroupProps) => {
53
+ return (
54
+ <RACCheckboxGroup
55
+ className={cx(cssProp ? css(cssProp) : undefined, className)}
56
+ {...rest}
57
+ >
58
+ {(renderProps) => (
59
+ <>
60
+ {label != null && (
61
+ <RACLabel className={field().label}>
62
+ {label}
63
+ {rest.isRequired ? <FieldRequiredIndicator /> : null}
64
+ </RACLabel>
65
+ )}
66
+ {typeof children === "function" ? children(renderProps) : children}
67
+ <FieldSupport
68
+ helperText={helperText}
69
+ errorMessage={errorMessage}
70
+ helperTextCss={helperTextCss}
71
+ />
72
+ </>
73
+ )}
74
+ </RACCheckboxGroup>
75
+ );
76
+ };
package/src/ComboBox.tsx CHANGED
@@ -25,12 +25,18 @@ import { RiArrowDownSLine } from "react-icons/ri";
25
25
  import { css, cx } from "styled-system/css";
26
26
  import { select, SelectVariantProps } from "styled-system/recipes";
27
27
  import { SystemStyleObject } from "styled-system/types";
28
+ import {
29
+ FieldRequiredIndicator,
30
+ FieldSupport,
31
+ FieldSupportProps,
32
+ } from "./FieldSupport";
28
33
  import { Icon } from "./Icon";
29
34
  import { SelectSlotProvider } from "./Select";
30
35
 
31
36
  export interface ComboBoxProps<T extends object>
32
37
  extends Omit<RACComboBoxProps<T>, "className" | "children" | "style">,
33
- SelectVariantProps {
38
+ SelectVariantProps,
39
+ FieldSupportProps {
34
40
  /** Visible label. Use `aria-label` instead where the design has none. */
35
41
  label?: ReactNode;
36
42
  placeholder?: string;
@@ -41,8 +47,15 @@ export interface ComboBoxProps<T extends object>
41
47
  * that (react-select did it with a custom `SingleValue`).
42
48
  */
43
49
  startContent?: ReactNode;
44
- /** `SelectOption`s. */
45
- children: ReactNode;
50
+ /**
51
+ * `SelectOption`s, or a render function over the `items` prop for a
52
+ * dynamic collection — which is how an async lookup works: drive `items`
53
+ * from loaded results (e.g. react-stately's useAsyncList) and filter
54
+ * server-side; react-aria skips its own text filtering when `items` is
55
+ * controlled. Pair with `emptyState` (swap its content while loading) and
56
+ * `isPopoverHidden` for a minimum query length.
57
+ */
58
+ children: ReactNode | ((item: T) => ReactNode);
46
59
  /**
47
60
  * Replaces the chevron; pass `null` for none, which is what a plain
48
61
  * autocomplete wants (react-select's `dropdownIndicator: display none`).
@@ -98,6 +111,9 @@ const ComboBoxInner = <T extends object>(
98
111
  isPopoverHidden,
99
112
  placement = "bottom start",
100
113
  maxHeight,
114
+ helperText,
115
+ errorMessage,
116
+ helperTextCss,
101
117
  css: cssProp,
102
118
  contentCss,
103
119
  className,
@@ -138,7 +154,12 @@ const ComboBoxInner = <T extends object>(
138
154
  {...(rest as RACComboBoxProps<T>)}
139
155
  className={cx(slots.root, className)}
140
156
  >
141
- {label != null && <RACLabel className={slots.label}>{label}</RACLabel>}
157
+ {label != null && (
158
+ <RACLabel className={slots.label}>
159
+ {label}
160
+ {props.isRequired ? <FieldRequiredIndicator /> : null}
161
+ </RACLabel>
162
+ )}
142
163
  <div
143
164
  ref={triggerRef}
144
165
  className={cx(slots.trigger, cssProp ? css(cssProp) : undefined)}
@@ -178,6 +199,11 @@ const ComboBoxInner = <T extends object>(
178
199
  </RACListBox>
179
200
  </Popover>
180
201
  )}
202
+ <FieldSupport
203
+ helperText={helperText}
204
+ errorMessage={errorMessage}
205
+ helperTextCss={helperTextCss}
206
+ />
181
207
  </RACComboBox>
182
208
  </SelectSlotProvider>
183
209
  );
package/src/Fade.tsx CHANGED
@@ -3,13 +3,20 @@
3
3
  *
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
- import { ReactNode } from "react";
6
+ import { CSSProperties, ReactNode } from "react";
7
7
  import { css, cx } from "styled-system/css";
8
8
  import { SystemStyleObject } from "styled-system/types";
9
9
 
10
10
  export interface FadeProps {
11
11
  /** Visible when true; faded out (but mounted) when false. */
12
12
  isOpen: boolean;
13
+ /**
14
+ * Fade-in time in seconds (Chakra's `transition.enter.duration`).
15
+ * Default 0.2, Chakra's.
16
+ */
17
+ enterDuration?: number;
18
+ /** Fade-out time in seconds (Chakra's `transition.exit.duration`). */
19
+ exitDuration?: number;
13
20
  css?: SystemStyleObject;
14
21
  className?: string;
15
22
  children: ReactNode;
@@ -21,12 +28,23 @@ export interface FadeProps {
21
28
  */
22
29
  export const Fade = ({
23
30
  isOpen,
31
+ enterDuration = 0.2,
32
+ exitDuration = 0.2,
24
33
  css: cssProp,
25
34
  className,
26
35
  children,
27
36
  }: FadeProps) => (
28
37
  <div
29
38
  data-open={isOpen ? "" : undefined}
39
+ // Runtime values, so an inline custom property rather than the css()
40
+ // object (gotcha #9: a non-literal duration would extract to nothing).
41
+ // The var switches in the same commit as the opacity, so the transition
42
+ // picks up the direction's own duration.
43
+ style={
44
+ {
45
+ "--fade-duration": `${isOpen ? enterDuration : exitDuration}s`,
46
+ } as CSSProperties
47
+ }
30
48
  className={cx(
31
49
  css(
32
50
  {
@@ -34,7 +52,7 @@ export const Fade = ({
34
52
  pointerEvents: "none",
35
53
  "&[data-open]": { opacity: 1, pointerEvents: "auto" },
36
54
  transitionProperty: "opacity",
37
- transitionDuration: "0.2s",
55
+ transitionDuration: "var(--fade-duration)",
38
56
  transitionTimingFunction: "ease-out",
39
57
  _motionReduce: { transition: "none" },
40
58
  },
@@ -0,0 +1,68 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { ReactNode } from "react";
7
+ import { FieldError, Text as RACText } from "react-aria-components";
8
+ import { css, cx } from "styled-system/css";
9
+ import { field } from "styled-system/recipes";
10
+ import { SystemStyleObject } from "styled-system/types";
11
+
12
+ /**
13
+ * The label/helper/error chrome every labelled form field shares — Chakra's
14
+ * FormControl parts, generalised out of TextField so Select, ComboBox,
15
+ * NumberField, RadioGroup and CheckboxGroup carry the same props
16
+ * (data-microbit-org's forms attach helper and error text to all of these).
17
+ */
18
+ export interface FieldSupportProps {
19
+ /** Help text below the field (Chakra's FormHelperText). */
20
+ helperText?: ReactNode;
21
+ /** Shown below the field when invalid (Chakra's FormErrorMessage). */
22
+ errorMessage?: ReactNode;
23
+ /** Per-instance style overrides for the helper text. */
24
+ helperTextCss?: SystemStyleObject;
25
+ }
26
+
27
+ /**
28
+ * Helper text and error message for a react-aria field container. Render
29
+ * inside any RAC component with field validation context (TextField, Select,
30
+ * ComboBox, NumberField, RadioGroup, CheckboxGroup) — react-aria wires the
31
+ * description to the input's aria-describedby, and the error renders only
32
+ * while the field is invalid. Also exported for app-side composites built on
33
+ * RAC containers.
34
+ */
35
+ export const FieldSupport = ({
36
+ helperText,
37
+ errorMessage,
38
+ helperTextCss,
39
+ }: FieldSupportProps) => {
40
+ const slots = field();
41
+ return (
42
+ <>
43
+ {helperText != null && (
44
+ <RACText
45
+ slot="description"
46
+ className={cx(
47
+ slots.helperText,
48
+ helperTextCss ? css(helperTextCss) : undefined,
49
+ )}
50
+ >
51
+ {helperText}
52
+ </RACText>
53
+ )}
54
+ <FieldError className={slots.errorMessage}>{errorMessage}</FieldError>
55
+ </>
56
+ );
57
+ };
58
+
59
+ /**
60
+ * The required-field asterisk (Chakra's FormLabel indicator). Render inside
61
+ * the field's label when `isRequired`; aria-hidden because react-aria already
62
+ * announces requiredness from the input itself.
63
+ */
64
+ export const FieldRequiredIndicator = () => (
65
+ <span aria-hidden className={field().requiredIndicator}>
66
+ *
67
+ </span>
68
+ );
@@ -44,6 +44,25 @@ export const heading = defineRecipe({
44
44
  // GT Walsheim in the private preset.
45
45
  variant: {
46
46
  marketing: { fontFamily: "display" },
47
+ // Page-title chrome in the accent colour (`headingAccent` — see
48
+ // base-preset.ts). Converged from classroom and data-microbit-org,
49
+ // which carried these two byte-identically app-side.
50
+ //
51
+ // `fontSize` goes through a doubled selector because `size` sets it too,
52
+ // responsively. Source order can't settle that: Panda hoists media
53
+ // queries below the base rules (gotcha #31) and emits variant rules in
54
+ // the order it meets them, so which of the two wins would depend on the
55
+ // breakpoint and on what other call sites exist. Two classes beat one at
56
+ // every width instead.
57
+ label: {
58
+ "&&": { fontSize: "4xl" },
59
+ color: "headingAccent",
60
+ },
61
+ subtitle: {
62
+ "&&": { fontSize: "xl" },
63
+ fontWeight: "normal",
64
+ color: "headingAccent",
65
+ },
47
66
  },
48
67
  },
49
68
  defaultVariants: {
@@ -17,8 +17,17 @@ const transitionCommon =
17
17
  *
18
18
  * Focus matches both native `:focus-visible` (plain inputs; browsers treat any
19
19
  * focus in a text field as focus-visible) and react-aria's `data-focused`
20
- * (inputs inside RAC TextField). Focus is declared after invalid so a focused
21
- * invalid field shows the focus ring, as in Chakra.
20
+ * (inputs inside RAC TextField).
21
+ *
22
+ * Hover, invalid and focus all set `borderColor`, so their precedence has to be
23
+ * hover < invalid < focus. Declaration order will not buy that: Panda sorts a
24
+ * recipe's state rules itself, ranking selectors against a fixed
25
+ * link/visited/focus/hover/active table, which puts `_hover` *after* focus and
26
+ * after anything the table doesn't mention (`[data-invalid]`). Equal-specificity
27
+ * rules then leave hover winning. So the ladder is spelled with repeated `&`
28
+ * instead — `&&` and `&&&` emit `.input.input` and `.input.input.input`, making
29
+ * precedence specificity rather than order, which nothing downstream can
30
+ * resort. Variants still override freely; they land in a later cascade layer.
22
31
  *
23
32
  * Registered in the base preset (base-preset.ts), which also has the
24
33
  * `staticCss` entry that keeps the runtime-prop size variants generated.
@@ -39,11 +48,11 @@ export const input = defineRecipe({
39
48
  bg: "inherit",
40
49
  color: "inherit",
41
50
  _hover: { borderColor: "gray.300" },
42
- "&[data-invalid], &:user-invalid": {
51
+ "&&:is([data-invalid], :user-invalid)": {
43
52
  borderColor: "danger.500",
44
53
  boxShadow: "0 0 0 1px token(colors.danger.500)",
45
54
  },
46
- "&:is(:focus-visible, [data-focused])": {
55
+ "&&&:is(:focus-visible, [data-focused])": {
47
56
  zIndex: 1,
48
57
  borderColor: "focusBorder",
49
58
  boxShadow: "0 0 0 1px token(colors.focusBorder)",
@@ -47,7 +47,13 @@ export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
47
47
  className={cx(
48
48
  input({ size }),
49
49
  css(
50
- { cursor: "pointer" },
50
+ // Chakra's Select field carried a 1px bottom padding its Input
51
+ // didn't (its option text sits a hair higher than input text).
52
+ {
53
+ cursor: "pointer",
54
+ paddingBottom: "1px",
55
+ _disabled: { cursor: "not-allowed" },
56
+ },
51
57
  // Room for the chevron overlay (Chakra Select's icon spacing,
52
58
  // constant across sizes).
53
59
  hideChevron ? undefined : { paddingRight: "8" },
@@ -84,6 +90,9 @@ export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
84
90
  height: "5",
85
91
  pointerEvents: "none",
86
92
  fill: "currentColor",
93
+ // The chevron sits outside the select so it doesn't inherit its
94
+ // disabled dimming; Chakra's Select icon dimmed to 0.5.
95
+ "select:disabled + &": { opacity: 0.5 },
87
96
  })}
88
97
  >
89
98
  <path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" />
@@ -16,10 +16,16 @@ import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
16
16
  import { css, cx } from "styled-system/css";
17
17
  import { field, input, numberField } from "styled-system/recipes";
18
18
  import { SystemStyleObject } from "styled-system/types";
19
+ import {
20
+ FieldRequiredIndicator,
21
+ FieldSupport,
22
+ FieldSupportProps,
23
+ } from "./FieldSupport";
19
24
  import { Icon } from "./Icon";
20
25
 
21
26
  export interface NumberFieldProps
22
- extends Omit<RACNumberFieldProps, "className" | "children" | "style"> {
27
+ extends Omit<RACNumberFieldProps, "className" | "children" | "style">,
28
+ FieldSupportProps {
23
29
  /** Visible label (optional; otherwise pass `aria-label`). */
24
30
  label?: ReactNode;
25
31
  /** Root style overrides (e.g. row layout for label-beside-field forms). */
@@ -40,7 +46,17 @@ export interface NumberFieldProps
40
46
  */
41
47
  export const NumberField = forwardRef<HTMLInputElement, NumberFieldProps>(
42
48
  function NumberField(
43
- { label, css: cssProp, labelCss, groupCss, inputCss, ...rest },
49
+ {
50
+ label,
51
+ helperText,
52
+ errorMessage,
53
+ helperTextCss,
54
+ css: cssProp,
55
+ labelCss,
56
+ groupCss,
57
+ inputCss,
58
+ ...rest
59
+ },
44
60
  ref,
45
61
  ) {
46
62
  const slots = numberField();
@@ -58,6 +74,7 @@ export const NumberField = forwardRef<HTMLInputElement, NumberFieldProps>(
58
74
  )}
59
75
  >
60
76
  {label}
77
+ {rest.isRequired ? <FieldRequiredIndicator /> : null}
61
78
  </RACLabel>
62
79
  )}
63
80
  <RACGroup
@@ -80,6 +97,11 @@ export const NumberField = forwardRef<HTMLInputElement, NumberFieldProps>(
80
97
  </RACButton>
81
98
  </div>
82
99
  </RACGroup>
100
+ <FieldSupport
101
+ helperText={helperText}
102
+ errorMessage={errorMessage}
103
+ helperTextCss={helperTextCss}
104
+ />
83
105
  </RACNumberField>
84
106
  );
85
107
  },
package/src/Radio.tsx CHANGED
@@ -5,37 +5,72 @@
5
5
  */
6
6
  import { ReactNode } from "react";
7
7
  import {
8
+ Label as RACLabel,
8
9
  Radio as RACRadio,
9
10
  RadioProps as RACRadioProps,
10
11
  RadioGroup as RACRadioGroup,
11
12
  RadioGroupProps as RACRadioGroupProps,
12
13
  } from "react-aria-components";
13
14
  import { css, cx } from "styled-system/css";
14
- import { radio, RadioVariantProps } from "styled-system/recipes";
15
+ import { field, radio, RadioVariantProps } from "styled-system/recipes";
15
16
  import { SystemStyleObject } from "styled-system/types";
17
+ import {
18
+ FieldRequiredIndicator,
19
+ FieldSupport,
20
+ FieldSupportProps,
21
+ } from "./FieldSupport";
16
22
 
17
23
  export interface RadioGroupProps
18
- extends Omit<RACRadioGroupProps, "className" | "style"> {
24
+ extends Omit<RACRadioGroupProps, "className" | "style">,
25
+ FieldSupportProps {
26
+ /**
27
+ * Visible label for the group (Chakra's FormLabel above it). Use
28
+ * `aria-label` instead where the design has none.
29
+ */
30
+ label?: ReactNode;
19
31
  /** Per-instance style overrides, merged after the recipe. */
20
32
  css?: SystemStyleObject;
21
33
  className?: string;
22
34
  }
23
35
 
24
36
  /**
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.
37
+ * RadioGroup — react-aria-components <RadioGroup> for a set of Radios. Beyond
38
+ * the optional field chrome (label/helperText/errorMessage Chakra's
39
+ * FormControl parts) it carries no styling of its own: compose with Stack for
40
+ * layout, as Chakra call sites did.
28
41
  */
29
42
  export const RadioGroup = ({
43
+ label,
44
+ helperText,
45
+ errorMessage,
46
+ helperTextCss,
30
47
  css: cssProp,
31
48
  className,
49
+ children,
32
50
  ...rest
33
51
  }: RadioGroupProps) => {
34
52
  return (
35
53
  <RACRadioGroup
36
54
  className={cx(cssProp ? css(cssProp) : undefined, className)}
37
55
  {...rest}
38
- />
56
+ >
57
+ {(renderProps) => (
58
+ <>
59
+ {label != null && (
60
+ <RACLabel className={field().label}>
61
+ {label}
62
+ {rest.isRequired ? <FieldRequiredIndicator /> : null}
63
+ </RACLabel>
64
+ )}
65
+ {typeof children === "function" ? children(renderProps) : children}
66
+ <FieldSupport
67
+ helperText={helperText}
68
+ errorMessage={errorMessage}
69
+ helperTextCss={helperTextCss}
70
+ />
71
+ </>
72
+ )}
73
+ </RACRadioGroup>
39
74
  );
40
75
  };
41
76
 
@@ -82,9 +82,12 @@ export const select = defineSlotRecipe({
82
82
  // `> &` rather than a descendant selector, so an app's own invalid form
83
83
  // wrapper cannot paint every control inside it red.
84
84
  //
85
- // Declared after hover and before focus so red beats a hover tint and
86
- // the focus ring beats red, as in the input recipe.
87
- "[data-invalid] > &": {
85
+ // Doubled `&` for the same reason as the input recipe: hover, invalid and
86
+ // focus all set `borderColor`, and Panda sorts state rules by its own
87
+ // pseudo-class table rather than declaration order, so hover would win
88
+ // these ties. The repeated `&` makes the hover < invalid < focus ladder a
89
+ // matter of specificity instead.
90
+ "[data-invalid] > &&": {
88
91
  borderColor: "danger.500",
89
92
  boxShadow: "0 0 0 1px token(colors.danger.500)",
90
93
  },
@@ -98,7 +101,7 @@ export const select = defineSlotRecipe({
98
101
  // focus moves to an option (aria-activedescendant) — which strips RAC's
99
102
  // attribute for as long as the list has an active option, real focus
100
103
  // never having left. Select's trigger holds no input, so it can't match.
101
- "&[data-focus-visible], &:has(input:focus)": {
104
+ "&&&[data-focus-visible], &&&:has(input:focus)": {
102
105
  boxShadow: "0 0 0 1px token(colors.focusBorder)",
103
106
  borderColor: "focusBorder",
104
107
  outline: "2px solid transparent",
package/src/Select.tsx CHANGED
@@ -20,6 +20,11 @@ import { RiArrowDownSLine } from "react-icons/ri";
20
20
  import { css, cx } from "styled-system/css";
21
21
  import { select, SelectVariantProps } from "styled-system/recipes";
22
22
  import { SystemStyleObject } from "styled-system/types";
23
+ import {
24
+ FieldRequiredIndicator,
25
+ FieldSupport,
26
+ FieldSupportProps,
27
+ } from "./FieldSupport";
23
28
  import { Icon } from "./Icon";
24
29
 
25
30
  export type SelectSlots = ReturnType<typeof select>;
@@ -37,7 +42,8 @@ export interface SelectProps<T extends object>
37
42
  RACSelectProps<T>,
38
43
  "className" | "children" | "style" | "placeholder"
39
44
  >,
40
- SelectVariantProps {
45
+ SelectVariantProps,
46
+ FieldSupportProps {
41
47
  /** Visible label. Use `aria-label` instead where the design has none. */
42
48
  label?: ReactNode;
43
49
  /** Shown in the trigger while nothing is chosen (Chakra's placeholder). */
@@ -78,6 +84,9 @@ export const Select = <T extends object>({
78
84
  indicator,
79
85
  placement = "bottom start",
80
86
  maxHeight,
87
+ helperText,
88
+ errorMessage,
89
+ helperTextCss,
81
90
  css: cssProp,
82
91
  contentCss,
83
92
  className,
@@ -93,7 +102,12 @@ export const Select = <T extends object>({
93
102
  {...(rest as RACSelectProps<T>)}
94
103
  className={cx(slots.root, className)}
95
104
  >
96
- {label != null && <RACLabel className={slots.label}>{label}</RACLabel>}
105
+ {label != null && (
106
+ <RACLabel className={slots.label}>
107
+ {label}
108
+ {props.isRequired ? <FieldRequiredIndicator /> : null}
109
+ </RACLabel>
110
+ )}
97
111
  <RACButton
98
112
  className={cx(slots.trigger, cssProp ? css(cssProp) : undefined)}
99
113
  >
@@ -118,6 +132,11 @@ export const Select = <T extends object>({
118
132
  >
119
133
  <RACListBox className={slots.list}>{children}</RACListBox>
120
134
  </Popover>
135
+ <FieldSupport
136
+ helperText={helperText}
137
+ errorMessage={errorMessage}
138
+ helperTextCss={helperTextCss}
139
+ />
121
140
  </RACSelect>
122
141
  </SelectSlotProvider>
123
142
  );
@@ -4,6 +4,9 @@
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
6
  import { createContext, ReactNode, useContext, useMemo } from "react";
7
+ import { I18nProvider } from "react-aria-components";
8
+ import { IntlContext } from "react-intl";
9
+ import { racLocale } from "./rac-locale";
7
10
 
8
11
  /**
9
12
  * Registers the close function of the currently open dismissable overlay, or
@@ -21,27 +24,44 @@ const SharedUIContext = createContext<SharedUIContextValue | null>(null);
21
24
 
22
25
  export interface SharedUIProviderProps {
23
26
  overlayCloseRegistrar?: OverlayCloseRegistrar;
27
+ /**
28
+ * Locale for react-aria's own built-in strings. Defaults to the surrounding
29
+ * IntlProvider's locale, which is what apps want: pass this only where the
30
+ * two must differ.
31
+ */
32
+ locale?: string;
24
33
  children: ReactNode;
25
34
  }
26
35
 
27
36
  /**
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).
37
+ * SharedUIProvider — the app-side installation point for shared-ui
38
+ * integrations: the optional overlay-close registrar, and the locale for
39
+ * react-aria's built-in strings.
40
+ *
41
+ * This package's own strings come from react-intl, so an IntlProvider must be
42
+ * mounted above shared-ui components (see the package README). react-aria
43
+ * translates its built-in strings itself, from its own bundled catalogs, and
44
+ * without this provider it picks the locale from the browser rather than from
45
+ * the app's language setting — mount it inside the IntlProvider so the two
46
+ * agree.
33
47
  */
34
48
  export const SharedUIProvider = ({
35
49
  overlayCloseRegistrar,
50
+ locale,
36
51
  children,
37
52
  }: SharedUIProviderProps) => {
53
+ // Read the context rather than calling useIntl(), which throws when there is
54
+ // no IntlProvider: react-aria falls back to the browser locale, as before.
55
+ const intlLocale = useContext(IntlContext)?.locale;
38
56
  const value = useMemo(
39
57
  () => ({ overlayCloseRegistrar }),
40
58
  [overlayCloseRegistrar],
41
59
  );
42
60
  return (
43
61
  <SharedUIContext.Provider value={value}>
44
- {children}
62
+ <I18nProvider locale={racLocale(locale ?? intlLocale)}>
63
+ {children}
64
+ </I18nProvider>
45
65
  </SharedUIContext.Provider>
46
66
  );
47
67
  };
@@ -37,6 +37,10 @@ export const field = defineSlotRecipe({
37
37
  color: "danger.500",
38
38
  },
39
39
  helperText: {
40
+ // RAC's Text renders a span, and RadioGroup/CheckboxGroup roots are not
41
+ // flex containers to blockify it, where an inline box would drop the
42
+ // margin below (gotcha #44).
43
+ display: "block",
40
44
  mt: "2",
41
45
  fontSize: "sm",
42
46
  lineHeight: "normal",
package/src/TextField.tsx CHANGED
@@ -5,31 +5,27 @@
5
5
  */
6
6
  import { FocusEvent, forwardRef, ReactNode } from "react";
7
7
  import {
8
- FieldError,
9
8
  Input as RACInput,
10
9
  Label as RACLabel,
11
- Text as RACText,
12
10
  TextField as RACTextField,
13
11
  TextFieldProps as RACTextFieldProps,
14
12
  } from "react-aria-components";
15
- import { css, cx } from "styled-system/css";
16
13
  import { field, input, InputVariantProps } from "styled-system/recipes";
17
- import { SystemStyleObject } from "styled-system/types";
14
+ import {
15
+ FieldRequiredIndicator,
16
+ FieldSupport,
17
+ FieldSupportProps,
18
+ } from "./FieldSupport";
18
19
 
19
20
  export interface TextFieldProps
20
21
  extends Omit<
21
22
  RACTextFieldProps,
22
23
  "className" | "children" | "style" | "onFocus" | "onBlur"
23
24
  >,
24
- InputVariantProps {
25
+ InputVariantProps,
26
+ FieldSupportProps {
25
27
  /** Visible label (Chakra's FormLabel; asterisk added when `isRequired`). */
26
28
  label: ReactNode;
27
- /** Help text below the input (Chakra's FormHelperText). */
28
- helperText?: ReactNode;
29
- /** Shown below the input when `isInvalid` (Chakra's FormErrorMessage). */
30
- errorMessage?: ReactNode;
31
- /** Per-instance style overrides for the helper text. */
32
- helperTextCss?: SystemStyleObject;
33
29
  onFocus?: (e: FocusEvent<HTMLInputElement>) => void;
34
30
  /** Input autocapitalize attribute (react-aria's TextField omits it). */
35
31
  autoCapitalize?: "off" | "none" | "on" | "sentences" | "words" | "characters";
@@ -61,11 +57,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
61
57
  <RACTextField {...rest} className={slots.root}>
62
58
  <RACLabel className={slots.label}>
63
59
  {label}
64
- {rest.isRequired ? (
65
- <span aria-hidden className={slots.requiredIndicator}>
66
- *
67
- </span>
68
- ) : null}
60
+ {rest.isRequired ? <FieldRequiredIndicator /> : null}
69
61
  </RACLabel>
70
62
  <RACInput
71
63
  ref={ref}
@@ -73,18 +65,11 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
73
65
  onFocus={onFocus}
74
66
  autoCapitalize={autoCapitalize}
75
67
  />
76
- {helperText && (
77
- <RACText
78
- slot="description"
79
- className={cx(
80
- slots.helperText,
81
- helperTextCss ? css(helperTextCss) : undefined,
82
- )}
83
- >
84
- {helperText}
85
- </RACText>
86
- )}
87
- <FieldError className={slots.errorMessage}>{errorMessage}</FieldError>
68
+ <FieldSupport
69
+ helperText={helperText}
70
+ errorMessage={errorMessage}
71
+ helperTextCss={helperTextCss}
72
+ />
88
73
  </RACTextField>
89
74
  );
90
75
  },
@@ -24,6 +24,7 @@ import {
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 { avatar } from "./Avatar.recipe";
27
+ import { breadcrumb } from "./Breadcrumb.recipe";
27
28
  import { button } from "./Button.recipe";
28
29
  import { card } from "./Card.recipe";
29
30
  import { checkbox } from "./Checkbox.recipe";
@@ -175,6 +176,12 @@ export const basePreset = definePreset({
175
176
  // follows; OSS language buttons are brand blue.)
176
177
  languageText: { value: "{colors.brand.500}" },
177
178
  languageTextHover: { value: "{colors.brand.600}" },
179
+ // The `label`/`subtitle` heading variants' colour (page-title chrome).
180
+ // classroom and data-microbit-org carried byte-identical variants with
181
+ // a hardcoded #cd0365 — the brand deep pink, which is data's
182
+ // `pink.500`; both override this to it. The OSS default follows the
183
+ // languageText precedent: the primary interactive brand.
184
+ headingAccent: { value: "{colors.brand.500}" },
178
185
  // The `primary`/`secondary` button variants' colours. Two brand
179
186
  // idioms exist in the family: brand-coloured buttons (ml-trainer,
180
187
  // python-editor — the defaults below) and a black-on-white system
@@ -216,6 +223,7 @@ export const basePreset = definePreset({
216
223
  },
217
224
  slotRecipes: {
218
225
  avatar,
226
+ breadcrumb,
219
227
  card,
220
228
  checkbox,
221
229
  dialog,
package/src/index.ts CHANGED
@@ -10,11 +10,14 @@
10
10
  */
11
11
  export * from "./system";
12
12
  export * from "./Avatar";
13
+ export * from "./Breadcrumb";
13
14
  export * from "./Button";
14
15
  export * from "./LinkButton";
15
16
  export * from "./ButtonGroup";
16
17
  export * from "./Card";
17
18
  export * from "./Checkbox";
19
+ export * from "./CheckboxGroup";
20
+ export * from "./FieldSupport";
18
21
  export * from "./IconButton";
19
22
  export * from "./Image";
20
23
  export * from "./Input";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+
7
+ /**
8
+ * Sanitizes one of our locale ids for react-aria's I18nProvider.
9
+ *
10
+ * No mapping is needed to reach react-aria's own strings: its fallback chain
11
+ * runs exact tag, then the bare language, then any variant of that language,
12
+ * then en-US — so `fr` finds fr-FR, `ja` finds ja-JP, and the locales it has no
13
+ * strings for at all (ca, cy, ga-IE, and our `lol` pseudo-locale) land on
14
+ * English.
15
+ *
16
+ * What does need handling is a malformed tag: I18nProvider's explicit-locale
17
+ * path feeds it straight to `new Intl.Locale` with no guard of its own (unlike
18
+ * its browser-locale path), so a bad tag throws during render and takes out the
19
+ * tree. Only reachable through SharedUIProvider's `locale` prop in practice —
20
+ * react-intl rejects a malformed locale before we could read it from context.
21
+ */
22
+ export const racLocale = (locale: string | undefined): string | undefined => {
23
+ if (locale === undefined) {
24
+ // Leave react-aria on its browser-locale default.
25
+ return undefined;
26
+ }
27
+ try {
28
+ Intl.DateTimeFormat.supportedLocalesOf([locale]);
29
+ } catch {
30
+ return "en-GB";
31
+ }
32
+ return locale;
33
+ };