@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/README.md +7 -5
- package/lang/ui.cy.json +22 -0
- package/lang/ui.it.json +22 -0
- package/package.json +2 -1
- package/src/Avatar.recipe.ts +168 -0
- package/src/Avatar.tsx +276 -0
- package/src/Button.recipe.ts +39 -12
- package/src/Checkbox.tsx +59 -29
- package/src/ComboBox.tsx +192 -0
- package/src/GridList.recipe.ts +46 -0
- package/src/GridList.tsx +81 -0
- package/src/Icon.tsx +23 -3
- package/src/Input.tsx +6 -2
- package/src/ListBox.recipe.ts +43 -0
- package/src/ListBox.tsx +88 -0
- package/src/Menu.recipe.ts +6 -1
- package/src/Menu.tsx +54 -24
- package/src/Modal.tsx +105 -7
- package/src/Select.recipe.ts +179 -0
- package/src/Select.tsx +153 -0
- package/src/Skeleton.tsx +146 -0
- package/src/Spinner.tsx +5 -0
- package/src/TextField.tsx +5 -3
- package/src/Tooltip.recipe.ts +37 -0
- package/src/Tooltip.tsx +3 -18
- package/src/base-preset.ts +54 -2
- package/src/data-attrs.ts +16 -0
- package/src/dense-preset.ts +108 -0
- package/src/hooks/useDisclosure.ts +32 -0
- package/src/index.ts +8 -0
- package/src/system.ts +9 -0
package/src/Menu.tsx
CHANGED
|
@@ -156,45 +156,75 @@ export const MenuItem = ({
|
|
|
156
156
|
);
|
|
157
157
|
};
|
|
158
158
|
|
|
159
|
-
|
|
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
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
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={
|
|
215
|
+
selectionMode={props.type === "checkbox" ? "multiple" : "single"}
|
|
216
|
+
selectedKeys={selectedKeys}
|
|
192
217
|
onSelectionChange={(keys) => {
|
|
193
|
-
if (keys
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 = ({
|
|
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
|
+
};
|