@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 +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/Checkbox.tsx +30 -2
- package/src/CheckboxGroup.tsx +67 -0
- package/src/ComboBox.tsx +50 -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/Input.recipe.ts +13 -4
- package/src/NativeSelect.tsx +10 -1
- package/src/NativeSelectField.tsx +81 -0
- package/src/NumberField.recipe.ts +25 -7
- package/src/NumberField.tsx +57 -18
- package/src/Radio.tsx +0 -27
- package/src/RadioGroup.tsx +64 -0
- package/src/Select.recipe.ts +29 -19
- package/src/Select.tsx +38 -9
- package/src/SharedUIProvider.tsx +26 -6
- package/src/Switch.recipe.ts +19 -0
- package/src/Switch.tsx +34 -3
- package/src/TextField.tsx +23 -32
- package/src/base-preset.ts +11 -1
- 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
|
|
|
@@ -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.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
|
-
{
|
|
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,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
|
-
/**
|
|
45
|
-
|
|
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
|
|
72
|
-
* `startContent` and its indicator,
|
|
73
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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(
|
|
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: "
|
|
55
|
+
transitionDuration: "var(--fade-duration)",
|
|
38
56
|
transitionTimingFunction: "ease-out",
|
|
39
57
|
_motionReduce: { transition: "none" },
|
|
40
58
|
},
|