@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.
- package/LICENSE.md +53 -0
- package/README.md +222 -0
- package/lang/ui.ca.json +22 -0
- package/lang/ui.cy.json +22 -0
- package/lang/ui.de.json +22 -0
- package/lang/ui.en-us.json +22 -0
- package/lang/ui.en.json +22 -0
- package/lang/ui.es-es.json +22 -0
- package/lang/ui.fr.json +22 -0
- package/lang/ui.ga-ie.json +22 -0
- package/lang/ui.it.json +22 -0
- package/lang/ui.ja.json +22 -0
- package/lang/ui.ko.json +22 -0
- package/lang/ui.lol.json +22 -0
- package/lang/ui.nl.json +22 -0
- package/lang/ui.pl.json +22 -0
- package/lang/ui.pt-br.json +22 -0
- package/lang/ui.zh-cn.json +22 -0
- package/lang/ui.zh-tw.json +22 -0
- package/package.json +66 -0
- package/postcss-legacy-safari.cjs +96 -0
- package/reset.css +35 -0
- package/src/Avatar.recipe.ts +168 -0
- package/src/Avatar.tsx +276 -0
- package/src/Button.recipe.ts +192 -0
- package/src/Button.tsx +69 -0
- package/src/ButtonGroup.tsx +70 -0
- package/src/Card.recipe.ts +49 -0
- package/src/Card.tsx +56 -0
- package/src/Checkbox.recipe.ts +111 -0
- package/src/Checkbox.tsx +106 -0
- package/src/CloseButton.tsx +87 -0
- package/src/CloseIcon.tsx +40 -0
- package/src/Code.tsx +20 -0
- package/src/Collapse.tsx +180 -0
- package/src/ComboBox.tsx +192 -0
- package/src/Divider.tsx +85 -0
- package/src/Drawer.recipe.ts +98 -0
- package/src/Drawer.tsx +138 -0
- package/src/Fade.tsx +48 -0
- package/src/GridList.recipe.ts +58 -0
- package/src/GridList.tsx +81 -0
- package/src/Heading.recipe.ts +52 -0
- package/src/Heading.tsx +14 -0
- package/src/Icon.tsx +75 -0
- package/src/IconButton.tsx +44 -0
- package/src/Image.tsx +11 -0
- package/src/Input.recipe.ts +75 -0
- package/src/Input.tsx +44 -0
- package/src/InputGroup.tsx +62 -0
- package/src/Kbd.tsx +26 -0
- package/src/Link.tsx +23 -0
- package/src/LinkBox.tsx +88 -0
- package/src/LinkButton.tsx +80 -0
- package/src/List.tsx +31 -0
- package/src/ListBox.recipe.ts +43 -0
- package/src/ListBox.tsx +88 -0
- package/src/Menu.recipe.ts +124 -0
- package/src/Menu.tsx +291 -0
- package/src/Modal.recipe.ts +163 -0
- package/src/Modal.tsx +377 -0
- package/src/NativeSelect.tsx +94 -0
- package/src/NumberField.recipe.ts +67 -0
- package/src/NumberField.tsx +86 -0
- package/src/PopoverArrow.tsx +67 -0
- package/src/ProgressBar.tsx +61 -0
- package/src/Radio.recipe.ts +112 -0
- package/src/Radio.tsx +89 -0
- package/src/Select.recipe.ts +210 -0
- package/src/Select.tsx +153 -0
- package/src/SharedUIProvider.tsx +55 -0
- package/src/Skeleton.tsx +146 -0
- package/src/Slide.tsx +54 -0
- package/src/Slider.recipe.ts +102 -0
- package/src/Slider.tsx +163 -0
- package/src/Spinner.tsx +74 -0
- package/src/Svg.tsx +23 -0
- package/src/Switch.recipe.ts +107 -0
- package/src/Switch.tsx +62 -0
- package/src/Text.recipe.ts +29 -0
- package/src/Text.tsx +16 -0
- package/src/TextField.recipe.ts +54 -0
- package/src/TextField.tsx +91 -0
- package/src/Toast.recipe.ts +98 -0
- package/src/Toast.tsx +173 -0
- package/src/Tooltip.recipe.ts +37 -0
- package/src/Tooltip.tsx +76 -0
- package/src/UnmountCallback.tsx +18 -0
- package/src/VisuallyHidden.tsx +14 -0
- package/src/base-preset.ts +371 -0
- package/src/base-tokens.ts +1017 -0
- package/src/button-icon.ts +23 -0
- package/src/data-attrs.ts +16 -0
- package/src/dense-preset.ts +108 -0
- package/src/hooks/useBreakpointValue.ts +63 -0
- package/src/hooks/useClipboard.ts +64 -0
- package/src/hooks/useDisclosure.ts +32 -0
- package/src/hooks/useMediaQuery.ts +28 -0
- package/src/hooks/usePrevious.ts +18 -0
- package/src/index.ts +63 -0
- package/src/messages.ts +17 -0
- 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;
|