@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
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
RadioGroup as RACRadioGroup,
|
|
9
|
+
RadioGroupProps as RACRadioGroupProps,
|
|
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 RadioGroupProps
|
|
16
|
+
extends Omit<RACRadioGroupProps, "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, merged after the recipe. */
|
|
24
|
+
css?: SystemStyleObject;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* RadioGroup — react-aria-components <RadioGroup> for a set of Radios. Beyond
|
|
30
|
+
* the optional field chrome (label/helperText/errorMessage — Chakra's
|
|
31
|
+
* FormControl parts) it carries no styling of its own: compose with Stack for
|
|
32
|
+
* layout, as Chakra call sites did.
|
|
33
|
+
*/
|
|
34
|
+
export const RadioGroup = ({
|
|
35
|
+
label,
|
|
36
|
+
helperText,
|
|
37
|
+
errorMessage,
|
|
38
|
+
helperTextCss,
|
|
39
|
+
css: cssProp,
|
|
40
|
+
className,
|
|
41
|
+
children,
|
|
42
|
+
...rest
|
|
43
|
+
}: RadioGroupProps) => {
|
|
44
|
+
return (
|
|
45
|
+
<RACRadioGroup
|
|
46
|
+
className={cx(cssProp ? css(cssProp) : undefined, className)}
|
|
47
|
+
{...rest}
|
|
48
|
+
>
|
|
49
|
+
{(renderProps) => (
|
|
50
|
+
<>
|
|
51
|
+
{label != null && (
|
|
52
|
+
<FieldLabel isRequired={rest.isRequired}>{label}</FieldLabel>
|
|
53
|
+
)}
|
|
54
|
+
{typeof children === "function" ? children(renderProps) : children}
|
|
55
|
+
<FieldSupport
|
|
56
|
+
helperText={helperText}
|
|
57
|
+
errorMessage={errorMessage}
|
|
58
|
+
helperTextCss={helperTextCss}
|
|
59
|
+
/>
|
|
60
|
+
</>
|
|
61
|
+
)}
|
|
62
|
+
</RACRadioGroup>
|
|
63
|
+
);
|
|
64
|
+
};
|
package/src/Select.recipe.ts
CHANGED
|
@@ -23,13 +23,16 @@ const transitionCommon =
|
|
|
23
23
|
* Apps restyle it through the `variant` group — classroom's `classroom`
|
|
24
24
|
* variant is the rounded pill its join form uses.
|
|
25
25
|
*
|
|
26
|
+
* The label is not a slot here: it comes from the `field` recipe, as every
|
|
27
|
+
* other labelled field's does. The only thing that costs is a per-`variant`
|
|
28
|
+
* label style — restyle the control and let the label match the family.
|
|
29
|
+
*
|
|
26
30
|
* Registered in the base preset (base-preset.ts).
|
|
27
31
|
*/
|
|
28
32
|
export const select = defineSlotRecipe({
|
|
29
33
|
className: "select",
|
|
30
34
|
slots: [
|
|
31
35
|
"root",
|
|
32
|
-
"label",
|
|
33
36
|
"trigger",
|
|
34
37
|
"value",
|
|
35
38
|
"indicator",
|
|
@@ -40,17 +43,11 @@ export const select = defineSlotRecipe({
|
|
|
40
43
|
"empty",
|
|
41
44
|
],
|
|
42
45
|
base: {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
},
|
|
48
|
-
label: {
|
|
49
|
-
fontSize: "md",
|
|
50
|
-
fontWeight: "medium",
|
|
51
|
-
marginEnd: "3",
|
|
52
|
-
mb: "2",
|
|
53
|
-
},
|
|
46
|
+
// No layout here: the component wears the `field` recipe's root alongside
|
|
47
|
+
// this slot, and that recipe is the single owner of field-root layout
|
|
48
|
+
// (else `labelPosition` would fight this slot over `flexDirection`). The
|
|
49
|
+
// slot stays for apps and variants to target.
|
|
50
|
+
root: {},
|
|
54
51
|
trigger: {
|
|
55
52
|
display: "flex",
|
|
56
53
|
alignItems: "center",
|
|
@@ -67,11 +64,8 @@ export const select = defineSlotRecipe({
|
|
|
67
64
|
transitionDuration: "normal",
|
|
68
65
|
border: "1px solid",
|
|
69
66
|
borderColor: "gray.200",
|
|
70
|
-
borderRadius: "md",
|
|
71
67
|
bg: "white",
|
|
72
68
|
color: "inherit",
|
|
73
|
-
h: "10",
|
|
74
|
-
px: "4",
|
|
75
69
|
// As the input recipe, so a Select, a NativeSelect and a TextField in one
|
|
76
70
|
// form all tint together on hover. (react-aria's TextField has no hover
|
|
77
71
|
// effect, but matching the family beats matching their docs.)
|
|
@@ -82,9 +76,12 @@ export const select = defineSlotRecipe({
|
|
|
82
76
|
// `> &` rather than a descendant selector, so an app's own invalid form
|
|
83
77
|
// wrapper cannot paint every control inside it red.
|
|
84
78
|
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
79
|
+
// Doubled `&` for the same reason as the input recipe: hover, invalid and
|
|
80
|
+
// focus all set `borderColor`, and Panda sorts state rules by its own
|
|
81
|
+
// pseudo-class table rather than declaration order, so hover would win
|
|
82
|
+
// these ties. The repeated `&` makes the hover < invalid < focus ladder a
|
|
83
|
+
// matter of specificity instead.
|
|
84
|
+
"[data-invalid] > &&": {
|
|
88
85
|
borderColor: "danger.500",
|
|
89
86
|
boxShadow: "0 0 0 1px token(colors.danger.500)",
|
|
90
87
|
},
|
|
@@ -98,7 +95,7 @@ export const select = defineSlotRecipe({
|
|
|
98
95
|
// focus moves to an option (aria-activedescendant) — which strips RAC's
|
|
99
96
|
// attribute for as long as the list has an active option, real focus
|
|
100
97
|
// never having left. Select's trigger holds no input, so it can't match.
|
|
101
|
-
"
|
|
98
|
+
"&&&[data-focus-visible], &&&:has(input:focus)": {
|
|
102
99
|
boxShadow: "0 0 0 1px token(colors.focusBorder)",
|
|
103
100
|
borderColor: "focusBorder",
|
|
104
101
|
outline: "2px solid transparent",
|
|
@@ -204,4 +201,17 @@ export const select = defineSlotRecipe({
|
|
|
204
201
|
color: "gray.600",
|
|
205
202
|
},
|
|
206
203
|
},
|
|
204
|
+
variants: {
|
|
205
|
+
// The `input` recipe's size ladder, step for step, so a Select sits level
|
|
206
|
+
// with a TextField beside it at every size. The indicator is em-sized and
|
|
207
|
+
// scales free; the dropdown card keeps one density across sizes.
|
|
208
|
+
size: {
|
|
209
|
+
lg: { trigger: { fontSize: "lg", px: "4", h: "12", borderRadius: "md" } },
|
|
210
|
+
md: { trigger: { fontSize: "md", px: "4", h: "10", borderRadius: "md" } },
|
|
211
|
+
sm: { trigger: { fontSize: "sm", px: "3", h: "8", borderRadius: "sm" } },
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
defaultVariants: {
|
|
215
|
+
size: "md",
|
|
216
|
+
},
|
|
207
217
|
});
|
package/src/Select.tsx
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
import { createContext, ReactNode, useContext } from "react";
|
|
7
7
|
import {
|
|
8
8
|
Button as RACButton,
|
|
9
|
-
Label as RACLabel,
|
|
10
9
|
ListBox as RACListBox,
|
|
11
10
|
ListBoxItem as RACListBoxItem,
|
|
12
11
|
ListBoxItemProps as RACListBoxItemProps,
|
|
@@ -18,8 +17,14 @@ import {
|
|
|
18
17
|
} from "react-aria-components";
|
|
19
18
|
import { RiArrowDownSLine } from "react-icons/ri";
|
|
20
19
|
import { css, cx } from "styled-system/css";
|
|
21
|
-
import { select, SelectVariantProps } from "styled-system/recipes";
|
|
20
|
+
import { field, select, SelectVariantProps } from "styled-system/recipes";
|
|
22
21
|
import { SystemStyleObject } from "styled-system/types";
|
|
22
|
+
import {
|
|
23
|
+
FieldLabel,
|
|
24
|
+
FieldLayoutProps,
|
|
25
|
+
FieldSupport,
|
|
26
|
+
FieldSupportProps,
|
|
27
|
+
} from "./Field";
|
|
23
28
|
import { Icon } from "./Icon";
|
|
24
29
|
|
|
25
30
|
export type SelectSlots = ReturnType<typeof select>;
|
|
@@ -37,7 +42,9 @@ export interface SelectProps<T extends object>
|
|
|
37
42
|
RACSelectProps<T>,
|
|
38
43
|
"className" | "children" | "style" | "placeholder"
|
|
39
44
|
>,
|
|
40
|
-
SelectVariantProps
|
|
45
|
+
SelectVariantProps,
|
|
46
|
+
FieldSupportProps,
|
|
47
|
+
FieldLayoutProps {
|
|
41
48
|
/** Visible label. Use `aria-label` instead where the design has none. */
|
|
42
49
|
label?: ReactNode;
|
|
43
50
|
/** Shown in the trigger while nothing is chosen (Chakra's placeholder). */
|
|
@@ -59,8 +66,8 @@ export interface SelectProps<T extends object>
|
|
|
59
66
|
* while positioning, which beats any class.
|
|
60
67
|
*/
|
|
61
68
|
maxHeight?: number;
|
|
62
|
-
/** Per-instance overrides for the trigger. */
|
|
63
|
-
|
|
69
|
+
/** Per-instance overrides for the trigger (the button the value sits in). */
|
|
70
|
+
triggerCss?: SystemStyleObject;
|
|
64
71
|
/** Per-instance overrides for the dropdown card. */
|
|
65
72
|
contentCss?: SystemStyleObject;
|
|
66
73
|
className?: string;
|
|
@@ -78,7 +85,11 @@ export const Select = <T extends object>({
|
|
|
78
85
|
indicator,
|
|
79
86
|
placement = "bottom start",
|
|
80
87
|
maxHeight,
|
|
81
|
-
|
|
88
|
+
helperText,
|
|
89
|
+
errorMessage,
|
|
90
|
+
helperTextCss,
|
|
91
|
+
labelPosition,
|
|
92
|
+
triggerCss,
|
|
82
93
|
contentCss,
|
|
83
94
|
className,
|
|
84
95
|
...props
|
|
@@ -87,15 +98,27 @@ export const Select = <T extends object>({
|
|
|
87
98
|
// groups to the recipe and they have to reach it (playbook gotcha #37).
|
|
88
99
|
const [variantProps, rest] = select.splitVariantProps(props);
|
|
89
100
|
const slots = select(variantProps);
|
|
101
|
+
const fieldSlots = field({ size: variantProps.size, labelPosition });
|
|
90
102
|
return (
|
|
91
103
|
<SelectSlotProvider value={slots}>
|
|
92
104
|
<RACSelect
|
|
93
105
|
{...(rest as RACSelectProps<T>)}
|
|
94
|
-
className={cx(slots.root, className)}
|
|
106
|
+
className={cx(fieldSlots.root, slots.root, className)}
|
|
95
107
|
>
|
|
96
|
-
{label != null &&
|
|
108
|
+
{label != null && (
|
|
109
|
+
<FieldLabel
|
|
110
|
+
size={variantProps.size}
|
|
111
|
+
labelPosition={labelPosition}
|
|
112
|
+
isRequired={props.isRequired}
|
|
113
|
+
>
|
|
114
|
+
{label}
|
|
115
|
+
</FieldLabel>
|
|
116
|
+
)}
|
|
97
117
|
<RACButton
|
|
98
|
-
className={cx(
|
|
118
|
+
className={cx(
|
|
119
|
+
slots.trigger,
|
|
120
|
+
triggerCss ? css(triggerCss) : undefined,
|
|
121
|
+
)}
|
|
99
122
|
>
|
|
100
123
|
<SelectValue className={slots.value}>
|
|
101
124
|
{({ isPlaceholder, defaultChildren }) =>
|
|
@@ -118,6 +141,12 @@ export const Select = <T extends object>({
|
|
|
118
141
|
>
|
|
119
142
|
<RACListBox className={slots.list}>{children}</RACListBox>
|
|
120
143
|
</Popover>
|
|
144
|
+
<FieldSupport
|
|
145
|
+
helperText={helperText}
|
|
146
|
+
errorMessage={errorMessage}
|
|
147
|
+
helperTextCss={helperTextCss}
|
|
148
|
+
labelPosition={labelPosition}
|
|
149
|
+
/>
|
|
121
150
|
</RACSelect>
|
|
122
151
|
</SelectSlotProvider>
|
|
123
152
|
);
|
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/Switch.recipe.ts
CHANGED
|
@@ -72,6 +72,24 @@ export const switchRecipe = defineSlotRecipe({
|
|
|
72
72
|
},
|
|
73
73
|
},
|
|
74
74
|
variants: {
|
|
75
|
+
// `start` puts the label first and the control at the row's end — the
|
|
76
|
+
// settings-row pattern (a preference name beside its switch), the toggle
|
|
77
|
+
// counterpart of the field recipe's `labelPosition="side"`. Values are
|
|
78
|
+
// start/end rather than top/side because the default label is already
|
|
79
|
+
// beside the control, after it. The label keeps an end margin so a long
|
|
80
|
+
// translation can't butt against the track (the SelectFormControl
|
|
81
|
+
// lesson — see the field-chrome roadmap bullet).
|
|
82
|
+
labelPosition: {
|
|
83
|
+
end: {},
|
|
84
|
+
start: {
|
|
85
|
+
root: {
|
|
86
|
+
flexDirection: "row-reverse",
|
|
87
|
+
justifyContent: "space-between",
|
|
88
|
+
width: "100%",
|
|
89
|
+
},
|
|
90
|
+
label: { marginStart: "0", marginEnd: "3" },
|
|
91
|
+
},
|
|
92
|
+
},
|
|
75
93
|
// Chakra's Switch size scale (track width x height; the selected-thumb
|
|
76
94
|
// translate is the difference between them).
|
|
77
95
|
size: {
|
|
@@ -103,5 +121,6 @@ export const switchRecipe = defineSlotRecipe({
|
|
|
103
121
|
},
|
|
104
122
|
defaultVariants: {
|
|
105
123
|
size: "md",
|
|
124
|
+
labelPosition: "end",
|
|
106
125
|
},
|
|
107
126
|
});
|
package/src/Switch.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
|
Switch as RACSwitch,
|
|
9
9
|
SwitchProps as RACSwitchProps,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { css, cx } from "styled-system/css";
|
|
12
12
|
import { switchRecipe, SwitchRecipeVariantProps } from "styled-system/recipes";
|
|
13
13
|
import { SystemStyleObject } from "styled-system/types";
|
|
14
|
+
import { FieldHelperText } from "./Field";
|
|
14
15
|
|
|
15
16
|
export interface SwitchProps
|
|
16
17
|
extends Omit<RACSwitchProps, "className" | "children" | "style">,
|
|
@@ -19,24 +20,43 @@ export interface SwitchProps
|
|
|
19
20
|
css?: SystemStyleObject;
|
|
20
21
|
className?: string;
|
|
21
22
|
children?: ReactNode;
|
|
23
|
+
/**
|
|
24
|
+
* Help text below the switch, wired to its `aria-describedby` — the same
|
|
25
|
+
* chrome the labelled fields' `helperText` renders. With it the component
|
|
26
|
+
* gains a wrapping `<div>`, so the switch-plus-text moves as one block.
|
|
27
|
+
*/
|
|
28
|
+
helperText?: ReactNode;
|
|
29
|
+
/** Per-instance style overrides for the helper text. */
|
|
30
|
+
helperTextCss?: SystemStyleObject;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
/**
|
|
25
34
|
* Switch — react-aria-components <Switch> styled like Chakra's switch.
|
|
26
35
|
* Children render as the label; pass `aria-label` for label-less switches.
|
|
36
|
+
* `labelPosition="start"` is the settings-row layout: label first, switch at
|
|
37
|
+
* the row's end.
|
|
27
38
|
*/
|
|
28
39
|
export const Switch = ({
|
|
29
40
|
size,
|
|
41
|
+
labelPosition,
|
|
30
42
|
css: cssProp,
|
|
31
43
|
className,
|
|
32
44
|
children,
|
|
45
|
+
helperText,
|
|
46
|
+
helperTextCss,
|
|
33
47
|
...rest
|
|
34
48
|
}: SwitchProps) => {
|
|
35
|
-
const slots = switchRecipe({ size });
|
|
36
|
-
|
|
49
|
+
const slots = switchRecipe({ size, labelPosition });
|
|
50
|
+
const helperId = useId();
|
|
51
|
+
const describedBy =
|
|
52
|
+
[rest["aria-describedby"], helperText != null ? helperId : undefined]
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.join(" ") || undefined;
|
|
55
|
+
const switchElement = (
|
|
37
56
|
<RACSwitch
|
|
38
57
|
className={cx(slots.root, cssProp ? css(cssProp) : undefined, className)}
|
|
39
58
|
{...rest}
|
|
59
|
+
aria-describedby={describedBy}
|
|
40
60
|
>
|
|
41
61
|
{({ isSelected, isFocusVisible, isDisabled }) => {
|
|
42
62
|
const state = {
|
|
@@ -59,4 +79,15 @@ export const Switch = ({
|
|
|
59
79
|
}}
|
|
60
80
|
</RACSwitch>
|
|
61
81
|
);
|
|
82
|
+
if (helperText == null) {
|
|
83
|
+
return switchElement;
|
|
84
|
+
}
|
|
85
|
+
return (
|
|
86
|
+
<div>
|
|
87
|
+
{switchElement}
|
|
88
|
+
<FieldHelperText id={helperId} css={helperTextCss}>
|
|
89
|
+
{helperText}
|
|
90
|
+
</FieldHelperText>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
62
93
|
};
|
package/src/TextField.tsx
CHANGED
|
@@ -5,31 +5,28 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { FocusEvent, forwardRef, ReactNode } from "react";
|
|
7
7
|
import {
|
|
8
|
-
FieldError,
|
|
9
8
|
Input as RACInput,
|
|
10
|
-
Label as RACLabel,
|
|
11
|
-
Text as RACText,
|
|
12
9
|
TextField as RACTextField,
|
|
13
10
|
TextFieldProps as RACTextFieldProps,
|
|
14
11
|
} from "react-aria-components";
|
|
15
|
-
import { css, cx } from "styled-system/css";
|
|
16
12
|
import { field, input, InputVariantProps } from "styled-system/recipes";
|
|
17
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
FieldLabel,
|
|
15
|
+
FieldLayoutProps,
|
|
16
|
+
FieldSupport,
|
|
17
|
+
FieldSupportProps,
|
|
18
|
+
} from "./Field";
|
|
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,
|
|
27
|
+
FieldLayoutProps {
|
|
25
28
|
/** Visible label (Chakra's FormLabel; asterisk added when `isRequired`). */
|
|
26
29
|
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
30
|
onFocus?: (e: FocusEvent<HTMLInputElement>) => void;
|
|
34
31
|
/** Input autocapitalize attribute (react-aria's TextField omits it). */
|
|
35
32
|
autoCapitalize?: "off" | "none" | "on" | "sentences" | "words" | "characters";
|
|
@@ -47,6 +44,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
|
|
47
44
|
helperText,
|
|
48
45
|
errorMessage,
|
|
49
46
|
helperTextCss,
|
|
47
|
+
labelPosition,
|
|
50
48
|
onFocus,
|
|
51
49
|
autoCapitalize,
|
|
52
50
|
...props
|
|
@@ -56,35 +54,28 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
|
|
56
54
|
// As Input: forward every recipe variant group, not just `size`, so a
|
|
57
55
|
// preset that adds one keeps working.
|
|
58
56
|
const [variantProps, rest] = input.splitVariantProps(props);
|
|
59
|
-
const slots = field();
|
|
57
|
+
const slots = field({ size: variantProps.size, labelPosition });
|
|
60
58
|
return (
|
|
61
59
|
<RACTextField {...rest} className={slots.root}>
|
|
62
|
-
<
|
|
60
|
+
<FieldLabel
|
|
61
|
+
size={variantProps.size}
|
|
62
|
+
labelPosition={labelPosition}
|
|
63
|
+
isRequired={rest.isRequired}
|
|
64
|
+
>
|
|
63
65
|
{label}
|
|
64
|
-
|
|
65
|
-
<span aria-hidden className={slots.requiredIndicator}>
|
|
66
|
-
*
|
|
67
|
-
</span>
|
|
68
|
-
) : null}
|
|
69
|
-
</RACLabel>
|
|
66
|
+
</FieldLabel>
|
|
70
67
|
<RACInput
|
|
71
68
|
ref={ref}
|
|
72
69
|
className={input(variantProps)}
|
|
73
70
|
onFocus={onFocus}
|
|
74
71
|
autoCapitalize={autoCapitalize}
|
|
75
72
|
/>
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
)}
|
|
83
|
-
>
|
|
84
|
-
{helperText}
|
|
85
|
-
</RACText>
|
|
86
|
-
)}
|
|
87
|
-
<FieldError className={slots.errorMessage}>{errorMessage}</FieldError>
|
|
73
|
+
<FieldSupport
|
|
74
|
+
helperText={helperText}
|
|
75
|
+
errorMessage={errorMessage}
|
|
76
|
+
helperTextCss={helperTextCss}
|
|
77
|
+
labelPosition={labelPosition}
|
|
78
|
+
/>
|
|
88
79
|
</RACTextField>
|
|
89
80
|
);
|
|
90
81
|
},
|
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";
|
|
@@ -41,7 +42,7 @@ import { switchRecipe } from "./Switch.recipe";
|
|
|
41
42
|
import { dialog } from "./Modal.recipe";
|
|
42
43
|
import { text } from "./Text.recipe";
|
|
43
44
|
import { tooltip } from "./Tooltip.recipe";
|
|
44
|
-
import { field } from "./
|
|
45
|
+
import { field } from "./Field.recipe";
|
|
45
46
|
import { toast } from "./Toast.recipe";
|
|
46
47
|
|
|
47
48
|
/**
|
|
@@ -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,
|
|
@@ -296,9 +304,11 @@ export const basePreset = definePreset({
|
|
|
296
304
|
// as a runtime prop, so generate the breakpoint-prefixed variants too.
|
|
297
305
|
dialog: [{ size: ["*"], responsive: true }, { centered: ["*"] }],
|
|
298
306
|
drawer: ["*"],
|
|
307
|
+
field: ["*"],
|
|
299
308
|
gridList: ["*"],
|
|
300
309
|
listBox: ["*"],
|
|
301
310
|
input: ["*"],
|
|
311
|
+
numberField: ["*"],
|
|
302
312
|
radio: ["*"],
|
|
303
313
|
select: ["*"],
|
|
304
314
|
switchRecipe: ["*"],
|
package/src/index.ts
CHANGED
|
@@ -10,20 +10,25 @@
|
|
|
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 "./Field";
|
|
18
21
|
export * from "./IconButton";
|
|
19
22
|
export * from "./Image";
|
|
20
23
|
export * from "./Input";
|
|
21
24
|
export * from "./InputGroup";
|
|
22
25
|
export * from "./LinkBox";
|
|
23
26
|
export * from "./NativeSelect";
|
|
27
|
+
export * from "./NativeSelectField";
|
|
24
28
|
export * from "./NumberField";
|
|
25
29
|
export * from "./ProgressBar";
|
|
26
30
|
export * from "./Radio";
|
|
31
|
+
export * from "./RadioGroup";
|
|
27
32
|
export * from "./Skeleton";
|
|
28
33
|
export * from "./Slide";
|
|
29
34
|
export * from "./Slider";
|
|
@@ -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
|
+
};
|