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

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 (45) hide show
  1. package/LICENSE.md +8 -0
  2. package/README.md +34 -11
  3. package/lang/ui.ca.json +36 -0
  4. package/lang/ui.cy.json +36 -0
  5. package/lang/ui.de.json +36 -0
  6. package/lang/ui.en-us.json +36 -0
  7. package/lang/ui.en.json +28 -0
  8. package/lang/ui.es-es.json +36 -0
  9. package/lang/ui.fr.json +36 -0
  10. package/lang/ui.ga-ie.json +36 -0
  11. package/lang/ui.it.json +36 -0
  12. package/lang/ui.ja.json +36 -0
  13. package/lang/ui.ko.json +36 -0
  14. package/lang/ui.lol.json +36 -0
  15. package/lang/ui.nl.json +36 -0
  16. package/lang/ui.pl.json +36 -0
  17. package/lang/ui.pt-br.json +36 -0
  18. package/lang/ui.zh-cn.json +36 -0
  19. package/lang/ui.zh-tw.json +36 -0
  20. package/package.json +1 -1
  21. package/src/Avatar.recipe.ts +3 -1
  22. package/src/Checkbox.recipe.ts +5 -0
  23. package/src/Checkbox.tsx +42 -2
  24. package/src/CheckboxGroup.tsx +6 -10
  25. package/src/ComboBox.tsx +38 -16
  26. package/src/Field.recipe.ts +113 -0
  27. package/src/Field.tsx +189 -0
  28. package/src/Input.recipe.ts +4 -2
  29. package/src/NativeSelectField.tsx +84 -0
  30. package/src/NumberField.recipe.ts +25 -7
  31. package/src/NumberField.tsx +52 -22
  32. package/src/Radio.recipe.ts +4 -0
  33. package/src/Radio.tsx +1 -63
  34. package/src/RadioGroup.tsx +69 -0
  35. package/src/Select.recipe.ts +27 -17
  36. package/src/Select.tsx +38 -15
  37. package/src/Skeleton.tsx +5 -3
  38. package/src/Switch.recipe.ts +19 -0
  39. package/src/Switch.tsx +34 -3
  40. package/src/TextField.tsx +19 -8
  41. package/src/Toast.tsx +17 -2
  42. package/src/base-preset.ts +45 -10
  43. package/src/index.ts +3 -1
  44. package/src/FieldSupport.tsx +0 -68
  45. package/src/TextField.recipe.ts +0 -58
package/src/Field.tsx ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { HTMLAttributes, ReactNode } from "react";
7
+ import {
8
+ FieldError,
9
+ Label as RACLabel,
10
+ LabelProps as RACLabelProps,
11
+ Text as RACText,
12
+ } from "react-aria-components";
13
+ import { css, cx } from "styled-system/css";
14
+ import { field, FieldVariantProps } from "styled-system/recipes";
15
+ import { SystemStyleObject } from "styled-system/types";
16
+
17
+ /**
18
+ * The label/helper/error chrome every labelled form field shares — Chakra's
19
+ * FormControl parts, generalised out of TextField so Select, ComboBox,
20
+ * NumberField, RadioGroup and CheckboxGroup carry the same props
21
+ * (data-microbit-org's forms attach helper and error text to all of these).
22
+ */
23
+ export interface FieldSupportProps {
24
+ /** Help text below the field (Chakra's FormHelperText). */
25
+ helperText?: ReactNode;
26
+ /** Shown below the field when invalid (Chakra's FormErrorMessage). */
27
+ errorMessage?: ReactNode;
28
+ /** Per-instance style overrides for the helper text. */
29
+ helperTextCss?: SystemStyleObject;
30
+ }
31
+
32
+ /**
33
+ * `labelPosition` for the four single-control fields (TextField, NumberField,
34
+ * Select, ComboBox). Deliberately not on RadioGroup/CheckboxGroup: their roots
35
+ * carry no layout, and RAC's own `orientation` prop is a different axis there
36
+ * (it lays out the radios, not the label).
37
+ */
38
+ export interface FieldLayoutProps {
39
+ /**
40
+ * `side` puts the label beside the control — the settings-row pattern. The
41
+ * label absorbs the free space; the control keeps its own width, so give it
42
+ * one (`groupCss`, `wrapperCss` or `triggerCss` depending on the field).
43
+ * Helper and error text drop to a full-width line below the pair.
44
+ */
45
+ labelPosition?: FieldVariantProps["labelPosition"];
46
+ }
47
+
48
+ /**
49
+ * Helper text and error message for a react-aria field container. Render
50
+ * inside any RAC component with field validation context (TextField, Select,
51
+ * ComboBox, NumberField, RadioGroup, CheckboxGroup) — react-aria wires the
52
+ * description to the input's aria-describedby, and the error renders only
53
+ * while the field is invalid. Also exported for app-side composites built on
54
+ * RAC containers.
55
+ */
56
+ export const FieldSupport = ({
57
+ helperText,
58
+ errorMessage,
59
+ helperTextCss,
60
+ labelPosition,
61
+ }: FieldSupportProps & FieldLayoutProps) => {
62
+ const slots = field({ labelPosition });
63
+ return (
64
+ <>
65
+ {helperText != null && (
66
+ <RACText
67
+ slot="description"
68
+ className={cx(
69
+ slots.helperText,
70
+ helperTextCss ? css(helperTextCss) : undefined,
71
+ )}
72
+ >
73
+ {helperText}
74
+ </RACText>
75
+ )}
76
+ <FieldError className={slots.errorMessage}>{errorMessage}</FieldError>
77
+ </>
78
+ );
79
+ };
80
+
81
+ interface FieldTextProps extends HTMLAttributes<HTMLDivElement> {
82
+ children: ReactNode;
83
+ /** The field's `labelPosition`, so the text lays out to match. */
84
+ labelPosition?: FieldVariantProps["labelPosition"];
85
+ /** Per-instance style overrides. */
86
+ css?: SystemStyleObject;
87
+ }
88
+
89
+ /**
90
+ * Context-free helper text (Chakra's FormHelperText) for a control react-aria
91
+ * isn't wiring — a native select or a masked input. Inside a RAC field
92
+ * container use `FieldSupport`, which wires `aria-describedby` and validation
93
+ * for free; here the caller owns that wiring: give this an `id` and reference
94
+ * it from the control's `aria-describedby` (as `NativeSelectField` does).
95
+ */
96
+ export const FieldHelperText = ({
97
+ children,
98
+ labelPosition,
99
+ css: cssProp,
100
+ ...rest
101
+ }: FieldTextProps) => (
102
+ <div
103
+ {...rest}
104
+ className={cx(
105
+ field({ labelPosition }).helperText,
106
+ cssProp ? css(cssProp) : undefined,
107
+ )}
108
+ >
109
+ {children}
110
+ </div>
111
+ );
112
+
113
+ /**
114
+ * Context-free error message (Chakra's FormErrorMessage), the counterpart to
115
+ * `FieldHelperText` — see its note on the wiring the caller owns. RAC's
116
+ * `FieldError` renders only while its field is invalid; here that decision is
117
+ * the caller's too: render it conditionally.
118
+ */
119
+ export const FieldErrorMessage = ({
120
+ children,
121
+ labelPosition,
122
+ css: cssProp,
123
+ ...rest
124
+ }: FieldTextProps) => (
125
+ <div
126
+ {...rest}
127
+ className={cx(
128
+ field({ labelPosition }).errorMessage,
129
+ cssProp ? css(cssProp) : undefined,
130
+ )}
131
+ >
132
+ {children}
133
+ </div>
134
+ );
135
+
136
+ /**
137
+ * The required-field asterisk (Chakra's FormLabel indicator). Render inside
138
+ * the field's label when `isRequired`; aria-hidden because react-aria already
139
+ * announces requiredness from the input itself.
140
+ */
141
+ export const FieldRequiredIndicator = () => (
142
+ <span aria-hidden className={field().requiredIndicator}>
143
+ *
144
+ </span>
145
+ );
146
+
147
+ export interface FieldLabelProps
148
+ extends Omit<RACLabelProps, "className" | "children" | "style"> {
149
+ children: ReactNode;
150
+ /** Adds the required asterisk; pass the field's `isRequired`. */
151
+ isRequired?: boolean;
152
+ /** The field's `size`, so the label scales with its control. */
153
+ size?: FieldVariantProps["size"];
154
+ /** The field's `labelPosition`, so the label lays out to match. */
155
+ labelPosition?: FieldVariantProps["labelPosition"];
156
+ /** Per-instance style overrides. */
157
+ css?: SystemStyleObject;
158
+ }
159
+
160
+ /**
161
+ * A field's visible label (Chakra's FormLabel), asterisk included. Every
162
+ * labelled field in the library renders its label through this, so the `field`
163
+ * recipe is the single answer to what a label looks like — Select and ComboBox
164
+ * previously carried a near-identical `label` slot on the `select` recipe,
165
+ * which is how the two drifted.
166
+ *
167
+ * Also exported for app-side composites: inside a RAC field container the
168
+ * association is automatic, and outside one (a masked or native input) pass
169
+ * `id` and `htmlFor` yourself.
170
+ */
171
+ export const FieldLabel = ({
172
+ children,
173
+ isRequired,
174
+ size,
175
+ labelPosition,
176
+ css: cssProp,
177
+ ...rest
178
+ }: FieldLabelProps) => (
179
+ <RACLabel
180
+ {...rest}
181
+ className={cx(
182
+ field({ size, labelPosition }).label,
183
+ cssProp ? css(cssProp) : undefined,
184
+ )}
185
+ >
186
+ {children}
187
+ {isRequired ? <FieldRequiredIndicator /> : null}
188
+ </RACLabel>
189
+ );
@@ -44,10 +44,12 @@ export const input = defineRecipe({
44
44
  transitionProperty: transitionCommon,
45
45
  transitionDuration: "normal",
46
46
  border: "1px solid",
47
- borderColor: "gray.200",
47
+ // The accessible outline stops: gray.400 is the ramp's 3:1-on-white
48
+ // boundary grey (WCAG 1.4.11), and hover steps darker, never lighter.
49
+ borderColor: "gray.400",
48
50
  bg: "inherit",
49
51
  color: "inherit",
50
- _hover: { borderColor: "gray.300" },
52
+ _hover: { borderColor: "gray.500" },
51
53
  "&&:is([data-invalid], :user-invalid)": {
52
54
  borderColor: "danger.500",
53
55
  boxShadow: "0 0 0 1px token(colors.danger.500)",
@@ -0,0 +1,84 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { forwardRef, ReactNode, useId } from "react";
7
+ import { css, cx } from "styled-system/css";
8
+ import { field } from "styled-system/recipes";
9
+ import { SystemStyleObject } from "styled-system/types";
10
+ import { FieldHelperText, FieldLabel, FieldLayoutProps } from "./Field";
11
+ import { NativeSelect, NativeSelectProps } from "./NativeSelect";
12
+
13
+ export interface NativeSelectFieldProps
14
+ extends NativeSelectProps,
15
+ FieldLayoutProps {
16
+ /** Visible label, associated with the select via `htmlFor`. */
17
+ label: ReactNode;
18
+ /** Label style overrides. */
19
+ labelCss?: SystemStyleObject;
20
+ /** Help text below the field, wired to the select's `aria-describedby`. */
21
+ helperText?: ReactNode;
22
+ /** Per-instance style overrides for the field root. */
23
+ rootCss?: SystemStyleObject;
24
+ }
25
+
26
+ /**
27
+ * NativeSelectField — a labelled `NativeSelect`, pairing the bare control with
28
+ * the field chrome the RAC fields get from react-aria, as `TextField` pairs
29
+ * with `Input`. The label association and `aria-describedby` are wired here,
30
+ * since there is no RAC context to do it; the label dims with the control
31
+ * exactly as the RAC fields' do. No `errorMessage` yet — no consumer needs
32
+ * one; render `FieldErrorMessage` beside it if yours does.
33
+ *
34
+ * With `labelPosition="side"` this is the settings row both
35
+ * `SelectFormControl`s hand-rolled: label beside a fixed-width select
36
+ * (`wrapperCss={{ width: "28ch" }}`), label absorbing the free space.
37
+ */
38
+ export const NativeSelectField = forwardRef<
39
+ HTMLSelectElement,
40
+ NativeSelectFieldProps
41
+ >(function NativeSelectField(
42
+ { label, labelCss, helperText, labelPosition, rootCss, id, ...rest },
43
+ ref,
44
+ ) {
45
+ const generatedId = useId();
46
+ const selectId = id ?? generatedId;
47
+ const helperId = useId();
48
+ const describedBy =
49
+ [rest["aria-describedby"], helperText != null ? helperId : undefined]
50
+ .filter(Boolean)
51
+ .join(" ") || undefined;
52
+ return (
53
+ <div
54
+ className={cx(
55
+ field({ size: rest.size, labelPosition }).root,
56
+ rootCss ? css(rootCss) : undefined,
57
+ )}
58
+ // The field recipe's label dims off the root's data-disabled, which RAC
59
+ // stamps for the other fields; here it is restated from the attribute.
60
+ data-disabled={rest.disabled || undefined}
61
+ >
62
+ <FieldLabel
63
+ htmlFor={selectId}
64
+ size={rest.size}
65
+ labelPosition={labelPosition}
66
+ isRequired={rest.required}
67
+ css={labelCss}
68
+ >
69
+ {label}
70
+ </FieldLabel>
71
+ <NativeSelect
72
+ ref={ref}
73
+ id={selectId}
74
+ {...rest}
75
+ aria-describedby={describedBy}
76
+ />
77
+ {helperText != null && (
78
+ <FieldHelperText id={helperId} labelPosition={labelPosition}>
79
+ {helperText}
80
+ </FieldHelperText>
81
+ )}
82
+ </div>
83
+ );
84
+ });
@@ -11,18 +11,18 @@ import { defineSlotRecipe } from "@pandacss/dev";
11
11
  * two stacked buttons. Consumed by the shared-ui NumberField
12
12
  * (react-aria-components NumberField).
13
13
  *
14
- * Registered in the base preset (base-preset.ts). No variants, so it needs
15
- * no `staticCss` entry.
14
+ * Registered in the base preset (base-preset.ts), which also has the
15
+ * `staticCss` entry that keeps the runtime-prop size variants generated.
16
16
  */
17
17
  export const numberField = defineSlotRecipe({
18
18
  className: "numberField",
19
19
  slots: ["root", "group", "stepper", "stepperButton"],
20
20
  base: {
21
- root: {
22
- display: "flex",
23
- flexDirection: "column",
24
- alignItems: "stretch",
25
- },
21
+ // No layout here: the component wears the `field` recipe's root alongside
22
+ // this slot, and that recipe is the single owner of field-root layout
23
+ // (else `labelPosition` would fight this slot over `flexDirection`). The
24
+ // slot stays for apps and variants to target.
25
+ root: {},
26
26
  group: {
27
27
  position: "relative",
28
28
  zIndex: 0,
@@ -64,4 +64,22 @@ export const numberField = defineSlotRecipe({
64
64
  "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
65
65
  },
66
66
  },
67
+ variants: {
68
+ // Chakra's NumberInput: the stepper column stays 24px wide at every size
69
+ // (as does the input padding paired with it in NumberField.tsx); only the
70
+ // arrow glyphs scale, at 0.75 × the field's font size. The base's `xs` is
71
+ // exactly md × 0.75, so md adds nothing.
72
+ size: {
73
+ lg: {
74
+ stepperButton: { fontSize: "calc(token(fontSizes.lg) * 0.75)" },
75
+ },
76
+ md: {},
77
+ sm: {
78
+ stepperButton: { fontSize: "calc(token(fontSizes.sm) * 0.75)" },
79
+ },
80
+ },
81
+ },
82
+ defaultVariants: {
83
+ size: "md",
84
+ },
67
85
  });
@@ -8,33 +8,43 @@ import {
8
8
  Button as RACButton,
9
9
  Group as RACGroup,
10
10
  Input as RACInput,
11
- Label as RACLabel,
12
11
  NumberField as RACNumberField,
13
12
  NumberFieldProps as RACNumberFieldProps,
14
13
  } from "react-aria-components";
14
+ import { useIntl } from "react-intl";
15
15
  import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
16
16
  import { css, cx } from "styled-system/css";
17
- import { field, input, numberField } from "styled-system/recipes";
17
+ import {
18
+ field,
19
+ input,
20
+ InputVariantProps,
21
+ numberField,
22
+ } from "styled-system/recipes";
18
23
  import { SystemStyleObject } from "styled-system/types";
19
24
  import {
20
- FieldRequiredIndicator,
25
+ FieldLabel,
26
+ FieldLayoutProps,
21
27
  FieldSupport,
22
28
  FieldSupportProps,
23
- } from "./FieldSupport";
29
+ } from "./Field";
24
30
  import { Icon } from "./Icon";
31
+ import { uiMessage } from "./messages";
25
32
 
26
33
  export interface NumberFieldProps
27
34
  extends Omit<RACNumberFieldProps, "className" | "children" | "style">,
28
- FieldSupportProps {
35
+ InputVariantProps,
36
+ FieldSupportProps,
37
+ FieldLayoutProps {
29
38
  /** Visible label (optional; otherwise pass `aria-label`). */
30
39
  label?: ReactNode;
31
- /** Root style overrides (e.g. row layout for label-beside-field forms). */
32
- css?: SystemStyleObject;
40
+ /** Root style overrides (for a label-beside-field row, prefer
41
+ * `labelPosition="side"`). */
42
+ rootCss?: SystemStyleObject;
33
43
  /** Label style overrides. */
34
44
  labelCss?: SystemStyleObject;
35
45
  /** Group (input + steppers) style overrides — set `width` here. */
36
46
  groupCss?: SystemStyleObject;
37
- /** Input style overrides (e.g. a smaller size than the recipe's md). */
47
+ /** Input style overrides (for sizing, prefer the `size` prop). */
38
48
  inputCss?: SystemStyleObject;
39
49
  }
40
50
 
@@ -51,31 +61,49 @@ export const NumberField = forwardRef<HTMLInputElement, NumberFieldProps>(
51
61
  helperText,
52
62
  errorMessage,
53
63
  helperTextCss,
54
- css: cssProp,
64
+ rootCss,
55
65
  labelCss,
56
66
  groupCss,
57
67
  inputCss,
58
- ...rest
68
+ labelPosition,
69
+ ...props
59
70
  },
60
71
  ref,
61
72
  ) {
62
- const slots = numberField();
63
- const fieldSlots = field();
73
+ // As Input: forward every recipe variant group, not just `size`, so a
74
+ // preset that adds one keeps working.
75
+ const [variantProps, rest] = input.splitVariantProps(props);
76
+ const slots = numberField({ size: variantProps.size });
77
+ const fieldSlots = field({ size: variantProps.size, labelPosition });
78
+ const intl = useIntl();
79
+ // Fold the field's name into the stepper labels as react-aria does; the
80
+ // trim eats the leftover space when there is no usable name.
81
+ const fieldLabel =
82
+ rest["aria-label"] ?? (typeof label === "string" ? label : "");
64
83
  return (
65
84
  <RACNumberField
85
+ incrementAriaLabel={intl
86
+ .formatMessage(uiMessage("ui.numberfield-increase"), { fieldLabel })
87
+ .trim()}
88
+ decrementAriaLabel={intl
89
+ .formatMessage(uiMessage("ui.numberfield-decrease"), { fieldLabel })
90
+ .trim()}
66
91
  {...rest}
67
- className={cx(slots.root, cssProp ? css(cssProp) : undefined)}
92
+ className={cx(
93
+ fieldSlots.root,
94
+ slots.root,
95
+ rootCss ? css(rootCss) : undefined,
96
+ )}
68
97
  >
69
98
  {label != null && (
70
- <RACLabel
71
- className={cx(
72
- fieldSlots.label,
73
- labelCss ? css(labelCss) : undefined,
74
- )}
99
+ <FieldLabel
100
+ size={variantProps.size}
101
+ labelPosition={labelPosition}
102
+ isRequired={rest.isRequired}
103
+ css={labelCss}
75
104
  >
76
105
  {label}
77
- {rest.isRequired ? <FieldRequiredIndicator /> : null}
78
- </RACLabel>
106
+ </FieldLabel>
79
107
  )}
80
108
  <RACGroup
81
109
  className={cx(slots.group, groupCss ? css(groupCss) : undefined)}
@@ -83,8 +111,9 @@ export const NumberField = forwardRef<HTMLInputElement, NumberFieldProps>(
83
111
  <RACInput
84
112
  ref={ref}
85
113
  className={cx(
86
- input(),
87
- // Room for the stepper column.
114
+ input(variantProps),
115
+ // Room for the stepper column (constant across sizes, as the
116
+ // stepper's width is).
88
117
  css({ paddingEnd: "6" }, inputCss),
89
118
  )}
90
119
  />
@@ -101,6 +130,7 @@ export const NumberField = forwardRef<HTMLInputElement, NumberFieldProps>(
101
130
  helperText={helperText}
102
131
  errorMessage={errorMessage}
103
132
  helperTextCss={helperTextCss}
133
+ labelPosition={labelPosition}
104
134
  />
105
135
  </RACNumberField>
106
136
  );
@@ -28,6 +28,10 @@ export const radio = defineSlotRecipe({
28
28
  verticalAlign: "top",
29
29
  cursor: "pointer",
30
30
  position: "relative",
31
+ // As the checkbox: the accessible outline stop (WCAG 1.4.11), on the
32
+ // root so the control's `borderColor: inherit` reads it and call
33
+ // sites can still tint at the root.
34
+ borderColor: "gray.400",
31
35
  "&[data-disabled]": { cursor: "not-allowed" },
32
36
  },
33
37
  control: {
package/src/Radio.tsx CHANGED
@@ -5,74 +5,12 @@
5
5
  */
6
6
  import { ReactNode } from "react";
7
7
  import {
8
- Label as RACLabel,
9
8
  Radio as RACRadio,
10
9
  RadioProps as RACRadioProps,
11
- RadioGroup as RACRadioGroup,
12
- RadioGroupProps as RACRadioGroupProps,
13
10
  } from "react-aria-components";
14
11
  import { css, cx } from "styled-system/css";
15
- import { field, radio, RadioVariantProps } from "styled-system/recipes";
12
+ import { radio, RadioVariantProps } from "styled-system/recipes";
16
13
  import { SystemStyleObject } from "styled-system/types";
17
- import {
18
- FieldRequiredIndicator,
19
- FieldSupport,
20
- FieldSupportProps,
21
- } from "./FieldSupport";
22
-
23
- export interface RadioGroupProps
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;
31
- /** Per-instance style overrides, merged after the recipe. */
32
- css?: SystemStyleObject;
33
- className?: string;
34
- }
35
-
36
- /**
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.
41
- */
42
- export const RadioGroup = ({
43
- label,
44
- helperText,
45
- errorMessage,
46
- helperTextCss,
47
- css: cssProp,
48
- className,
49
- children,
50
- ...rest
51
- }: RadioGroupProps) => {
52
- return (
53
- <RACRadioGroup
54
- className={cx(cssProp ? css(cssProp) : undefined, className)}
55
- {...rest}
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>
74
- );
75
- };
76
14
 
77
15
  export interface RadioProps
78
16
  extends Omit<RACRadioProps, "className" | "children" | "style">,
@@ -0,0 +1,69 @@
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
+ RadioGroup as RACRadioGroup,
9
+ RadioGroupProps as RACRadioGroupProps,
10
+ } from "react-aria-components";
11
+ import { css, cx } from "styled-system/css";
12
+ import { SystemStyleObject } from "styled-system/types";
13
+ import { FieldLabel, FieldSupport, FieldSupportProps } from "./Field";
14
+
15
+ export interface RadioGroupProps
16
+ extends Omit<RACRadioGroupProps, "className" | "style">,
17
+ FieldSupportProps {
18
+ /**
19
+ * Visible label for the group (Chakra's FormLabel above it). Use
20
+ * `aria-label` instead where the design has none.
21
+ */
22
+ label?: ReactNode;
23
+ /** Label style overrides. */
24
+ labelCss?: SystemStyleObject;
25
+ /** Per-instance style overrides, merged after the recipe. */
26
+ css?: SystemStyleObject;
27
+ className?: string;
28
+ }
29
+
30
+ /**
31
+ * RadioGroup — react-aria-components <RadioGroup> for a set of Radios. Beyond
32
+ * the optional field chrome (label/helperText/errorMessage — Chakra's
33
+ * FormControl parts) it carries no styling of its own: compose with Stack for
34
+ * layout, as Chakra call sites did.
35
+ */
36
+ export const RadioGroup = ({
37
+ label,
38
+ labelCss,
39
+ helperText,
40
+ errorMessage,
41
+ helperTextCss,
42
+ css: cssProp,
43
+ className,
44
+ children,
45
+ ...rest
46
+ }: RadioGroupProps) => {
47
+ return (
48
+ <RACRadioGroup
49
+ className={cx(cssProp ? css(cssProp) : undefined, className)}
50
+ {...rest}
51
+ >
52
+ {(renderProps) => (
53
+ <>
54
+ {label != null && (
55
+ <FieldLabel isRequired={rest.isRequired} css={labelCss}>
56
+ {label}
57
+ </FieldLabel>
58
+ )}
59
+ {typeof children === "function" ? children(renderProps) : children}
60
+ <FieldSupport
61
+ helperText={helperText}
62
+ errorMessage={errorMessage}
63
+ helperTextCss={helperTextCss}
64
+ />
65
+ </>
66
+ )}
67
+ </RACRadioGroup>
68
+ );
69
+ };