@microbit/ui 0.1.0-alpha.13 → 0.1.0-alpha.15

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.
package/src/Menu.tsx CHANGED
@@ -156,45 +156,75 @@ export const MenuItem = ({
156
156
  );
157
157
  };
158
158
 
159
- export interface MenuOptionGroupProps {
159
+ interface MenuOptionGroupBaseProps {
160
160
  /** Group heading shown above the options (Chakra's `title`). */
161
161
  title?: ReactNode;
162
- /** The selected `MenuItemOption`'s value (radio semantics). */
163
- value?: string;
164
- /** Called with the newly selected option's value. */
165
- onChange?: (value: string) => void;
166
162
  /** `MenuItemOption` children. */
167
163
  children: ReactNode;
168
164
  css?: SystemStyleObject;
169
165
  className?: string;
170
166
  }
171
167
 
168
+ export interface MenuOptionGroupRadioProps extends MenuOptionGroupBaseProps {
169
+ type?: "radio";
170
+ /** The selected `MenuItemOption`'s value. */
171
+ value?: string;
172
+ /** Called with the newly selected option's value. */
173
+ onChange?: (value: string) => void;
174
+ }
175
+
176
+ export interface MenuOptionGroupCheckboxProps extends MenuOptionGroupBaseProps {
177
+ type: "checkbox";
178
+ /** The checked `MenuItemOption`s' values. */
179
+ value?: string[];
180
+ /** Called with the full set of checked values after a toggle. */
181
+ onChange?: (value: string[]) => void;
182
+ }
183
+
184
+ export type MenuOptionGroupProps =
185
+ | MenuOptionGroupRadioProps
186
+ | MenuOptionGroupCheckboxProps;
187
+
172
188
  /**
173
- * MenuOptionGroup — a single-select (radio) group of `MenuItemOption`s within
174
- * a menu, replacing Chakra's `MenuOptionGroup type="radio"`. Selection is
175
- * section-scoped (RAC MenuSection), so a menu can mix action items and option
176
- * groups.
189
+ * MenuOptionGroup — a group of checkable `MenuItemOption`s within a menu,
190
+ * replacing Chakra's `MenuOptionGroup`. `type="radio"` (the default) is
191
+ * single-select and renders `menuitemradio`; `type="checkbox"` is multi-select
192
+ * and renders `menuitemcheckbox`, each option toggling independently.
193
+ *
194
+ * Selection is section-scoped (RAC MenuSection), so a menu can mix action items
195
+ * and option groups. An option's own `onAction` still fires on every press,
196
+ * including the press that deselects it — so a lone toggle can be driven either
197
+ * by the group's `onChange` or by the item's `onAction`.
198
+ *
199
+ * Choosing an option leaves the menu open (a plain `MenuItem` closes it), which
200
+ * is what Chakra's checkbox groups did. Chakra's *radio* groups closed on
201
+ * select, so a ported radio group is a deliberate behaviour change.
177
202
  */
178
- export const MenuOptionGroup = ({
179
- title,
180
- value,
181
- onChange,
182
- children,
183
- css: cssProp,
184
- className,
185
- }: MenuOptionGroupProps) => {
203
+ export const MenuOptionGroup = (props: MenuOptionGroupProps) => {
204
+ const { title, children, css: cssProp, className } = props;
186
205
  const slots = menu();
206
+ const selectedKeys =
207
+ props.type === "checkbox"
208
+ ? props.value ?? []
209
+ : props.value != null
210
+ ? [props.value]
211
+ : [];
187
212
  return (
188
213
  <RACMenuSection
189
214
  className={cx(slots.group, cssProp ? css(cssProp) : undefined, className)}
190
- selectionMode="single"
191
- selectedKeys={value != null ? [value] : []}
215
+ selectionMode={props.type === "checkbox" ? "multiple" : "single"}
216
+ selectedKeys={selectedKeys}
192
217
  onSelectionChange={(keys) => {
193
- if (keys !== "all") {
194
- const key = keys.values().next().value;
195
- if (key != null) {
196
- onChange?.(String(key));
197
- }
218
+ if (keys === "all") {
219
+ return;
220
+ }
221
+ const values = [...keys].map(String);
222
+ if (props.type === "checkbox") {
223
+ props.onChange?.(values);
224
+ } else if (values.length > 0) {
225
+ // Radio: a press that clears the selection reports nothing, matching
226
+ // Chakra, which had no way to express an empty radio group.
227
+ props.onChange?.(values[0]);
198
228
  }
199
229
  }}
200
230
  >
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,9 +51,7 @@ 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;
@@ -96,12 +97,53 @@ export interface ModalProps {
96
97
  */
97
98
  "aria-label"?: string;
98
99
  children: ReactNode;
100
+ /**
101
+ * `data-*` attributes land on the dialog box (where Chakra's went, on
102
+ * ModalContent), so end-to-end tests can address a dialog. Shells that
103
+ * forward their caller's data attributes can spread them straight in.
104
+ */
105
+ [key: `data-${string}`]: unknown;
99
106
  }
100
107
 
108
+ /**
109
+ * A Modal you drive yourself. Also the type for a component that *forwards*
110
+ * modal props — `Omit<ControlledModalProps, "children">` — because a spread
111
+ * cannot be matched against the union `ModalProps` is: TypeScript has no way
112
+ * to know which half of it an object with `isOpen?: boolean` satisfies.
113
+ */
114
+ export type ControlledModalProps = ModalOwnProps & {
115
+ /** Whether the dialog is showing. */
116
+ isOpen: boolean;
117
+ /** Called when the dialog asks to close. */
118
+ onClose: () => void;
119
+ };
120
+
121
+ /**
122
+ * The props of a `Modal`: its own, plus an open state that is either entirely
123
+ * yours or entirely a `DialogTrigger`'s. Never half of each — `isOpen`
124
+ * without `onClose` leaves the close button and Escape with nothing to call,
125
+ * so the pair is enforced rather than merely documented.
126
+ */
127
+ export type ModalProps =
128
+ | ControlledModalProps
129
+ | (ModalOwnProps & { isOpen?: never; onClose?: never });
130
+
101
131
  /**
102
132
  * Modal — a focus-trapping dialog. Collapses Chakra's
103
133
  * Modal/ModalOverlay/ModalContent into a single shell; place ModalHeader,
104
134
  * ModalBody and ModalFooter inside.
135
+ *
136
+ * Two ways to drive it:
137
+ *
138
+ * - **Controlled** (`isOpen` + `onClose`), which is what a Chakra app ports
139
+ * to, and what any dialog with more than one opener needs — a menu item and
140
+ * a toolbar button opening the same dialog, or one opened from a handler
141
+ * after an async result.
142
+ * - **Inside a `DialogTrigger`**, with neither prop: react-aria holds the
143
+ * open state, the trigger opens it, and `ModalCloseButton` and the footer's
144
+ * `useDialogClose()` still close it. Prefer this where a dialog has exactly
145
+ * one trigger sitting next to it — there is no state to hold, and none to
146
+ * get out of step.
105
147
  */
106
148
  export const Modal = ({
107
149
  isOpen,
@@ -119,7 +161,14 @@ export const Modal = ({
119
161
  finalFocusRef,
120
162
  "aria-label": ariaLabel,
121
163
  children,
164
+ ...rest
122
165
  }: ModalProps) => {
166
+ // Set by a DialogTrigger (or any react-aria overlay trigger) above us. When
167
+ // `isOpen` is given it is ignored: RAC's ModalOverlay prefers an explicit
168
+ // prop over the context, and so do we for the close function.
169
+ const triggerState = useContext(OverlayTriggerStateContext);
170
+ const close = onClose ?? (() => triggerState?.close());
171
+ const dataProps = dataAttrs(rest);
123
172
  const slots = dialog({ size, centered: isCentered });
124
173
  const motionlessClass = motionless
125
174
  ? css({
@@ -141,7 +190,7 @@ export const Modal = ({
141
190
  isOpen={isOpen}
142
191
  onOpenChange={(open) => {
143
192
  if (!open) {
144
- onClose();
193
+ close();
145
194
  }
146
195
  }}
147
196
  isDismissable={isDismissable}
@@ -154,6 +203,7 @@ export const Modal = ({
154
203
  >
155
204
  <UnmountCallback callback={handleUnmount} />
156
205
  <RACModal
206
+ {...dataProps}
157
207
  style={contentStyle}
158
208
  className={cx(
159
209
  slots.content,
@@ -162,7 +212,7 @@ export const Modal = ({
162
212
  )}
163
213
  >
164
214
  <Dialog role={role} aria-label={ariaLabel} className={slots.inner}>
165
- <SlotContext.Provider value={{ slots, onClose }}>
215
+ <SlotContext.Provider value={{ slots, onClose: close }}>
166
216
  {children}
167
217
  </SlotContext.Provider>
168
218
  </Dialog>
@@ -171,10 +221,40 @@ export const Modal = ({
171
221
  );
172
222
  };
173
223
 
224
+ /**
225
+ * DialogTrigger — react-aria-components' <DialogTrigger>: wrap a trigger
226
+ * element and a `Modal`, and the open state is theirs rather than yours.
227
+ *
228
+ * ```tsx
229
+ * <DialogTrigger>
230
+ * <Button>Settings</Button>
231
+ * <Modal size="lg">
232
+ * <ModalHeader>Settings</ModalHeader>
233
+ * …
234
+ * </Modal>
235
+ * </DialogTrigger>
236
+ * ```
237
+ *
238
+ * Only for a dialog with a single trigger beside it. A dialog opened from
239
+ * more than one place, from a menu item (which cannot hold a dialog — a
240
+ * non-collection child truncates the menu), or from a handler, wants the
241
+ * controlled `Modal` instead.
242
+ */
243
+ export const DialogTrigger = RACDialogTrigger;
244
+
245
+ /**
246
+ * The current dialog's close function — the same one `ModalCloseButton` uses,
247
+ * for a footer's own Cancel/Done buttons. Works in both modes, so a dialog's
248
+ * content need not know which is driving it.
249
+ */
250
+ export const useDialogClose = () => useDialog().onClose;
251
+
174
252
  interface SlotProps {
175
253
  children?: ReactNode;
176
254
  css?: SystemStyleObject;
177
255
  className?: string;
256
+ /** `data-*` attributes land on the slot element, as they did on Chakra's. */
257
+ [key: `data-${string}`]: unknown;
178
258
  }
179
259
 
180
260
  /** Modal title. Rendered as RAC's labelling heading for the dialog. */
@@ -183,12 +263,18 @@ export const ModalHeader = ({
183
263
  css: cssProp,
184
264
  className,
185
265
  level,
266
+ ...rest
186
267
  }: SlotProps & {
187
- /** Heading element level (default 3, like RAC). */ level?: number;
268
+ /**
269
+ * Heading element level. Defaults to 2: RAC's Dialog supplies that through
270
+ * HeadingContext for the `title` slot, not the bare Heading default of 3.
271
+ */
272
+ level?: number;
188
273
  }) => {
189
274
  const { slots } = useDialog();
190
275
  return (
191
276
  <RACHeading
277
+ {...dataAttrs(rest)}
192
278
  slot="title"
193
279
  level={level}
194
280
  className={cx(
@@ -202,10 +288,16 @@ export const ModalHeader = ({
202
288
  );
203
289
  };
204
290
 
205
- export const ModalBody = ({ children, css: cssProp, className }: SlotProps) => {
291
+ export const ModalBody = ({
292
+ children,
293
+ css: cssProp,
294
+ className,
295
+ ...rest
296
+ }: SlotProps) => {
206
297
  const { slots } = useDialog();
207
298
  return (
208
299
  <div
300
+ {...dataAttrs(rest)}
209
301
  className={cx(slots.body, cssProp ? css(cssProp) : undefined, className)}
210
302
  >
211
303
  {children}
@@ -217,10 +309,12 @@ export const ModalFooter = ({
217
309
  children,
218
310
  css: cssProp,
219
311
  className,
312
+ ...rest
220
313
  }: SlotProps) => {
221
314
  const { slots } = useDialog();
222
315
  return (
223
316
  <div
317
+ {...dataAttrs(rest)}
224
318
  className={cx(
225
319
  slots.footer,
226
320
  cssProp ? css(cssProp) : undefined,
@@ -235,6 +329,8 @@ export const ModalFooter = ({
235
329
  export interface ModalCloseButtonProps {
236
330
  /** Accessible name; defaults to the localized close label. */
237
331
  "aria-label"?: string;
332
+ /** `data-*` attributes land on the button. */
333
+ [key: `data-${string}`]: unknown;
238
334
  }
239
335
 
240
336
  /**
@@ -243,11 +339,13 @@ export interface ModalCloseButtonProps {
243
339
  */
244
340
  export const ModalCloseButton = ({
245
341
  "aria-label": ariaLabel,
342
+ ...rest
246
343
  }: ModalCloseButtonProps) => {
247
344
  const intl = useIntl();
248
345
  const { slots, onClose } = useDialog();
249
346
  return (
250
347
  <RACButton
348
+ {...dataAttrs(rest)}
251
349
  aria-label={ariaLabel ?? intl.formatMessage(uiMessage("ui.close-action"))}
252
350
  onPress={onClose}
253
351
  className={cx(
@@ -0,0 +1,179 @@
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
+ borderColor: "gray.200",
70
+ borderRadius: "md",
71
+ bg: "white",
72
+ color: "inherit",
73
+ h: "10",
74
+ px: "4",
75
+ _hover: { borderColor: "gray.300" },
76
+ "&[data-focus-visible]": {
77
+ focusShadow: "outline",
78
+ borderColor: "focusBorder",
79
+ },
80
+ // A ComboBox's control is an input, which is focused whenever it is open.
81
+ "&[data-focused]": { focusShadow: "outline", borderColor: "focusBorder" },
82
+ "&[data-invalid]": { borderColor: "danger.500" },
83
+ "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
84
+ },
85
+ // Whatever shows the current value: Select's SelectValue, ComboBox's
86
+ // input. One slot for both, so an app restyling the placeholder (say)
87
+ // does not have to know which kind of control it is looking at.
88
+ value: {
89
+ flex: "1",
90
+ minWidth: 0,
91
+ overflow: "hidden",
92
+ textOverflow: "ellipsis",
93
+ whiteSpace: "nowrap",
94
+ outline: "none",
95
+ bg: "transparent",
96
+ color: "inherit",
97
+ font: "inherit",
98
+ // RAC sets data-placeholder on SelectValue when nothing is chosen; the
99
+ // ComboBox input uses the real placeholder attribute.
100
+ "&[data-placeholder]": { color: "gray.500" },
101
+ _placeholder: { color: "gray.500" },
102
+ },
103
+ indicator: {
104
+ display: "inline-flex",
105
+ flexShrink: 0,
106
+ alignItems: "center",
107
+ justifyContent: "center",
108
+ fontSize: "1.25em",
109
+ color: "inherit",
110
+ // No pointer-events:none here: in a ComboBox this slot is the button
111
+ // that opens the list. Select's is an aria-hidden span inside the
112
+ // trigger, so it needs no help being inert.
113
+ background: "transparent",
114
+ border: "none",
115
+ cursor: "pointer",
116
+ outline: "none",
117
+ "&[data-focus-visible]": { focusShadow: "outline" },
118
+ },
119
+ content: {
120
+ // Line the card up with the control, as a select should and as
121
+ // react-select did. `Select` gets this from RAC, whose trigger is the
122
+ // button it measures; `ComboBox` measures its own control and sets the
123
+ // width inline, because RAC's var is the *input's* width there.
124
+ minWidth: "var(--trigger-width)",
125
+ bg: "white",
126
+ color: "inherit",
127
+ py: "2",
128
+ zIndex: "popover",
129
+ borderRadius: "md",
130
+ borderWidth: "1px",
131
+ borderColor: "gray.200",
132
+ boxShadow: "sm",
133
+ // Matches the menu recipe's fade/scale.
134
+ transformOrigin: "top",
135
+ opacity: 1,
136
+ transform: "scale(1)",
137
+ transition: "opacity 0.1s ease-out, transform 0.1s ease-out",
138
+ "&[data-entering]": { opacity: 0, transform: "scale(0.95)" },
139
+ "&[data-exiting]": { opacity: 0, transform: "scale(0.95)" },
140
+ _motionReduce: { transition: "none" },
141
+ },
142
+ list: {
143
+ outline: "none",
144
+ maxHeight: "inherit",
145
+ overflowY: "auto",
146
+ },
147
+ option: {
148
+ display: "flex",
149
+ alignItems: "center",
150
+ gap: "2",
151
+ py: "1.5",
152
+ px: "3",
153
+ cursor: "pointer",
154
+ color: "inherit",
155
+ outline: "none",
156
+ transitionProperty: "background",
157
+ transitionDuration: "ultra-fast",
158
+ transitionTimingFunction: "ease-in",
159
+ "&[data-focused]": { bg: "gray.100" },
160
+ "&[data-pressed]": { bg: "gray.200" },
161
+ "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
162
+ },
163
+ optionIndicator: {
164
+ display: "inline-flex",
165
+ flexShrink: 0,
166
+ alignItems: "center",
167
+ justifyContent: "center",
168
+ marginStart: "auto",
169
+ fontSize: "0.8em",
170
+ opacity: 0,
171
+ "[data-selected] &": { opacity: 1 },
172
+ },
173
+ empty: {
174
+ px: "3",
175
+ py: "2",
176
+ color: "gray.600",
177
+ },
178
+ },
179
+ });
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
+ };