@microbit/ui 0.1.0-alpha.3 → 0.1.0-alpha.30

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.
Files changed (114) hide show
  1. package/LICENSE.md +40 -0
  2. package/README.md +256 -26
  3. package/lang/ui.ca.json +43 -3
  4. package/lang/ui.cy.json +62 -0
  5. package/lang/ui.de.json +62 -0
  6. package/lang/ui.en-us.json +40 -0
  7. package/lang/ui.en.json +40 -0
  8. package/lang/ui.es-es.json +43 -3
  9. package/lang/ui.fr.json +44 -4
  10. package/lang/ui.ga-ie.json +62 -0
  11. package/lang/ui.it.json +62 -0
  12. package/lang/ui.ja.json +44 -4
  13. package/lang/ui.ko.json +44 -4
  14. package/lang/ui.lo.json +62 -0
  15. package/lang/ui.lol.json +40 -0
  16. package/lang/ui.nl.json +44 -4
  17. package/lang/ui.pl.json +44 -4
  18. package/lang/ui.pt-br.json +44 -4
  19. package/lang/ui.vi.json +62 -0
  20. package/lang/ui.zh-cn.json +62 -0
  21. package/lang/ui.zh-tw.json +44 -4
  22. package/package.json +19 -10
  23. package/postcss-legacy-safari.cjs +96 -0
  24. package/reset.css +35 -0
  25. package/src/Avatar.recipe.ts +166 -0
  26. package/src/Avatar.tsx +278 -0
  27. package/src/Breadcrumb.recipe.ts +39 -0
  28. package/src/Breadcrumb.tsx +114 -0
  29. package/src/Button.recipe.ts +88 -50
  30. package/src/Button.tsx +64 -26
  31. package/src/ButtonGroup.tsx +37 -15
  32. package/src/Card.recipe.ts +1 -2
  33. package/src/Card.tsx +2 -1
  34. package/src/Checkbox.recipe.ts +49 -14
  35. package/src/Checkbox.tsx +111 -32
  36. package/src/CheckboxGroup.tsx +70 -0
  37. package/src/CloseButton.tsx +3 -3
  38. package/src/CloseIcon.tsx +6 -4
  39. package/src/Code.tsx +20 -0
  40. package/src/Collapse.tsx +179 -0
  41. package/src/ComboBox.tsx +246 -0
  42. package/src/Divider.tsx +72 -8
  43. package/src/Drawer.recipe.ts +4 -5
  44. package/src/Drawer.tsx +3 -4
  45. package/src/ExternalLink.tsx +43 -0
  46. package/src/Fade.tsx +62 -0
  47. package/src/Field.recipe.ts +114 -0
  48. package/src/Field.tsx +187 -0
  49. package/src/GridList.recipe.ts +57 -0
  50. package/src/GridList.tsx +81 -0
  51. package/src/Heading.recipe.ts +20 -1
  52. package/src/Heading.tsx +3 -3
  53. package/src/Icon.tsx +23 -7
  54. package/src/IconButton.tsx +7 -12
  55. package/src/Image.tsx +1 -1
  56. package/src/Input.recipe.ts +41 -28
  57. package/src/Input.tsx +23 -7
  58. package/src/InputGroup.tsx +21 -8
  59. package/src/Kbd.tsx +26 -0
  60. package/src/Link.tsx +3 -3
  61. package/src/LinkBox.tsx +2 -3
  62. package/src/LinkButton.tsx +81 -0
  63. package/src/List.tsx +8 -6
  64. package/src/ListBox.recipe.ts +43 -0
  65. package/src/ListBox.tsx +88 -0
  66. package/src/Menu.recipe.ts +51 -17
  67. package/src/Menu.tsx +117 -2
  68. package/src/Modal.recipe.ts +14 -9
  69. package/src/Modal.tsx +137 -24
  70. package/src/MoreMenuButton.tsx +63 -0
  71. package/src/NativeSelect.tsx +39 -12
  72. package/src/NativeSelectField.tsx +84 -0
  73. package/src/NumberField.recipe.ts +108 -0
  74. package/src/NumberField.tsx +138 -0
  75. package/src/PopoverArrow.tsx +19 -5
  76. package/src/ProgressBar.tsx +3 -5
  77. package/src/Radio.recipe.ts +108 -0
  78. package/src/Radio.tsx +62 -0
  79. package/src/RadioGroup.tsx +68 -0
  80. package/src/Select.recipe.ts +211 -0
  81. package/src/Select.tsx +194 -0
  82. package/src/SharedUIProvider.tsx +51 -7
  83. package/src/Skeleton.tsx +146 -0
  84. package/src/Slide.tsx +2 -2
  85. package/src/Slider.recipe.ts +24 -15
  86. package/src/Slider.tsx +65 -5
  87. package/src/Spinner.tsx +11 -9
  88. package/src/Svg.tsx +2 -3
  89. package/src/Switch.recipe.ts +58 -13
  90. package/src/Switch.tsx +39 -7
  91. package/src/Text.recipe.ts +26 -0
  92. package/src/Text.tsx +6 -2
  93. package/src/TextField.tsx +54 -40
  94. package/src/Toast.recipe.ts +49 -12
  95. package/src/Toast.tsx +151 -46
  96. package/src/Tooltip.recipe.ts +32 -0
  97. package/src/Tooltip.tsx +51 -28
  98. package/src/TooltipButton.tsx +229 -0
  99. package/src/UnmountCallback.tsx +2 -2
  100. package/src/VisuallyHidden.tsx +1 -1
  101. package/src/base-preset.ts +336 -84
  102. package/src/{chakra-tokens.ts → base-tokens.ts} +7 -7
  103. package/src/button-icon.ts +22 -0
  104. package/src/data-attrs.ts +16 -0
  105. package/src/dense-preset.ts +107 -0
  106. package/src/hooks/useBreakpointValue.ts +4 -4
  107. package/src/hooks/useClipboard.ts +63 -0
  108. package/src/hooks/useDisclosure.ts +31 -0
  109. package/src/hooks/useMediaQuery.ts +27 -0
  110. package/src/hooks/usePrevious.ts +15 -0
  111. package/src/index.ts +29 -3
  112. package/src/rac-locale.ts +33 -0
  113. package/src/system.ts +22 -1
  114. package/src/TextField.recipe.ts +0 -54
@@ -0,0 +1,246 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import {
7
+ ForwardedRef,
8
+ forwardRef,
9
+ ReactNode,
10
+ useLayoutEffect,
11
+ useRef,
12
+ useState,
13
+ } from "react";
14
+ import {
15
+ Button as RACButton,
16
+ ComboBox as RACComboBox,
17
+ ComboBoxProps as RACComboBoxProps,
18
+ Input as RACInput,
19
+ ListBox as RACListBox,
20
+ Popover,
21
+ PopoverProps,
22
+ } from "react-aria-components";
23
+ import { useFocusVisible } from "react-aria";
24
+ import { useIntl } from "react-intl";
25
+ import { RiArrowDownSLine } from "react-icons/ri";
26
+ import { css, cx } from "styled-system/css";
27
+ import { field, select, SelectVariantProps } from "styled-system/recipes";
28
+ import { SystemStyleObject } from "styled-system/types";
29
+ import {
30
+ FieldLabel,
31
+ FieldLayoutProps,
32
+ FieldSupport,
33
+ FieldSupportProps,
34
+ } from "./Field";
35
+ import { Icon } from "./Icon";
36
+ import { uiMessage } from "./messages";
37
+ import { SelectSlotProvider } from "./Select";
38
+
39
+ export interface ComboBoxProps<T extends object>
40
+ extends Omit<RACComboBoxProps<T>, "className" | "children" | "style">,
41
+ SelectVariantProps,
42
+ FieldSupportProps,
43
+ FieldLayoutProps {
44
+ /** Visible label. Use `aria-label` instead where the design has none. */
45
+ label?: ReactNode;
46
+ /** Label style overrides. */
47
+ labelCss?: SystemStyleObject;
48
+ placeholder?: string;
49
+ /**
50
+ * Rendered inside the control, before the input — an icon for the current
51
+ * value, say. A ComboBox's control is a text input, so unlike a Select it
52
+ * cannot show anything but text for what is chosen; this is the way round
53
+ * that (react-select did it with a custom `SingleValue`).
54
+ */
55
+ startContent?: ReactNode;
56
+ /**
57
+ * `SelectOption`s, or a render function over the `items` prop for a
58
+ * dynamic collection — which is how an async lookup works: drive `items`
59
+ * from loaded results (e.g. react-stately's useAsyncList) and filter
60
+ * server-side; react-aria skips its own text filtering when `items` is
61
+ * controlled. Pair with `emptyState` (swap its content while loading) and
62
+ * `isPopoverHidden` for a minimum query length.
63
+ */
64
+ children: ReactNode | ((item: T) => ReactNode);
65
+ /**
66
+ * Replaces the chevron; pass `null` for none, which is what a plain
67
+ * autocomplete wants (react-select's `dropdownIndicator: display none`).
68
+ */
69
+ indicator?: ReactNode | null;
70
+ /**
71
+ * Shown in place of the list when nothing matches (react-select's
72
+ * `noOptionsMessage`). Implies `allowsEmptyCollection`, since RAC otherwise
73
+ * closes the popover the moment the collection empties.
74
+ */
75
+ emptyState?: ReactNode;
76
+ /**
77
+ * Keep the dropdown shut until this prop is true. For gating on a minimum
78
+ * query length — react-aria has no `minLength`, and rendering an empty list
79
+ * still opens an empty card.
80
+ */
81
+ isPopoverHidden?: boolean;
82
+ placement?: PopoverProps["placement"];
83
+ /**
84
+ * Cap the dropdown's height (react-select's `maxMenuHeight`). A prop rather
85
+ * than a `contentCss` rule because RAC writes its own max-height inline
86
+ * while positioning, which beats any class.
87
+ */
88
+ maxHeight?: number;
89
+ /**
90
+ * Per-instance overrides for the trigger — the box around the input, its
91
+ * `startContent` and its indicator, the same slot `Select`'s `triggerCss`
92
+ * styles. Reach the input itself through the `select` recipe's `value`
93
+ * slot.
94
+ */
95
+ triggerCss?: SystemStyleObject;
96
+ /** Per-instance overrides for the dropdown card. */
97
+ contentCss?: SystemStyleObject;
98
+ className?: string;
99
+ }
100
+
101
+ /**
102
+ * ComboBox — a text input that filters a listbox, for choosing one of a known
103
+ * set where typing to narrow it down is the point. Use Select where the list
104
+ * is short enough to just pick from.
105
+ *
106
+ * Note the react-select difference this replaces: react-select filtered on
107
+ * `label` and kept the menu open on selection unless told otherwise, whereas
108
+ * react-aria filters on each item's `textValue` and closes on selection.
109
+ */
110
+ const ComboBoxInner = <T extends object>(
111
+ {
112
+ label,
113
+ labelCss,
114
+ placeholder,
115
+ startContent,
116
+ children,
117
+ indicator,
118
+ emptyState,
119
+ isPopoverHidden,
120
+ placement = "bottom start",
121
+ maxHeight,
122
+ helperText,
123
+ errorMessage,
124
+ helperTextCss,
125
+ labelPosition,
126
+ triggerCss,
127
+ contentCss,
128
+ className,
129
+ ...props
130
+ }: ComboBoxProps<T>,
131
+ ref: ForwardedRef<HTMLInputElement>,
132
+ ) => {
133
+ // As Select: forward whatever variant groups the merged recipe has.
134
+ const [variantProps, rest] = select.splitVariantProps(props);
135
+ const intl = useIntl();
136
+ const slots = select(variantProps);
137
+ const fieldSlots = field({ size: variantProps.size, labelPosition });
138
+ // Anchor the card to the whole control, not to the bare input inside it —
139
+ // otherwise it hangs off the text baseline and is as narrow as the input.
140
+ // Global modality (text-input key filter): unlike the input's own RAC
141
+ // attributes, it survives react-aria's synthetic blur during virtual
142
+ // focus — the select recipe's ring rule pairs it with :has(input:focus).
143
+ const { isFocusVisible } = useFocusVisible({ isTextInput: true });
144
+ const triggerRef = useRef<HTMLDivElement>(null);
145
+ // RAC's --trigger-width measures the input it anchors a ComboBox to, which
146
+ // is the control's content box — so a card sized from it is narrower than
147
+ // the field by the padding and border. Measure the control instead. State
148
+ // rather than reading the ref at render time: the popover is mounted from
149
+ // the first render, before the ref is set, and nothing would re-render it.
150
+ const [triggerWidth, setTriggerWidth] = useState<number>();
151
+ useLayoutEffect(() => {
152
+ const el = triggerRef.current;
153
+ if (!el) {
154
+ return;
155
+ }
156
+ const update = () => setTriggerWidth(el.offsetWidth);
157
+ update();
158
+ if (typeof ResizeObserver === "undefined") {
159
+ return;
160
+ }
161
+ const observer = new ResizeObserver(update);
162
+ observer.observe(el);
163
+ return () => observer.disconnect();
164
+ }, []);
165
+ return (
166
+ <SelectSlotProvider value={slots}>
167
+ <RACComboBox
168
+ allowsEmptyCollection={emptyState != null}
169
+ {...(rest as RACComboBoxProps<T>)}
170
+ className={cx(fieldSlots.root, slots.root, className)}
171
+ >
172
+ {label != null && (
173
+ <FieldLabel
174
+ size={variantProps.size}
175
+ labelPosition={labelPosition}
176
+ isRequired={props.isRequired}
177
+ css={labelCss}
178
+ >
179
+ {label}
180
+ </FieldLabel>
181
+ )}
182
+ <div
183
+ ref={triggerRef}
184
+ data-keyboard-modality={isFocusVisible || undefined}
185
+ className={cx(
186
+ slots.trigger,
187
+ triggerCss ? css(triggerCss) : undefined,
188
+ )}
189
+ >
190
+ {startContent}
191
+ <RACInput
192
+ ref={ref}
193
+ placeholder={placeholder}
194
+ className={slots.value}
195
+ />
196
+ {indicator !== null && (
197
+ <RACButton
198
+ aria-label={intl.formatMessage(uiMessage("ui.combobox-trigger"))}
199
+ className={slots.indicator}
200
+ >
201
+ {indicator ?? <Icon as={RiArrowDownSLine} />}
202
+ </RACButton>
203
+ )}
204
+ </div>
205
+ {!isPopoverHidden && (
206
+ <Popover
207
+ triggerRef={triggerRef}
208
+ placement={placement}
209
+ maxHeight={maxHeight}
210
+ style={triggerWidth ? { width: triggerWidth } : undefined}
211
+ className={cx(
212
+ slots.content,
213
+ contentCss ? css(contentCss) : undefined,
214
+ )}
215
+ >
216
+ <RACListBox
217
+ aria-label={intl.formatMessage(uiMessage("ui.combobox-listbox"))}
218
+ className={slots.list}
219
+ renderEmptyState={
220
+ emptyState
221
+ ? () => <div className={slots.empty}>{emptyState}</div>
222
+ : undefined
223
+ }
224
+ >
225
+ {children}
226
+ </RACListBox>
227
+ </Popover>
228
+ )}
229
+ <FieldSupport
230
+ helperText={helperText}
231
+ errorMessage={errorMessage}
232
+ helperTextCss={helperTextCss}
233
+ labelPosition={labelPosition}
234
+ />
235
+ </RACComboBox>
236
+ </SelectSlotProvider>
237
+ );
238
+ };
239
+
240
+ /**
241
+ * forwardRef with generics needs the cast (React's types cannot express it),
242
+ * so the ref lands on the input — call sites focus it for validation.
243
+ */
244
+ export const ComboBox = forwardRef(ComboBoxInner) as <T extends object>(
245
+ props: ComboBoxProps<T> & { ref?: ForwardedRef<HTMLInputElement> },
246
+ ) => ReturnType<typeof ComboBoxInner>;
package/src/Divider.tsx CHANGED
@@ -3,19 +3,83 @@
3
3
  *
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
+ import { ComponentProps, forwardRef } from "react";
6
7
  import { styled } from "styled-system/jsx";
7
8
 
8
- /**
9
- * Divider — a horizontal rule matching Chakra's <Divider> (hairline at 60%
10
- * opacity; set `borderColor` to tint). Horizontal only.
11
- */
12
- export const Divider = styled("hr", {
9
+ const StyledDivider = styled("hr", {
13
10
  base: {
14
11
  border: 0,
15
- borderBottomWidth: "1px",
16
- borderBottomStyle: "solid",
17
12
  borderColor: "gray.200",
18
13
  opacity: 0.6,
19
- width: "100%",
20
14
  },
15
+ variants: {
16
+ orientation: {
17
+ horizontal: {
18
+ borderBottomStyle: "solid",
19
+ width: "100%",
20
+ },
21
+ vertical: {
22
+ borderLeftStyle: "solid",
23
+ height: "100%",
24
+ },
25
+ },
26
+ // Rule weight, on the orientation's drawn edge (see compoundVariants).
27
+ // Semantic rather than a raw border width so call sites don't need to
28
+ // know which edge an orientation draws with.
29
+ thickness: {
30
+ thin: {},
31
+ thick: {},
32
+ },
33
+ },
34
+ compoundVariants: [
35
+ {
36
+ orientation: "horizontal",
37
+ thickness: "thin",
38
+ css: { borderBottomWidth: "1px" },
39
+ },
40
+ {
41
+ orientation: "horizontal",
42
+ thickness: "thick",
43
+ css: { borderBottomWidth: "2px" },
44
+ },
45
+ {
46
+ orientation: "vertical",
47
+ thickness: "thin",
48
+ css: { borderLeftWidth: "1px" },
49
+ },
50
+ {
51
+ orientation: "vertical",
52
+ thickness: "thick",
53
+ css: { borderLeftWidth: "2px" },
54
+ },
55
+ ],
56
+ defaultVariants: { orientation: "horizontal", thickness: "thin" },
21
57
  });
58
+
59
+ export interface DividerProps extends ComponentProps<typeof StyledDivider> {}
60
+
61
+ /**
62
+ * Divider — a hairline rule (60% opacity; set `borderColor` to tint,
63
+ * `thickness="thick"` for a 2px rule).
64
+ * `orientation="vertical"` needs a height from the layout, e.g. a stretched
65
+ * flex row.
66
+ *
67
+ * Decorative by default: hidden from assistive tech, since a visual rule
68
+ * between sections is noise as an announced separator (and every call site
69
+ * was opting out by hand). Pass `aria-hidden={false}` for a divider that
70
+ * should be exposed as a semantic separator.
71
+ */
72
+ export const Divider = forwardRef<HTMLHRElement, DividerProps>(
73
+ function Divider(props, ref) {
74
+ return (
75
+ <StyledDivider
76
+ ref={ref}
77
+ aria-hidden
78
+ aria-orientation={
79
+ props.orientation === "vertical" ? "vertical" : "horizontal"
80
+ }
81
+ {...props}
82
+ />
83
+ );
84
+ },
85
+ );
@@ -6,12 +6,11 @@
6
6
  import { defineSlotRecipe } from "@pandacss/dev";
7
7
 
8
8
  /**
9
- * Drawer slot recipe — Chakra's default Drawer parts (light mode) at its
10
- * default `xs` size (20rem panel). Consumed by the shared-ui Drawer, which maps
11
- * the slots onto react-aria-components' ModalOverlay / Modal / Dialog.
9
+ * Drawer slot recipe — a 20rem (`xs`) side panel. Consumed by the shared-ui
10
+ * Drawer, which maps the slots onto react-aria-components' ModalOverlay /
11
+ * Modal / Dialog.
12
12
  *
13
- * The enter transition approximates Chakra's slide spring (damping 25,
14
- * stiffness 180) with a decelerating tween; exit matches its 0.15s ease-in-out
13
+ * The enter transition is a decelerating tween; the exit a 0.15s ease-in-out
15
14
  * tween.
16
15
  *
17
16
  * Registered in the base preset (base-preset.ts); `placement` is
package/src/Drawer.tsx CHANGED
@@ -24,7 +24,7 @@ export interface DrawerProps extends DrawerVariantProps {
24
24
  onClose: () => void;
25
25
  /**
26
26
  * Called after the drawer has fully closed (exit transition done and the
27
- * drawer removed). Matches Chakra's `onCloseComplete`.
27
+ * drawer removed).
28
28
  */
29
29
  onCloseComplete?: () => void;
30
30
  /** Allow closing by clicking the backdrop / pressing Escape (default true). */
@@ -39,9 +39,8 @@ export interface DrawerProps extends DrawerVariantProps {
39
39
  }
40
40
 
41
41
  /**
42
- * Drawer — a focus-trapping panel that slides in from the side. Collapses
43
- * Chakra's Drawer/DrawerOverlay/DrawerContent into a single shell; place
44
- * DrawerHeader and DrawerBody inside.
42
+ * Drawer — a focus-trapping panel that slides in from the side, in a single
43
+ * shell; place DrawerHeader and DrawerBody inside.
45
44
  */
46
45
  export const Drawer = ({
47
46
  isOpen,
@@ -0,0 +1,43 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { ComponentProps } from "react";
7
+ import { RiExternalLinkLine } from "react-icons/ri";
8
+ import { FormattedMessage } from "react-intl";
9
+ import { Icon } from "./Icon";
10
+ import { Link } from "./Link";
11
+ import { uiMessage } from "./messages";
12
+ import { VisuallyHidden } from "./VisuallyHidden";
13
+
14
+ export type ExternalLinkProps = Omit<
15
+ ComponentProps<typeof Link>,
16
+ "target" | "rel"
17
+ >;
18
+
19
+ /**
20
+ * ExternalLink — a Link that opens in a new tab and says so both ways: a
21
+ * trailing external-link glyph for sighted users, and a visually hidden,
22
+ * translated "opens in a new tab" for assistive tech. Use this rather than
23
+ * restating `target="_blank"` + icon so the announcement can't be forgotten.
24
+ *
25
+ * For app chrome, where a new tab protects the user's session-only state.
26
+ * Content surfaces should prefer same-tab links, or a visible
27
+ * "(opens in new tab)" suffix and no icon.
28
+ *
29
+ * Inline-safe: the icon is 1em and follows the text colour, so the link can
30
+ * sit in prose as well as stand alone.
31
+ */
32
+ export const ExternalLink = ({ children, ...props }: ExternalLinkProps) => (
33
+ <Link {...props} target="_blank" rel="noopener">
34
+ {children}
35
+ <VisuallyHidden>
36
+ {/* The comma separates the suffix in the accessible name: inline nodes
37
+ concatenate without a space, and leading whitespace is trimmed. */}
38
+ {", "}
39
+ <FormattedMessage {...uiMessage("ui.new-tab-notice")} />
40
+ </VisuallyHidden>
41
+ <Icon as={RiExternalLinkLine} aria-hidden css={{ ml: 1 }} />
42
+ </Link>
43
+ );
package/src/Fade.tsx ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { CSSProperties, ReactNode } from "react";
7
+ import { css, cx } from "styled-system/css";
8
+ import { SystemStyleObject } from "styled-system/types";
9
+
10
+ export interface FadeProps {
11
+ /** Visible when true; faded out (but mounted) when false. */
12
+ isOpen: boolean;
13
+ /** Fade-in time in seconds. Default 0.2. */
14
+ enterDuration?: number;
15
+ /** Fade-out time in seconds. Default 0.2. */
16
+ exitDuration?: number;
17
+ css?: SystemStyleObject;
18
+ className?: string;
19
+ children: ReactNode;
20
+ }
21
+
22
+ /**
23
+ * Fade — a CSS opacity transition (see Slide for the pattern). Content stays
24
+ * mounted throughout.
25
+ */
26
+ export const Fade = ({
27
+ isOpen,
28
+ enterDuration = 0.2,
29
+ exitDuration = 0.2,
30
+ css: cssProp,
31
+ className,
32
+ children,
33
+ }: FadeProps) => (
34
+ <div
35
+ data-open={isOpen ? "" : undefined}
36
+ // Runtime values, so an inline custom property rather than the css()
37
+ // object. The var switches in the same commit as the opacity, so the
38
+ // transition picks up the direction's own duration.
39
+ style={
40
+ {
41
+ "--fade-duration": `${isOpen ? enterDuration : exitDuration}s`,
42
+ } as CSSProperties
43
+ }
44
+ className={cx(
45
+ css(
46
+ {
47
+ opacity: 0,
48
+ pointerEvents: "none",
49
+ "&[data-open]": { opacity: 1, pointerEvents: "auto" },
50
+ transitionProperty: "opacity",
51
+ transitionDuration: "var(--fade-duration)",
52
+ transitionTimingFunction: "ease-out",
53
+ _motionReduce: { transition: "none" },
54
+ },
55
+ cssProp,
56
+ ),
57
+ className,
58
+ )}
59
+ >
60
+ {children}
61
+ </div>
62
+ );
@@ -0,0 +1,114 @@
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 — the label/helper/error chrome, mapped onto
10
+ * react-aria-components' Label/Text/FieldError by `FieldLabel` and
11
+ * `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 `normal`, not `medium`: no font in the family's stack
39
+ // has a 500 face (the two render identically on macOS and Windows),
40
+ // and the call sites that cared (settings rows) want `normal`.
41
+ fontWeight: "normal",
42
+ marginEnd: "3",
43
+ mb: "2",
44
+ transitionProperty: "opacity",
45
+ transitionDuration: "normal",
46
+ // RAC stamps `data-disabled` on the field root and on the control, never
47
+ // on the label, so an `&[data-disabled]` rule here matches nothing — it
48
+ // has to come down from the root. Direct child rather than a
49
+ // descendant selector, as the select recipe's invalid rule: an app's own
50
+ // disabled form wrapper must not be able to dim every label inside it.
51
+ "[data-disabled] > &": { opacity: 0.4 },
52
+ },
53
+ requiredIndicator: {
54
+ marginStart: "1",
55
+ color: "danger.500",
56
+ },
57
+ helperText: {
58
+ // RAC's Text renders a span, and RadioGroup/CheckboxGroup roots are not
59
+ // flex containers to blockify it, where an inline box would drop the
60
+ // margin below.
61
+ display: "block",
62
+ mt: "2",
63
+ fontSize: "sm",
64
+ lineHeight: "normal",
65
+ color: "gray.600",
66
+ },
67
+ errorMessage: {
68
+ display: "flex",
69
+ alignItems: "center",
70
+ mt: "2",
71
+ fontSize: "sm",
72
+ lineHeight: "normal",
73
+ color: "danger.500",
74
+ },
75
+ },
76
+ variants: {
77
+ // The label follows its control's size (the control itself is sized by
78
+ // the `input`/`select` recipes), so one `size` prop scales the whole row.
79
+ // Helper and error text stay `sm` at every size.
80
+ size: {
81
+ lg: { label: { fontSize: "lg" } },
82
+ md: { label: { fontSize: "md" } },
83
+ sm: { label: { fontSize: "sm" } },
84
+ },
85
+ // `side` puts the label beside its control — the settings-row pattern,
86
+ // where the label is a preference name and absorbs the free space. The
87
+ // control keeps its own width; give it one at the call site. Named after
88
+ // React Spectrum's `labelPosition`, not `orientation`, which RAC's
89
+ // RadioGroup already uses for the radios' own layout.
90
+ labelPosition: {
91
+ top: {},
92
+ side: {
93
+ root: {
94
+ flexDirection: "row",
95
+ alignItems: "center",
96
+ // Helper and error text are full-width items, so they wrap to
97
+ // their own line below the label/control pair.
98
+ flexWrap: "wrap",
99
+ },
100
+ // Basis 0, not auto: the wrap container breaks lines on hypothetical
101
+ // sizes, and an auto basis is the label's max-content — a long
102
+ // translated label would push the control onto its own line instead
103
+ // of wrapping its text beside it.
104
+ label: { mb: "0", flex: "1 1 0" },
105
+ helperText: { width: "100%" },
106
+ errorMessage: { width: "100%" },
107
+ },
108
+ },
109
+ },
110
+ defaultVariants: {
111
+ size: "md",
112
+ labelPosition: "top",
113
+ },
114
+ });