@microbit/ui 0.1.0-alpha.9 → 0.1.1

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 (113) hide show
  1. package/LICENSE.md +8 -0
  2. package/README.md +171 -20
  3. package/lang/ui.ar.json +62 -0
  4. package/lang/ui.ca.json +43 -3
  5. package/lang/ui.cy.json +62 -0
  6. package/lang/ui.de.json +44 -4
  7. package/lang/ui.en-us.json +40 -0
  8. package/lang/ui.en.json +40 -0
  9. package/lang/ui.es-es.json +43 -3
  10. package/lang/ui.fr.json +44 -4
  11. package/lang/ui.ga-ie.json +43 -3
  12. package/lang/ui.it.json +62 -0
  13. package/lang/ui.ja.json +44 -4
  14. package/lang/ui.ko.json +44 -4
  15. package/lang/ui.lo.json +62 -0
  16. package/lang/ui.lol.json +40 -0
  17. package/lang/ui.nl.json +44 -4
  18. package/lang/ui.pl.json +44 -4
  19. package/lang/ui.pt-br.json +44 -4
  20. package/lang/ui.vi.json +62 -0
  21. package/lang/ui.zh-cn.json +44 -4
  22. package/lang/ui.zh-tw.json +44 -4
  23. package/package.json +13 -11
  24. package/src/Avatar.recipe.ts +191 -0
  25. package/src/Avatar.tsx +278 -0
  26. package/src/Breadcrumb.recipe.ts +45 -0
  27. package/src/Breadcrumb.tsx +114 -0
  28. package/src/Button.recipe.ts +88 -50
  29. package/src/Button.tsx +67 -14
  30. package/src/ButtonGroup.tsx +37 -15
  31. package/src/Card.recipe.ts +1 -2
  32. package/src/Card.tsx +2 -1
  33. package/src/Checkbox.recipe.ts +13 -12
  34. package/src/Checkbox.tsx +103 -34
  35. package/src/CheckboxGroup.tsx +70 -0
  36. package/src/CloseButton.tsx +3 -3
  37. package/src/CloseIcon.tsx +6 -4
  38. package/src/Code.tsx +1 -1
  39. package/src/Collapse.tsx +13 -14
  40. package/src/ComboBox.tsx +246 -0
  41. package/src/Divider.tsx +40 -7
  42. package/src/Drawer.recipe.ts +21 -10
  43. package/src/Drawer.tsx +3 -4
  44. package/src/ExternalLink.tsx +43 -0
  45. package/src/Fade.tsx +18 -4
  46. package/src/Field.recipe.ts +114 -0
  47. package/src/Field.tsx +187 -0
  48. package/src/GridList.recipe.ts +57 -0
  49. package/src/GridList.tsx +81 -0
  50. package/src/Heading.recipe.ts +20 -1
  51. package/src/Heading.tsx +3 -3
  52. package/src/Icon.tsx +23 -7
  53. package/src/IconButton.tsx +8 -18
  54. package/src/Image.tsx +1 -1
  55. package/src/Input.recipe.ts +30 -26
  56. package/src/Input.tsx +16 -7
  57. package/src/InputGroup.tsx +9 -9
  58. package/src/Kbd.tsx +1 -1
  59. package/src/Link.tsx +3 -3
  60. package/src/LinkBox.tsx +2 -3
  61. package/src/LinkButton.tsx +14 -13
  62. package/src/List.tsx +5 -7
  63. package/src/ListBox.recipe.ts +43 -0
  64. package/src/ListBox.tsx +88 -0
  65. package/src/Menu.recipe.ts +22 -19
  66. package/src/Menu.tsx +54 -28
  67. package/src/Modal.recipe.ts +17 -9
  68. package/src/Modal.tsx +123 -24
  69. package/src/MoreMenuButton.tsx +63 -0
  70. package/src/NativeSelect.tsx +37 -14
  71. package/src/NativeSelectField.tsx +84 -0
  72. package/src/NumberField.recipe.ts +56 -15
  73. package/src/NumberField.tsx +74 -22
  74. package/src/PopoverArrow.tsx +1 -2
  75. package/src/ProgressBar.tsx +3 -5
  76. package/src/Radio.recipe.ts +13 -12
  77. package/src/Radio.tsx +2 -29
  78. package/src/RadioGroup.tsx +68 -0
  79. package/src/Select.recipe.ts +211 -0
  80. package/src/Select.tsx +194 -0
  81. package/src/SharedUIProvider.tsx +63 -7
  82. package/src/Skeleton.tsx +146 -0
  83. package/src/Slide.tsx +2 -2
  84. package/src/Slider.recipe.ts +27 -16
  85. package/src/Slider.tsx +20 -12
  86. package/src/Spinner.tsx +11 -9
  87. package/src/Svg.tsx +2 -3
  88. package/src/Switch.recipe.ts +40 -21
  89. package/src/Switch.tsx +35 -5
  90. package/src/Text.recipe.ts +26 -0
  91. package/src/Text.tsx +6 -2
  92. package/src/TextField.tsx +35 -38
  93. package/src/Toast.recipe.ts +52 -15
  94. package/src/Toast.tsx +147 -48
  95. package/src/Tooltip.recipe.ts +32 -0
  96. package/src/Tooltip.tsx +68 -28
  97. package/src/TooltipButton.tsx +229 -0
  98. package/src/UnmountCallback.tsx +2 -2
  99. package/src/VisuallyHidden.tsx +1 -1
  100. package/src/base-preset.ts +323 -84
  101. package/src/base-tokens.ts +0 -3
  102. package/src/button-icon.ts +4 -5
  103. package/src/data-attrs.ts +16 -0
  104. package/src/dense-preset.ts +107 -0
  105. package/src/hooks/useBreakpointValue.ts +4 -4
  106. package/src/hooks/useClipboard.ts +5 -6
  107. package/src/hooks/useDisclosure.ts +31 -0
  108. package/src/hooks/useMediaQuery.ts +3 -4
  109. package/src/hooks/usePrevious.ts +1 -4
  110. package/src/index.ts +19 -3
  111. package/src/rac-locale.ts +33 -0
  112. package/src/system.ts +22 -1
  113. package/src/TextField.recipe.ts +0 -54
package/src/Modal.tsx CHANGED
@@ -13,15 +13,18 @@ import {
13
13
  import {
14
14
  Button as RACButton,
15
15
  Dialog,
16
+ DialogTrigger as RACDialogTrigger,
16
17
  Heading as RACHeading,
17
18
  Modal as RACModal,
18
19
  ModalOverlay,
20
+ OverlayTriggerStateContext,
19
21
  } from "react-aria-components";
20
22
  import { css, cx } from "styled-system/css";
21
23
  import { dialog } from "styled-system/recipes";
22
24
  import { ConditionalValue, SystemStyleObject } from "styled-system/types";
23
25
  import { useIntl } from "react-intl";
24
26
  import { CloseIcon } from "./CloseIcon";
27
+ import { dataAttrs } from "./data-attrs";
25
28
  import { uiMessage } from "./messages";
26
29
  import { UnmountCallback } from "./UnmountCallback";
27
30
 
@@ -48,17 +51,15 @@ export type ModalSize = ConditionalValue<
48
51
  | "full"
49
52
  >;
50
53
 
51
- export interface ModalProps {
52
- isOpen: boolean;
53
- onClose: () => void;
54
+ export interface ModalOwnProps {
54
55
  size?: ModalSize;
55
56
  /** Allow closing by clicking the backdrop (default true; Escape always closes). */
56
57
  isDismissable?: boolean;
57
- /** Disable the enter/exit transitions (Chakra's motionPreset="none"). */
58
+ /** Disable the enter/exit transitions. */
58
59
  motionless?: boolean;
59
- /** Prevent Escape closing the dialog (Chakra's closeOnEsc={false}). */
60
+ /** Prevent Escape closing the dialog. */
60
61
  isKeyboardDismissDisabled?: boolean;
61
- /** Style overrides for the dialog box (Chakra's ModalContent props). */
62
+ /** Style overrides for the dialog box. */
62
63
  contentCss?: SystemStyleObject;
63
64
  /**
64
65
  * Inline styles for the dialog box, for runtime-computed positioning that
@@ -67,25 +68,22 @@ export interface ModalProps {
67
68
  */
68
69
  contentStyle?: CSSProperties;
69
70
  /**
70
- * Style overrides for the backdrop (Chakra's ModalOverlay props), e.g. a
71
- * transparent backdrop when something else provides the dimming.
71
+ * Style overrides for the backdrop, e.g. a transparent backdrop when
72
+ * something else provides the dimming.
72
73
  */
73
74
  overlayCss?: SystemStyleObject;
74
- /**
75
- * Use "alertdialog" for confirmations that interrupt the user (Chakra's
76
- * AlertDialog).
77
- */
75
+ /** Use "alertdialog" for confirmations that interrupt the user. */
78
76
  role?: "dialog" | "alertdialog";
79
- /** Vertically centre the dialog (Chakra's `isCentered`). */
77
+ /** Vertically centre the dialog. */
80
78
  isCentered?: boolean;
81
79
  /**
82
80
  * Called after the dialog has fully closed (exit transition done and the
83
- * dialog removed). Matches Chakra's `onCloseComplete`.
81
+ * dialog removed).
84
82
  */
85
83
  onCloseComplete?: () => void;
86
84
  /**
87
85
  * Element to focus when the dialog closes, instead of the element that was
88
- * focused when it opened. Matches Chakra's `finalFocusRef`.
86
+ * focused when it opened.
89
87
  */
90
88
  finalFocusRef?: RefObject<HTMLElement>;
91
89
  /**
@@ -96,12 +94,51 @@ export interface ModalProps {
96
94
  */
97
95
  "aria-label"?: string;
98
96
  children: ReactNode;
97
+ /**
98
+ * `data-*` attributes land on the dialog box, so end-to-end tests can
99
+ * address a dialog. Shells that forward their caller's data attributes can
100
+ * spread them straight in.
101
+ */
102
+ [key: `data-${string}`]: unknown;
99
103
  }
100
104
 
101
105
  /**
102
- * Modal a focus-trapping dialog. Collapses Chakra's
103
- * Modal/ModalOverlay/ModalContent into a single shell; place ModalHeader,
106
+ * A Modal you drive yourself. Also the type for a component that *forwards*
107
+ * modal props `Omit<ControlledModalProps, "children">` because a spread
108
+ * cannot be matched against the union `ModalProps` is: TypeScript has no way
109
+ * to know which half of it an object with `isOpen?: boolean` satisfies.
110
+ */
111
+ export type ControlledModalProps = ModalOwnProps & {
112
+ /** Whether the dialog is showing. */
113
+ isOpen: boolean;
114
+ /** Called when the dialog asks to close. */
115
+ onClose: () => void;
116
+ };
117
+
118
+ /**
119
+ * The props of a `Modal`: its own, plus an open state that is either entirely
120
+ * yours or entirely a `DialogTrigger`'s. Never half of each — `isOpen`
121
+ * without `onClose` leaves the close button and Escape with nothing to call,
122
+ * so the pair is enforced rather than merely documented.
123
+ */
124
+ export type ModalProps =
125
+ | ControlledModalProps
126
+ | (ModalOwnProps & { isOpen?: never; onClose?: never });
127
+
128
+ /**
129
+ * Modal — a focus-trapping dialog in a single shell; place ModalHeader,
104
130
  * ModalBody and ModalFooter inside.
131
+ *
132
+ * Two ways to drive it:
133
+ *
134
+ * - **Controlled** (`isOpen` + `onClose`), which is what any dialog with more
135
+ * than one opener needs — a menu item and a toolbar button opening the same
136
+ * dialog, or one opened from a handler after an async result.
137
+ * - **Inside a `DialogTrigger`**, with neither prop: react-aria holds the
138
+ * open state, the trigger opens it, and `ModalCloseButton` and the footer's
139
+ * `useDialogClose()` still close it. Prefer this where a dialog has exactly
140
+ * one trigger sitting next to it — there is no state to hold, and none to
141
+ * get out of step.
105
142
  */
106
143
  export const Modal = ({
107
144
  isOpen,
@@ -119,7 +156,14 @@ export const Modal = ({
119
156
  finalFocusRef,
120
157
  "aria-label": ariaLabel,
121
158
  children,
159
+ ...rest
122
160
  }: ModalProps) => {
161
+ // Set by a DialogTrigger (or any react-aria overlay trigger) above us. When
162
+ // `isOpen` is given it is ignored: RAC's ModalOverlay prefers an explicit
163
+ // prop over the context, and so do we for the close function.
164
+ const triggerState = useContext(OverlayTriggerStateContext);
165
+ const close = onClose ?? (() => triggerState?.close());
166
+ const dataProps = dataAttrs(rest);
123
167
  const slots = dialog({ size, centered: isCentered });
124
168
  const motionlessClass = motionless
125
169
  ? css({
@@ -141,11 +185,17 @@ export const Modal = ({
141
185
  isOpen={isOpen}
142
186
  onOpenChange={(open) => {
143
187
  if (!open) {
144
- onClose();
188
+ close();
145
189
  }
146
190
  }}
147
191
  isDismissable={isDismissable}
148
192
  isKeyboardDismissDisabled={isKeyboardDismissDisabled}
193
+ // Marker for the html:has() rule in base-preset.ts that releases the
194
+ // scroll lock's reserved scrollbar gutter while a full-size dialog is
195
+ // open: the reserved strip is a hit-testing dead zone (clicks fall
196
+ // through to the root and read as outside-dismissal), and the page
197
+ // reflowing behind an opaque full-screen dialog is invisible.
198
+ data-fullsize={size === "full" || undefined}
149
199
  className={cx(
150
200
  slots.overlay,
151
201
  motionlessClass,
@@ -154,6 +204,7 @@ export const Modal = ({
154
204
  >
155
205
  <UnmountCallback callback={handleUnmount} />
156
206
  <RACModal
207
+ {...dataProps}
157
208
  style={contentStyle}
158
209
  className={cx(
159
210
  slots.content,
@@ -162,7 +213,7 @@ export const Modal = ({
162
213
  )}
163
214
  >
164
215
  <Dialog role={role} aria-label={ariaLabel} className={slots.inner}>
165
- <SlotContext.Provider value={{ slots, onClose }}>
216
+ <SlotContext.Provider value={{ slots, onClose: close }}>
166
217
  {children}
167
218
  </SlotContext.Provider>
168
219
  </Dialog>
@@ -171,10 +222,40 @@ export const Modal = ({
171
222
  );
172
223
  };
173
224
 
225
+ /**
226
+ * DialogTrigger — react-aria-components' <DialogTrigger>: wrap a trigger
227
+ * element and a `Modal`, and the open state is theirs rather than yours.
228
+ *
229
+ * ```tsx
230
+ * <DialogTrigger>
231
+ * <Button>Settings</Button>
232
+ * <Modal size="lg">
233
+ * <ModalHeader>Settings</ModalHeader>
234
+ * …
235
+ * </Modal>
236
+ * </DialogTrigger>
237
+ * ```
238
+ *
239
+ * Only for a dialog with a single trigger beside it. A dialog opened from
240
+ * more than one place, from a menu item (which cannot hold a dialog — a
241
+ * non-collection child truncates the menu), or from a handler, wants the
242
+ * controlled `Modal` instead.
243
+ */
244
+ export const DialogTrigger = RACDialogTrigger;
245
+
246
+ /**
247
+ * The current dialog's close function — the same one `ModalCloseButton` uses,
248
+ * for a footer's own Cancel/Done buttons. Works in both modes, so a dialog's
249
+ * content need not know which is driving it.
250
+ */
251
+ export const useDialogClose = () => useDialog().onClose;
252
+
174
253
  interface SlotProps {
175
254
  children?: ReactNode;
176
255
  css?: SystemStyleObject;
177
256
  className?: string;
257
+ /** `data-*` attributes land on the slot element. */
258
+ [key: `data-${string}`]: unknown;
178
259
  }
179
260
 
180
261
  /** Modal title. Rendered as RAC's labelling heading for the dialog. */
@@ -183,12 +264,18 @@ export const ModalHeader = ({
183
264
  css: cssProp,
184
265
  className,
185
266
  level,
267
+ ...rest
186
268
  }: SlotProps & {
187
- /** Heading element level (default 3, like RAC). */ level?: number;
269
+ /**
270
+ * Heading element level. Defaults to 2: RAC's Dialog supplies that through
271
+ * HeadingContext for the `title` slot, not the bare Heading default of 3.
272
+ */
273
+ level?: number;
188
274
  }) => {
189
275
  const { slots } = useDialog();
190
276
  return (
191
277
  <RACHeading
278
+ {...dataAttrs(rest)}
192
279
  slot="title"
193
280
  level={level}
194
281
  className={cx(
@@ -202,10 +289,16 @@ export const ModalHeader = ({
202
289
  );
203
290
  };
204
291
 
205
- export const ModalBody = ({ children, css: cssProp, className }: SlotProps) => {
292
+ export const ModalBody = ({
293
+ children,
294
+ css: cssProp,
295
+ className,
296
+ ...rest
297
+ }: SlotProps) => {
206
298
  const { slots } = useDialog();
207
299
  return (
208
300
  <div
301
+ {...dataAttrs(rest)}
209
302
  className={cx(slots.body, cssProp ? css(cssProp) : undefined, className)}
210
303
  >
211
304
  {children}
@@ -217,10 +310,12 @@ export const ModalFooter = ({
217
310
  children,
218
311
  css: cssProp,
219
312
  className,
313
+ ...rest
220
314
  }: SlotProps) => {
221
315
  const { slots } = useDialog();
222
316
  return (
223
317
  <div
318
+ {...dataAttrs(rest)}
224
319
  className={cx(
225
320
  slots.footer,
226
321
  cssProp ? css(cssProp) : undefined,
@@ -235,19 +330,23 @@ export const ModalFooter = ({
235
330
  export interface ModalCloseButtonProps {
236
331
  /** Accessible name; defaults to the localized close label. */
237
332
  "aria-label"?: string;
333
+ /** `data-*` attributes land on the button. */
334
+ [key: `data-${string}`]: unknown;
238
335
  }
239
336
 
240
337
  /**
241
- * ModalCloseButton — the X in the dialog's top corner (Chakra's
242
- * ModalCloseButton at its default md size). Closes via the Modal's onClose.
338
+ * ModalCloseButton — the X in the dialog's top corner. Closes via the Modal's
339
+ * onClose.
243
340
  */
244
341
  export const ModalCloseButton = ({
245
342
  "aria-label": ariaLabel,
343
+ ...rest
246
344
  }: ModalCloseButtonProps) => {
247
345
  const intl = useIntl();
248
346
  const { slots, onClose } = useDialog();
249
347
  return (
250
348
  <RACButton
349
+ {...dataAttrs(rest)}
251
350
  aria-label={ariaLabel ?? intl.formatMessage(uiMessage("ui.close-action"))}
252
351
  onPress={onClose}
253
352
  className={cx(
@@ -269,7 +368,7 @@ export const ModalCloseButton = ({
269
368
  transitionDuration: "normal",
270
369
  _hover: { bg: "blackAlpha.100" },
271
370
  _active: { bg: "blackAlpha.200" },
272
- _focusVisible: { focusShadow: "outline" },
371
+ _focusVisible: { focusRing: "outline" },
273
372
  }),
274
373
  )}
275
374
  >
@@ -0,0 +1,63 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { forwardRef } from "react";
7
+ import { MdMoreVert } from "react-icons/md";
8
+ import { Icon } from "./Icon";
9
+ import { IconButton, IconButtonProps } from "./IconButton";
10
+
11
+ export interface MoreMenuButtonProps
12
+ extends Omit<IconButtonProps, "children"> {}
13
+
14
+ /**
15
+ * MoreMenuButton — the "more options" half of a split button. Use as the
16
+ * trigger inside a MenuTrigger, alongside the main action, both in an
17
+ * attached ButtonGroup:
18
+ *
19
+ * ```tsx
20
+ * <ButtonGroup isAttached>
21
+ * <Button variant="primary" onPress={send}>Send</Button>
22
+ * <MenuTrigger>
23
+ * <MoreMenuButton variant="primary" aria-label="More send options" />
24
+ * <MenuList>…</MenuList>
25
+ * </MenuTrigger>
26
+ * </ButtonGroup>
27
+ * ```
28
+ *
29
+ * Give it the main action's `variant`, `tone` and `size`: the two halves are
30
+ * one control. The seam between them is ButtonGroup's job.
31
+ */
32
+ export const MoreMenuButton = forwardRef<
33
+ HTMLButtonElement,
34
+ MoreMenuButtonProps
35
+ >(function MoreMenuButton({ css: cssProp, ...props }, ref) {
36
+ return (
37
+ <IconButton ref={ref} css={cssProp} {...props}>
38
+ <Icon
39
+ as={MdMoreVert}
40
+ css={{
41
+ // Optical centring: an attached end button is a rectangle plus a
42
+ // semicircle, whose area sits 4.7% of the width towards the flat
43
+ // edge. A share of the width rather than a length because that is a
44
+ // property of the shape, not the size — the button stays square and
45
+ // the radius always clamps to half its height (see IconButton), so
46
+ // one percentage holds everywhere. Leading is the mirror image; a
47
+ // middle or lone button is symmetric and needs nothing.
48
+ //
49
+ // Position rather than margin so the nudge can't feed back into the
50
+ // width the percentage resolves against.
51
+ "[data-attached] > *:not(:first-child):last-child &": {
52
+ position: "relative",
53
+ insetInlineStart: "-4.7%",
54
+ },
55
+ "[data-attached] > *:first-child:not(:last-child) &": {
56
+ position: "relative",
57
+ insetInlineStart: "4.7%",
58
+ },
59
+ }}
60
+ />
61
+ </IconButton>
62
+ );
63
+ });
@@ -9,8 +9,8 @@ import { input, InputVariantProps } from "styled-system/recipes";
9
9
  import { SystemStyleObject } from "styled-system/types";
10
10
 
11
11
  export interface NativeSelectProps
12
- // `size` is the recipe's size scale, as in Chakra; the native visible-rows
13
- // attribute it shadows was unused.
12
+ // `size` is the recipe's size scale; the native visible-rows attribute it
13
+ // shadows was unused.
14
14
  extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "className" | "size">,
15
15
  InputVariantProps {
16
16
  /**
@@ -19,19 +19,25 @@ export interface NativeSelectProps
19
19
  * wrapper), so it can participate directly in e.g. an attached ButtonGroup.
20
20
  */
21
21
  hideChevron?: boolean;
22
- /** Per-instance style overrides, merged after the recipe. */
22
+ /** Per-instance style overrides for the select, merged after the recipe. */
23
23
  css?: SystemStyleObject;
24
+ /**
25
+ * Style overrides for the chevron wrapper, which is where width constraints
26
+ * belong (the select fills it). No wrapper is rendered with `hideChevron`;
27
+ * constrain the select directly.
28
+ */
29
+ wrapperCss?: SystemStyleObject;
24
30
  className?: string;
25
31
  }
26
32
 
27
33
  /**
28
- * NativeSelect — a native select styled like Chakra's Select field (outline).
29
- * The recipe's `appearance: none` removes the platform chevron, so one is
30
- * drawn back in by default (Chakra Select's glyph, `currentColor`).
34
+ * NativeSelect — a native select styled like the outline Input. The recipe's
35
+ * `appearance: none` removes the platform chevron, so one is drawn back in by
36
+ * default (`currentColor`).
31
37
  */
32
38
  export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
33
39
  function NativeSelect(
34
- { hideChevron = false, size, css: cssProp, className, ...rest },
40
+ { hideChevron = false, size, css: cssProp, wrapperCss, className, ...rest },
35
41
  ref,
36
42
  ) {
37
43
  const select = (
@@ -40,10 +46,15 @@ export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
40
46
  className={cx(
41
47
  input({ size }),
42
48
  css(
43
- { cursor: "pointer" },
44
- // Room for the chevron overlay (Chakra Select's icon spacing,
45
- // constant across sizes).
46
- hideChevron ? undefined : { paddingRight: "8" },
49
+ // Option text sits a hair higher than input text; the 1px bottom
50
+ // padding compensates.
51
+ {
52
+ cursor: "pointer",
53
+ paddingBottom: "1px",
54
+ _disabled: { cursor: "not-allowed" },
55
+ },
56
+ // Room for the chevron overlay (constant across sizes).
57
+ hideChevron ? undefined : { paddingEnd: "8" },
47
58
  cssProp,
48
59
  ),
49
60
  className,
@@ -55,21 +66,33 @@ export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
55
66
  return select;
56
67
  }
57
68
  return (
58
- <span className={css({ position: "relative", display: "inline-flex" })}>
69
+ <span
70
+ className={css(
71
+ // Full-width like every other field; the select's own recipe width
72
+ // fills it. Constrain via wrapperCss.
73
+ { position: "relative", display: "inline-flex", width: "100%" },
74
+ wrapperCss,
75
+ )}
76
+ >
59
77
  {select}
60
- {/* Chakra Select's chevron, fixed-size across field sizes. */}
78
+ {/* The chevron, fixed-size across field sizes. The path is Chakra
79
+ UI's Select chevron, inlined for visual parity with the apps'
80
+ original look (see the notice in LICENSE.md). */}
61
81
  <svg
62
82
  viewBox="0 0 24 24"
63
83
  aria-hidden
64
84
  className={css({
65
85
  position: "absolute",
66
- right: "2",
86
+ insetEnd: "2",
67
87
  top: "50%",
68
88
  transform: "translateY(-50%)",
69
89
  width: "5",
70
90
  height: "5",
71
91
  pointerEvents: "none",
72
92
  fill: "currentColor",
93
+ // The chevron sits outside the select so it doesn't inherit its
94
+ // disabled dimming; dim it explicitly to match.
95
+ "select:disabled + &": { opacity: 0.5 },
73
96
  })}
74
97
  >
75
98
  <path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" />
@@ -0,0 +1,84 @@
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
+ /** Label style overrides. */
19
+ labelCss?: SystemStyleObject;
20
+ /** Help text below the field, wired to the select's `aria-describedby`. */
21
+ helperText?: ReactNode;
22
+ /** Per-instance style overrides for the field root. */
23
+ rootCss?: SystemStyleObject;
24
+ }
25
+
26
+ /**
27
+ * NativeSelectField — a labelled `NativeSelect`, pairing the bare control with
28
+ * the field chrome the RAC fields get from react-aria, as `TextField` pairs
29
+ * with `Input`. The label association and `aria-describedby` are wired here,
30
+ * since there is no RAC context to do it; the label dims with the control
31
+ * exactly as the RAC fields' do. No `errorMessage` yet — no consumer needs
32
+ * one; render `FieldErrorMessage` beside it if yours does.
33
+ *
34
+ * With `labelPosition="side"` this is the settings row both
35
+ * `SelectFormControl`s hand-rolled: label beside a fixed-width select
36
+ * (`wrapperCss={{ width: "28ch" }}`), label absorbing the free space.
37
+ */
38
+ export const NativeSelectField = forwardRef<
39
+ HTMLSelectElement,
40
+ NativeSelectFieldProps
41
+ >(function NativeSelectField(
42
+ { label, labelCss, helperText, labelPosition, rootCss, id, ...rest },
43
+ ref,
44
+ ) {
45
+ const generatedId = useId();
46
+ const selectId = id ?? generatedId;
47
+ const helperId = useId();
48
+ const describedBy =
49
+ [rest["aria-describedby"], helperText != null ? helperId : undefined]
50
+ .filter(Boolean)
51
+ .join(" ") || undefined;
52
+ return (
53
+ <div
54
+ className={cx(
55
+ field({ size: rest.size, labelPosition }).root,
56
+ rootCss ? css(rootCss) : undefined,
57
+ )}
58
+ // The field recipe's label dims off the root's data-disabled, which RAC
59
+ // stamps for the other fields; here it is restated from the attribute.
60
+ data-disabled={rest.disabled || undefined}
61
+ >
62
+ <FieldLabel
63
+ htmlFor={selectId}
64
+ size={rest.size}
65
+ labelPosition={labelPosition}
66
+ isRequired={rest.required}
67
+ css={labelCss}
68
+ >
69
+ {label}
70
+ </FieldLabel>
71
+ <NativeSelect
72
+ ref={ref}
73
+ id={selectId}
74
+ {...rest}
75
+ aria-describedby={describedBy}
76
+ />
77
+ {helperText != null && (
78
+ <FieldHelperText id={helperId} labelPosition={labelPosition}>
79
+ {helperText}
80
+ </FieldHelperText>
81
+ )}
82
+ </div>
83
+ );
84
+ });
@@ -6,37 +6,44 @@
6
6
  import { defineSlotRecipe } from "@pandacss/dev";
7
7
 
8
8
  /**
9
- * NumberField slot recipe — Chakra's NumberInput look: an outline input (the
10
- * `input` recipe styles the input itself) with a right-hand stepper column of
11
- * two stacked buttons. Consumed by the shared-ui NumberField
12
- * (react-aria-components NumberField).
9
+ * NumberField slot recipe — an outline input (the `input` recipe styles the
10
+ * input itself) with a right-hand stepper column of two stacked buttons.
11
+ * Consumed by the shared-ui NumberField (react-aria-components NumberField).
13
12
  *
14
- * Registered in the base preset (base-preset.ts). No variants, so it needs
15
- * no `staticCss` entry.
13
+ * Registered in the base preset (base-preset.ts), which also has the
14
+ * `staticCss` entry that keeps the runtime-prop size variants generated.
16
15
  */
17
16
  export const numberField = defineSlotRecipe({
18
17
  className: "numberField",
19
18
  slots: ["root", "group", "stepper", "stepperButton"],
20
19
  base: {
21
- root: {
22
- display: "flex",
23
- flexDirection: "column",
24
- alignItems: "stretch",
25
- },
20
+ // No layout here: the component wears the `field` recipe's root alongside
21
+ // this slot, and that recipe is the single owner of field-root layout
22
+ // (else `labelPosition` would fight this slot over `flexDirection`). The
23
+ // slot stays for apps and variants to target.
24
+ root: {},
26
25
  group: {
27
26
  position: "relative",
28
27
  zIndex: 0,
28
+ // The stepper overlays the input as a sibling, so the input's own
29
+ // :hover misses it — the group carries the hover tint. The :not()
30
+ // list is required: slot recipes layer above plain recipes
31
+ // (recipes.slots), so this would otherwise override focused/invalid
32
+ // (see docs/hints.md). :user-invalid takes its own :not(): :not() is
33
+ // not forgiving, so one list would drop the whole selector on a
34
+ // browser that doesn't know the pseudo-class (Safari < 16.5).
35
+ "&:hover input:not(:focus, [data-focused], [data-invalid]):not(:user-invalid)":
36
+ { borderColor: "gray.500" },
29
37
  },
30
- // Chakra's NumberInputStepper: a column overlaying the input's right
31
- // edge, inset by the input border.
38
+ // A column overlaying the input's right edge, inset by the input border.
32
39
  stepper: {
33
40
  display: "flex",
34
41
  flexDirection: "column",
35
42
  position: "absolute",
36
43
  insetEnd: "0",
37
44
  top: "0",
38
- height: "calc(100% - 2px)",
39
- margin: "1px",
45
+ height: "calc(100% - 4px)",
46
+ margin: "2px",
40
47
  width: "6",
41
48
  zIndex: 1,
42
49
  },
@@ -54,14 +61,48 @@ export const numberField = defineSlotRecipe({
54
61
  borderColor: "gray.200",
55
62
  transitionProperty: "background",
56
63
  transitionDuration: "ultra-fast",
64
+ // Follow the input's corners, less the 2px border the stepper is inset
65
+ // by, so the hover and pressed fills curve away with the border instead
66
+ // of squaring off over its arc. Radii track the input recipe's size
67
+ // scale, so the `sm` variant restates them.
68
+ "&:first-child": {
69
+ borderStartEndRadius: "calc(token(radii.md) - 2px)",
70
+ },
57
71
  "&:last-child": {
58
72
  borderTop: "1px solid",
59
73
  borderTopColor: "gray.200",
60
74
  marginTop: "-1px",
75
+ borderEndEndRadius: "calc(token(radii.md) - 2px)",
61
76
  },
62
77
  "&[data-hovered]": { bg: "gray.100" },
63
78
  "&[data-pressed]": { bg: "gray.200" },
64
79
  "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
65
80
  },
66
81
  },
82
+ variants: {
83
+ // The stepper column stays 24px wide at every size (as does the input
84
+ // padding paired with it in NumberField.tsx); only the arrow glyphs
85
+ // scale, at 0.75 × the field's font size. The base's `xs` is exactly
86
+ // md × 0.75, so md adds nothing.
87
+ size: {
88
+ lg: {
89
+ stepperButton: { fontSize: "calc(token(fontSizes.lg) * 0.75)" },
90
+ },
91
+ md: {},
92
+ sm: {
93
+ stepperButton: {
94
+ fontSize: "calc(token(fontSizes.sm) * 0.75)",
95
+ "&:first-child": {
96
+ borderStartEndRadius: "calc(token(radii.sm) - 2px)",
97
+ },
98
+ "&:last-child": {
99
+ borderEndEndRadius: "calc(token(radii.sm) - 2px)",
100
+ },
101
+ },
102
+ },
103
+ },
104
+ },
105
+ defaultVariants: {
106
+ size: "md",
107
+ },
67
108
  });