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