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

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 (115) hide show
  1. package/LICENSE.md +40 -0
  2. package/README.md +279 -26
  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 +62 -0
  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 +62 -0
  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 +62 -0
  22. package/lang/ui.zh-tw.json +44 -4
  23. package/package.json +19 -10
  24. package/postcss-legacy-safari.cjs +96 -0
  25. package/reset.css +35 -0
  26. package/src/Avatar.recipe.ts +191 -0
  27. package/src/Avatar.tsx +278 -0
  28. package/src/Breadcrumb.recipe.ts +45 -0
  29. package/src/Breadcrumb.tsx +114 -0
  30. package/src/Button.recipe.ts +88 -50
  31. package/src/Button.tsx +68 -30
  32. package/src/ButtonGroup.tsx +37 -15
  33. package/src/Card.recipe.ts +1 -2
  34. package/src/Card.tsx +2 -1
  35. package/src/Checkbox.recipe.ts +49 -14
  36. package/src/Checkbox.tsx +111 -32
  37. package/src/CheckboxGroup.tsx +70 -0
  38. package/src/CloseButton.tsx +3 -3
  39. package/src/CloseIcon.tsx +6 -4
  40. package/src/Code.tsx +20 -0
  41. package/src/Collapse.tsx +179 -0
  42. package/src/ComboBox.tsx +246 -0
  43. package/src/Divider.tsx +72 -8
  44. package/src/Drawer.recipe.ts +21 -10
  45. package/src/Drawer.tsx +3 -4
  46. package/src/ExternalLink.tsx +43 -0
  47. package/src/Fade.tsx +62 -0
  48. package/src/Field.recipe.ts +114 -0
  49. package/src/Field.tsx +187 -0
  50. package/src/GridList.recipe.ts +57 -0
  51. package/src/GridList.tsx +81 -0
  52. package/src/Heading.recipe.ts +20 -1
  53. package/src/Heading.tsx +3 -3
  54. package/src/Icon.tsx +23 -7
  55. package/src/IconButton.tsx +8 -13
  56. package/src/Image.tsx +1 -1
  57. package/src/Input.recipe.ts +41 -28
  58. package/src/Input.tsx +23 -7
  59. package/src/InputGroup.tsx +26 -12
  60. package/src/Kbd.tsx +26 -0
  61. package/src/Link.tsx +3 -3
  62. package/src/LinkBox.tsx +2 -3
  63. package/src/LinkButton.tsx +81 -0
  64. package/src/List.tsx +8 -6
  65. package/src/ListBox.recipe.ts +43 -0
  66. package/src/ListBox.tsx +88 -0
  67. package/src/Menu.recipe.ts +51 -17
  68. package/src/Menu.tsx +117 -2
  69. package/src/Modal.recipe.ts +17 -9
  70. package/src/Modal.tsx +137 -24
  71. package/src/MoreMenuButton.tsx +63 -0
  72. package/src/NativeSelect.tsx +41 -14
  73. package/src/NativeSelectField.tsx +84 -0
  74. package/src/NumberField.recipe.ts +108 -0
  75. package/src/NumberField.tsx +138 -0
  76. package/src/PopoverArrow.tsx +19 -5
  77. package/src/ProgressBar.tsx +3 -5
  78. package/src/Radio.recipe.ts +108 -0
  79. package/src/Radio.tsx +62 -0
  80. package/src/RadioGroup.tsx +68 -0
  81. package/src/Select.recipe.ts +211 -0
  82. package/src/Select.tsx +194 -0
  83. package/src/SharedUIProvider.tsx +63 -7
  84. package/src/Skeleton.tsx +146 -0
  85. package/src/Slide.tsx +2 -2
  86. package/src/Slider.recipe.ts +27 -16
  87. package/src/Slider.tsx +78 -6
  88. package/src/Spinner.tsx +11 -9
  89. package/src/Svg.tsx +2 -3
  90. package/src/Switch.recipe.ts +57 -11
  91. package/src/Switch.tsx +39 -7
  92. package/src/Text.recipe.ts +26 -0
  93. package/src/Text.tsx +6 -2
  94. package/src/TextField.tsx +54 -40
  95. package/src/Toast.recipe.ts +52 -15
  96. package/src/Toast.tsx +151 -46
  97. package/src/Tooltip.recipe.ts +32 -0
  98. package/src/Tooltip.tsx +68 -28
  99. package/src/TooltipButton.tsx +229 -0
  100. package/src/UnmountCallback.tsx +2 -2
  101. package/src/VisuallyHidden.tsx +1 -1
  102. package/src/base-preset.ts +336 -84
  103. package/src/{chakra-tokens.ts → base-tokens.ts} +7 -7
  104. package/src/button-icon.ts +22 -0
  105. package/src/data-attrs.ts +16 -0
  106. package/src/dense-preset.ts +107 -0
  107. package/src/hooks/useBreakpointValue.ts +4 -4
  108. package/src/hooks/useClipboard.ts +63 -0
  109. package/src/hooks/useDisclosure.ts +31 -0
  110. package/src/hooks/useMediaQuery.ts +27 -0
  111. package/src/hooks/usePrevious.ts +15 -0
  112. package/src/index.ts +29 -3
  113. package/src/rac-locale.ts +33 -0
  114. package/src/system.ts +22 -1
  115. package/src/TextField.recipe.ts +0 -54
@@ -6,9 +6,9 @@
6
6
  import { defineSlotRecipe } from "@pandacss/dev";
7
7
 
8
8
  /**
9
- * Dialog slot recipe Chakra's default Modal parts (light mode), plus this
10
- * app's overrides: a full-viewport overlay (the iOS WKWebView 100% fix) and a
11
- * `full` size with safe-area insets and the brand status-bar gradient.
9
+ * Dialog slot recipe. The overlay spans the full viewport (the iOS WKWebView
10
+ * 100% fix) and the `full` size adds safe-area insets and the brand
11
+ * status-bar gradient.
12
12
  *
13
13
  * A config recipe (rather than an atomic `sva`) so the `size` variant accepts
14
14
  * responsive values, e.g. `{ base: "full", md: "4xl" }`. Consumed by the
@@ -33,6 +33,9 @@ const dialogBox = {
33
33
  };
34
34
 
35
35
  // The `full` variant also styles these slots; same symmetry requirement.
36
+ // The header's padding stays physical to pair with that variant's
37
+ // window-controls inset, which is a screen position rather than a
38
+ // reading-order one; the base `px` covers the start side either way.
36
39
  const dialogSlots = {
37
40
  header: { pl: "6" },
38
41
  body: { overflowY: "visible" },
@@ -54,7 +57,13 @@ export const dialog = defineSlotRecipe({
54
57
  overlay: {
55
58
  position: "fixed",
56
59
  inset: 0,
57
- w: "100%",
60
+ // 100vw, not 100%: react-aria's scroll lock reserves the root
61
+ // scrollbar gutter (scrollbar-gutter: stable), which narrows the
62
+ // containing block for fixed elements — 100% leaves an uncovered
63
+ // strip where the page scrollbar was. Viewport units span the
64
+ // reserved gutter, so the backdrop (and a full-size dialog) reach
65
+ // the real viewport edge.
66
+ w: "100vw",
58
67
  h: "100%",
59
68
  bg: "blackAlpha.600",
60
69
  zIndex: "modal",
@@ -79,7 +88,7 @@ export const dialog = defineSlotRecipe({
79
88
  display: "flex",
80
89
  flexDirection: "column",
81
90
  outline: "none",
82
- // Approximate Chakra's fade + scale enter/exit.
91
+ // Fade + scale enter/exit.
83
92
  opacity: 1,
84
93
  transform: "scale(1)",
85
94
  transition: "opacity 0.2s ease-out, transform 0.2s ease-out",
@@ -108,16 +117,15 @@ export const dialog = defineSlotRecipe({
108
117
  display: "flex",
109
118
  alignItems: "center",
110
119
  justifyContent: "flex-end",
111
- // Chakra had no footer gap (call sites wrapped buttons in HStacks);
112
- // baked in as the house style. Override via css for tighter layouts.
120
+ // The gap is house style; override via css for tighter layouts.
113
121
  gap: "5",
114
122
  flexShrink: 0,
115
123
  },
116
124
  closeTrigger: { position: "absolute", top: "2", insetEnd: "3" },
117
125
  },
118
126
  variants: {
119
- // Chakra's `isCentered`: vertically centre the dialog in the viewport
120
- // rather than the default top alignment.
127
+ // Vertically centre the dialog in the viewport rather than the default
128
+ // top alignment.
121
129
  centered: {
122
130
  true: {
123
131
  overlay: { alignItems: "center" },
package/src/Modal.tsx CHANGED
@@ -3,19 +3,28 @@
3
3
  *
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
- import { createContext, ReactNode, RefObject, useContext } from "react";
6
+ import {
7
+ createContext,
8
+ CSSProperties,
9
+ ReactNode,
10
+ RefObject,
11
+ useContext,
12
+ } from "react";
7
13
  import {
8
14
  Button as RACButton,
9
15
  Dialog,
16
+ DialogTrigger as RACDialogTrigger,
10
17
  Heading as RACHeading,
11
18
  Modal as RACModal,
12
19
  ModalOverlay,
20
+ OverlayTriggerStateContext,
13
21
  } from "react-aria-components";
14
22
  import { css, cx } from "styled-system/css";
15
23
  import { dialog } from "styled-system/recipes";
16
24
  import { ConditionalValue, SystemStyleObject } from "styled-system/types";
17
25
  import { useIntl } from "react-intl";
18
26
  import { CloseIcon } from "./CloseIcon";
27
+ import { dataAttrs } from "./data-attrs";
19
28
  import { uiMessage } from "./messages";
20
29
  import { UnmountCallback } from "./UnmountCallback";
21
30
 
@@ -42,38 +51,39 @@ export type ModalSize = ConditionalValue<
42
51
  | "full"
43
52
  >;
44
53
 
45
- export interface ModalProps {
46
- isOpen: boolean;
47
- onClose: () => void;
54
+ export interface ModalOwnProps {
48
55
  size?: ModalSize;
49
56
  /** Allow closing by clicking the backdrop (default true; Escape always closes). */
50
57
  isDismissable?: boolean;
51
- /** Disable the enter/exit transitions (Chakra's motionPreset="none"). */
58
+ /** Disable the enter/exit transitions. */
52
59
  motionless?: boolean;
53
- /** Prevent Escape closing the dialog (Chakra's closeOnEsc={false}). */
60
+ /** Prevent Escape closing the dialog. */
54
61
  isKeyboardDismissDisabled?: boolean;
55
- /** Style overrides for the dialog box (Chakra's ModalContent props). */
62
+ /** Style overrides for the dialog box. */
56
63
  contentCss?: SystemStyleObject;
57
64
  /**
58
- * Style overrides for the backdrop (Chakra's ModalOverlay props), e.g. a
59
- * transparent backdrop when something else provides the dimming.
65
+ * Inline styles for the dialog box, for runtime-computed positioning that
66
+ * Panda can't statically extract (e.g. a dialog aligned to a measured
67
+ * element). Prefer `contentCss` for static styles.
60
68
  */
61
- overlayCss?: SystemStyleObject;
69
+ contentStyle?: CSSProperties;
62
70
  /**
63
- * Use "alertdialog" for confirmations that interrupt the user (Chakra's
64
- * AlertDialog).
71
+ * Style overrides for the backdrop, e.g. a transparent backdrop when
72
+ * something else provides the dimming.
65
73
  */
74
+ overlayCss?: SystemStyleObject;
75
+ /** Use "alertdialog" for confirmations that interrupt the user. */
66
76
  role?: "dialog" | "alertdialog";
67
- /** Vertically centre the dialog (Chakra's `isCentered`). */
77
+ /** Vertically centre the dialog. */
68
78
  isCentered?: boolean;
69
79
  /**
70
80
  * Called after the dialog has fully closed (exit transition done and the
71
- * dialog removed). Matches Chakra's `onCloseComplete`.
81
+ * dialog removed).
72
82
  */
73
83
  onCloseComplete?: () => void;
74
84
  /**
75
85
  * Element to focus when the dialog closes, instead of the element that was
76
- * focused when it opened. Matches Chakra's `finalFocusRef`.
86
+ * focused when it opened.
77
87
  */
78
88
  finalFocusRef?: RefObject<HTMLElement>;
79
89
  /**
@@ -84,12 +94,51 @@ export interface ModalProps {
84
94
  */
85
95
  "aria-label"?: string;
86
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;
87
103
  }
88
104
 
89
105
  /**
90
- * Modal a focus-trapping dialog. Collapses Chakra's
91
- * 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,
92
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.
93
142
  */
94
143
  export const Modal = ({
95
144
  isOpen,
@@ -99,6 +148,7 @@ export const Modal = ({
99
148
  motionless,
100
149
  isKeyboardDismissDisabled,
101
150
  contentCss,
151
+ contentStyle,
102
152
  overlayCss,
103
153
  role,
104
154
  isCentered,
@@ -106,7 +156,14 @@ export const Modal = ({
106
156
  finalFocusRef,
107
157
  "aria-label": ariaLabel,
108
158
  children,
159
+ ...rest
109
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);
110
167
  const slots = dialog({ size, centered: isCentered });
111
168
  const motionlessClass = motionless
112
169
  ? css({
@@ -128,11 +185,17 @@ export const Modal = ({
128
185
  isOpen={isOpen}
129
186
  onOpenChange={(open) => {
130
187
  if (!open) {
131
- onClose();
188
+ close();
132
189
  }
133
190
  }}
134
191
  isDismissable={isDismissable}
135
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}
136
199
  className={cx(
137
200
  slots.overlay,
138
201
  motionlessClass,
@@ -141,6 +204,8 @@ export const Modal = ({
141
204
  >
142
205
  <UnmountCallback callback={handleUnmount} />
143
206
  <RACModal
207
+ {...dataProps}
208
+ style={contentStyle}
144
209
  className={cx(
145
210
  slots.content,
146
211
  motionlessClass,
@@ -148,7 +213,7 @@ export const Modal = ({
148
213
  )}
149
214
  >
150
215
  <Dialog role={role} aria-label={ariaLabel} className={slots.inner}>
151
- <SlotContext.Provider value={{ slots, onClose }}>
216
+ <SlotContext.Provider value={{ slots, onClose: close }}>
152
217
  {children}
153
218
  </SlotContext.Provider>
154
219
  </Dialog>
@@ -157,10 +222,40 @@ export const Modal = ({
157
222
  );
158
223
  };
159
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
+
160
253
  interface SlotProps {
161
254
  children?: ReactNode;
162
255
  css?: SystemStyleObject;
163
256
  className?: string;
257
+ /** `data-*` attributes land on the slot element. */
258
+ [key: `data-${string}`]: unknown;
164
259
  }
165
260
 
166
261
  /** Modal title. Rendered as RAC's labelling heading for the dialog. */
@@ -169,12 +264,18 @@ export const ModalHeader = ({
169
264
  css: cssProp,
170
265
  className,
171
266
  level,
267
+ ...rest
172
268
  }: SlotProps & {
173
- /** 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;
174
274
  }) => {
175
275
  const { slots } = useDialog();
176
276
  return (
177
277
  <RACHeading
278
+ {...dataAttrs(rest)}
178
279
  slot="title"
179
280
  level={level}
180
281
  className={cx(
@@ -188,10 +289,16 @@ export const ModalHeader = ({
188
289
  );
189
290
  };
190
291
 
191
- export const ModalBody = ({ children, css: cssProp, className }: SlotProps) => {
292
+ export const ModalBody = ({
293
+ children,
294
+ css: cssProp,
295
+ className,
296
+ ...rest
297
+ }: SlotProps) => {
192
298
  const { slots } = useDialog();
193
299
  return (
194
300
  <div
301
+ {...dataAttrs(rest)}
195
302
  className={cx(slots.body, cssProp ? css(cssProp) : undefined, className)}
196
303
  >
197
304
  {children}
@@ -203,10 +310,12 @@ export const ModalFooter = ({
203
310
  children,
204
311
  css: cssProp,
205
312
  className,
313
+ ...rest
206
314
  }: SlotProps) => {
207
315
  const { slots } = useDialog();
208
316
  return (
209
317
  <div
318
+ {...dataAttrs(rest)}
210
319
  className={cx(
211
320
  slots.footer,
212
321
  cssProp ? css(cssProp) : undefined,
@@ -221,19 +330,23 @@ export const ModalFooter = ({
221
330
  export interface ModalCloseButtonProps {
222
331
  /** Accessible name; defaults to the localized close label. */
223
332
  "aria-label"?: string;
333
+ /** `data-*` attributes land on the button. */
334
+ [key: `data-${string}`]: unknown;
224
335
  }
225
336
 
226
337
  /**
227
- * ModalCloseButton — the X in the dialog's top corner (Chakra's
228
- * 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.
229
340
  */
230
341
  export const ModalCloseButton = ({
231
342
  "aria-label": ariaLabel,
343
+ ...rest
232
344
  }: ModalCloseButtonProps) => {
233
345
  const intl = useIntl();
234
346
  const { slots, onClose } = useDialog();
235
347
  return (
236
348
  <RACButton
349
+ {...dataAttrs(rest)}
237
350
  aria-label={ariaLabel ?? intl.formatMessage(uiMessage("ui.close-action"))}
238
351
  onPress={onClose}
239
352
  className={cx(
@@ -255,7 +368,7 @@ export const ModalCloseButton = ({
255
368
  transitionDuration: "normal",
256
369
  _hover: { bg: "blackAlpha.100" },
257
370
  _active: { bg: "blackAlpha.200" },
258
- _focusVisible: { focusShadow: "outline" },
371
+ _focusVisible: { focusRing: "outline" },
259
372
  }),
260
373
  )}
261
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
+ });
@@ -5,41 +5,56 @@
5
5
  */
6
6
  import { forwardRef, SelectHTMLAttributes } from "react";
7
7
  import { css, cx } from "styled-system/css";
8
- import { input } from "styled-system/recipes";
8
+ import { input, InputVariantProps } from "styled-system/recipes";
9
9
  import { SystemStyleObject } from "styled-system/types";
10
10
 
11
11
  export interface NativeSelectProps
12
- extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "className"> {
12
+ // `size` is the recipe's size scale; the native visible-rows attribute it
13
+ // shadows was unused.
14
+ extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "className" | "size">,
15
+ InputVariantProps {
13
16
  /**
14
17
  * Suppress the dropdown chevron, e.g. when an adjacent control provides the
15
18
  * affordance. Without the chevron a bare <select> element is rendered (no
16
19
  * wrapper), so it can participate directly in e.g. an attached ButtonGroup.
17
20
  */
18
21
  hideChevron?: boolean;
19
- /** Per-instance style overrides, merged after the recipe. */
22
+ /** Per-instance style overrides for the select, merged after the recipe. */
20
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;
21
30
  className?: string;
22
31
  }
23
32
 
24
33
  /**
25
- * NativeSelect — a native select styled like Chakra's Select field (md,
26
- * outline). The recipe's `appearance: none` removes the platform chevron, so
27
- * one is 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`).
28
37
  */
29
38
  export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
30
39
  function NativeSelect(
31
- { hideChevron = false, css: cssProp, className, ...rest },
40
+ { hideChevron = false, size, css: cssProp, wrapperCss, className, ...rest },
32
41
  ref,
33
42
  ) {
34
43
  const select = (
35
44
  <select
36
45
  ref={ref}
37
46
  className={cx(
38
- input(),
47
+ input({ size }),
39
48
  css(
40
- { cursor: "pointer" },
41
- // Room for the chevron overlay.
42
- 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" },
43
58
  cssProp,
44
59
  ),
45
60
  className,
@@ -51,21 +66,33 @@ export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
51
66
  return select;
52
67
  }
53
68
  return (
54
- <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
+ >
55
77
  {select}
56
- {/* Chakra Select's chevron. */}
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). */}
57
81
  <svg
58
82
  viewBox="0 0 24 24"
59
83
  aria-hidden
60
84
  className={css({
61
85
  position: "absolute",
62
- right: "2",
86
+ insetEnd: "2",
63
87
  top: "50%",
64
88
  transform: "translateY(-50%)",
65
89
  width: "5",
66
90
  height: "5",
67
91
  pointerEvents: "none",
68
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 },
69
96
  })}
70
97
  >
71
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
+ });