@microbit/ui 0.0.0-gray.ramp.92 → 0.0.0-gray.ramp.v2.106

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/Radio.tsx CHANGED
@@ -7,38 +7,11 @@ import { ReactNode } from "react";
7
7
  import {
8
8
  Radio as RACRadio,
9
9
  RadioProps as RACRadioProps,
10
- RadioGroup as RACRadioGroup,
11
- RadioGroupProps as RACRadioGroupProps,
12
10
  } from "react-aria-components";
13
11
  import { css, cx } from "styled-system/css";
14
12
  import { radio, RadioVariantProps } from "styled-system/recipes";
15
13
  import { SystemStyleObject } from "styled-system/types";
16
14
 
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
15
  export interface RadioProps
43
16
  extends Omit<RACRadioProps, "className" | "children" | "style">,
44
17
  RadioVariantProps {
@@ -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
+ };
@@ -23,13 +23,16 @@ const transitionCommon =
23
23
  * Apps restyle it through the `variant` group — classroom's `classroom`
24
24
  * variant is the rounded pill its join form uses.
25
25
  *
26
+ * The label is not a slot here: it comes from the `field` recipe, as every
27
+ * other labelled field's does. The only thing that costs is a per-`variant`
28
+ * label style — restyle the control and let the label match the family.
29
+ *
26
30
  * Registered in the base preset (base-preset.ts).
27
31
  */
28
32
  export const select = defineSlotRecipe({
29
33
  className: "select",
30
34
  slots: [
31
35
  "root",
32
- "label",
33
36
  "trigger",
34
37
  "value",
35
38
  "indicator",
@@ -40,17 +43,11 @@ export const select = defineSlotRecipe({
40
43
  "empty",
41
44
  ],
42
45
  base: {
43
- root: {
44
- display: "flex",
45
- flexDirection: "column",
46
- width: "100%",
47
- },
48
- label: {
49
- fontSize: "md",
50
- fontWeight: "medium",
51
- marginEnd: "3",
52
- mb: "2",
53
- },
46
+ // No layout here: the component wears the `field` recipe's root alongside
47
+ // this slot, and that recipe is the single owner of field-root layout
48
+ // (else `labelPosition` would fight this slot over `flexDirection`). The
49
+ // slot stays for apps and variants to target.
50
+ root: {},
54
51
  trigger: {
55
52
  display: "flex",
56
53
  alignItems: "center",
@@ -70,11 +67,8 @@ export const select = defineSlotRecipe({
70
67
  // stepping darker. The dropdown card below keeps the light gray.200 —
71
68
  // it's a surface edge, not a form-control boundary.
72
69
  borderColor: "gray.400",
73
- borderRadius: "md",
74
70
  bg: "white",
75
71
  color: "inherit",
76
- h: "10",
77
- px: "4",
78
72
  // As the input recipe, so a Select, a NativeSelect and a TextField in one
79
73
  // form all tint together on hover. (react-aria's TextField has no hover
80
74
  // effect, but matching the family beats matching their docs.)
@@ -210,4 +204,17 @@ export const select = defineSlotRecipe({
210
204
  color: "gray.600",
211
205
  },
212
206
  },
207
+ variants: {
208
+ // The `input` recipe's size ladder, step for step, so a Select sits level
209
+ // with a TextField beside it at every size. The indicator is em-sized and
210
+ // scales free; the dropdown card keeps one density across sizes.
211
+ size: {
212
+ lg: { trigger: { fontSize: "lg", px: "4", h: "12", borderRadius: "md" } },
213
+ md: { trigger: { fontSize: "md", px: "4", h: "10", borderRadius: "md" } },
214
+ sm: { trigger: { fontSize: "sm", px: "3", h: "8", borderRadius: "sm" } },
215
+ },
216
+ },
217
+ defaultVariants: {
218
+ size: "md",
219
+ },
213
220
  });
package/src/Select.tsx CHANGED
@@ -6,7 +6,6 @@
6
6
  import { createContext, ReactNode, useContext } from "react";
7
7
  import {
8
8
  Button as RACButton,
9
- Label as RACLabel,
10
9
  ListBox as RACListBox,
11
10
  ListBoxItem as RACListBoxItem,
12
11
  ListBoxItemProps as RACListBoxItemProps,
@@ -18,8 +17,14 @@ import {
18
17
  } from "react-aria-components";
19
18
  import { RiArrowDownSLine } from "react-icons/ri";
20
19
  import { css, cx } from "styled-system/css";
21
- import { select, SelectVariantProps } from "styled-system/recipes";
20
+ import { field, select, SelectVariantProps } from "styled-system/recipes";
22
21
  import { SystemStyleObject } from "styled-system/types";
22
+ import {
23
+ FieldLabel,
24
+ FieldLayoutProps,
25
+ FieldSupport,
26
+ FieldSupportProps,
27
+ } from "./Field";
23
28
  import { Icon } from "./Icon";
24
29
 
25
30
  export type SelectSlots = ReturnType<typeof select>;
@@ -37,9 +42,13 @@ export interface SelectProps<T extends object>
37
42
  RACSelectProps<T>,
38
43
  "className" | "children" | "style" | "placeholder"
39
44
  >,
40
- SelectVariantProps {
45
+ SelectVariantProps,
46
+ FieldSupportProps,
47
+ FieldLayoutProps {
41
48
  /** Visible label. Use `aria-label` instead where the design has none. */
42
49
  label?: ReactNode;
50
+ /** Label style overrides. */
51
+ labelCss?: SystemStyleObject;
43
52
  /** Shown in the trigger while nothing is chosen (Chakra's placeholder). */
44
53
  placeholder?: string;
45
54
  /** `SelectOption`s. */
@@ -59,8 +68,8 @@ export interface SelectProps<T extends object>
59
68
  * while positioning, which beats any class.
60
69
  */
61
70
  maxHeight?: number;
62
- /** Per-instance overrides for the trigger. */
63
- css?: SystemStyleObject;
71
+ /** Per-instance overrides for the trigger (the button the value sits in). */
72
+ triggerCss?: SystemStyleObject;
64
73
  /** Per-instance overrides for the dropdown card. */
65
74
  contentCss?: SystemStyleObject;
66
75
  className?: string;
@@ -73,12 +82,17 @@ export interface SelectProps<T extends object>
73
82
  */
74
83
  export const Select = <T extends object>({
75
84
  label,
85
+ labelCss,
76
86
  placeholder,
77
87
  children,
78
88
  indicator,
79
89
  placement = "bottom start",
80
90
  maxHeight,
81
- css: cssProp,
91
+ helperText,
92
+ errorMessage,
93
+ helperTextCss,
94
+ labelPosition,
95
+ triggerCss,
82
96
  contentCss,
83
97
  className,
84
98
  ...props
@@ -87,15 +101,28 @@ export const Select = <T extends object>({
87
101
  // groups to the recipe and they have to reach it (playbook gotcha #37).
88
102
  const [variantProps, rest] = select.splitVariantProps(props);
89
103
  const slots = select(variantProps);
104
+ const fieldSlots = field({ size: variantProps.size, labelPosition });
90
105
  return (
91
106
  <SelectSlotProvider value={slots}>
92
107
  <RACSelect
93
108
  {...(rest as RACSelectProps<T>)}
94
- className={cx(slots.root, className)}
109
+ className={cx(fieldSlots.root, slots.root, className)}
95
110
  >
96
- {label != null && <RACLabel className={slots.label}>{label}</RACLabel>}
111
+ {label != null && (
112
+ <FieldLabel
113
+ size={variantProps.size}
114
+ labelPosition={labelPosition}
115
+ isRequired={props.isRequired}
116
+ css={labelCss}
117
+ >
118
+ {label}
119
+ </FieldLabel>
120
+ )}
97
121
  <RACButton
98
- className={cx(slots.trigger, cssProp ? css(cssProp) : undefined)}
122
+ className={cx(
123
+ slots.trigger,
124
+ triggerCss ? css(triggerCss) : undefined,
125
+ )}
99
126
  >
100
127
  <SelectValue className={slots.value}>
101
128
  {({ isPlaceholder, defaultChildren }) =>
@@ -118,6 +145,12 @@ export const Select = <T extends object>({
118
145
  >
119
146
  <RACListBox className={slots.list}>{children}</RACListBox>
120
147
  </Popover>
148
+ <FieldSupport
149
+ helperText={helperText}
150
+ errorMessage={errorMessage}
151
+ helperTextCss={helperTextCss}
152
+ labelPosition={labelPosition}
153
+ />
121
154
  </RACSelect>
122
155
  </SelectSlotProvider>
123
156
  );
@@ -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
  };
package/src/Skeleton.tsx CHANGED
@@ -17,7 +17,9 @@ import { SystemStyleObject } from "styled-system/types";
17
17
  */
18
18
  const skeletonBase: SystemStyleObject = {
19
19
  "--skeleton-start-color": "token(colors.gray.100)",
20
- "--skeleton-end-color": "token(colors.gray.400)",
20
+ // 350, not 400: decorative fill weight, as the Avatar default
21
+ // (docs/gray-ramp.md).
22
+ "--skeleton-end-color": "token(colors.gray.350)",
21
23
  background: "var(--skeleton-start-color)",
22
24
  borderColor: "var(--skeleton-end-color)",
23
25
  opacity: 0.7,
@@ -72,6 +72,24 @@ export const switchRecipe = defineSlotRecipe({
72
72
  },
73
73
  },
74
74
  variants: {
75
+ // `start` puts the label first and the control at the row's end — the
76
+ // settings-row pattern (a preference name beside its switch), the toggle
77
+ // counterpart of the field recipe's `labelPosition="side"`. Values are
78
+ // start/end rather than top/side because the default label is already
79
+ // beside the control, after it. The label keeps an end margin so a long
80
+ // translation can't butt against the track (the SelectFormControl
81
+ // lesson — see the field-chrome roadmap bullet).
82
+ labelPosition: {
83
+ end: {},
84
+ start: {
85
+ root: {
86
+ flexDirection: "row-reverse",
87
+ justifyContent: "space-between",
88
+ width: "100%",
89
+ },
90
+ label: { marginStart: "0", marginEnd: "3" },
91
+ },
92
+ },
75
93
  // Chakra's Switch size scale (track width x height; the selected-thumb
76
94
  // translate is the difference between them).
77
95
  size: {
@@ -103,5 +121,6 @@ export const switchRecipe = defineSlotRecipe({
103
121
  },
104
122
  defaultVariants: {
105
123
  size: "md",
124
+ labelPosition: "end",
106
125
  },
107
126
  });
package/src/Switch.tsx CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
- import { ReactNode } from "react";
6
+ import { ReactNode, useId } from "react";
7
7
  import {
8
8
  Switch as RACSwitch,
9
9
  SwitchProps as RACSwitchProps,
@@ -11,6 +11,7 @@ import {
11
11
  import { css, cx } from "styled-system/css";
12
12
  import { switchRecipe, SwitchRecipeVariantProps } from "styled-system/recipes";
13
13
  import { SystemStyleObject } from "styled-system/types";
14
+ import { FieldHelperText } from "./Field";
14
15
 
15
16
  export interface SwitchProps
16
17
  extends Omit<RACSwitchProps, "className" | "children" | "style">,
@@ -19,24 +20,43 @@ export interface SwitchProps
19
20
  css?: SystemStyleObject;
20
21
  className?: string;
21
22
  children?: ReactNode;
23
+ /**
24
+ * Help text below the switch, wired to its `aria-describedby` — the same
25
+ * chrome the labelled fields' `helperText` renders. With it the component
26
+ * gains a wrapping `<div>`, so the switch-plus-text moves as one block.
27
+ */
28
+ helperText?: ReactNode;
29
+ /** Per-instance style overrides for the helper text. */
30
+ helperTextCss?: SystemStyleObject;
22
31
  }
23
32
 
24
33
  /**
25
34
  * Switch — react-aria-components <Switch> styled like Chakra's switch.
26
35
  * Children render as the label; pass `aria-label` for label-less switches.
36
+ * `labelPosition="start"` is the settings-row layout: label first, switch at
37
+ * the row's end.
27
38
  */
28
39
  export const Switch = ({
29
40
  size,
41
+ labelPosition,
30
42
  css: cssProp,
31
43
  className,
32
44
  children,
45
+ helperText,
46
+ helperTextCss,
33
47
  ...rest
34
48
  }: SwitchProps) => {
35
- const slots = switchRecipe({ size });
36
- return (
49
+ const slots = switchRecipe({ size, labelPosition });
50
+ const helperId = useId();
51
+ const describedBy =
52
+ [rest["aria-describedby"], helperText != null ? helperId : undefined]
53
+ .filter(Boolean)
54
+ .join(" ") || undefined;
55
+ const switchElement = (
37
56
  <RACSwitch
38
57
  className={cx(slots.root, cssProp ? css(cssProp) : undefined, className)}
39
58
  {...rest}
59
+ aria-describedby={describedBy}
40
60
  >
41
61
  {({ isSelected, isFocusVisible, isDisabled }) => {
42
62
  const state = {
@@ -59,4 +79,15 @@ export const Switch = ({
59
79
  }}
60
80
  </RACSwitch>
61
81
  );
82
+ if (helperText == null) {
83
+ return switchElement;
84
+ }
85
+ return (
86
+ <div>
87
+ {switchElement}
88
+ <FieldHelperText id={helperId} css={helperTextCss}>
89
+ {helperText}
90
+ </FieldHelperText>
91
+ </div>
92
+ );
62
93
  };
package/src/TextField.tsx CHANGED
@@ -5,31 +5,31 @@
5
5
  */
6
6
  import { FocusEvent, forwardRef, ReactNode } from "react";
7
7
  import {
8
- FieldError,
9
8
  Input as RACInput,
10
- Label as RACLabel,
11
- Text as RACText,
12
9
  TextField as RACTextField,
13
10
  TextFieldProps as RACTextFieldProps,
14
11
  } from "react-aria-components";
15
- import { css, cx } from "styled-system/css";
16
12
  import { field, input, InputVariantProps } from "styled-system/recipes";
17
13
  import { SystemStyleObject } from "styled-system/types";
14
+ import {
15
+ FieldLabel,
16
+ FieldLayoutProps,
17
+ FieldSupport,
18
+ FieldSupportProps,
19
+ } from "./Field";
18
20
 
19
21
  export interface TextFieldProps
20
22
  extends Omit<
21
23
  RACTextFieldProps,
22
24
  "className" | "children" | "style" | "onFocus" | "onBlur"
23
25
  >,
24
- InputVariantProps {
26
+ InputVariantProps,
27
+ FieldSupportProps,
28
+ FieldLayoutProps {
25
29
  /** Visible label (Chakra's FormLabel; asterisk added when `isRequired`). */
26
30
  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;
31
+ /** Label style overrides. */
32
+ labelCss?: SystemStyleObject;
33
33
  onFocus?: (e: FocusEvent<HTMLInputElement>) => void;
34
34
  /** Input autocapitalize attribute (react-aria's TextField omits it). */
35
35
  autoCapitalize?: "off" | "none" | "on" | "sentences" | "words" | "characters";
@@ -44,9 +44,11 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
44
44
  function TextField(
45
45
  {
46
46
  label,
47
+ labelCss,
47
48
  helperText,
48
49
  errorMessage,
49
50
  helperTextCss,
51
+ labelPosition,
50
52
  onFocus,
51
53
  autoCapitalize,
52
54
  ...props
@@ -56,35 +58,29 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
56
58
  // As Input: forward every recipe variant group, not just `size`, so a
57
59
  // preset that adds one keeps working.
58
60
  const [variantProps, rest] = input.splitVariantProps(props);
59
- const slots = field();
61
+ const slots = field({ size: variantProps.size, labelPosition });
60
62
  return (
61
63
  <RACTextField {...rest} className={slots.root}>
62
- <RACLabel className={slots.label}>
64
+ <FieldLabel
65
+ size={variantProps.size}
66
+ labelPosition={labelPosition}
67
+ isRequired={rest.isRequired}
68
+ css={labelCss}
69
+ >
63
70
  {label}
64
- {rest.isRequired ? (
65
- <span aria-hidden className={slots.requiredIndicator}>
66
- *
67
- </span>
68
- ) : null}
69
- </RACLabel>
71
+ </FieldLabel>
70
72
  <RACInput
71
73
  ref={ref}
72
74
  className={input(variantProps)}
73
75
  onFocus={onFocus}
74
76
  autoCapitalize={autoCapitalize}
75
77
  />
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>
78
+ <FieldSupport
79
+ helperText={helperText}
80
+ errorMessage={errorMessage}
81
+ helperTextCss={helperTextCss}
82
+ labelPosition={labelPosition}
83
+ />
88
84
  </RACTextField>
89
85
  );
90
86
  },