@microbit/ui 0.1.0-alpha.3

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 (67) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +118 -0
  3. package/lang/ui.ca.json +22 -0
  4. package/lang/ui.en-us.json +22 -0
  5. package/lang/ui.en.json +22 -0
  6. package/lang/ui.es-es.json +22 -0
  7. package/lang/ui.fr.json +22 -0
  8. package/lang/ui.ja.json +22 -0
  9. package/lang/ui.ko.json +22 -0
  10. package/lang/ui.lol.json +22 -0
  11. package/lang/ui.nl.json +22 -0
  12. package/lang/ui.pl.json +22 -0
  13. package/lang/ui.pt-br.json +22 -0
  14. package/lang/ui.zh-tw.json +22 -0
  15. package/package.json +54 -0
  16. package/src/Button.recipe.ts +162 -0
  17. package/src/Button.tsx +84 -0
  18. package/src/ButtonGroup.tsx +63 -0
  19. package/src/Card.recipe.ts +49 -0
  20. package/src/Card.tsx +56 -0
  21. package/src/Checkbox.recipe.ts +71 -0
  22. package/src/Checkbox.tsx +66 -0
  23. package/src/CloseButton.tsx +87 -0
  24. package/src/CloseIcon.tsx +40 -0
  25. package/src/Divider.tsx +21 -0
  26. package/src/Drawer.recipe.ts +98 -0
  27. package/src/Drawer.tsx +138 -0
  28. package/src/Heading.recipe.ts +52 -0
  29. package/src/Heading.tsx +14 -0
  30. package/src/Icon.tsx +55 -0
  31. package/src/IconButton.tsx +39 -0
  32. package/src/Image.tsx +11 -0
  33. package/src/Input.recipe.ts +63 -0
  34. package/src/Input.tsx +33 -0
  35. package/src/InputGroup.tsx +48 -0
  36. package/src/Link.tsx +23 -0
  37. package/src/LinkBox.tsx +88 -0
  38. package/src/List.tsx +27 -0
  39. package/src/Menu.recipe.ts +88 -0
  40. package/src/Menu.tsx +172 -0
  41. package/src/Modal.recipe.ts +163 -0
  42. package/src/Modal.tsx +265 -0
  43. package/src/NativeSelect.tsx +76 -0
  44. package/src/PopoverArrow.tsx +52 -0
  45. package/src/ProgressBar.tsx +61 -0
  46. package/src/SharedUIProvider.tsx +55 -0
  47. package/src/Slide.tsx +54 -0
  48. package/src/Slider.recipe.ts +91 -0
  49. package/src/Slider.tsx +99 -0
  50. package/src/Spinner.tsx +69 -0
  51. package/src/Svg.tsx +23 -0
  52. package/src/Switch.recipe.ts +80 -0
  53. package/src/Switch.tsx +60 -0
  54. package/src/Text.tsx +12 -0
  55. package/src/TextField.recipe.ts +54 -0
  56. package/src/TextField.tsx +72 -0
  57. package/src/Toast.recipe.ts +98 -0
  58. package/src/Toast.tsx +165 -0
  59. package/src/Tooltip.tsx +91 -0
  60. package/src/UnmountCallback.tsx +18 -0
  61. package/src/VisuallyHidden.tsx +14 -0
  62. package/src/base-preset.ts +278 -0
  63. package/src/chakra-tokens.ts +1014 -0
  64. package/src/hooks/useBreakpointValue.ts +63 -0
  65. package/src/index.ts +45 -0
  66. package/src/messages.ts +17 -0
  67. package/src/system.ts +36 -0
package/src/Menu.tsx ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { ReactNode, useCallback, useState } from "react";
7
+ import {
8
+ Menu as RACMenu,
9
+ MenuItem as RACMenuItem,
10
+ MenuItemProps as RACMenuItemProps,
11
+ MenuTrigger as RACMenuTrigger,
12
+ Popover,
13
+ PopoverProps,
14
+ Separator,
15
+ } from "react-aria-components";
16
+ import { css, cx } from "styled-system/css";
17
+ import { menu } from "styled-system/recipes";
18
+ import { SystemStyleObject } from "styled-system/types";
19
+ import { useOverlayCloseRegistrar } from "./SharedUIProvider";
20
+
21
+ export interface MenuTriggerProps {
22
+ /** Called when the menu opens. */
23
+ onOpen?: () => void;
24
+ /** Called when the menu closes. */
25
+ onClose?: () => void;
26
+ /** The trigger element followed by a `MenuList` (react-aria pattern). */
27
+ children: ReactNode;
28
+ }
29
+
30
+ /**
31
+ * MenuTrigger — react-aria-components' <MenuTrigger>. Its first child is the
32
+ * trigger (e.g. a Button); the second is a `MenuList`. RAC returns focus to the
33
+ * trigger when the menu closes.
34
+ *
35
+ * When the app installs an overlay-close registrar (e.g. so the Android back
36
+ * button can close the menu), the trigger runs controlled and registers its
37
+ * close function while open. Otherwise it's an uncontrolled pass-through.
38
+ */
39
+ export const MenuTrigger = ({
40
+ onOpen,
41
+ onClose,
42
+ children,
43
+ }: MenuTriggerProps) => {
44
+ const registerClose = useOverlayCloseRegistrar();
45
+ const [isOpen, setIsOpen] = useState(false);
46
+
47
+ const handleOpenChange = useCallback(
48
+ (open: boolean) => {
49
+ setIsOpen(open);
50
+ if (open) {
51
+ registerClose?.(() => {
52
+ setIsOpen(false);
53
+ registerClose(null);
54
+ });
55
+ onOpen?.();
56
+ } else {
57
+ registerClose?.(null);
58
+ onClose?.();
59
+ }
60
+ },
61
+ [registerClose, onOpen, onClose],
62
+ );
63
+
64
+ if (!registerClose) {
65
+ return (
66
+ <RACMenuTrigger
67
+ onOpenChange={(open) => (open ? onOpen?.() : onClose?.())}
68
+ >
69
+ {children}
70
+ </RACMenuTrigger>
71
+ );
72
+ }
73
+
74
+ return (
75
+ <RACMenuTrigger isOpen={isOpen} onOpenChange={handleOpenChange}>
76
+ {children}
77
+ </RACMenuTrigger>
78
+ );
79
+ };
80
+
81
+ export interface MenuListProps {
82
+ children: ReactNode;
83
+ /** Placement of the dropdown relative to the trigger. */
84
+ placement?: PopoverProps["placement"];
85
+ /** Per-instance style overrides, merged after the recipe. */
86
+ css?: SystemStyleObject;
87
+ className?: string;
88
+ }
89
+
90
+ /**
91
+ * MenuList — the dropdown surface: RAC's <Popover> (positioned card) wrapping a
92
+ * RAC <Menu> (the list). Place `MenuItem`s inside.
93
+ */
94
+ export const MenuList = ({
95
+ children,
96
+ // Matches Chakra Menu's default "bottom-start".
97
+ placement = "bottom start",
98
+ css: cssProp,
99
+ className,
100
+ }: MenuListProps) => {
101
+ const slots = menu();
102
+ return (
103
+ <Popover
104
+ placement={placement}
105
+ className={cx(
106
+ slots.content,
107
+ cssProp ? css(cssProp) : undefined,
108
+ className,
109
+ )}
110
+ >
111
+ <RACMenu className={slots.list}>{children}</RACMenu>
112
+ </Popover>
113
+ );
114
+ };
115
+
116
+ export interface MenuItemProps
117
+ extends Omit<RACMenuItemProps, "className" | "children"> {
118
+ /** Icon rendered before the label, matching Chakra's MenuItem `icon`. */
119
+ icon?: ReactNode;
120
+ /** Per-instance style overrides, merged after the recipe. */
121
+ css?: SystemStyleObject;
122
+ className?: string;
123
+ children?: ReactNode;
124
+ }
125
+
126
+ /**
127
+ * MenuItem — a RAC <MenuItem> styled with the `menu` recipe's `item` slot.
128
+ * Use `onAction` for the click behaviour (RAC closes the menu automatically).
129
+ */
130
+ export const MenuItem = ({
131
+ icon,
132
+ css: cssProp,
133
+ className,
134
+ children,
135
+ ...rest
136
+ }: MenuItemProps) => {
137
+ const slots = menu();
138
+ return (
139
+ <RACMenuItem
140
+ className={cx(slots.item, cssProp ? css(cssProp) : undefined, className)}
141
+ {...rest}
142
+ >
143
+ {icon ? (
144
+ <>
145
+ <span className={slots.icon}>{icon}</span>
146
+ <span className={slots.label}>{children}</span>
147
+ </>
148
+ ) : (
149
+ children
150
+ )}
151
+ </RACMenuItem>
152
+ );
153
+ };
154
+
155
+ export interface MenuDividerProps {
156
+ css?: SystemStyleObject;
157
+ className?: string;
158
+ }
159
+
160
+ /** Horizontal rule separating groups of menu items. */
161
+ export const MenuDivider = ({ css: cssProp, className }: MenuDividerProps) => {
162
+ const slots = menu();
163
+ return (
164
+ <Separator
165
+ className={cx(
166
+ slots.divider,
167
+ cssProp ? css(cssProp) : undefined,
168
+ className,
169
+ )}
170
+ />
171
+ );
172
+ };
@@ -0,0 +1,163 @@
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
+ * 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.
12
+ *
13
+ * A config recipe (rather than an atomic `sva`) so the `size` variant accepts
14
+ * responsive values, e.g. `{ base: "full", md: "4xl" }`. Consumed by the
15
+ * shared-ui Modal, which maps the slots onto react-aria-components'
16
+ * ModalOverlay / Modal / Dialog.
17
+ *
18
+ * Registered in the base preset (base-preset.ts).
19
+ */
20
+ // Appearance of a normal (non-full) modal box. Restated by every non-full size
21
+ // variant so that, when `size` is responsive (e.g. { base: "full", md: "4xl" }),
22
+ // the larger breakpoint fully overrides the `full` variant rather than leaking
23
+ // its margin:0 / border-radius:0 / full-height / gradient into desktop. Panda
24
+ // applies the base-breakpoint variant value unconditionally, so symmetric
25
+ // property sets across size values are required.
26
+ const dialogBox = {
27
+ my: "16",
28
+ mx: "2",
29
+ borderRadius: "md",
30
+ background: "white",
31
+ minHeight: "auto",
32
+ padding: "0",
33
+ };
34
+
35
+ // The `full` variant also styles these slots; same symmetry requirement.
36
+ const dialogSlots = {
37
+ header: { pl: "6" },
38
+ body: { overflowY: "visible" },
39
+ closeTrigger: { top: "2" },
40
+ };
41
+
42
+ export const dialog = defineSlotRecipe({
43
+ className: "dialog",
44
+ slots: [
45
+ "overlay",
46
+ "content",
47
+ "inner",
48
+ "header",
49
+ "body",
50
+ "footer",
51
+ "closeTrigger",
52
+ ],
53
+ base: {
54
+ overlay: {
55
+ position: "fixed",
56
+ inset: 0,
57
+ w: "100%",
58
+ h: "100%",
59
+ bg: "blackAlpha.600",
60
+ zIndex: "modal",
61
+ display: "flex",
62
+ justifyContent: "center",
63
+ alignItems: "flex-start",
64
+ overflow: "auto",
65
+ overscrollBehaviorY: "none",
66
+ // Fade the backdrop in/out. RAC toggles data-entering/data-exiting and
67
+ // waits for the transition before unmounting.
68
+ opacity: 1,
69
+ transition: "opacity 0.2s ease-out",
70
+ "&[data-entering]": { opacity: 0 },
71
+ "&[data-exiting]": { opacity: 0 },
72
+ _motionReduce: { transition: "none" },
73
+ },
74
+ content: {
75
+ position: "relative",
76
+ color: "inherit",
77
+ boxShadow: "lg",
78
+ width: "100%",
79
+ display: "flex",
80
+ flexDirection: "column",
81
+ outline: "none",
82
+ // Approximate Chakra's fade + scale enter/exit.
83
+ opacity: 1,
84
+ transform: "scale(1)",
85
+ transition: "opacity 0.2s ease-out, transform 0.2s ease-out",
86
+ "&[data-entering]": { opacity: 0, transform: "scale(0.95)" },
87
+ "&[data-exiting]": { opacity: 0, transform: "scale(0.95)" },
88
+ _motionReduce: { transition: "none" },
89
+ },
90
+ inner: {
91
+ outline: "none",
92
+ display: "flex",
93
+ flexDirection: "column",
94
+ width: "100%",
95
+ flex: "1 1 auto",
96
+ },
97
+ header: {
98
+ px: "6",
99
+ py: "4",
100
+ fontSize: "xl",
101
+ fontWeight: "semibold",
102
+ flexShrink: 0,
103
+ },
104
+ body: { px: "6", py: "2", flex: "1" },
105
+ footer: {
106
+ px: "6",
107
+ py: "4",
108
+ display: "flex",
109
+ alignItems: "center",
110
+ 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.
113
+ gap: "5",
114
+ flexShrink: 0,
115
+ },
116
+ closeTrigger: { position: "absolute", top: "2", insetEnd: "3" },
117
+ },
118
+ variants: {
119
+ // Chakra's `isCentered`: vertically centre the dialog in the viewport
120
+ // rather than the default top alignment.
121
+ centered: {
122
+ true: {
123
+ overlay: { alignItems: "center" },
124
+ },
125
+ },
126
+ size: {
127
+ xs: { ...dialogSlots, content: { ...dialogBox, maxWidth: "xs" } },
128
+ sm: { ...dialogSlots, content: { ...dialogBox, maxWidth: "sm" } },
129
+ md: { ...dialogSlots, content: { ...dialogBox, maxWidth: "md" } },
130
+ lg: { ...dialogSlots, content: { ...dialogBox, maxWidth: "lg" } },
131
+ xl: { ...dialogSlots, content: { ...dialogBox, maxWidth: "xl" } },
132
+ "2xl": { ...dialogSlots, content: { ...dialogBox, maxWidth: "2xl" } },
133
+ "3xl": { ...dialogSlots, content: { ...dialogBox, maxWidth: "3xl" } },
134
+ "4xl": { ...dialogSlots, content: { ...dialogBox, maxWidth: "4xl" } },
135
+ "5xl": { ...dialogSlots, content: { ...dialogBox, maxWidth: "5xl" } },
136
+ "6xl": { ...dialogSlots, content: { ...dialogBox, maxWidth: "6xl" } },
137
+ full: {
138
+ content: {
139
+ maxWidth: "100vw",
140
+ minHeight: "100dvh",
141
+ my: "0",
142
+ mx: "0",
143
+ borderRadius: "0",
144
+ paddingTop: "env(safe-area-inset-top)",
145
+ paddingBottom: "env(safe-area-inset-bottom)",
146
+ paddingLeft: "env(safe-area-inset-left)",
147
+ paddingRight: "env(safe-area-inset-right)",
148
+ // brand colour in the status-bar area, white below (matches ActionBar)
149
+ background:
150
+ "linear-gradient(to bottom, token(colors.statusBarBg) env(safe-area-inset-top), white env(safe-area-inset-top))",
151
+ },
152
+ header: {
153
+ pl: "calc(var(--window-controls-left, 0px) + token(spacing.6))",
154
+ },
155
+ body: { flex: "1", overflowY: "auto" },
156
+ closeTrigger: {
157
+ top: "calc(env(safe-area-inset-top) + token(spacing.2))",
158
+ },
159
+ },
160
+ },
161
+ },
162
+ defaultVariants: { size: "md" },
163
+ });
package/src/Modal.tsx ADDED
@@ -0,0 +1,265 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { createContext, ReactNode, RefObject, useContext } from "react";
7
+ import {
8
+ Button as RACButton,
9
+ Dialog,
10
+ Heading as RACHeading,
11
+ Modal as RACModal,
12
+ ModalOverlay,
13
+ } from "react-aria-components";
14
+ import { css, cx } from "styled-system/css";
15
+ import { dialog } from "styled-system/recipes";
16
+ import { ConditionalValue, SystemStyleObject } from "styled-system/types";
17
+ import { useIntl } from "react-intl";
18
+ import { CloseIcon } from "./CloseIcon";
19
+ import { uiMessage } from "./messages";
20
+ import { UnmountCallback } from "./UnmountCallback";
21
+
22
+ type DialogSlots = ReturnType<typeof dialog>;
23
+
24
+ const SlotContext = createContext<{ slots: DialogSlots; onClose: () => void }>({
25
+ slots: dialog({}),
26
+ onClose: () => undefined,
27
+ });
28
+
29
+ const useDialog = () => useContext(SlotContext);
30
+
31
+ export type ModalSize = ConditionalValue<
32
+ | "xs"
33
+ | "sm"
34
+ | "md"
35
+ | "lg"
36
+ | "xl"
37
+ | "2xl"
38
+ | "3xl"
39
+ | "4xl"
40
+ | "5xl"
41
+ | "6xl"
42
+ | "full"
43
+ >;
44
+
45
+ export interface ModalProps {
46
+ isOpen: boolean;
47
+ onClose: () => void;
48
+ size?: ModalSize;
49
+ /** Allow closing by clicking the backdrop (default true; Escape always closes). */
50
+ isDismissable?: boolean;
51
+ /** Disable the enter/exit transitions (Chakra's motionPreset="none"). */
52
+ motionless?: boolean;
53
+ /** Prevent Escape closing the dialog (Chakra's closeOnEsc={false}). */
54
+ isKeyboardDismissDisabled?: boolean;
55
+ /** Style overrides for the dialog box (Chakra's ModalContent props). */
56
+ contentCss?: SystemStyleObject;
57
+ /**
58
+ * Style overrides for the backdrop (Chakra's ModalOverlay props), e.g. a
59
+ * transparent backdrop when something else provides the dimming.
60
+ */
61
+ overlayCss?: SystemStyleObject;
62
+ /**
63
+ * Use "alertdialog" for confirmations that interrupt the user (Chakra's
64
+ * AlertDialog).
65
+ */
66
+ role?: "dialog" | "alertdialog";
67
+ /** Vertically centre the dialog (Chakra's `isCentered`). */
68
+ isCentered?: boolean;
69
+ /**
70
+ * Called after the dialog has fully closed (exit transition done and the
71
+ * dialog removed). Matches Chakra's `onCloseComplete`.
72
+ */
73
+ onCloseComplete?: () => void;
74
+ /**
75
+ * Element to focus when the dialog closes, instead of the element that was
76
+ * focused when it opened. Matches Chakra's `finalFocusRef`.
77
+ */
78
+ finalFocusRef?: RefObject<HTMLElement>;
79
+ /**
80
+ * Accessible name for dialogs without a ModalHeader (which otherwise
81
+ * provides the label). One of the two is required — react-aria warns in
82
+ * dev builds when a dialog has neither. An explicit `aria-label` wins
83
+ * over a ModalHeader.
84
+ */
85
+ "aria-label"?: string;
86
+ children: ReactNode;
87
+ }
88
+
89
+ /**
90
+ * Modal — a focus-trapping dialog. Collapses Chakra's
91
+ * Modal/ModalOverlay/ModalContent into a single shell; place ModalHeader,
92
+ * ModalBody and ModalFooter inside.
93
+ */
94
+ export const Modal = ({
95
+ isOpen,
96
+ onClose,
97
+ size,
98
+ isDismissable = true,
99
+ motionless,
100
+ isKeyboardDismissDisabled,
101
+ contentCss,
102
+ overlayCss,
103
+ role,
104
+ isCentered,
105
+ onCloseComplete,
106
+ finalFocusRef,
107
+ "aria-label": ariaLabel,
108
+ children,
109
+ }: ModalProps) => {
110
+ const slots = dialog({ size, centered: isCentered });
111
+ const motionlessClass = motionless
112
+ ? css({
113
+ transition: "none",
114
+ "&[data-entering]": { opacity: 1, transform: "none" },
115
+ "&[data-exiting]": { opacity: 1, transform: "none" },
116
+ })
117
+ : undefined;
118
+ const handleUnmount = () => {
119
+ onCloseComplete?.();
120
+ const el = finalFocusRef?.current;
121
+ if (el) {
122
+ // After RAC's own focus restoration, which also runs on unmount.
123
+ requestAnimationFrame(() => el.focus());
124
+ }
125
+ };
126
+ return (
127
+ <ModalOverlay
128
+ isOpen={isOpen}
129
+ onOpenChange={(open) => {
130
+ if (!open) {
131
+ onClose();
132
+ }
133
+ }}
134
+ isDismissable={isDismissable}
135
+ isKeyboardDismissDisabled={isKeyboardDismissDisabled}
136
+ className={cx(
137
+ slots.overlay,
138
+ motionlessClass,
139
+ overlayCss ? css(overlayCss) : undefined,
140
+ )}
141
+ >
142
+ <UnmountCallback callback={handleUnmount} />
143
+ <RACModal
144
+ className={cx(
145
+ slots.content,
146
+ motionlessClass,
147
+ contentCss ? css(contentCss) : undefined,
148
+ )}
149
+ >
150
+ <Dialog role={role} aria-label={ariaLabel} className={slots.inner}>
151
+ <SlotContext.Provider value={{ slots, onClose }}>
152
+ {children}
153
+ </SlotContext.Provider>
154
+ </Dialog>
155
+ </RACModal>
156
+ </ModalOverlay>
157
+ );
158
+ };
159
+
160
+ interface SlotProps {
161
+ children?: ReactNode;
162
+ css?: SystemStyleObject;
163
+ className?: string;
164
+ }
165
+
166
+ /** Modal title. Rendered as RAC's labelling heading for the dialog. */
167
+ export const ModalHeader = ({
168
+ children,
169
+ css: cssProp,
170
+ className,
171
+ level,
172
+ }: SlotProps & {
173
+ /** Heading element level (default 3, like RAC). */ level?: number;
174
+ }) => {
175
+ const { slots } = useDialog();
176
+ return (
177
+ <RACHeading
178
+ slot="title"
179
+ level={level}
180
+ className={cx(
181
+ slots.header,
182
+ cssProp ? css(cssProp) : undefined,
183
+ className,
184
+ )}
185
+ >
186
+ {children}
187
+ </RACHeading>
188
+ );
189
+ };
190
+
191
+ export const ModalBody = ({ children, css: cssProp, className }: SlotProps) => {
192
+ const { slots } = useDialog();
193
+ return (
194
+ <div
195
+ className={cx(slots.body, cssProp ? css(cssProp) : undefined, className)}
196
+ >
197
+ {children}
198
+ </div>
199
+ );
200
+ };
201
+
202
+ export const ModalFooter = ({
203
+ children,
204
+ css: cssProp,
205
+ className,
206
+ }: SlotProps) => {
207
+ const { slots } = useDialog();
208
+ return (
209
+ <div
210
+ className={cx(
211
+ slots.footer,
212
+ cssProp ? css(cssProp) : undefined,
213
+ className,
214
+ )}
215
+ >
216
+ {children}
217
+ </div>
218
+ );
219
+ };
220
+
221
+ export interface ModalCloseButtonProps {
222
+ /** Accessible name; defaults to the localized close label. */
223
+ "aria-label"?: string;
224
+ }
225
+
226
+ /**
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.
229
+ */
230
+ export const ModalCloseButton = ({
231
+ "aria-label": ariaLabel,
232
+ }: ModalCloseButtonProps) => {
233
+ const intl = useIntl();
234
+ const { slots, onClose } = useDialog();
235
+ return (
236
+ <RACButton
237
+ aria-label={ariaLabel ?? intl.formatMessage(uiMessage("ui.close-action"))}
238
+ onPress={onClose}
239
+ className={cx(
240
+ slots.closeTrigger,
241
+ css({
242
+ display: "inline-flex",
243
+ alignItems: "center",
244
+ justifyContent: "center",
245
+ width: "8",
246
+ height: "8",
247
+ fontSize: "xs",
248
+ borderRadius: "md",
249
+ cursor: "pointer",
250
+ bg: "transparent",
251
+ border: "none",
252
+ color: "inherit",
253
+ outline: "none",
254
+ transitionProperty: "background-color, box-shadow",
255
+ transitionDuration: "normal",
256
+ _hover: { bg: "blackAlpha.100" },
257
+ _active: { bg: "blackAlpha.200" },
258
+ _focusVisible: { focusShadow: "outline" },
259
+ }),
260
+ )}
261
+ >
262
+ <CloseIcon />
263
+ </RACButton>
264
+ );
265
+ };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { forwardRef, SelectHTMLAttributes } from "react";
7
+ import { css, cx } from "styled-system/css";
8
+ import { input } from "styled-system/recipes";
9
+ import { SystemStyleObject } from "styled-system/types";
10
+
11
+ export interface NativeSelectProps
12
+ extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "className"> {
13
+ /**
14
+ * Suppress the dropdown chevron, e.g. when an adjacent control provides the
15
+ * affordance. Without the chevron a bare <select> element is rendered (no
16
+ * wrapper), so it can participate directly in e.g. an attached ButtonGroup.
17
+ */
18
+ hideChevron?: boolean;
19
+ /** Per-instance style overrides, merged after the recipe. */
20
+ css?: SystemStyleObject;
21
+ className?: string;
22
+ }
23
+
24
+ /**
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`).
28
+ */
29
+ export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
30
+ function NativeSelect(
31
+ { hideChevron = false, css: cssProp, className, ...rest },
32
+ ref,
33
+ ) {
34
+ const select = (
35
+ <select
36
+ ref={ref}
37
+ className={cx(
38
+ input(),
39
+ css(
40
+ { cursor: "pointer" },
41
+ // Room for the chevron overlay.
42
+ hideChevron ? undefined : { paddingRight: "8" },
43
+ cssProp,
44
+ ),
45
+ className,
46
+ )}
47
+ {...rest}
48
+ />
49
+ );
50
+ if (hideChevron) {
51
+ return select;
52
+ }
53
+ return (
54
+ <span className={css({ position: "relative", display: "inline-flex" })}>
55
+ {select}
56
+ {/* Chakra Select's chevron. */}
57
+ <svg
58
+ viewBox="0 0 24 24"
59
+ aria-hidden
60
+ className={css({
61
+ position: "absolute",
62
+ right: "2",
63
+ top: "50%",
64
+ transform: "translateY(-50%)",
65
+ width: "5",
66
+ height: "5",
67
+ pointerEvents: "none",
68
+ fill: "currentColor",
69
+ })}
70
+ >
71
+ <path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" />
72
+ </svg>
73
+ </span>
74
+ );
75
+ },
76
+ );