@microbit/ui 0.0.0-gray.ramp.90

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 (102) hide show
  1. package/LICENSE.md +53 -0
  2. package/README.md +222 -0
  3. package/lang/ui.ca.json +22 -0
  4. package/lang/ui.cy.json +22 -0
  5. package/lang/ui.de.json +22 -0
  6. package/lang/ui.en-us.json +22 -0
  7. package/lang/ui.en.json +22 -0
  8. package/lang/ui.es-es.json +22 -0
  9. package/lang/ui.fr.json +22 -0
  10. package/lang/ui.ga-ie.json +22 -0
  11. package/lang/ui.it.json +22 -0
  12. package/lang/ui.ja.json +22 -0
  13. package/lang/ui.ko.json +22 -0
  14. package/lang/ui.lol.json +22 -0
  15. package/lang/ui.nl.json +22 -0
  16. package/lang/ui.pl.json +22 -0
  17. package/lang/ui.pt-br.json +22 -0
  18. package/lang/ui.zh-cn.json +22 -0
  19. package/lang/ui.zh-tw.json +22 -0
  20. package/package.json +66 -0
  21. package/postcss-legacy-safari.cjs +96 -0
  22. package/reset.css +35 -0
  23. package/src/Avatar.recipe.ts +168 -0
  24. package/src/Avatar.tsx +276 -0
  25. package/src/Button.recipe.ts +192 -0
  26. package/src/Button.tsx +69 -0
  27. package/src/ButtonGroup.tsx +70 -0
  28. package/src/Card.recipe.ts +49 -0
  29. package/src/Card.tsx +56 -0
  30. package/src/Checkbox.recipe.ts +111 -0
  31. package/src/Checkbox.tsx +106 -0
  32. package/src/CloseButton.tsx +87 -0
  33. package/src/CloseIcon.tsx +40 -0
  34. package/src/Code.tsx +20 -0
  35. package/src/Collapse.tsx +180 -0
  36. package/src/ComboBox.tsx +192 -0
  37. package/src/Divider.tsx +85 -0
  38. package/src/Drawer.recipe.ts +98 -0
  39. package/src/Drawer.tsx +138 -0
  40. package/src/Fade.tsx +48 -0
  41. package/src/GridList.recipe.ts +58 -0
  42. package/src/GridList.tsx +81 -0
  43. package/src/Heading.recipe.ts +52 -0
  44. package/src/Heading.tsx +14 -0
  45. package/src/Icon.tsx +75 -0
  46. package/src/IconButton.tsx +44 -0
  47. package/src/Image.tsx +11 -0
  48. package/src/Input.recipe.ts +75 -0
  49. package/src/Input.tsx +44 -0
  50. package/src/InputGroup.tsx +62 -0
  51. package/src/Kbd.tsx +26 -0
  52. package/src/Link.tsx +23 -0
  53. package/src/LinkBox.tsx +88 -0
  54. package/src/LinkButton.tsx +80 -0
  55. package/src/List.tsx +31 -0
  56. package/src/ListBox.recipe.ts +43 -0
  57. package/src/ListBox.tsx +88 -0
  58. package/src/Menu.recipe.ts +124 -0
  59. package/src/Menu.tsx +291 -0
  60. package/src/Modal.recipe.ts +163 -0
  61. package/src/Modal.tsx +377 -0
  62. package/src/NativeSelect.tsx +94 -0
  63. package/src/NumberField.recipe.ts +67 -0
  64. package/src/NumberField.tsx +86 -0
  65. package/src/PopoverArrow.tsx +67 -0
  66. package/src/ProgressBar.tsx +61 -0
  67. package/src/Radio.recipe.ts +112 -0
  68. package/src/Radio.tsx +89 -0
  69. package/src/Select.recipe.ts +210 -0
  70. package/src/Select.tsx +153 -0
  71. package/src/SharedUIProvider.tsx +55 -0
  72. package/src/Skeleton.tsx +146 -0
  73. package/src/Slide.tsx +54 -0
  74. package/src/Slider.recipe.ts +102 -0
  75. package/src/Slider.tsx +163 -0
  76. package/src/Spinner.tsx +74 -0
  77. package/src/Svg.tsx +23 -0
  78. package/src/Switch.recipe.ts +107 -0
  79. package/src/Switch.tsx +62 -0
  80. package/src/Text.recipe.ts +29 -0
  81. package/src/Text.tsx +16 -0
  82. package/src/TextField.recipe.ts +54 -0
  83. package/src/TextField.tsx +91 -0
  84. package/src/Toast.recipe.ts +98 -0
  85. package/src/Toast.tsx +173 -0
  86. package/src/Tooltip.recipe.ts +37 -0
  87. package/src/Tooltip.tsx +76 -0
  88. package/src/UnmountCallback.tsx +18 -0
  89. package/src/VisuallyHidden.tsx +14 -0
  90. package/src/base-preset.ts +371 -0
  91. package/src/base-tokens.ts +1017 -0
  92. package/src/button-icon.ts +23 -0
  93. package/src/data-attrs.ts +16 -0
  94. package/src/dense-preset.ts +108 -0
  95. package/src/hooks/useBreakpointValue.ts +63 -0
  96. package/src/hooks/useClipboard.ts +64 -0
  97. package/src/hooks/useDisclosure.ts +32 -0
  98. package/src/hooks/useMediaQuery.ts +28 -0
  99. package/src/hooks/usePrevious.ts +18 -0
  100. package/src/index.ts +63 -0
  101. package/src/messages.ts +17 -0
  102. package/src/system.ts +45 -0
@@ -0,0 +1,61 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { ProgressBar as RACProgressBar } from "react-aria-components";
7
+ import { css } from "styled-system/css";
8
+ import { SystemStyleObject } from "styled-system/types";
9
+
10
+ export interface ProgressBarProps {
11
+ /** 0–100. */
12
+ value: number;
13
+ "aria-label": string;
14
+ /** Track overrides (size, radius, width), merged after the base. */
15
+ css?: SystemStyleObject;
16
+ /** Fill overrides (colour), merged after the base. */
17
+ barCss?: SystemStyleObject;
18
+ }
19
+
20
+ /**
21
+ * ProgressBar — react-aria-components <ProgressBar> styled like Chakra's md
22
+ * Progress (12px gray.100 track). Set the fill colour per call site via
23
+ * `barCss` (Chakra's colorScheme).
24
+ */
25
+ export const ProgressBar = ({
26
+ value,
27
+ "aria-label": ariaLabel,
28
+ css: cssProp,
29
+ barCss,
30
+ }: ProgressBarProps) => (
31
+ <RACProgressBar
32
+ value={value}
33
+ aria-label={ariaLabel}
34
+ // Single css() calls so caller overrides of base properties (radius, fill
35
+ // colour) are deduped at merge time rather than racing on stylesheet order.
36
+ className={css(
37
+ {
38
+ width: "100%",
39
+ height: 3,
40
+ bg: "gray.100",
41
+ overflow: "hidden",
42
+ borderRadius: "sm",
43
+ },
44
+ cssProp,
45
+ )}
46
+ >
47
+ {({ percentage }) => (
48
+ <div
49
+ className={css(
50
+ {
51
+ height: "100%",
52
+ bg: "progressFilledTrack",
53
+ transition: "width 0.2s",
54
+ },
55
+ barCss,
56
+ )}
57
+ style={{ width: `${percentage ?? 0}%` }}
58
+ />
59
+ )}
60
+ </RACProgressBar>
61
+ );
@@ -0,0 +1,112 @@
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
+ * Radio slot recipe — Chakra's radio with the default blue colorScheme
10
+ * (light mode) and its sm/md/lg size scale: the checkbox control rounded
11
+ * fully, with a 50% `currentColor` dot when selected instead of the check
12
+ * glyph. As with the checkbox, `borderColor: inherit` picks up a
13
+ * `borderColor` set on the root.
14
+ *
15
+ * State styling keys off data attributes stamped by the shared-ui Radio
16
+ * (react-aria provides the state via render props).
17
+ *
18
+ * Registered in the base preset (base-preset.ts), which also has the
19
+ * `staticCss` entry that keeps the runtime-prop size variants generated.
20
+ */
21
+ export const radio = defineSlotRecipe({
22
+ className: "radio",
23
+ slots: ["root", "control", "label"],
24
+ base: {
25
+ root: {
26
+ display: "inline-flex",
27
+ alignItems: "center",
28
+ verticalAlign: "top",
29
+ cursor: "pointer",
30
+ position: "relative",
31
+ // As the checkbox: the control's `borderColor: inherit` reads this
32
+ // (call sites can still tint at the root); the reset's gray.200
33
+ // fallback was ~1.3:1. The accessible outline stop, as the input
34
+ // recipe (docs/gray-ramp.md).
35
+ borderColor: "gray.400",
36
+ "&[data-disabled]": { cursor: "not-allowed" },
37
+ },
38
+ control: {
39
+ display: "inline-flex",
40
+ alignItems: "center",
41
+ justifyContent: "center",
42
+ flexShrink: 0,
43
+ transitionProperty: "box-shadow",
44
+ transitionDuration: "normal",
45
+ borderWidth: "2px",
46
+ borderStyle: "solid",
47
+ borderRadius: "full",
48
+ borderColor: "inherit",
49
+ color: "white",
50
+ bg: "white",
51
+ "&[data-selected]": {
52
+ bg: "controlCheckedBg",
53
+ borderColor: "controlCheckedBg",
54
+ color: "white",
55
+ _hover: {
56
+ bg: "controlCheckedHoverBg",
57
+ borderColor: "controlCheckedHoverBg",
58
+ },
59
+ // Chakra's radio dot.
60
+ _before: {
61
+ content: '""',
62
+ display: "inline-block",
63
+ position: "relative",
64
+ width: "50%",
65
+ height: "50%",
66
+ borderRadius: "50%",
67
+ bg: "currentColor",
68
+ },
69
+ },
70
+ // Chakra's disabled greys; the selected block restates _hover so the
71
+ // widened native-:hover condition can't re-tint a disabled control.
72
+ "&[data-disabled]": {
73
+ bg: "gray.100",
74
+ borderColor: "gray.100",
75
+ },
76
+ "&[data-selected][data-disabled]": {
77
+ bg: "gray.200",
78
+ borderColor: "gray.200",
79
+ color: "gray.500",
80
+ _hover: { bg: "gray.200", borderColor: "gray.200" },
81
+ },
82
+ "&[data-focus-visible]": {
83
+ focusShadow: "outline",
84
+ },
85
+ },
86
+ label: {
87
+ userSelect: "none",
88
+ marginStart: "2",
89
+ "&[data-disabled]": { opacity: 0.4 },
90
+ },
91
+ },
92
+ variants: {
93
+ // Chakra's Radio size scale (the Checkbox control scale).
94
+ size: {
95
+ sm: {
96
+ control: { width: "3", height: "3" },
97
+ label: { fontSize: "sm" },
98
+ },
99
+ md: {
100
+ control: { width: "4", height: "4" },
101
+ label: { fontSize: "md" },
102
+ },
103
+ lg: {
104
+ control: { width: "5", height: "5" },
105
+ label: { fontSize: "lg" },
106
+ },
107
+ },
108
+ },
109
+ defaultVariants: {
110
+ size: "md",
111
+ },
112
+ });
package/src/Radio.tsx ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { ReactNode } from "react";
7
+ import {
8
+ Radio as RACRadio,
9
+ RadioProps as RACRadioProps,
10
+ RadioGroup as RACRadioGroup,
11
+ RadioGroupProps as RACRadioGroupProps,
12
+ } from "react-aria-components";
13
+ import { css, cx } from "styled-system/css";
14
+ import { radio, RadioVariantProps } from "styled-system/recipes";
15
+ import { SystemStyleObject } from "styled-system/types";
16
+
17
+ export interface RadioGroupProps
18
+ extends Omit<RACRadioGroupProps, "className" | "style"> {
19
+ /** Per-instance style overrides, merged after the recipe. */
20
+ css?: SystemStyleObject;
21
+ className?: string;
22
+ }
23
+
24
+ /**
25
+ * RadioGroup — react-aria-components <RadioGroup> for a set of Radios. Carries
26
+ * no styling of its own: compose with Stack for layout, as Chakra call sites
27
+ * did.
28
+ */
29
+ export const RadioGroup = ({
30
+ css: cssProp,
31
+ className,
32
+ ...rest
33
+ }: RadioGroupProps) => {
34
+ return (
35
+ <RACRadioGroup
36
+ className={cx(cssProp ? css(cssProp) : undefined, className)}
37
+ {...rest}
38
+ />
39
+ );
40
+ };
41
+
42
+ export interface RadioProps
43
+ extends Omit<RACRadioProps, "className" | "children" | "style">,
44
+ RadioVariantProps {
45
+ /** Per-instance style overrides for the root, merged after the recipe. */
46
+ css?: SystemStyleObject;
47
+ className?: string;
48
+ children?: ReactNode;
49
+ }
50
+
51
+ /**
52
+ * Radio — react-aria-components <Radio> styled like Chakra's radio. Must be
53
+ * rendered inside a RadioGroup. Children render as the label.
54
+ */
55
+ export const Radio = ({
56
+ size,
57
+ css: cssProp,
58
+ className,
59
+ children,
60
+ ...rest
61
+ }: RadioProps) => {
62
+ const slots = radio({ size });
63
+ return (
64
+ <RACRadio
65
+ className={cx(slots.root, cssProp ? css(cssProp) : undefined, className)}
66
+ {...rest}
67
+ >
68
+ {({ isSelected, isFocusVisible, isDisabled }) => (
69
+ <>
70
+ <span
71
+ className={slots.control}
72
+ data-selected={isSelected || undefined}
73
+ data-focus-visible={isFocusVisible || undefined}
74
+ data-disabled={isDisabled || undefined}
75
+ aria-hidden
76
+ />
77
+ {children != null && (
78
+ <span
79
+ className={slots.label}
80
+ data-disabled={isDisabled || undefined}
81
+ >
82
+ {children}
83
+ </span>
84
+ )}
85
+ </>
86
+ )}
87
+ </RACRadio>
88
+ );
89
+ };
@@ -0,0 +1,210 @@
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
+ // Chakra's transition.property.common, inlined (Panda has no transitionProperty
9
+ // token category).
10
+ const transitionCommon =
11
+ "background-color, border-color, color, fill, stroke, opacity, box-shadow, transform";
12
+
13
+ /**
14
+ * Select slot recipe — the dropdown pair, shared by `Select` (a listbox behind
15
+ * a button) and `ComboBox` (a listbox behind a text input). One recipe because
16
+ * the two differ only in what the control is: keeping them together is what
17
+ * stops a searchable and a non-searchable picker drifting apart visually.
18
+ *
19
+ * `trigger` is styled from the Chakra outline Input field so a select sits
20
+ * level with a TextField beside it; `content` matches the `menu` recipe's card
21
+ * so every dropdown surface in the family agrees.
22
+ *
23
+ * Apps restyle it through the `variant` group — classroom's `classroom`
24
+ * variant is the rounded pill its join form uses.
25
+ *
26
+ * Registered in the base preset (base-preset.ts).
27
+ */
28
+ export const select = defineSlotRecipe({
29
+ className: "select",
30
+ slots: [
31
+ "root",
32
+ "label",
33
+ "trigger",
34
+ "value",
35
+ "indicator",
36
+ "content",
37
+ "list",
38
+ "option",
39
+ "optionIndicator",
40
+ "empty",
41
+ ],
42
+ base: {
43
+ root: {
44
+ display: "flex",
45
+ flexDirection: "column",
46
+ width: "100%",
47
+ },
48
+ label: {
49
+ fontSize: "md",
50
+ fontWeight: "medium",
51
+ marginEnd: "3",
52
+ mb: "2",
53
+ },
54
+ trigger: {
55
+ display: "flex",
56
+ alignItems: "center",
57
+ justifyContent: "space-between",
58
+ gap: "2",
59
+ width: "100%",
60
+ minWidth: 0,
61
+ outline: "none",
62
+ appearance: "none",
63
+ font: "inherit",
64
+ textAlign: "start",
65
+ cursor: "pointer",
66
+ transitionProperty: transitionCommon,
67
+ transitionDuration: "normal",
68
+ border: "1px solid",
69
+ // As the input recipe: the ramp's accessible outline stop, with hover
70
+ // stepping darker. The dropdown card below keeps the light gray.200 —
71
+ // it's a surface edge, not a form-control boundary.
72
+ borderColor: "gray.400",
73
+ borderRadius: "md",
74
+ bg: "white",
75
+ color: "inherit",
76
+ h: "10",
77
+ px: "4",
78
+ // As the input recipe, so a Select, a NativeSelect and a TextField in one
79
+ // form all tint together on hover. (react-aria's TextField has no hover
80
+ // effect, but matching the family beats matching their docs.)
81
+ _hover: { borderColor: "gray.500" },
82
+ // `data-invalid` lands on the root — and, in a ComboBox, on the input —
83
+ // but never on the trigger: a RAC Button has no validity state, and our
84
+ // ComboBox control is a plain div. So it comes down from the parent.
85
+ // `> &` rather than a descendant selector, so an app's own invalid form
86
+ // wrapper cannot paint every control inside it red.
87
+ //
88
+ // Declared after hover and before focus so red beats a hover tint and
89
+ // the focus ring beats red, as in the input recipe.
90
+ "[data-invalid] > &": {
91
+ borderColor: "danger.500",
92
+ boxShadow: "0 0 0 1px token(colors.danger.500)",
93
+ },
94
+ // Two focus cases. `data-focus-visible` is Select's button on keyboard
95
+ // focus only (RAC leaves it unset for mouse, matching the react-aria
96
+ // docs' Select). The `:has()` arm is ComboBox: its control is a plain
97
+ // div wrapping an input, so it gets no RAC attributes itself, and as a
98
+ // text field it should show focus on any modality. That arm watches
99
+ // native `:focus` rather than the input's `data-focused`, because
100
+ // react-aria dispatches a synthetic blur at the input whenever virtual
101
+ // focus moves to an option (aria-activedescendant) — which strips RAC's
102
+ // attribute for as long as the list has an active option, real focus
103
+ // never having left. Select's trigger holds no input, so it can't match.
104
+ "&[data-focus-visible], &:has(input:focus)": {
105
+ boxShadow: "0 0 0 1px token(colors.focusBorder)",
106
+ borderColor: "focusBorder",
107
+ outline: "2px solid transparent",
108
+ outlineOffset: "2px",
109
+ },
110
+ "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
111
+ },
112
+ // Whatever shows the current value: Select's SelectValue, ComboBox's
113
+ // input. One slot for both, so an app restyling the placeholder (say)
114
+ // does not have to know which kind of control it is looking at.
115
+ value: {
116
+ flex: "1",
117
+ minWidth: 0,
118
+ overflow: "hidden",
119
+ textOverflow: "ellipsis",
120
+ whiteSpace: "nowrap",
121
+ outline: "none",
122
+ bg: "transparent",
123
+ color: "inherit",
124
+ font: "inherit",
125
+ // RAC sets data-placeholder on SelectValue when nothing is chosen; the
126
+ // ComboBox input uses the real placeholder attribute.
127
+ "&[data-placeholder]": { color: "gray.500" },
128
+ _placeholder: { color: "gray.500" },
129
+ },
130
+ indicator: {
131
+ display: "inline-flex",
132
+ flexShrink: 0,
133
+ alignItems: "center",
134
+ justifyContent: "center",
135
+ fontSize: "1.25em",
136
+ color: "inherit",
137
+ // No pointer-events:none here: in a ComboBox this slot is the button
138
+ // that opens the list. Select's is an aria-hidden span inside the
139
+ // trigger, so it needs no help being inert.
140
+ background: "transparent",
141
+ border: "none",
142
+ cursor: "pointer",
143
+ // No focus styling, deliberately: react-aria keeps a ComboBox's toggle
144
+ // button out of the tab order (the input owns the keyboard), so a ring
145
+ // here would only ever be reachable programmatically, and would suggest
146
+ // the chevron is a tab stop. The whole control shows focus instead.
147
+ outline: "none",
148
+ },
149
+ content: {
150
+ // Line the card up with the control, as a select should and as
151
+ // react-select did. `Select` gets this from RAC, whose trigger is the
152
+ // button it measures; `ComboBox` measures its own control and sets the
153
+ // width inline, because RAC's var is the *input's* width there.
154
+ minWidth: "var(--trigger-width)",
155
+ display: "flex",
156
+ flexDirection: "column",
157
+ bg: "white",
158
+ color: "inherit",
159
+ py: "2",
160
+ zIndex: "popover",
161
+ borderRadius: "md",
162
+ borderWidth: "1px",
163
+ borderColor: "gray.200",
164
+ boxShadow: "sm",
165
+ // Matches the menu recipe's fade/scale.
166
+ transformOrigin: "top",
167
+ opacity: 1,
168
+ transform: "scale(1)",
169
+ transition: "opacity 0.1s ease-out, transform 0.1s ease-out",
170
+ "&[data-entering]": { opacity: 0, transform: "scale(0.95)" },
171
+ "&[data-exiting]": { opacity: 0, transform: "scale(0.95)" },
172
+ _motionReduce: { transition: "none" },
173
+ },
174
+ list: {
175
+ outline: "none",
176
+ overflowY: "auto",
177
+ },
178
+ option: {
179
+ display: "flex",
180
+ alignItems: "center",
181
+ gap: "2",
182
+ py: "1.5",
183
+ px: "3",
184
+ cursor: "pointer",
185
+ color: "inherit",
186
+ outline: "none",
187
+ transitionProperty: "background",
188
+ transitionDuration: "ultra-fast",
189
+ transitionTimingFunction: "ease-in",
190
+ "&[data-focused]": { bg: "gray.100" },
191
+ "&[data-pressed]": { bg: "gray.200" },
192
+ "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
193
+ },
194
+ optionIndicator: {
195
+ display: "inline-flex",
196
+ flexShrink: 0,
197
+ alignItems: "center",
198
+ justifyContent: "center",
199
+ marginStart: "auto",
200
+ fontSize: "0.8em",
201
+ opacity: 0,
202
+ "[data-selected] &": { opacity: 1 },
203
+ },
204
+ empty: {
205
+ px: "3",
206
+ py: "2",
207
+ color: "gray.600",
208
+ },
209
+ },
210
+ });
package/src/Select.tsx ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { createContext, ReactNode, useContext } from "react";
7
+ import {
8
+ Button as RACButton,
9
+ Label as RACLabel,
10
+ ListBox as RACListBox,
11
+ ListBoxItem as RACListBoxItem,
12
+ ListBoxItemProps as RACListBoxItemProps,
13
+ Popover,
14
+ PopoverProps,
15
+ Select as RACSelect,
16
+ SelectProps as RACSelectProps,
17
+ SelectValue,
18
+ } from "react-aria-components";
19
+ import { RiArrowDownSLine } from "react-icons/ri";
20
+ import { css, cx } from "styled-system/css";
21
+ import { select, SelectVariantProps } from "styled-system/recipes";
22
+ import { SystemStyleObject } from "styled-system/types";
23
+ import { Icon } from "./Icon";
24
+
25
+ export type SelectSlots = ReturnType<typeof select>;
26
+
27
+ // Options are children, so they can't see the variant their Select was given.
28
+ // The parent hands its resolved slots down, as Modal does for its own slots.
29
+ const SlotContext = createContext<SelectSlots>(select({}));
30
+
31
+ export const useSelectSlots = () => useContext(SlotContext);
32
+
33
+ export const SelectSlotProvider = SlotContext.Provider;
34
+
35
+ export interface SelectProps<T extends object>
36
+ extends Omit<
37
+ RACSelectProps<T>,
38
+ "className" | "children" | "style" | "placeholder"
39
+ >,
40
+ SelectVariantProps {
41
+ /** Visible label. Use `aria-label` instead where the design has none. */
42
+ label?: ReactNode;
43
+ /** Shown in the trigger while nothing is chosen (Chakra's placeholder). */
44
+ placeholder?: string;
45
+ /** `SelectOption`s. */
46
+ children: ReactNode;
47
+ /**
48
+ * Replaces the chevron; `null` removes it. Rarely right on a Select — the
49
+ * chevron is the only thing marking its trigger as a dropdown rather than
50
+ * a label, where a ComboBox's text input speaks for itself (which is why
51
+ * classroom's chevron-less autocomplete is a ComboBox).
52
+ */
53
+ indicator?: ReactNode | null;
54
+ /** Placement of the dropdown relative to the trigger. */
55
+ placement?: PopoverProps["placement"];
56
+ /**
57
+ * Cap the dropdown's height (react-select's `maxMenuHeight`). A prop rather
58
+ * than a `contentCss` rule because RAC writes its own max-height inline
59
+ * while positioning, which beats any class.
60
+ */
61
+ maxHeight?: number;
62
+ /** Per-instance overrides for the trigger. */
63
+ css?: SystemStyleObject;
64
+ /** Per-instance overrides for the dropdown card. */
65
+ contentCss?: SystemStyleObject;
66
+ className?: string;
67
+ }
68
+
69
+ /**
70
+ * Select — a listbox behind a button, for choosing one of a known set.
71
+ * Replaces Chakra-era react-select at non-searchable call sites; use ComboBox
72
+ * where the user should be able to type to filter.
73
+ */
74
+ export const Select = <T extends object>({
75
+ label,
76
+ placeholder,
77
+ children,
78
+ indicator,
79
+ placement = "bottom start",
80
+ maxHeight,
81
+ css: cssProp,
82
+ contentCss,
83
+ className,
84
+ ...props
85
+ }: SelectProps<T>) => {
86
+ // splitVariantProps, not a hand-picked list: an app preset can add variant
87
+ // groups to the recipe and they have to reach it (playbook gotcha #37).
88
+ const [variantProps, rest] = select.splitVariantProps(props);
89
+ const slots = select(variantProps);
90
+ return (
91
+ <SelectSlotProvider value={slots}>
92
+ <RACSelect
93
+ {...(rest as RACSelectProps<T>)}
94
+ className={cx(slots.root, className)}
95
+ >
96
+ {label != null && <RACLabel className={slots.label}>{label}</RACLabel>}
97
+ <RACButton
98
+ className={cx(slots.trigger, cssProp ? css(cssProp) : undefined)}
99
+ >
100
+ <SelectValue className={slots.value}>
101
+ {({ isPlaceholder, defaultChildren }) =>
102
+ isPlaceholder ? placeholder ?? "" : defaultChildren
103
+ }
104
+ </SelectValue>
105
+ {indicator !== null && (
106
+ <span className={slots.indicator} aria-hidden>
107
+ {indicator ?? <Icon as={RiArrowDownSLine} />}
108
+ </span>
109
+ )}
110
+ </RACButton>
111
+ <Popover
112
+ placement={placement}
113
+ maxHeight={maxHeight}
114
+ className={cx(
115
+ slots.content,
116
+ contentCss ? css(contentCss) : undefined,
117
+ )}
118
+ >
119
+ <RACListBox className={slots.list}>{children}</RACListBox>
120
+ </Popover>
121
+ </RACSelect>
122
+ </SelectSlotProvider>
123
+ );
124
+ };
125
+
126
+ export interface SelectOptionProps
127
+ extends Omit<RACListBoxItemProps, "className" | "children" | "style"> {
128
+ children?: ReactNode;
129
+ css?: SystemStyleObject;
130
+ className?: string;
131
+ }
132
+
133
+ /** A row in a `Select` or `ComboBox` list. */
134
+ export const SelectOption = ({
135
+ children,
136
+ css: cssProp,
137
+ className,
138
+ ...rest
139
+ }: SelectOptionProps) => {
140
+ const slots = useSelectSlots();
141
+ return (
142
+ <RACListBoxItem
143
+ {...rest}
144
+ className={cx(
145
+ slots.option,
146
+ cssProp ? css(cssProp) : undefined,
147
+ className,
148
+ )}
149
+ >
150
+ {children}
151
+ </RACListBoxItem>
152
+ );
153
+ };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { createContext, ReactNode, useContext, useMemo } from "react";
7
+
8
+ /**
9
+ * Registers the close function of the currently open dismissable overlay, or
10
+ * clears it (null) when the overlay closes. Installed by apps that need to
11
+ * dismiss overlays from outside the component tree — e.g. the Android
12
+ * hardware back button. Only one overlay is open at a time.
13
+ */
14
+ export type OverlayCloseRegistrar = (close: (() => void) | null) => void;
15
+
16
+ interface SharedUIContextValue {
17
+ overlayCloseRegistrar?: OverlayCloseRegistrar;
18
+ }
19
+
20
+ const SharedUIContext = createContext<SharedUIContextValue | null>(null);
21
+
22
+ export interface SharedUIProviderProps {
23
+ overlayCloseRegistrar?: OverlayCloseRegistrar;
24
+ children: ReactNode;
25
+ }
26
+
27
+ /**
28
+ * SharedUIProvider — the app-side installation point for optional shared-ui
29
+ * integrations. Currently that is only the overlay-close registrar, so apps
30
+ * without one can omit the provider entirely. Localized strings come from
31
+ * react-intl: an IntlProvider must be mounted above shared-ui components
32
+ * (see the package README for merging this package's message catalogs).
33
+ */
34
+ export const SharedUIProvider = ({
35
+ overlayCloseRegistrar,
36
+ children,
37
+ }: SharedUIProviderProps) => {
38
+ const value = useMemo(
39
+ () => ({ overlayCloseRegistrar }),
40
+ [overlayCloseRegistrar],
41
+ );
42
+ return (
43
+ <SharedUIContext.Provider value={value}>
44
+ {children}
45
+ </SharedUIContext.Provider>
46
+ );
47
+ };
48
+
49
+ /**
50
+ * The app-installed overlay-close registrar, if any. Overlay components run
51
+ * controlled and register their close function while open so the app can
52
+ * dismiss them; without a registrar they manage open state internally.
53
+ */
54
+ export const useOverlayCloseRegistrar = (): OverlayCloseRegistrar | undefined =>
55
+ useContext(SharedUIContext)?.overlayCloseRegistrar;