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

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.18",
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
  },
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,67 @@
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
+ /** Per-instance style overrides. */
24
+ css?: SystemStyleObject;
25
+ className?: string;
26
+ }
27
+
28
+ /**
29
+ * CheckboxGroup — react-aria-components <CheckboxGroup> for a set of
30
+ * Checkboxes sharing one value array (each Checkbox's `value` marks its
31
+ * entry). Beyond the optional field chrome (label/helperText/errorMessage —
32
+ * Chakra's FormControl parts) it carries no styling of its own: compose with
33
+ * Stack for layout, as RadioGroup does. Chakra's CheckboxGroup was a bare
34
+ * context provider, so ported call sites gain the chrome rather than
35
+ * restating it around the group.
36
+ */
37
+ export const CheckboxGroup = ({
38
+ label,
39
+ helperText,
40
+ errorMessage,
41
+ helperTextCss,
42
+ css: cssProp,
43
+ className,
44
+ children,
45
+ ...rest
46
+ }: CheckboxGroupProps) => {
47
+ return (
48
+ <RACCheckboxGroup
49
+ className={cx(cssProp ? css(cssProp) : undefined, className)}
50
+ {...rest}
51
+ >
52
+ {(renderProps) => (
53
+ <>
54
+ {label != null && (
55
+ <FieldLabel isRequired={rest.isRequired}>{label}</FieldLabel>
56
+ )}
57
+ {typeof children === "function" ? children(renderProps) : children}
58
+ <FieldSupport
59
+ helperText={helperText}
60
+ errorMessage={errorMessage}
61
+ helperTextCss={helperTextCss}
62
+ />
63
+ </>
64
+ )}
65
+ </RACCheckboxGroup>
66
+ );
67
+ };
package/src/ComboBox.tsx CHANGED
@@ -16,21 +16,28 @@ 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;
36
43
  placeholder?: string;
@@ -41,8 +48,15 @@ export interface ComboBoxProps<T extends object>
41
48
  * that (react-select did it with a custom `SingleValue`).
42
49
  */
43
50
  startContent?: ReactNode;
44
- /** `SelectOption`s. */
45
- children: ReactNode;
51
+ /**
52
+ * `SelectOption`s, or a render function over the `items` prop for a
53
+ * dynamic collection — which is how an async lookup works: drive `items`
54
+ * from loaded results (e.g. react-stately's useAsyncList) and filter
55
+ * server-side; react-aria skips its own text filtering when `items` is
56
+ * controlled. Pair with `emptyState` (swap its content while loading) and
57
+ * `isPopoverHidden` for a minimum query length.
58
+ */
59
+ children: ReactNode | ((item: T) => ReactNode);
46
60
  /**
47
61
  * Replaces the chevron; pass `null` for none, which is what a plain
48
62
  * autocomplete wants (react-select's `dropdownIndicator: display none`).
@@ -68,11 +82,12 @@ export interface ComboBoxProps<T extends object>
68
82
  */
69
83
  maxHeight?: number;
70
84
  /**
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.
85
+ * Per-instance overrides for the trigger — the box around the input, its
86
+ * `startContent` and its indicator, the same slot `Select`'s `triggerCss`
87
+ * styles. Reach the input itself through the `select` recipe's `value`
88
+ * slot.
74
89
  */
75
- css?: SystemStyleObject;
90
+ triggerCss?: SystemStyleObject;
76
91
  /** Per-instance overrides for the dropdown card. */
77
92
  contentCss?: SystemStyleObject;
78
93
  className?: string;
@@ -98,7 +113,11 @@ const ComboBoxInner = <T extends object>(
98
113
  isPopoverHidden,
99
114
  placement = "bottom start",
100
115
  maxHeight,
101
- css: cssProp,
116
+ helperText,
117
+ errorMessage,
118
+ helperTextCss,
119
+ labelPosition,
120
+ triggerCss,
102
121
  contentCss,
103
122
  className,
104
123
  ...props
@@ -108,6 +127,7 @@ const ComboBoxInner = <T extends object>(
108
127
  // As Select: forward whatever variant groups the merged recipe has.
109
128
  const [variantProps, rest] = select.splitVariantProps(props);
110
129
  const slots = select(variantProps);
130
+ const fieldSlots = field({ size: variantProps.size, labelPosition });
111
131
  // Anchor the card to the whole control, not to the bare input inside it —
112
132
  // otherwise it hangs off the text baseline and is as narrow as the input.
113
133
  const triggerRef = useRef<HTMLDivElement>(null);
@@ -136,12 +156,23 @@ const ComboBoxInner = <T extends object>(
136
156
  <RACComboBox
137
157
  allowsEmptyCollection={emptyState != null}
138
158
  {...(rest as RACComboBoxProps<T>)}
139
- className={cx(slots.root, className)}
159
+ className={cx(fieldSlots.root, slots.root, className)}
140
160
  >
141
- {label != null && <RACLabel className={slots.label}>{label}</RACLabel>}
161
+ {label != null && (
162
+ <FieldLabel
163
+ size={variantProps.size}
164
+ labelPosition={labelPosition}
165
+ isRequired={props.isRequired}
166
+ >
167
+ {label}
168
+ </FieldLabel>
169
+ )}
142
170
  <div
143
171
  ref={triggerRef}
144
- className={cx(slots.trigger, cssProp ? css(cssProp) : undefined)}
172
+ className={cx(
173
+ slots.trigger,
174
+ triggerCss ? css(triggerCss) : undefined,
175
+ )}
145
176
  >
146
177
  {startContent}
147
178
  <RACInput
@@ -178,6 +209,12 @@ const ComboBoxInner = <T extends object>(
178
209
  </RACListBox>
179
210
  </Popover>
180
211
  )}
212
+ <FieldSupport
213
+ helperText={helperText}
214
+ errorMessage={errorMessage}
215
+ helperTextCss={helperTextCss}
216
+ labelPosition={labelPosition}
217
+ />
181
218
  </RACComboBox>
182
219
  </SelectSlotProvider>
183
220
  );
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
  },