@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,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import { ButtonHTMLAttributes, forwardRef } from "react";
|
|
7
|
+
import { css, cx } from "styled-system/css";
|
|
8
|
+
import { SystemStyleObject } from "styled-system/types";
|
|
9
|
+
import { CloseIcon } from "./CloseIcon";
|
|
10
|
+
|
|
11
|
+
export interface CloseButtonProps
|
|
12
|
+
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "className"> {
|
|
13
|
+
/** Chakra CloseButton sizes: sm 24px box, md 32px (default). */
|
|
14
|
+
size?: "sm" | "md";
|
|
15
|
+
/**
|
|
16
|
+
* Grow the touch target 8px beyond the visible button on every side via an
|
|
17
|
+
* ::after overlay.
|
|
18
|
+
*/
|
|
19
|
+
expandHitArea?: boolean;
|
|
20
|
+
"aria-label": string;
|
|
21
|
+
/** Per-instance style overrides, merged after the base. */
|
|
22
|
+
css?: SystemStyleObject;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* CloseButton — Chakra's standalone X button. A plain button (not react-aria)
|
|
28
|
+
* so call sites can extend the hit area with pseudo-elements, which
|
|
29
|
+
* react-aria's press bounding-rect check would defeat.
|
|
30
|
+
*/
|
|
31
|
+
export const CloseButton = forwardRef<HTMLButtonElement, CloseButtonProps>(
|
|
32
|
+
function CloseButton(
|
|
33
|
+
{ size = "md", expandHitArea = false, css: cssProp, className, ...rest },
|
|
34
|
+
ref,
|
|
35
|
+
) {
|
|
36
|
+
return (
|
|
37
|
+
<button
|
|
38
|
+
ref={ref}
|
|
39
|
+
type="button"
|
|
40
|
+
className={cx(
|
|
41
|
+
// A single css() call so per-instance overrides of base properties
|
|
42
|
+
// (e.g. borderRadius) merge rather than racing on stylesheet order.
|
|
43
|
+
css(
|
|
44
|
+
{
|
|
45
|
+
display: "inline-flex",
|
|
46
|
+
alignItems: "center",
|
|
47
|
+
justifyContent: "center",
|
|
48
|
+
flexShrink: 0,
|
|
49
|
+
position: "relative",
|
|
50
|
+
cursor: "pointer",
|
|
51
|
+
bg: "transparent",
|
|
52
|
+
border: "none",
|
|
53
|
+
color: "inherit",
|
|
54
|
+
outline: "none",
|
|
55
|
+
borderRadius: "md",
|
|
56
|
+
transitionProperty: "background-color, box-shadow",
|
|
57
|
+
transitionDuration: "normal",
|
|
58
|
+
_hover: { bg: "blackAlpha.100" },
|
|
59
|
+
_active: { bg: "blackAlpha.200" },
|
|
60
|
+
_focusVisible: { focusShadow: "outline" },
|
|
61
|
+
},
|
|
62
|
+
size === "sm"
|
|
63
|
+
? { width: "6", height: "6", fontSize: "2xs" }
|
|
64
|
+
: { width: "8", height: "8", fontSize: "xs" },
|
|
65
|
+
expandHitArea
|
|
66
|
+
? {
|
|
67
|
+
_after: {
|
|
68
|
+
position: "absolute",
|
|
69
|
+
top: -2,
|
|
70
|
+
right: -2,
|
|
71
|
+
bottom: -2,
|
|
72
|
+
left: -2,
|
|
73
|
+
content: '""',
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
: undefined,
|
|
77
|
+
cssProp,
|
|
78
|
+
),
|
|
79
|
+
className,
|
|
80
|
+
)}
|
|
81
|
+
{...rest}
|
|
82
|
+
>
|
|
83
|
+
<CloseIcon />
|
|
84
|
+
</button>
|
|
85
|
+
);
|
|
86
|
+
},
|
|
87
|
+
);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import { css, cx } from "styled-system/css";
|
|
7
|
+
import { SystemStyleObject } from "styled-system/types";
|
|
8
|
+
|
|
9
|
+
export interface CloseIconProps {
|
|
10
|
+
css?: SystemStyleObject;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* CloseIcon — the "✕" glyph used on close buttons (dialogs, toasts). This is
|
|
16
|
+
* Chakra's exact CloseButton path so the visual matches; sized to `1em` and
|
|
17
|
+
* `fill: currentColor` like the other icons. Reused wherever a close control
|
|
18
|
+
* is needed.
|
|
19
|
+
*/
|
|
20
|
+
export const CloseIcon = ({ css: cssProp, className }: CloseIconProps) => (
|
|
21
|
+
<svg
|
|
22
|
+
viewBox="0 0 24 24"
|
|
23
|
+
focusable="false"
|
|
24
|
+
aria-hidden="true"
|
|
25
|
+
className={cx(
|
|
26
|
+
css({
|
|
27
|
+
width: "1em",
|
|
28
|
+
height: "1em",
|
|
29
|
+
display: "inline-block",
|
|
30
|
+
lineHeight: "1em",
|
|
31
|
+
flexShrink: 0,
|
|
32
|
+
fill: "currentColor",
|
|
33
|
+
...cssProp,
|
|
34
|
+
}),
|
|
35
|
+
className,
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
<path d="M.439,21.44a1.5,1.5,0,0,0,2.122,2.121L11.823,14.3a.25.25,0,0,1,.354,0l9.262,9.263a1.5,1.5,0,1,0,2.122-2.121L14.3,12.177a.25.25,0,0,1,0-.354l9.263-9.262A1.5,1.5,0,0,0,21.439.44L12.177,9.7a.25.25,0,0,1-.354,0L2.561.44A1.5,1.5,0,0,0,.439,2.561L9.7,11.823a.25.25,0,0,1,0,.354Z" />
|
|
39
|
+
</svg>
|
|
40
|
+
);
|
package/src/Code.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import { styled } from "styled-system/jsx";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Code — inline code chip matching Chakra's <Code> (gray subtle, light mode).
|
|
10
|
+
*/
|
|
11
|
+
export const Code = styled("code", {
|
|
12
|
+
base: {
|
|
13
|
+
fontFamily: "mono",
|
|
14
|
+
fontSize: "sm",
|
|
15
|
+
px: "0.2em",
|
|
16
|
+
borderRadius: "sm",
|
|
17
|
+
bg: "gray.100",
|
|
18
|
+
color: "gray.800",
|
|
19
|
+
},
|
|
20
|
+
});
|
package/src/Collapse.tsx
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
CSSProperties,
|
|
8
|
+
ReactNode,
|
|
9
|
+
useEffect,
|
|
10
|
+
useLayoutEffect,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from "react";
|
|
14
|
+
import { css, cx } from "styled-system/css";
|
|
15
|
+
import { SystemStyleObject } from "styled-system/types";
|
|
16
|
+
|
|
17
|
+
export interface CollapseProps {
|
|
18
|
+
/** Expanded when true (Chakra's `in`). */
|
|
19
|
+
isOpen: boolean;
|
|
20
|
+
/** Height when collapsed (Chakra's `startingHeight`). */
|
|
21
|
+
startingHeight?: number | string;
|
|
22
|
+
/**
|
|
23
|
+
* Height when expanded (Chakra's `endingHeight`; defaults to the measured
|
|
24
|
+
* content height, tracked with a ResizeObserver so nested expansion works).
|
|
25
|
+
*/
|
|
26
|
+
endingHeight?: number | string;
|
|
27
|
+
/** Remove the content from the DOM once the exit transition finishes. */
|
|
28
|
+
unmountOnExit?: boolean;
|
|
29
|
+
css?: SystemStyleObject;
|
|
30
|
+
className?: string;
|
|
31
|
+
style?: CSSProperties;
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const toCssSize = (v: number | string) =>
|
|
36
|
+
typeof v === "number" ? `${v}px` : v;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Collapse — Chakra's Collapse transition without framer-motion: the content
|
|
40
|
+
* is measured and the wrapper's height (and opacity, when collapsing to zero)
|
|
41
|
+
* transitions between the collapsed and expanded sizes. Content stays mounted
|
|
42
|
+
* unless `unmountOnExit` is set.
|
|
43
|
+
*
|
|
44
|
+
* As with framer-motion, only `isOpen` changes animate: initial mounts render
|
|
45
|
+
* at rest, and height corrections (late measurement, content growth) snap —
|
|
46
|
+
* otherwise a collapse mounted inside an entering view morphs during the
|
|
47
|
+
* outer animation.
|
|
48
|
+
*/
|
|
49
|
+
export const Collapse = ({
|
|
50
|
+
isOpen,
|
|
51
|
+
startingHeight = 0,
|
|
52
|
+
endingHeight,
|
|
53
|
+
unmountOnExit = false,
|
|
54
|
+
css: cssProp,
|
|
55
|
+
className,
|
|
56
|
+
style,
|
|
57
|
+
children,
|
|
58
|
+
}: CollapseProps) => {
|
|
59
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
60
|
+
const [measuredHeight, setMeasuredHeight] = useState<number>();
|
|
61
|
+
// For unmountOnExit: stay in the DOM until the exit transition ends.
|
|
62
|
+
const [present, setPresent] = useState(isOpen);
|
|
63
|
+
// The state the DOM shows. It follows isOpen from a layout effect so the
|
|
64
|
+
// transition-enable and the height flip land in the same commit (a
|
|
65
|
+
// render-time flip detector is unreliable: React can discard renders,
|
|
66
|
+
// losing the state update while keeping ref mutations).
|
|
67
|
+
const [displayOpen, setDisplayOpen] = useState(isOpen);
|
|
68
|
+
const [animating, setAnimating] = useState(false);
|
|
69
|
+
|
|
70
|
+
useLayoutEffect(() => {
|
|
71
|
+
if (isOpen === displayOpen) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (isOpen) {
|
|
75
|
+
// Opening: mount (collapsed) first, then flip on the next frame so
|
|
76
|
+
// the browser has a computed collapsed state to transition from.
|
|
77
|
+
setPresent(true);
|
|
78
|
+
const raf = requestAnimationFrame(() => {
|
|
79
|
+
setAnimating(true);
|
|
80
|
+
setDisplayOpen(true);
|
|
81
|
+
});
|
|
82
|
+
return () => cancelAnimationFrame(raf);
|
|
83
|
+
}
|
|
84
|
+
// Closing: enable the transition and flip together, pre-paint.
|
|
85
|
+
setAnimating(true);
|
|
86
|
+
setDisplayOpen(false);
|
|
87
|
+
}, [isOpen, displayOpen]);
|
|
88
|
+
|
|
89
|
+
useLayoutEffect(() => {
|
|
90
|
+
const el = contentRef.current;
|
|
91
|
+
if (!el || endingHeight !== undefined) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
setMeasuredHeight(el.scrollHeight);
|
|
95
|
+
const observer = new ResizeObserver(() =>
|
|
96
|
+
setMeasuredHeight(el.scrollHeight),
|
|
97
|
+
);
|
|
98
|
+
observer.observe(el);
|
|
99
|
+
return () => observer.disconnect();
|
|
100
|
+
}, [endingHeight, present]);
|
|
101
|
+
|
|
102
|
+
// Fallback for browsers/edge cases where transitionend doesn't fire.
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (animating) {
|
|
105
|
+
const timeout = setTimeout(() => {
|
|
106
|
+
setAnimating(false);
|
|
107
|
+
if (!isOpen && unmountOnExit) {
|
|
108
|
+
setPresent(false);
|
|
109
|
+
}
|
|
110
|
+
}, 400);
|
|
111
|
+
return () => clearTimeout(timeout);
|
|
112
|
+
}
|
|
113
|
+
}, [animating, isOpen, unmountOnExit]);
|
|
114
|
+
|
|
115
|
+
if (unmountOnExit && !present) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const expanded =
|
|
120
|
+
endingHeight !== undefined
|
|
121
|
+
? toCssSize(endingHeight)
|
|
122
|
+
: measuredHeight !== undefined
|
|
123
|
+
? `${measuredHeight}px`
|
|
124
|
+
: "auto";
|
|
125
|
+
const collapsed = toCssSize(startingHeight);
|
|
126
|
+
const hideWhenCollapsed = collapsed === "0px" || collapsed === "0";
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
onTransitionEnd={(e) => {
|
|
131
|
+
if (e.propertyName === "height" && e.target === e.currentTarget) {
|
|
132
|
+
setAnimating(false);
|
|
133
|
+
if (!isOpen && unmountOnExit) {
|
|
134
|
+
setPresent(false);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}}
|
|
138
|
+
className={cx(
|
|
139
|
+
css(
|
|
140
|
+
{
|
|
141
|
+
overflow: "hidden",
|
|
142
|
+
display: "block",
|
|
143
|
+
transitionDuration: "0.25s",
|
|
144
|
+
transitionTimingFunction: "cubic-bezier(0.4, 0, 0.2, 1)",
|
|
145
|
+
_motionReduce: { transition: "none" },
|
|
146
|
+
},
|
|
147
|
+
cssProp,
|
|
148
|
+
),
|
|
149
|
+
className,
|
|
150
|
+
)}
|
|
151
|
+
// Runtime-measured/caller-supplied heights; transitions enabled only
|
|
152
|
+
// while an isOpen change is in flight.
|
|
153
|
+
style={{
|
|
154
|
+
transitionProperty: animating ? "height, opacity" : "none",
|
|
155
|
+
height: displayOpen ? expanded : collapsed,
|
|
156
|
+
opacity: displayOpen || !hideWhenCollapsed ? 1 : 0,
|
|
157
|
+
// At rest fully-collapsed, remove the content from the a11y tree
|
|
158
|
+
// and tab order, as Chakra did (kept visible while animating so
|
|
159
|
+
// the exit transition shows).
|
|
160
|
+
visibility:
|
|
161
|
+
displayOpen || animating || !hideWhenCollapsed ? undefined : "hidden",
|
|
162
|
+
...style,
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
<div
|
|
166
|
+
ref={contentRef}
|
|
167
|
+
// flow-root: the measured element must be a block formatting
|
|
168
|
+
// context, or children's margins collapse through it and
|
|
169
|
+
// scrollHeight under-measures — the outer (overflow: hidden)
|
|
170
|
+
// wrapper IS a BFC and needs the full height, so the mismatch
|
|
171
|
+
// clips the last line of margined content (python-editor's API
|
|
172
|
+
// docs). Rendering is unchanged: the margins always displayed
|
|
173
|
+
// inside the outer BFC.
|
|
174
|
+
className={css({ display: "flow-root" })}
|
|
175
|
+
>
|
|
176
|
+
{children}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
};
|
package/src/ComboBox.tsx
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
ForwardedRef,
|
|
8
|
+
forwardRef,
|
|
9
|
+
ReactNode,
|
|
10
|
+
useLayoutEffect,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from "react";
|
|
14
|
+
import {
|
|
15
|
+
Button as RACButton,
|
|
16
|
+
ComboBox as RACComboBox,
|
|
17
|
+
ComboBoxProps as RACComboBoxProps,
|
|
18
|
+
Input as RACInput,
|
|
19
|
+
Label as RACLabel,
|
|
20
|
+
ListBox as RACListBox,
|
|
21
|
+
Popover,
|
|
22
|
+
PopoverProps,
|
|
23
|
+
} from "react-aria-components";
|
|
24
|
+
import { RiArrowDownSLine } from "react-icons/ri";
|
|
25
|
+
import { css, cx } from "styled-system/css";
|
|
26
|
+
import { select, SelectVariantProps } from "styled-system/recipes";
|
|
27
|
+
import { SystemStyleObject } from "styled-system/types";
|
|
28
|
+
import { Icon } from "./Icon";
|
|
29
|
+
import { SelectSlotProvider } from "./Select";
|
|
30
|
+
|
|
31
|
+
export interface ComboBoxProps<T extends object>
|
|
32
|
+
extends Omit<RACComboBoxProps<T>, "className" | "children" | "style">,
|
|
33
|
+
SelectVariantProps {
|
|
34
|
+
/** Visible label. Use `aria-label` instead where the design has none. */
|
|
35
|
+
label?: ReactNode;
|
|
36
|
+
placeholder?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Rendered inside the control, before the input — an icon for the current
|
|
39
|
+
* value, say. A ComboBox's control is a text input, so unlike a Select it
|
|
40
|
+
* cannot show anything but text for what is chosen; this is the way round
|
|
41
|
+
* that (react-select did it with a custom `SingleValue`).
|
|
42
|
+
*/
|
|
43
|
+
startContent?: ReactNode;
|
|
44
|
+
/** `SelectOption`s. */
|
|
45
|
+
children: ReactNode;
|
|
46
|
+
/**
|
|
47
|
+
* Replaces the chevron; pass `null` for none, which is what a plain
|
|
48
|
+
* autocomplete wants (react-select's `dropdownIndicator: display none`).
|
|
49
|
+
*/
|
|
50
|
+
indicator?: ReactNode | null;
|
|
51
|
+
/**
|
|
52
|
+
* Shown in place of the list when nothing matches (react-select's
|
|
53
|
+
* `noOptionsMessage`). Implies `allowsEmptyCollection`, since RAC otherwise
|
|
54
|
+
* closes the popover the moment the collection empties.
|
|
55
|
+
*/
|
|
56
|
+
emptyState?: ReactNode;
|
|
57
|
+
/**
|
|
58
|
+
* Keep the dropdown shut until this prop is true. For gating on a minimum
|
|
59
|
+
* query length — react-aria has no `minLength`, and rendering an empty list
|
|
60
|
+
* still opens an empty card.
|
|
61
|
+
*/
|
|
62
|
+
isPopoverHidden?: boolean;
|
|
63
|
+
placement?: PopoverProps["placement"];
|
|
64
|
+
/**
|
|
65
|
+
* Cap the dropdown's height (react-select's `maxMenuHeight`). A prop rather
|
|
66
|
+
* than a `contentCss` rule because RAC writes its own max-height inline
|
|
67
|
+
* while positioning, which beats any class.
|
|
68
|
+
*/
|
|
69
|
+
maxHeight?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Per-instance overrides for the control — the box around the input, its
|
|
72
|
+
* `startContent` and its indicator, which is what `Select`'s `css` styles
|
|
73
|
+
* too. Reach the input itself through the `select` recipe's `value` slot.
|
|
74
|
+
*/
|
|
75
|
+
css?: SystemStyleObject;
|
|
76
|
+
/** Per-instance overrides for the dropdown card. */
|
|
77
|
+
contentCss?: SystemStyleObject;
|
|
78
|
+
className?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* ComboBox — a text input that filters a listbox, for choosing one of a known
|
|
83
|
+
* set where typing to narrow it down is the point. Use Select where the list
|
|
84
|
+
* is short enough to just pick from.
|
|
85
|
+
*
|
|
86
|
+
* Note the react-select difference this replaces: react-select filtered on
|
|
87
|
+
* `label` and kept the menu open on selection unless told otherwise, whereas
|
|
88
|
+
* react-aria filters on each item's `textValue` and closes on selection.
|
|
89
|
+
*/
|
|
90
|
+
const ComboBoxInner = <T extends object>(
|
|
91
|
+
{
|
|
92
|
+
label,
|
|
93
|
+
placeholder,
|
|
94
|
+
startContent,
|
|
95
|
+
children,
|
|
96
|
+
indicator,
|
|
97
|
+
emptyState,
|
|
98
|
+
isPopoverHidden,
|
|
99
|
+
placement = "bottom start",
|
|
100
|
+
maxHeight,
|
|
101
|
+
css: cssProp,
|
|
102
|
+
contentCss,
|
|
103
|
+
className,
|
|
104
|
+
...props
|
|
105
|
+
}: ComboBoxProps<T>,
|
|
106
|
+
ref: ForwardedRef<HTMLInputElement>,
|
|
107
|
+
) => {
|
|
108
|
+
// As Select: forward whatever variant groups the merged recipe has.
|
|
109
|
+
const [variantProps, rest] = select.splitVariantProps(props);
|
|
110
|
+
const slots = select(variantProps);
|
|
111
|
+
// Anchor the card to the whole control, not to the bare input inside it —
|
|
112
|
+
// otherwise it hangs off the text baseline and is as narrow as the input.
|
|
113
|
+
const triggerRef = useRef<HTMLDivElement>(null);
|
|
114
|
+
// RAC's --trigger-width measures the input it anchors a ComboBox to, which
|
|
115
|
+
// is the control's content box — so a card sized from it is narrower than
|
|
116
|
+
// the field by the padding and border. Measure the control instead. State
|
|
117
|
+
// rather than reading the ref at render time: the popover is mounted from
|
|
118
|
+
// the first render, before the ref is set, and nothing would re-render it.
|
|
119
|
+
const [triggerWidth, setTriggerWidth] = useState<number>();
|
|
120
|
+
useLayoutEffect(() => {
|
|
121
|
+
const el = triggerRef.current;
|
|
122
|
+
if (!el) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const update = () => setTriggerWidth(el.offsetWidth);
|
|
126
|
+
update();
|
|
127
|
+
if (typeof ResizeObserver === "undefined") {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const observer = new ResizeObserver(update);
|
|
131
|
+
observer.observe(el);
|
|
132
|
+
return () => observer.disconnect();
|
|
133
|
+
}, []);
|
|
134
|
+
return (
|
|
135
|
+
<SelectSlotProvider value={slots}>
|
|
136
|
+
<RACComboBox
|
|
137
|
+
allowsEmptyCollection={emptyState != null}
|
|
138
|
+
{...(rest as RACComboBoxProps<T>)}
|
|
139
|
+
className={cx(slots.root, className)}
|
|
140
|
+
>
|
|
141
|
+
{label != null && <RACLabel className={slots.label}>{label}</RACLabel>}
|
|
142
|
+
<div
|
|
143
|
+
ref={triggerRef}
|
|
144
|
+
className={cx(slots.trigger, cssProp ? css(cssProp) : undefined)}
|
|
145
|
+
>
|
|
146
|
+
{startContent}
|
|
147
|
+
<RACInput
|
|
148
|
+
ref={ref}
|
|
149
|
+
placeholder={placeholder}
|
|
150
|
+
className={slots.value}
|
|
151
|
+
/>
|
|
152
|
+
{indicator !== null && (
|
|
153
|
+
<RACButton className={slots.indicator}>
|
|
154
|
+
{indicator ?? <Icon as={RiArrowDownSLine} />}
|
|
155
|
+
</RACButton>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
{!isPopoverHidden && (
|
|
159
|
+
<Popover
|
|
160
|
+
triggerRef={triggerRef}
|
|
161
|
+
placement={placement}
|
|
162
|
+
maxHeight={maxHeight}
|
|
163
|
+
style={triggerWidth ? { width: triggerWidth } : undefined}
|
|
164
|
+
className={cx(
|
|
165
|
+
slots.content,
|
|
166
|
+
contentCss ? css(contentCss) : undefined,
|
|
167
|
+
)}
|
|
168
|
+
>
|
|
169
|
+
<RACListBox
|
|
170
|
+
className={slots.list}
|
|
171
|
+
renderEmptyState={
|
|
172
|
+
emptyState
|
|
173
|
+
? () => <div className={slots.empty}>{emptyState}</div>
|
|
174
|
+
: undefined
|
|
175
|
+
}
|
|
176
|
+
>
|
|
177
|
+
{children}
|
|
178
|
+
</RACListBox>
|
|
179
|
+
</Popover>
|
|
180
|
+
)}
|
|
181
|
+
</RACComboBox>
|
|
182
|
+
</SelectSlotProvider>
|
|
183
|
+
);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* forwardRef with generics needs the cast (React's types cannot express it),
|
|
188
|
+
* so the ref lands on the input — call sites focus it for validation.
|
|
189
|
+
*/
|
|
190
|
+
export const ComboBox = forwardRef(ComboBoxInner) as <T extends object>(
|
|
191
|
+
props: ComboBoxProps<T> & { ref?: ForwardedRef<HTMLInputElement> },
|
|
192
|
+
) => ReturnType<typeof ComboBoxInner>;
|
package/src/Divider.tsx
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import { ComponentProps, forwardRef } from "react";
|
|
7
|
+
import { styled } from "styled-system/jsx";
|
|
8
|
+
|
|
9
|
+
const StyledDivider = styled("hr", {
|
|
10
|
+
base: {
|
|
11
|
+
border: 0,
|
|
12
|
+
borderColor: "gray.200",
|
|
13
|
+
opacity: 0.6,
|
|
14
|
+
},
|
|
15
|
+
variants: {
|
|
16
|
+
orientation: {
|
|
17
|
+
horizontal: {
|
|
18
|
+
borderBottomStyle: "solid",
|
|
19
|
+
width: "100%",
|
|
20
|
+
},
|
|
21
|
+
vertical: {
|
|
22
|
+
borderLeftStyle: "solid",
|
|
23
|
+
height: "100%",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
// Rule weight, on the orientation's drawn edge (see compoundVariants).
|
|
27
|
+
// Semantic rather than a raw border width so call sites don't need to
|
|
28
|
+
// know which edge an orientation draws with.
|
|
29
|
+
thickness: {
|
|
30
|
+
thin: {},
|
|
31
|
+
thick: {},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
compoundVariants: [
|
|
35
|
+
{
|
|
36
|
+
orientation: "horizontal",
|
|
37
|
+
thickness: "thin",
|
|
38
|
+
css: { borderBottomWidth: "1px" },
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
orientation: "horizontal",
|
|
42
|
+
thickness: "thick",
|
|
43
|
+
css: { borderBottomWidth: "2px" },
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
orientation: "vertical",
|
|
47
|
+
thickness: "thin",
|
|
48
|
+
css: { borderLeftWidth: "1px" },
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
orientation: "vertical",
|
|
52
|
+
thickness: "thick",
|
|
53
|
+
css: { borderLeftWidth: "2px" },
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
defaultVariants: { orientation: "horizontal", thickness: "thin" },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export interface DividerProps extends ComponentProps<typeof StyledDivider> {}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Divider — a hairline rule matching Chakra's <Divider> (60% opacity; set
|
|
63
|
+
* `borderColor` to tint, `thickness="thick"` for a 2px rule).
|
|
64
|
+
* `orientation="vertical"` needs a height from the layout, e.g. a stretched
|
|
65
|
+
* flex row (Chakra's vertical divider likewise relied on `height: 100%`).
|
|
66
|
+
*
|
|
67
|
+
* Decorative by default: hidden from assistive tech, since a visual rule
|
|
68
|
+
* between sections is noise as an announced separator (and every call site
|
|
69
|
+
* was opting out by hand). Pass `aria-hidden={false}` for a divider that
|
|
70
|
+
* should be exposed as a semantic separator.
|
|
71
|
+
*/
|
|
72
|
+
export const Divider = forwardRef<HTMLHRElement, DividerProps>(
|
|
73
|
+
function Divider(props, ref) {
|
|
74
|
+
return (
|
|
75
|
+
<StyledDivider
|
|
76
|
+
ref={ref}
|
|
77
|
+
aria-hidden
|
|
78
|
+
aria-orientation={
|
|
79
|
+
props.orientation === "vertical" ? "vertical" : "horizontal"
|
|
80
|
+
}
|
|
81
|
+
{...props}
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
},
|
|
85
|
+
);
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
* Drawer slot recipe — Chakra's default Drawer parts (light mode) at its
|
|
10
|
+
* default `xs` size (20rem panel). Consumed by the shared-ui Drawer, which maps
|
|
11
|
+
* the slots onto react-aria-components' ModalOverlay / Modal / Dialog.
|
|
12
|
+
*
|
|
13
|
+
* The enter transition approximates Chakra's slide spring (damping 25,
|
|
14
|
+
* stiffness 180) with a decelerating tween; exit matches its 0.15s ease-in-out
|
|
15
|
+
* tween.
|
|
16
|
+
*
|
|
17
|
+
* Registered in the base preset (base-preset.ts); `placement` is
|
|
18
|
+
* forwarded as a runtime prop so the variants are generated via `staticCss`.
|
|
19
|
+
*/
|
|
20
|
+
export const drawer = defineSlotRecipe({
|
|
21
|
+
className: "drawer",
|
|
22
|
+
slots: ["overlay", "content", "inner", "header", "body"],
|
|
23
|
+
base: {
|
|
24
|
+
overlay: {
|
|
25
|
+
position: "fixed",
|
|
26
|
+
inset: 0,
|
|
27
|
+
w: "100%",
|
|
28
|
+
h: "100%",
|
|
29
|
+
bg: "blackAlpha.600",
|
|
30
|
+
zIndex: "modal",
|
|
31
|
+
opacity: 1,
|
|
32
|
+
transition: "opacity 0.2s ease-out",
|
|
33
|
+
"&[data-entering]": { opacity: 0 },
|
|
34
|
+
"&[data-exiting]": { opacity: 0 },
|
|
35
|
+
_motionReduce: { transition: "none" },
|
|
36
|
+
},
|
|
37
|
+
content: {
|
|
38
|
+
position: "fixed",
|
|
39
|
+
top: 0,
|
|
40
|
+
bottom: 0,
|
|
41
|
+
width: "100%",
|
|
42
|
+
maxWidth: "xs",
|
|
43
|
+
maxH: "100dvh",
|
|
44
|
+
color: "inherit",
|
|
45
|
+
bg: "white",
|
|
46
|
+
boxShadow: "lg",
|
|
47
|
+
display: "flex",
|
|
48
|
+
flexDirection: "column",
|
|
49
|
+
outline: "none",
|
|
50
|
+
zIndex: "modal",
|
|
51
|
+
transform: "translateX(0)",
|
|
52
|
+
transition: "transform 0.3s cubic-bezier(0, 0, 0.2, 1)",
|
|
53
|
+
"&[data-exiting]": {
|
|
54
|
+
transition: "transform 0.15s cubic-bezier(0.4, 0, 0.2, 1)",
|
|
55
|
+
},
|
|
56
|
+
_motionReduce: { transition: "none" },
|
|
57
|
+
},
|
|
58
|
+
inner: {
|
|
59
|
+
outline: "none",
|
|
60
|
+
display: "flex",
|
|
61
|
+
flexDirection: "column",
|
|
62
|
+
width: "100%",
|
|
63
|
+
height: "100%",
|
|
64
|
+
},
|
|
65
|
+
header: {
|
|
66
|
+
px: "6",
|
|
67
|
+
py: "4",
|
|
68
|
+
fontSize: "xl",
|
|
69
|
+
fontWeight: "semibold",
|
|
70
|
+
flexShrink: 0,
|
|
71
|
+
},
|
|
72
|
+
body: {
|
|
73
|
+
px: "6",
|
|
74
|
+
py: "2",
|
|
75
|
+
flex: "1",
|
|
76
|
+
overflow: "auto",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
variants: {
|
|
80
|
+
placement: {
|
|
81
|
+
left: {
|
|
82
|
+
content: {
|
|
83
|
+
left: 0,
|
|
84
|
+
"&[data-entering]": { transform: "translateX(-100%)" },
|
|
85
|
+
"&[data-exiting]": { transform: "translateX(-100%)" },
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
right: {
|
|
89
|
+
content: {
|
|
90
|
+
right: 0,
|
|
91
|
+
"&[data-entering]": { transform: "translateX(100%)" },
|
|
92
|
+
"&[data-exiting]": { transform: "translateX(100%)" },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
defaultVariants: { placement: "left" },
|
|
98
|
+
});
|