@microbit/ui 0.1.0-alpha.17 → 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/package.json +1 -1
- package/src/Checkbox.tsx +30 -2
- package/src/CheckboxGroup.tsx +2 -11
- package/src/ComboBox.tsx +26 -15
- package/src/Field.recipe.ts +113 -0
- package/src/Field.tsx +189 -0
- package/src/NativeSelectField.tsx +81 -0
- package/src/NumberField.recipe.ts +25 -7
- package/src/NumberField.tsx +39 -22
- package/src/Radio.tsx +1 -63
- package/src/RadioGroup.tsx +64 -0
- package/src/Select.recipe.ts +22 -15
- package/src/Select.tsx +23 -13
- package/src/Switch.recipe.ts +19 -0
- package/src/Switch.tsx +34 -3
- package/src/TextField.tsx +14 -8
- package/src/base-preset.ts +3 -1
- package/src/index.ts +3 -1
- package/src/FieldSupport.tsx +0 -68
- package/src/TextField.recipe.ts +0 -58
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",
|
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
|
};
|
package/src/CheckboxGroup.tsx
CHANGED
|
@@ -7,16 +7,10 @@ import { ReactNode } from "react";
|
|
|
7
7
|
import {
|
|
8
8
|
CheckboxGroup as RACCheckboxGroup,
|
|
9
9
|
CheckboxGroupProps as RACCheckboxGroupProps,
|
|
10
|
-
Label as RACLabel,
|
|
11
10
|
} from "react-aria-components";
|
|
12
11
|
import { css, cx } from "styled-system/css";
|
|
13
|
-
import { field } from "styled-system/recipes";
|
|
14
12
|
import { SystemStyleObject } from "styled-system/types";
|
|
15
|
-
import {
|
|
16
|
-
FieldRequiredIndicator,
|
|
17
|
-
FieldSupport,
|
|
18
|
-
FieldSupportProps,
|
|
19
|
-
} from "./FieldSupport";
|
|
13
|
+
import { FieldLabel, FieldSupport, FieldSupportProps } from "./Field";
|
|
20
14
|
|
|
21
15
|
export interface CheckboxGroupProps
|
|
22
16
|
extends Omit<RACCheckboxGroupProps, "className" | "style">,
|
|
@@ -58,10 +52,7 @@ export const CheckboxGroup = ({
|
|
|
58
52
|
{(renderProps) => (
|
|
59
53
|
<>
|
|
60
54
|
{label != null && (
|
|
61
|
-
<
|
|
62
|
-
{label}
|
|
63
|
-
{rest.isRequired ? <FieldRequiredIndicator /> : null}
|
|
64
|
-
</RACLabel>
|
|
55
|
+
<FieldLabel isRequired={rest.isRequired}>{label}</FieldLabel>
|
|
65
56
|
)}
|
|
66
57
|
{typeof children === "function" ? children(renderProps) : children}
|
|
67
58
|
<FieldSupport
|
package/src/ComboBox.tsx
CHANGED
|
@@ -16,27 +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";
|
|
28
27
|
import {
|
|
29
|
-
|
|
28
|
+
FieldLabel,
|
|
29
|
+
FieldLayoutProps,
|
|
30
30
|
FieldSupport,
|
|
31
31
|
FieldSupportProps,
|
|
32
|
-
} from "./
|
|
32
|
+
} from "./Field";
|
|
33
33
|
import { Icon } from "./Icon";
|
|
34
34
|
import { SelectSlotProvider } from "./Select";
|
|
35
35
|
|
|
36
36
|
export interface ComboBoxProps<T extends object>
|
|
37
37
|
extends Omit<RACComboBoxProps<T>, "className" | "children" | "style">,
|
|
38
38
|
SelectVariantProps,
|
|
39
|
-
FieldSupportProps
|
|
39
|
+
FieldSupportProps,
|
|
40
|
+
FieldLayoutProps {
|
|
40
41
|
/** Visible label. Use `aria-label` instead where the design has none. */
|
|
41
42
|
label?: ReactNode;
|
|
42
43
|
placeholder?: string;
|
|
@@ -81,11 +82,12 @@ export interface ComboBoxProps<T extends object>
|
|
|
81
82
|
*/
|
|
82
83
|
maxHeight?: number;
|
|
83
84
|
/**
|
|
84
|
-
* Per-instance overrides for the
|
|
85
|
-
* `startContent` and its indicator,
|
|
86
|
-
*
|
|
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.
|
|
87
89
|
*/
|
|
88
|
-
|
|
90
|
+
triggerCss?: SystemStyleObject;
|
|
89
91
|
/** Per-instance overrides for the dropdown card. */
|
|
90
92
|
contentCss?: SystemStyleObject;
|
|
91
93
|
className?: string;
|
|
@@ -114,7 +116,8 @@ const ComboBoxInner = <T extends object>(
|
|
|
114
116
|
helperText,
|
|
115
117
|
errorMessage,
|
|
116
118
|
helperTextCss,
|
|
117
|
-
|
|
119
|
+
labelPosition,
|
|
120
|
+
triggerCss,
|
|
118
121
|
contentCss,
|
|
119
122
|
className,
|
|
120
123
|
...props
|
|
@@ -124,6 +127,7 @@ const ComboBoxInner = <T extends object>(
|
|
|
124
127
|
// As Select: forward whatever variant groups the merged recipe has.
|
|
125
128
|
const [variantProps, rest] = select.splitVariantProps(props);
|
|
126
129
|
const slots = select(variantProps);
|
|
130
|
+
const fieldSlots = field({ size: variantProps.size, labelPosition });
|
|
127
131
|
// Anchor the card to the whole control, not to the bare input inside it —
|
|
128
132
|
// otherwise it hangs off the text baseline and is as narrow as the input.
|
|
129
133
|
const triggerRef = useRef<HTMLDivElement>(null);
|
|
@@ -152,17 +156,23 @@ const ComboBoxInner = <T extends object>(
|
|
|
152
156
|
<RACComboBox
|
|
153
157
|
allowsEmptyCollection={emptyState != null}
|
|
154
158
|
{...(rest as RACComboBoxProps<T>)}
|
|
155
|
-
className={cx(slots.root, className)}
|
|
159
|
+
className={cx(fieldSlots.root, slots.root, className)}
|
|
156
160
|
>
|
|
157
161
|
{label != null && (
|
|
158
|
-
<
|
|
162
|
+
<FieldLabel
|
|
163
|
+
size={variantProps.size}
|
|
164
|
+
labelPosition={labelPosition}
|
|
165
|
+
isRequired={props.isRequired}
|
|
166
|
+
>
|
|
159
167
|
{label}
|
|
160
|
-
|
|
161
|
-
</RACLabel>
|
|
168
|
+
</FieldLabel>
|
|
162
169
|
)}
|
|
163
170
|
<div
|
|
164
171
|
ref={triggerRef}
|
|
165
|
-
className={cx(
|
|
172
|
+
className={cx(
|
|
173
|
+
slots.trigger,
|
|
174
|
+
triggerCss ? css(triggerCss) : undefined,
|
|
175
|
+
)}
|
|
166
176
|
>
|
|
167
177
|
{startContent}
|
|
168
178
|
<RACInput
|
|
@@ -203,6 +213,7 @@ const ComboBoxInner = <T extends object>(
|
|
|
203
213
|
helperText={helperText}
|
|
204
214
|
errorMessage={errorMessage}
|
|
205
215
|
helperTextCss={helperTextCss}
|
|
216
|
+
labelPosition={labelPosition}
|
|
206
217
|
/>
|
|
207
218
|
</RACComboBox>
|
|
208
219
|
</SelectSlotProvider>
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
* Field slot recipe — Chakra's FormControl parts (FormLabel/FormHelperText/
|
|
10
|
+
* FormErrorMessage, light mode), mapped onto react-aria-components'
|
|
11
|
+
* Label/Text/FieldError by `FieldLabel` and `FieldSupport`. Every labelled
|
|
12
|
+
* field in the library draws its chrome from here, including Select and
|
|
13
|
+
* ComboBox, whose own recipe styles only the dropdown pair; the input itself is
|
|
14
|
+
* styled by the `input` recipe (Input.recipe.ts).
|
|
15
|
+
*
|
|
16
|
+
* `root` is also the single owner of field-root *layout*: Select, ComboBox and
|
|
17
|
+
* NumberField wear it alongside their own recipe's root slot, which carries no
|
|
18
|
+
* layout of its own — two recipes fighting over `flexDirection` would leave
|
|
19
|
+
* `labelPosition` at the mercy of emission order. RadioGroup and CheckboxGroup
|
|
20
|
+
* deliberately don't wear it (their roots carry no layout at all).
|
|
21
|
+
*
|
|
22
|
+
* Registered in the base preset (base-preset.ts), which also has the
|
|
23
|
+
* `staticCss` entry that keeps the runtime-prop size variants generated.
|
|
24
|
+
*/
|
|
25
|
+
export const field = defineSlotRecipe({
|
|
26
|
+
className: "field",
|
|
27
|
+
slots: ["root", "label", "requiredIndicator", "helperText", "errorMessage"],
|
|
28
|
+
base: {
|
|
29
|
+
root: {
|
|
30
|
+
display: "flex",
|
|
31
|
+
flexDirection: "column",
|
|
32
|
+
alignItems: "stretch",
|
|
33
|
+
width: "100%",
|
|
34
|
+
},
|
|
35
|
+
label: {
|
|
36
|
+
display: "block",
|
|
37
|
+
fontSize: "md",
|
|
38
|
+
// Deliberately not Chakra FormLabel's `medium`: no font in the family's
|
|
39
|
+
// stack has a 500 face, so the two were pixel-identical on macOS and
|
|
40
|
+
// Windows, and the only call sites that cared overrode to `normal`
|
|
41
|
+
// (settings rows). See the playbook's expected behavioural deltas.
|
|
42
|
+
fontWeight: "normal",
|
|
43
|
+
marginEnd: "3",
|
|
44
|
+
mb: "2",
|
|
45
|
+
transitionProperty: "opacity",
|
|
46
|
+
transitionDuration: "normal",
|
|
47
|
+
// RAC stamps `data-disabled` on the field root and on the control, never
|
|
48
|
+
// on the label, so an `&[data-disabled]` rule here matches nothing — it
|
|
49
|
+
// has to come down from the root (gotcha #45). Direct child rather than a
|
|
50
|
+
// descendant selector, as the select recipe's invalid rule: an app's own
|
|
51
|
+
// disabled form wrapper must not be able to dim every label inside it.
|
|
52
|
+
"[data-disabled] > &": { opacity: 0.4 },
|
|
53
|
+
},
|
|
54
|
+
requiredIndicator: {
|
|
55
|
+
marginStart: "1",
|
|
56
|
+
color: "danger.500",
|
|
57
|
+
},
|
|
58
|
+
helperText: {
|
|
59
|
+
// RAC's Text renders a span, and RadioGroup/CheckboxGroup roots are not
|
|
60
|
+
// flex containers to blockify it, where an inline box would drop the
|
|
61
|
+
// margin below (gotcha #44).
|
|
62
|
+
display: "block",
|
|
63
|
+
mt: "2",
|
|
64
|
+
fontSize: "sm",
|
|
65
|
+
lineHeight: "normal",
|
|
66
|
+
color: "gray.600",
|
|
67
|
+
},
|
|
68
|
+
errorMessage: {
|
|
69
|
+
display: "flex",
|
|
70
|
+
alignItems: "center",
|
|
71
|
+
mt: "2",
|
|
72
|
+
fontSize: "sm",
|
|
73
|
+
lineHeight: "normal",
|
|
74
|
+
color: "danger.500",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
variants: {
|
|
78
|
+
// The label follows its control's size (the control itself is sized by
|
|
79
|
+
// the `input`/`select` recipes), so one `size` prop scales the whole row.
|
|
80
|
+
// Chakra's FormLabel never scaled — a deliberate delta; see the playbook's
|
|
81
|
+
// field-chrome notes. Helper and error text stay `sm` at every size, as
|
|
82
|
+
// Chakra's did.
|
|
83
|
+
size: {
|
|
84
|
+
lg: { label: { fontSize: "lg" } },
|
|
85
|
+
md: { label: { fontSize: "md" } },
|
|
86
|
+
sm: { label: { fontSize: "sm" } },
|
|
87
|
+
},
|
|
88
|
+
// `side` puts the label beside its control — the settings-row pattern,
|
|
89
|
+
// where the label is a preference name and absorbs the free space. The
|
|
90
|
+
// control keeps its own width; give it one at the call site. Named after
|
|
91
|
+
// React Spectrum's `labelPosition`, not `orientation`, which RAC's
|
|
92
|
+
// RadioGroup already uses for the radios' own layout.
|
|
93
|
+
labelPosition: {
|
|
94
|
+
top: {},
|
|
95
|
+
side: {
|
|
96
|
+
root: {
|
|
97
|
+
flexDirection: "row",
|
|
98
|
+
alignItems: "center",
|
|
99
|
+
// Helper and error text are full-width items, so they wrap to
|
|
100
|
+
// their own line below the label/control pair.
|
|
101
|
+
flexWrap: "wrap",
|
|
102
|
+
},
|
|
103
|
+
label: { mb: "0", flex: "1 1 auto" },
|
|
104
|
+
helperText: { width: "100%" },
|
|
105
|
+
errorMessage: { width: "100%" },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
defaultVariants: {
|
|
110
|
+
size: "md",
|
|
111
|
+
labelPosition: "top",
|
|
112
|
+
},
|
|
113
|
+
});
|
package/src/Field.tsx
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import { HTMLAttributes, ReactNode } from "react";
|
|
7
|
+
import {
|
|
8
|
+
FieldError,
|
|
9
|
+
Label as RACLabel,
|
|
10
|
+
LabelProps as RACLabelProps,
|
|
11
|
+
Text as RACText,
|
|
12
|
+
} from "react-aria-components";
|
|
13
|
+
import { css, cx } from "styled-system/css";
|
|
14
|
+
import { field, FieldVariantProps } from "styled-system/recipes";
|
|
15
|
+
import { SystemStyleObject } from "styled-system/types";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The label/helper/error chrome every labelled form field shares — Chakra's
|
|
19
|
+
* FormControl parts, generalised out of TextField so Select, ComboBox,
|
|
20
|
+
* NumberField, RadioGroup and CheckboxGroup carry the same props
|
|
21
|
+
* (data-microbit-org's forms attach helper and error text to all of these).
|
|
22
|
+
*/
|
|
23
|
+
export interface FieldSupportProps {
|
|
24
|
+
/** Help text below the field (Chakra's FormHelperText). */
|
|
25
|
+
helperText?: ReactNode;
|
|
26
|
+
/** Shown below the field when invalid (Chakra's FormErrorMessage). */
|
|
27
|
+
errorMessage?: ReactNode;
|
|
28
|
+
/** Per-instance style overrides for the helper text. */
|
|
29
|
+
helperTextCss?: SystemStyleObject;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `labelPosition` for the four single-control fields (TextField, NumberField,
|
|
34
|
+
* Select, ComboBox). Deliberately not on RadioGroup/CheckboxGroup: their roots
|
|
35
|
+
* carry no layout, and RAC's own `orientation` prop is a different axis there
|
|
36
|
+
* (it lays out the radios, not the label).
|
|
37
|
+
*/
|
|
38
|
+
export interface FieldLayoutProps {
|
|
39
|
+
/**
|
|
40
|
+
* `side` puts the label beside the control — the settings-row pattern. The
|
|
41
|
+
* label absorbs the free space; the control keeps its own width, so give it
|
|
42
|
+
* one (`groupCss`, `wrapperCss` or `triggerCss` depending on the field).
|
|
43
|
+
* Helper and error text drop to a full-width line below the pair.
|
|
44
|
+
*/
|
|
45
|
+
labelPosition?: FieldVariantProps["labelPosition"];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Helper text and error message for a react-aria field container. Render
|
|
50
|
+
* inside any RAC component with field validation context (TextField, Select,
|
|
51
|
+
* ComboBox, NumberField, RadioGroup, CheckboxGroup) — react-aria wires the
|
|
52
|
+
* description to the input's aria-describedby, and the error renders only
|
|
53
|
+
* while the field is invalid. Also exported for app-side composites built on
|
|
54
|
+
* RAC containers.
|
|
55
|
+
*/
|
|
56
|
+
export const FieldSupport = ({
|
|
57
|
+
helperText,
|
|
58
|
+
errorMessage,
|
|
59
|
+
helperTextCss,
|
|
60
|
+
labelPosition,
|
|
61
|
+
}: FieldSupportProps & FieldLayoutProps) => {
|
|
62
|
+
const slots = field({ labelPosition });
|
|
63
|
+
return (
|
|
64
|
+
<>
|
|
65
|
+
{helperText != null && (
|
|
66
|
+
<RACText
|
|
67
|
+
slot="description"
|
|
68
|
+
className={cx(
|
|
69
|
+
slots.helperText,
|
|
70
|
+
helperTextCss ? css(helperTextCss) : undefined,
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
{helperText}
|
|
74
|
+
</RACText>
|
|
75
|
+
)}
|
|
76
|
+
<FieldError className={slots.errorMessage}>{errorMessage}</FieldError>
|
|
77
|
+
</>
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
interface FieldTextProps extends HTMLAttributes<HTMLDivElement> {
|
|
82
|
+
children: ReactNode;
|
|
83
|
+
/** The field's `labelPosition`, so the text lays out to match. */
|
|
84
|
+
labelPosition?: FieldVariantProps["labelPosition"];
|
|
85
|
+
/** Per-instance style overrides. */
|
|
86
|
+
css?: SystemStyleObject;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Context-free helper text (Chakra's FormHelperText) for a control react-aria
|
|
91
|
+
* isn't wiring — a native select or a masked input. Inside a RAC field
|
|
92
|
+
* container use `FieldSupport`, which wires `aria-describedby` and validation
|
|
93
|
+
* for free; here the caller owns that wiring: give this an `id` and reference
|
|
94
|
+
* it from the control's `aria-describedby` (as `NativeSelectField` does).
|
|
95
|
+
*/
|
|
96
|
+
export const FieldHelperText = ({
|
|
97
|
+
children,
|
|
98
|
+
labelPosition,
|
|
99
|
+
css: cssProp,
|
|
100
|
+
...rest
|
|
101
|
+
}: FieldTextProps) => (
|
|
102
|
+
<div
|
|
103
|
+
{...rest}
|
|
104
|
+
className={cx(
|
|
105
|
+
field({ labelPosition }).helperText,
|
|
106
|
+
cssProp ? css(cssProp) : undefined,
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
109
|
+
{children}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Context-free error message (Chakra's FormErrorMessage), the counterpart to
|
|
115
|
+
* `FieldHelperText` — see its note on the wiring the caller owns. RAC's
|
|
116
|
+
* `FieldError` renders only while its field is invalid; here that decision is
|
|
117
|
+
* the caller's too: render it conditionally.
|
|
118
|
+
*/
|
|
119
|
+
export const FieldErrorMessage = ({
|
|
120
|
+
children,
|
|
121
|
+
labelPosition,
|
|
122
|
+
css: cssProp,
|
|
123
|
+
...rest
|
|
124
|
+
}: FieldTextProps) => (
|
|
125
|
+
<div
|
|
126
|
+
{...rest}
|
|
127
|
+
className={cx(
|
|
128
|
+
field({ labelPosition }).errorMessage,
|
|
129
|
+
cssProp ? css(cssProp) : undefined,
|
|
130
|
+
)}
|
|
131
|
+
>
|
|
132
|
+
{children}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The required-field asterisk (Chakra's FormLabel indicator). Render inside
|
|
138
|
+
* the field's label when `isRequired`; aria-hidden because react-aria already
|
|
139
|
+
* announces requiredness from the input itself.
|
|
140
|
+
*/
|
|
141
|
+
export const FieldRequiredIndicator = () => (
|
|
142
|
+
<span aria-hidden className={field().requiredIndicator}>
|
|
143
|
+
*
|
|
144
|
+
</span>
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
export interface FieldLabelProps
|
|
148
|
+
extends Omit<RACLabelProps, "className" | "children" | "style"> {
|
|
149
|
+
children: ReactNode;
|
|
150
|
+
/** Adds the required asterisk; pass the field's `isRequired`. */
|
|
151
|
+
isRequired?: boolean;
|
|
152
|
+
/** The field's `size`, so the label scales with its control. */
|
|
153
|
+
size?: FieldVariantProps["size"];
|
|
154
|
+
/** The field's `labelPosition`, so the label lays out to match. */
|
|
155
|
+
labelPosition?: FieldVariantProps["labelPosition"];
|
|
156
|
+
/** Per-instance style overrides. */
|
|
157
|
+
css?: SystemStyleObject;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A field's visible label (Chakra's FormLabel), asterisk included. Every
|
|
162
|
+
* labelled field in the library renders its label through this, so the `field`
|
|
163
|
+
* recipe is the single answer to what a label looks like — Select and ComboBox
|
|
164
|
+
* previously carried a near-identical `label` slot on the `select` recipe,
|
|
165
|
+
* which is how the two drifted.
|
|
166
|
+
*
|
|
167
|
+
* Also exported for app-side composites: inside a RAC field container the
|
|
168
|
+
* association is automatic, and outside one (a masked or native input) pass
|
|
169
|
+
* `id` and `htmlFor` yourself.
|
|
170
|
+
*/
|
|
171
|
+
export const FieldLabel = ({
|
|
172
|
+
children,
|
|
173
|
+
isRequired,
|
|
174
|
+
size,
|
|
175
|
+
labelPosition,
|
|
176
|
+
css: cssProp,
|
|
177
|
+
...rest
|
|
178
|
+
}: FieldLabelProps) => (
|
|
179
|
+
<RACLabel
|
|
180
|
+
{...rest}
|
|
181
|
+
className={cx(
|
|
182
|
+
field({ size, labelPosition }).label,
|
|
183
|
+
cssProp ? css(cssProp) : undefined,
|
|
184
|
+
)}
|
|
185
|
+
>
|
|
186
|
+
{children}
|
|
187
|
+
{isRequired ? <FieldRequiredIndicator /> : null}
|
|
188
|
+
</RACLabel>
|
|
189
|
+
);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import { forwardRef, ReactNode, useId } from "react";
|
|
7
|
+
import { css, cx } from "styled-system/css";
|
|
8
|
+
import { field } from "styled-system/recipes";
|
|
9
|
+
import { SystemStyleObject } from "styled-system/types";
|
|
10
|
+
import { FieldHelperText, FieldLabel, FieldLayoutProps } from "./Field";
|
|
11
|
+
import { NativeSelect, NativeSelectProps } from "./NativeSelect";
|
|
12
|
+
|
|
13
|
+
export interface NativeSelectFieldProps
|
|
14
|
+
extends NativeSelectProps,
|
|
15
|
+
FieldLayoutProps {
|
|
16
|
+
/** Visible label, associated with the select via `htmlFor`. */
|
|
17
|
+
label: ReactNode;
|
|
18
|
+
/** Help text below the field, wired to the select's `aria-describedby`. */
|
|
19
|
+
helperText?: ReactNode;
|
|
20
|
+
/** Per-instance style overrides for the field root. */
|
|
21
|
+
rootCss?: SystemStyleObject;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* NativeSelectField — a labelled `NativeSelect`, pairing the bare control with
|
|
26
|
+
* the field chrome the RAC fields get from react-aria, as `TextField` pairs
|
|
27
|
+
* with `Input`. The label association and `aria-describedby` are wired here,
|
|
28
|
+
* since there is no RAC context to do it; the label dims with the control
|
|
29
|
+
* exactly as the RAC fields' do. No `errorMessage` yet — no consumer needs
|
|
30
|
+
* one; render `FieldErrorMessage` beside it if yours does.
|
|
31
|
+
*
|
|
32
|
+
* With `labelPosition="side"` this is the settings row both
|
|
33
|
+
* `SelectFormControl`s hand-rolled: label beside a fixed-width select
|
|
34
|
+
* (`wrapperCss={{ width: "28ch" }}`), label absorbing the free space.
|
|
35
|
+
*/
|
|
36
|
+
export const NativeSelectField = forwardRef<
|
|
37
|
+
HTMLSelectElement,
|
|
38
|
+
NativeSelectFieldProps
|
|
39
|
+
>(function NativeSelectField(
|
|
40
|
+
{ label, helperText, labelPosition, rootCss, id, ...rest },
|
|
41
|
+
ref,
|
|
42
|
+
) {
|
|
43
|
+
const generatedId = useId();
|
|
44
|
+
const selectId = id ?? generatedId;
|
|
45
|
+
const helperId = useId();
|
|
46
|
+
const describedBy =
|
|
47
|
+
[rest["aria-describedby"], helperText != null ? helperId : undefined]
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.join(" ") || undefined;
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
className={cx(
|
|
53
|
+
field({ size: rest.size, labelPosition }).root,
|
|
54
|
+
rootCss ? css(rootCss) : undefined,
|
|
55
|
+
)}
|
|
56
|
+
// The field recipe's label dims off the root's data-disabled, which RAC
|
|
57
|
+
// stamps for the other fields; here it is restated from the attribute.
|
|
58
|
+
data-disabled={rest.disabled || undefined}
|
|
59
|
+
>
|
|
60
|
+
<FieldLabel
|
|
61
|
+
htmlFor={selectId}
|
|
62
|
+
size={rest.size}
|
|
63
|
+
labelPosition={labelPosition}
|
|
64
|
+
isRequired={rest.required}
|
|
65
|
+
>
|
|
66
|
+
{label}
|
|
67
|
+
</FieldLabel>
|
|
68
|
+
<NativeSelect
|
|
69
|
+
ref={ref}
|
|
70
|
+
id={selectId}
|
|
71
|
+
{...rest}
|
|
72
|
+
aria-describedby={describedBy}
|
|
73
|
+
/>
|
|
74
|
+
{helperText != null && (
|
|
75
|
+
<FieldHelperText id={helperId} labelPosition={labelPosition}>
|
|
76
|
+
{helperText}
|
|
77
|
+
</FieldHelperText>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
});
|
|
@@ -11,18 +11,18 @@ import { defineSlotRecipe } from "@pandacss/dev";
|
|
|
11
11
|
* two stacked buttons. Consumed by the shared-ui NumberField
|
|
12
12
|
* (react-aria-components NumberField).
|
|
13
13
|
*
|
|
14
|
-
* Registered in the base preset (base-preset.ts)
|
|
15
|
-
*
|
|
14
|
+
* Registered in the base preset (base-preset.ts), which also has the
|
|
15
|
+
* `staticCss` entry that keeps the runtime-prop size variants generated.
|
|
16
16
|
*/
|
|
17
17
|
export const numberField = defineSlotRecipe({
|
|
18
18
|
className: "numberField",
|
|
19
19
|
slots: ["root", "group", "stepper", "stepperButton"],
|
|
20
20
|
base: {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
},
|
|
21
|
+
// No layout here: the component wears the `field` recipe's root alongside
|
|
22
|
+
// this slot, and that recipe is the single owner of field-root layout
|
|
23
|
+
// (else `labelPosition` would fight this slot over `flexDirection`). The
|
|
24
|
+
// slot stays for apps and variants to target.
|
|
25
|
+
root: {},
|
|
26
26
|
group: {
|
|
27
27
|
position: "relative",
|
|
28
28
|
zIndex: 0,
|
|
@@ -64,4 +64,22 @@ export const numberField = defineSlotRecipe({
|
|
|
64
64
|
"&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
|
|
65
65
|
},
|
|
66
66
|
},
|
|
67
|
+
variants: {
|
|
68
|
+
// Chakra's NumberInput: the stepper column stays 24px wide at every size
|
|
69
|
+
// (as does the input padding paired with it in NumberField.tsx); only the
|
|
70
|
+
// arrow glyphs scale, at 0.75 × the field's font size. The base's `xs` is
|
|
71
|
+
// exactly md × 0.75, so md adds nothing.
|
|
72
|
+
size: {
|
|
73
|
+
lg: {
|
|
74
|
+
stepperButton: { fontSize: "calc(token(fontSizes.lg) * 0.75)" },
|
|
75
|
+
},
|
|
76
|
+
md: {},
|
|
77
|
+
sm: {
|
|
78
|
+
stepperButton: { fontSize: "calc(token(fontSizes.sm) * 0.75)" },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
defaultVariants: {
|
|
83
|
+
size: "md",
|
|
84
|
+
},
|
|
67
85
|
});
|