@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/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
 
@@ -168,6 +171,19 @@ Semantic tokens (`languageText`, `statusBarBg`, `danger.*`, `toast*Bg`,
168
171
  brand presets override; they resolve through var indirection, so overrides
169
172
  apply wherever the token is consumed.
170
173
 
174
+ ### The grey rule
175
+
176
+ The default `gray` ramp is **neutral and complete** (see
177
+ [docs/gray-ramp.md](../../docs/gray-ramp.md) for the grading and its
178
+ contrast guarantees). App/brand presets may override its _values_ — never
179
+ its names, and never partially in a way that lets a stop fall through to
180
+ a different grey system. Light stops (10–300) are free: byte-pin an app's
181
+ existing surfaces or tint them. Dark-half values (350–900) may only be
182
+ overridden luminance-matched to the family grading — each stop's contrast
183
+ figure is the contract (400 ≥ 3:1 outlines, 500 ≥ 4.5:1 text), hue is
184
+ free. Which stops an app tints is a per-app design decision, not a
185
+ pattern to copy. New apps: take the default and ship.
186
+
171
187
  ## Runtime token lookups
172
188
 
173
189
  For values that feed _computation_ rather than stylesheets (canvas painting,
@@ -179,7 +195,7 @@ runtime lookup:
179
195
  import { token } from "@microbit/ui"; // re-exports styled-system/tokens
180
196
 
181
197
  token("colors.brand.500"); // "#007dbc" — raw value, safe for colour math
182
- token("colors.statusBarBg"); // "var(--colors-gray-500)" — CSS contexts only
198
+ token("colors.statusBarBg"); // "var(--colors-brand2-500)" — CSS contexts only
183
199
  ```
184
200
 
185
201
  Base tokens resolve to raw values; **semantic tokens resolve to `var()`
@@ -207,6 +223,20 @@ Crowdin ZIP>` (config-driven over packages in
207
223
  `bin/update-translations.cjs`), after which you run `npm run i18n:tidy`
208
224
  from the root.
209
225
 
226
+ ### react-aria's own strings
227
+
228
+ Separately from all of the above, react-aria has built-in strings of its own —
229
+ stepper button labels, the toast region's landmark label, hidden dismiss
230
+ buttons, listbox and selection announcements — and translates them from
231
+ catalogs it bundles, nothing to do with react-intl. `SharedUIProvider` hands it
232
+ the app's locale so the two agree.
233
+
234
+ It covers 32 locales. Of ours, ca, cy and ga-IE (and the `lol` pseudo-locale)
235
+ are not among them and fall back to English; the rest resolve, including where
236
+ our id is less specific than react-aria's (`fr` finds its fr-FR). There is no
237
+ supported way to supply translations for the locales it misses, so where one of
238
+ its strings matters we pass our own text in as a label instead.
239
+
210
240
  ## Chakra UI heritage and license
211
241
 
212
242
  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.0.0-gray.ramp.92",
3
+ "version": "0.0.0-gray.ramp.v2.106",
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",
@@ -61,6 +61,6 @@
61
61
  "vitest": "^4.1.10"
62
62
  },
63
63
  "publishConfig": {
64
- "tag": "gray-ramp"
64
+ "tag": "gray-ramp-v2"
65
65
  }
66
66
  }
@@ -44,7 +44,10 @@ export const avatar = defineSlotRecipe({
44
44
  fontWeight: "medium",
45
45
  borderRadius: "full",
46
46
  // Chakra's no-name defaults; the name-derived pair arrives inline.
47
- background: "var(--avatar-bg, token(colors.gray.400))",
47
+ // 350, not 400: a decorative fill written against Chakra's 2.26:1
48
+ // mid-grey, which the outline re-grade made an ink stop — 350 keeps
49
+ // the disc's weight (docs/gray-ramp.md).
50
+ background: "var(--avatar-bg, token(colors.gray.350))",
48
51
  color: "var(--avatar-color, token(colors.white))",
49
52
  borderColor: "white",
50
53
  },
@@ -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
+ };
@@ -178,9 +178,6 @@ export const button = defineRecipe({
178
178
  borderWidth: "2px",
179
179
  borderColor: "gray.200",
180
180
  color: "languageText",
181
- // The hover wash reads as a highlight because the app's gray ramp
182
- // carries its brand undertone (per-app tinted ramps) — an
183
- // achromatic gray.100 here goes drab, a tinted one doesn't.
184
181
  _hover: { color: "languageTextHover", bg: "gray.100" },
185
182
  },
186
183
  },
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
  },
package/src/Checkbox.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
  Checkbox as RACCheckbox,
9
9
  CheckboxProps as RACCheckboxProps,
@@ -11,6 +11,7 @@ import {
11
11
  import { css, cx } from "styled-system/css";
12
12
  import { checkbox, CheckboxVariantProps } from "styled-system/recipes";
13
13
  import { SystemStyleObject } from "styled-system/types";
14
+ import { FieldHelperText } from "./Field";
14
15
 
15
16
  /** What a render-prop child is told about the checkbox. */
16
17
  export interface CheckboxState {
@@ -39,6 +40,14 @@ export interface CheckboxProps
39
40
  * @default true
40
41
  */
41
42
  control?: boolean;
43
+ /**
44
+ * Help text below the checkbox, wired to its `aria-describedby` — the same
45
+ * chrome the labelled fields' `helperText` renders. With it the component
46
+ * gains a wrapping `<div>`, so the checkbox-plus-text moves as one block.
47
+ */
48
+ helperText?: ReactNode;
49
+ /** Per-instance style overrides for the helper text. */
50
+ helperTextCss?: SystemStyleObject;
42
51
  }
43
52
 
44
53
  /**
@@ -52,13 +61,21 @@ export const Checkbox = ({
52
61
  className,
53
62
  children,
54
63
  control,
64
+ helperText,
65
+ helperTextCss,
55
66
  ...rest
56
67
  }: CheckboxProps) => {
57
68
  const slots = checkbox({ size });
58
- return (
69
+ const helperId = useId();
70
+ const describedBy =
71
+ [rest["aria-describedby"], helperText != null ? helperId : undefined]
72
+ .filter(Boolean)
73
+ .join(" ") || undefined;
74
+ const checkboxElement = (
59
75
  <RACCheckbox
60
76
  className={cx(slots.root, cssProp ? css(cssProp) : undefined, className)}
61
77
  {...rest}
78
+ aria-describedby={describedBy}
62
79
  >
63
80
  {({ isSelected, isFocusVisible, isDisabled }) => {
64
81
  const content =
@@ -103,4 +120,15 @@ export const Checkbox = ({
103
120
  }}
104
121
  </RACCheckbox>
105
122
  );
123
+ if (helperText == null) {
124
+ return checkboxElement;
125
+ }
126
+ return (
127
+ <div>
128
+ {checkboxElement}
129
+ <FieldHelperText id={helperId} css={helperTextCss}>
130
+ {helperText}
131
+ </FieldHelperText>
132
+ </div>
133
+ );
106
134
  };
@@ -0,0 +1,72 @@
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
+ } 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 CheckboxGroupProps
16
+ extends Omit<RACCheckboxGroupProps, "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. */
26
+ css?: SystemStyleObject;
27
+ className?: string;
28
+ }
29
+
30
+ /**
31
+ * CheckboxGroup — react-aria-components <CheckboxGroup> for a set of
32
+ * Checkboxes sharing one value array (each Checkbox's `value` marks its
33
+ * entry). Beyond the optional field chrome (label/helperText/errorMessage —
34
+ * Chakra's FormControl parts) it carries no styling of its own: compose with
35
+ * Stack for layout, as RadioGroup does. Chakra's CheckboxGroup was a bare
36
+ * context provider, so ported call sites gain the chrome rather than
37
+ * restating it around the group.
38
+ */
39
+ export const CheckboxGroup = ({
40
+ label,
41
+ labelCss,
42
+ helperText,
43
+ errorMessage,
44
+ helperTextCss,
45
+ css: cssProp,
46
+ className,
47
+ children,
48
+ ...rest
49
+ }: CheckboxGroupProps) => {
50
+ return (
51
+ <RACCheckboxGroup
52
+ className={cx(cssProp ? css(cssProp) : undefined, className)}
53
+ {...rest}
54
+ >
55
+ {(renderProps) => (
56
+ <>
57
+ {label != null && (
58
+ <FieldLabel isRequired={rest.isRequired} css={labelCss}>
59
+ {label}
60
+ </FieldLabel>
61
+ )}
62
+ {typeof children === "function" ? children(renderProps) : children}
63
+ <FieldSupport
64
+ helperText={helperText}
65
+ errorMessage={errorMessage}
66
+ helperTextCss={helperTextCss}
67
+ />
68
+ </>
69
+ )}
70
+ </RACCheckboxGroup>
71
+ );
72
+ };
package/src/ComboBox.tsx CHANGED
@@ -16,23 +16,32 @@ import {
16
16
  ComboBox as RACComboBox,
17
17
  ComboBoxProps as RACComboBoxProps,
18
18
  Input as RACInput,
19
- Label as RACLabel,
20
19
  ListBox as RACListBox,
21
20
  Popover,
22
21
  PopoverProps,
23
22
  } from "react-aria-components";
24
23
  import { RiArrowDownSLine } from "react-icons/ri";
25
24
  import { css, cx } from "styled-system/css";
26
- import { select, SelectVariantProps } from "styled-system/recipes";
25
+ import { field, select, SelectVariantProps } from "styled-system/recipes";
27
26
  import { SystemStyleObject } from "styled-system/types";
27
+ import {
28
+ FieldLabel,
29
+ FieldLayoutProps,
30
+ FieldSupport,
31
+ FieldSupportProps,
32
+ } from "./Field";
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,
40
+ FieldLayoutProps {
34
41
  /** Visible label. Use `aria-label` instead where the design has none. */
35
42
  label?: ReactNode;
43
+ /** Label style overrides. */
44
+ labelCss?: SystemStyleObject;
36
45
  placeholder?: string;
37
46
  /**
38
47
  * Rendered inside the control, before the input — an icon for the current
@@ -41,8 +50,15 @@ export interface ComboBoxProps<T extends object>
41
50
  * that (react-select did it with a custom `SingleValue`).
42
51
  */
43
52
  startContent?: ReactNode;
44
- /** `SelectOption`s. */
45
- children: ReactNode;
53
+ /**
54
+ * `SelectOption`s, or a render function over the `items` prop for a
55
+ * dynamic collection — which is how an async lookup works: drive `items`
56
+ * from loaded results (e.g. react-stately's useAsyncList) and filter
57
+ * server-side; react-aria skips its own text filtering when `items` is
58
+ * controlled. Pair with `emptyState` (swap its content while loading) and
59
+ * `isPopoverHidden` for a minimum query length.
60
+ */
61
+ children: ReactNode | ((item: T) => ReactNode);
46
62
  /**
47
63
  * Replaces the chevron; pass `null` for none, which is what a plain
48
64
  * autocomplete wants (react-select's `dropdownIndicator: display none`).
@@ -68,11 +84,12 @@ export interface ComboBoxProps<T extends object>
68
84
  */
69
85
  maxHeight?: number;
70
86
  /**
71
- * Per-instance overrides for the control — the box around the input, its
72
- * `startContent` and its indicator, which is what `Select`'s `css` styles
73
- * too. Reach the input itself through the `select` recipe's `value` slot.
87
+ * Per-instance overrides for the trigger — the box around the input, its
88
+ * `startContent` and its indicator, the same slot `Select`'s `triggerCss`
89
+ * styles. Reach the input itself through the `select` recipe's `value`
90
+ * slot.
74
91
  */
75
- css?: SystemStyleObject;
92
+ triggerCss?: SystemStyleObject;
76
93
  /** Per-instance overrides for the dropdown card. */
77
94
  contentCss?: SystemStyleObject;
78
95
  className?: string;
@@ -90,6 +107,7 @@ export interface ComboBoxProps<T extends object>
90
107
  const ComboBoxInner = <T extends object>(
91
108
  {
92
109
  label,
110
+ labelCss,
93
111
  placeholder,
94
112
  startContent,
95
113
  children,
@@ -98,7 +116,11 @@ const ComboBoxInner = <T extends object>(
98
116
  isPopoverHidden,
99
117
  placement = "bottom start",
100
118
  maxHeight,
101
- css: cssProp,
119
+ helperText,
120
+ errorMessage,
121
+ helperTextCss,
122
+ labelPosition,
123
+ triggerCss,
102
124
  contentCss,
103
125
  className,
104
126
  ...props
@@ -108,6 +130,7 @@ const ComboBoxInner = <T extends object>(
108
130
  // As Select: forward whatever variant groups the merged recipe has.
109
131
  const [variantProps, rest] = select.splitVariantProps(props);
110
132
  const slots = select(variantProps);
133
+ const fieldSlots = field({ size: variantProps.size, labelPosition });
111
134
  // Anchor the card to the whole control, not to the bare input inside it —
112
135
  // otherwise it hangs off the text baseline and is as narrow as the input.
113
136
  const triggerRef = useRef<HTMLDivElement>(null);
@@ -136,12 +159,24 @@ const ComboBoxInner = <T extends object>(
136
159
  <RACComboBox
137
160
  allowsEmptyCollection={emptyState != null}
138
161
  {...(rest as RACComboBoxProps<T>)}
139
- className={cx(slots.root, className)}
162
+ className={cx(fieldSlots.root, slots.root, className)}
140
163
  >
141
- {label != null && <RACLabel className={slots.label}>{label}</RACLabel>}
164
+ {label != null && (
165
+ <FieldLabel
166
+ size={variantProps.size}
167
+ labelPosition={labelPosition}
168
+ isRequired={props.isRequired}
169
+ css={labelCss}
170
+ >
171
+ {label}
172
+ </FieldLabel>
173
+ )}
142
174
  <div
143
175
  ref={triggerRef}
144
- className={cx(slots.trigger, cssProp ? css(cssProp) : undefined)}
176
+ className={cx(
177
+ slots.trigger,
178
+ triggerCss ? css(triggerCss) : undefined,
179
+ )}
145
180
  >
146
181
  {startContent}
147
182
  <RACInput
@@ -178,6 +213,12 @@ const ComboBoxInner = <T extends object>(
178
213
  </RACListBox>
179
214
  </Popover>
180
215
  )}
216
+ <FieldSupport
217
+ helperText={helperText}
218
+ errorMessage={errorMessage}
219
+ helperTextCss={helperTextCss}
220
+ labelPosition={labelPosition}
221
+ />
181
222
  </RACComboBox>
182
223
  </SelectSlotProvider>
183
224
  );