@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microbit/ui",
3
- "version": "0.1.0-alpha.17",
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
- return (
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
  };
@@ -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
- <RACLabel className={field().label}>
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
- FieldRequiredIndicator,
28
+ FieldLabel,
29
+ FieldLayoutProps,
30
30
  FieldSupport,
31
31
  FieldSupportProps,
32
- } from "./FieldSupport";
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 control — the box around the input, its
85
- * `startContent` and its indicator, which is what `Select`'s `css` styles
86
- * too. Reach the input itself through the `select` recipe's `value` slot.
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
- css?: SystemStyleObject;
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
- css: cssProp,
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
- <RACLabel className={slots.label}>
162
+ <FieldLabel
163
+ size={variantProps.size}
164
+ labelPosition={labelPosition}
165
+ isRequired={props.isRequired}
166
+ >
159
167
  {label}
160
- {props.isRequired ? <FieldRequiredIndicator /> : null}
161
- </RACLabel>
168
+ </FieldLabel>
162
169
  )}
163
170
  <div
164
171
  ref={triggerRef}
165
- className={cx(slots.trigger, cssProp ? css(cssProp) : undefined)}
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). No variants, so it needs
15
- * no `staticCss` entry.
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
- root: {
22
- display: "flex",
23
- flexDirection: "column",
24
- alignItems: "stretch",
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
  });