@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,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
|
+
);
|
package/src/Heading.recipe.ts
CHANGED
|
@@ -44,6 +44,25 @@ export const heading = defineRecipe({
|
|
|
44
44
|
// GT Walsheim in the private preset.
|
|
45
45
|
variant: {
|
|
46
46
|
marketing: { fontFamily: "display" },
|
|
47
|
+
// Page-title chrome in the accent colour (`headingAccent` — see
|
|
48
|
+
// base-preset.ts). Converged from classroom and data-microbit-org,
|
|
49
|
+
// which carried these two byte-identically app-side.
|
|
50
|
+
//
|
|
51
|
+
// `fontSize` goes through a doubled selector because `size` sets it too,
|
|
52
|
+
// responsively. Source order can't settle that: Panda hoists media
|
|
53
|
+
// queries below the base rules (gotcha #31) and emits variant rules in
|
|
54
|
+
// the order it meets them, so which of the two wins would depend on the
|
|
55
|
+
// breakpoint and on what other call sites exist. Two classes beat one at
|
|
56
|
+
// every width instead.
|
|
57
|
+
label: {
|
|
58
|
+
"&&": { fontSize: "4xl" },
|
|
59
|
+
color: "headingAccent",
|
|
60
|
+
},
|
|
61
|
+
subtitle: {
|
|
62
|
+
"&&": { fontSize: "xl" },
|
|
63
|
+
fontWeight: "normal",
|
|
64
|
+
color: "headingAccent",
|
|
65
|
+
},
|
|
47
66
|
},
|
|
48
67
|
},
|
|
49
68
|
defaultVariants: {
|
package/src/Input.recipe.ts
CHANGED
|
@@ -17,8 +17,17 @@ const transitionCommon =
|
|
|
17
17
|
*
|
|
18
18
|
* Focus matches both native `:focus-visible` (plain inputs; browsers treat any
|
|
19
19
|
* focus in a text field as focus-visible) and react-aria's `data-focused`
|
|
20
|
-
* (inputs inside RAC TextField).
|
|
21
|
-
*
|
|
20
|
+
* (inputs inside RAC TextField).
|
|
21
|
+
*
|
|
22
|
+
* Hover, invalid and focus all set `borderColor`, so their precedence has to be
|
|
23
|
+
* hover < invalid < focus. Declaration order will not buy that: Panda sorts a
|
|
24
|
+
* recipe's state rules itself, ranking selectors against a fixed
|
|
25
|
+
* link/visited/focus/hover/active table, which puts `_hover` *after* focus and
|
|
26
|
+
* after anything the table doesn't mention (`[data-invalid]`). Equal-specificity
|
|
27
|
+
* rules then leave hover winning. So the ladder is spelled with repeated `&`
|
|
28
|
+
* instead — `&&` and `&&&` emit `.input.input` and `.input.input.input`, making
|
|
29
|
+
* precedence specificity rather than order, which nothing downstream can
|
|
30
|
+
* resort. Variants still override freely; they land in a later cascade layer.
|
|
22
31
|
*
|
|
23
32
|
* Registered in the base preset (base-preset.ts), which also has the
|
|
24
33
|
* `staticCss` entry that keeps the runtime-prop size variants generated.
|
|
@@ -39,11 +48,11 @@ export const input = defineRecipe({
|
|
|
39
48
|
bg: "inherit",
|
|
40
49
|
color: "inherit",
|
|
41
50
|
_hover: { borderColor: "gray.300" },
|
|
42
|
-
"
|
|
51
|
+
"&&:is([data-invalid], :user-invalid)": {
|
|
43
52
|
borderColor: "danger.500",
|
|
44
53
|
boxShadow: "0 0 0 1px token(colors.danger.500)",
|
|
45
54
|
},
|
|
46
|
-
"
|
|
55
|
+
"&&&:is(:focus-visible, [data-focused])": {
|
|
47
56
|
zIndex: 1,
|
|
48
57
|
borderColor: "focusBorder",
|
|
49
58
|
boxShadow: "0 0 0 1px token(colors.focusBorder)",
|
package/src/NativeSelect.tsx
CHANGED
|
@@ -47,7 +47,13 @@ export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
|
|
|
47
47
|
className={cx(
|
|
48
48
|
input({ size }),
|
|
49
49
|
css(
|
|
50
|
-
|
|
50
|
+
// Chakra's Select field carried a 1px bottom padding its Input
|
|
51
|
+
// didn't (its option text sits a hair higher than input text).
|
|
52
|
+
{
|
|
53
|
+
cursor: "pointer",
|
|
54
|
+
paddingBottom: "1px",
|
|
55
|
+
_disabled: { cursor: "not-allowed" },
|
|
56
|
+
},
|
|
51
57
|
// Room for the chevron overlay (Chakra Select's icon spacing,
|
|
52
58
|
// constant across sizes).
|
|
53
59
|
hideChevron ? undefined : { paddingRight: "8" },
|
|
@@ -84,6 +90,9 @@ export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
|
|
|
84
90
|
height: "5",
|
|
85
91
|
pointerEvents: "none",
|
|
86
92
|
fill: "currentColor",
|
|
93
|
+
// The chevron sits outside the select so it doesn't inherit its
|
|
94
|
+
// disabled dimming; Chakra's Select icon dimmed to 0.5.
|
|
95
|
+
"select:disabled + &": { opacity: 0.5 },
|
|
87
96
|
})}
|
|
88
97
|
>
|
|
89
98
|
<path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" />
|
|
@@ -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
|
});
|
package/src/NumberField.tsx
CHANGED
|
@@ -8,27 +8,41 @@ import {
|
|
|
8
8
|
Button as RACButton,
|
|
9
9
|
Group as RACGroup,
|
|
10
10
|
Input as RACInput,
|
|
11
|
-
Label as RACLabel,
|
|
12
11
|
NumberField as RACNumberField,
|
|
13
12
|
NumberFieldProps as RACNumberFieldProps,
|
|
14
13
|
} from "react-aria-components";
|
|
15
14
|
import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
|
|
16
15
|
import { css, cx } from "styled-system/css";
|
|
17
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
field,
|
|
18
|
+
input,
|
|
19
|
+
InputVariantProps,
|
|
20
|
+
numberField,
|
|
21
|
+
} from "styled-system/recipes";
|
|
18
22
|
import { SystemStyleObject } from "styled-system/types";
|
|
23
|
+
import {
|
|
24
|
+
FieldLabel,
|
|
25
|
+
FieldLayoutProps,
|
|
26
|
+
FieldSupport,
|
|
27
|
+
FieldSupportProps,
|
|
28
|
+
} from "./Field";
|
|
19
29
|
import { Icon } from "./Icon";
|
|
20
30
|
|
|
21
31
|
export interface NumberFieldProps
|
|
22
|
-
extends Omit<RACNumberFieldProps, "className" | "children" | "style"
|
|
32
|
+
extends Omit<RACNumberFieldProps, "className" | "children" | "style">,
|
|
33
|
+
InputVariantProps,
|
|
34
|
+
FieldSupportProps,
|
|
35
|
+
FieldLayoutProps {
|
|
23
36
|
/** Visible label (optional; otherwise pass `aria-label`). */
|
|
24
37
|
label?: ReactNode;
|
|
25
|
-
/** Root style overrides (
|
|
26
|
-
|
|
38
|
+
/** Root style overrides (for a label-beside-field row, prefer
|
|
39
|
+
* `labelPosition="side"`). */
|
|
40
|
+
rootCss?: SystemStyleObject;
|
|
27
41
|
/** Label style overrides. */
|
|
28
42
|
labelCss?: SystemStyleObject;
|
|
29
43
|
/** Group (input + steppers) style overrides — set `width` here. */
|
|
30
44
|
groupCss?: SystemStyleObject;
|
|
31
|
-
/** Input style overrides (
|
|
45
|
+
/** Input style overrides (for sizing, prefer the `size` prop). */
|
|
32
46
|
inputCss?: SystemStyleObject;
|
|
33
47
|
}
|
|
34
48
|
|
|
@@ -40,25 +54,43 @@ export interface NumberFieldProps
|
|
|
40
54
|
*/
|
|
41
55
|
export const NumberField = forwardRef<HTMLInputElement, NumberFieldProps>(
|
|
42
56
|
function NumberField(
|
|
43
|
-
{
|
|
57
|
+
{
|
|
58
|
+
label,
|
|
59
|
+
helperText,
|
|
60
|
+
errorMessage,
|
|
61
|
+
helperTextCss,
|
|
62
|
+
rootCss,
|
|
63
|
+
labelCss,
|
|
64
|
+
groupCss,
|
|
65
|
+
inputCss,
|
|
66
|
+
labelPosition,
|
|
67
|
+
...props
|
|
68
|
+
},
|
|
44
69
|
ref,
|
|
45
70
|
) {
|
|
46
|
-
|
|
47
|
-
|
|
71
|
+
// As Input: forward every recipe variant group, not just `size`, so a
|
|
72
|
+
// preset that adds one keeps working.
|
|
73
|
+
const [variantProps, rest] = input.splitVariantProps(props);
|
|
74
|
+
const slots = numberField({ size: variantProps.size });
|
|
75
|
+
const fieldSlots = field({ size: variantProps.size, labelPosition });
|
|
48
76
|
return (
|
|
49
77
|
<RACNumberField
|
|
50
78
|
{...rest}
|
|
51
|
-
className={cx(
|
|
79
|
+
className={cx(
|
|
80
|
+
fieldSlots.root,
|
|
81
|
+
slots.root,
|
|
82
|
+
rootCss ? css(rootCss) : undefined,
|
|
83
|
+
)}
|
|
52
84
|
>
|
|
53
85
|
{label != null && (
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
86
|
+
<FieldLabel
|
|
87
|
+
size={variantProps.size}
|
|
88
|
+
labelPosition={labelPosition}
|
|
89
|
+
isRequired={rest.isRequired}
|
|
90
|
+
css={labelCss}
|
|
59
91
|
>
|
|
60
92
|
{label}
|
|
61
|
-
</
|
|
93
|
+
</FieldLabel>
|
|
62
94
|
)}
|
|
63
95
|
<RACGroup
|
|
64
96
|
className={cx(slots.group, groupCss ? css(groupCss) : undefined)}
|
|
@@ -66,8 +98,9 @@ export const NumberField = forwardRef<HTMLInputElement, NumberFieldProps>(
|
|
|
66
98
|
<RACInput
|
|
67
99
|
ref={ref}
|
|
68
100
|
className={cx(
|
|
69
|
-
input(),
|
|
70
|
-
// Room for the stepper column
|
|
101
|
+
input(variantProps),
|
|
102
|
+
// Room for the stepper column (constant across sizes, as the
|
|
103
|
+
// stepper's width is).
|
|
71
104
|
css({ paddingEnd: "6" }, inputCss),
|
|
72
105
|
)}
|
|
73
106
|
/>
|
|
@@ -80,6 +113,12 @@ export const NumberField = forwardRef<HTMLInputElement, NumberFieldProps>(
|
|
|
80
113
|
</RACButton>
|
|
81
114
|
</div>
|
|
82
115
|
</RACGroup>
|
|
116
|
+
<FieldSupport
|
|
117
|
+
helperText={helperText}
|
|
118
|
+
errorMessage={errorMessage}
|
|
119
|
+
helperTextCss={helperTextCss}
|
|
120
|
+
labelPosition={labelPosition}
|
|
121
|
+
/>
|
|
83
122
|
</RACNumberField>
|
|
84
123
|
);
|
|
85
124
|
},
|
package/src/Radio.tsx
CHANGED
|
@@ -7,38 +7,11 @@ import { ReactNode } from "react";
|
|
|
7
7
|
import {
|
|
8
8
|
Radio as RACRadio,
|
|
9
9
|
RadioProps as RACRadioProps,
|
|
10
|
-
RadioGroup as RACRadioGroup,
|
|
11
|
-
RadioGroupProps as RACRadioGroupProps,
|
|
12
10
|
} from "react-aria-components";
|
|
13
11
|
import { css, cx } from "styled-system/css";
|
|
14
12
|
import { radio, RadioVariantProps } from "styled-system/recipes";
|
|
15
13
|
import { SystemStyleObject } from "styled-system/types";
|
|
16
14
|
|
|
17
|
-
export interface RadioGroupProps
|
|
18
|
-
extends Omit<RACRadioGroupProps, "className" | "style"> {
|
|
19
|
-
/** Per-instance style overrides, merged after the recipe. */
|
|
20
|
-
css?: SystemStyleObject;
|
|
21
|
-
className?: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* RadioGroup — react-aria-components <RadioGroup> for a set of Radios. Carries
|
|
26
|
-
* no styling of its own: compose with Stack for layout, as Chakra call sites
|
|
27
|
-
* did.
|
|
28
|
-
*/
|
|
29
|
-
export const RadioGroup = ({
|
|
30
|
-
css: cssProp,
|
|
31
|
-
className,
|
|
32
|
-
...rest
|
|
33
|
-
}: RadioGroupProps) => {
|
|
34
|
-
return (
|
|
35
|
-
<RACRadioGroup
|
|
36
|
-
className={cx(cssProp ? css(cssProp) : undefined, className)}
|
|
37
|
-
{...rest}
|
|
38
|
-
/>
|
|
39
|
-
);
|
|
40
|
-
};
|
|
41
|
-
|
|
42
15
|
export interface RadioProps
|
|
43
16
|
extends Omit<RACRadioProps, "className" | "children" | "style">,
|
|
44
17
|
RadioVariantProps {
|