@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 +35 -5
- package/lang/ui.en.json +8 -0
- package/package.json +2 -2
- package/src/Avatar.recipe.ts +4 -1
- package/src/Breadcrumb.recipe.ts +43 -0
- package/src/Breadcrumb.tsx +116 -0
- package/src/Button.recipe.ts +0 -3
- package/src/Button.tsx +61 -7
- package/src/Checkbox.tsx +30 -2
- package/src/CheckboxGroup.tsx +72 -0
- package/src/ComboBox.tsx +54 -13
- package/src/Fade.tsx +20 -2
- package/src/Field.recipe.ts +113 -0
- package/src/Field.tsx +189 -0
- package/src/Heading.recipe.ts +19 -0
- package/src/NativeSelect.tsx +10 -1
- package/src/NativeSelectField.tsx +84 -0
- package/src/NumberField.recipe.ts +25 -7
- package/src/NumberField.tsx +57 -18
- package/src/Radio.tsx +0 -27
- package/src/RadioGroup.tsx +69 -0
- package/src/Select.recipe.ts +22 -15
- package/src/Select.tsx +42 -9
- package/src/SharedUIProvider.tsx +26 -6
- package/src/Skeleton.tsx +3 -1
- package/src/Switch.recipe.ts +19 -0
- package/src/Switch.tsx +34 -3
- package/src/TextField.tsx +27 -31
- package/src/base-preset.ts +50 -40
- package/src/base-tokens.ts +162 -7
- package/src/index.ts +5 -0
- package/src/rac-locale.ts +33 -0
- package/src/TextField.recipe.ts +0 -54
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
|
|
|
@@ -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-
|
|
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.
|
|
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
|
}
|
package/src/Avatar.recipe.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|
package/src/Button.recipe.ts
CHANGED
|
@@ -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
|
-
{
|
|
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
|
},
|
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
|
-
|
|
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
|
-
/**
|
|
45
|
-
|
|
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
|
|
72
|
-
* `startContent` and its indicator,
|
|
73
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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(
|
|
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
|
);
|