@microbit/ui 0.1.0-alpha.3
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 +21 -0
- package/README.md +118 -0
- package/lang/ui.ca.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.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-tw.json +22 -0
- package/package.json +54 -0
- package/src/Button.recipe.ts +162 -0
- package/src/Button.tsx +84 -0
- package/src/ButtonGroup.tsx +63 -0
- package/src/Card.recipe.ts +49 -0
- package/src/Card.tsx +56 -0
- package/src/Checkbox.recipe.ts +71 -0
- package/src/Checkbox.tsx +66 -0
- package/src/CloseButton.tsx +87 -0
- package/src/CloseIcon.tsx +40 -0
- package/src/Divider.tsx +21 -0
- package/src/Drawer.recipe.ts +98 -0
- package/src/Drawer.tsx +138 -0
- package/src/Heading.recipe.ts +52 -0
- package/src/Heading.tsx +14 -0
- package/src/Icon.tsx +55 -0
- package/src/IconButton.tsx +39 -0
- package/src/Image.tsx +11 -0
- package/src/Input.recipe.ts +63 -0
- package/src/Input.tsx +33 -0
- package/src/InputGroup.tsx +48 -0
- package/src/Link.tsx +23 -0
- package/src/LinkBox.tsx +88 -0
- package/src/List.tsx +27 -0
- package/src/Menu.recipe.ts +88 -0
- package/src/Menu.tsx +172 -0
- package/src/Modal.recipe.ts +163 -0
- package/src/Modal.tsx +265 -0
- package/src/NativeSelect.tsx +76 -0
- package/src/PopoverArrow.tsx +52 -0
- package/src/ProgressBar.tsx +61 -0
- package/src/SharedUIProvider.tsx +55 -0
- package/src/Slide.tsx +54 -0
- package/src/Slider.recipe.ts +91 -0
- package/src/Slider.tsx +99 -0
- package/src/Spinner.tsx +69 -0
- package/src/Svg.tsx +23 -0
- package/src/Switch.recipe.ts +80 -0
- package/src/Switch.tsx +60 -0
- package/src/Text.tsx +12 -0
- package/src/TextField.recipe.ts +54 -0
- package/src/TextField.tsx +72 -0
- package/src/Toast.recipe.ts +98 -0
- package/src/Toast.tsx +165 -0
- package/src/Tooltip.tsx +91 -0
- package/src/UnmountCallback.tsx +18 -0
- package/src/VisuallyHidden.tsx +14 -0
- package/src/base-preset.ts +278 -0
- package/src/chakra-tokens.ts +1014 -0
- package/src/hooks/useBreakpointValue.ts +63 -0
- package/src/index.ts +45 -0
- package/src/messages.ts +17 -0
- package/src/system.ts +36 -0
package/src/Menu.tsx
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import { ReactNode, useCallback, useState } from "react";
|
|
7
|
+
import {
|
|
8
|
+
Menu as RACMenu,
|
|
9
|
+
MenuItem as RACMenuItem,
|
|
10
|
+
MenuItemProps as RACMenuItemProps,
|
|
11
|
+
MenuTrigger as RACMenuTrigger,
|
|
12
|
+
Popover,
|
|
13
|
+
PopoverProps,
|
|
14
|
+
Separator,
|
|
15
|
+
} from "react-aria-components";
|
|
16
|
+
import { css, cx } from "styled-system/css";
|
|
17
|
+
import { menu } from "styled-system/recipes";
|
|
18
|
+
import { SystemStyleObject } from "styled-system/types";
|
|
19
|
+
import { useOverlayCloseRegistrar } from "./SharedUIProvider";
|
|
20
|
+
|
|
21
|
+
export interface MenuTriggerProps {
|
|
22
|
+
/** Called when the menu opens. */
|
|
23
|
+
onOpen?: () => void;
|
|
24
|
+
/** Called when the menu closes. */
|
|
25
|
+
onClose?: () => void;
|
|
26
|
+
/** The trigger element followed by a `MenuList` (react-aria pattern). */
|
|
27
|
+
children: ReactNode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* MenuTrigger — react-aria-components' <MenuTrigger>. Its first child is the
|
|
32
|
+
* trigger (e.g. a Button); the second is a `MenuList`. RAC returns focus to the
|
|
33
|
+
* trigger when the menu closes.
|
|
34
|
+
*
|
|
35
|
+
* When the app installs an overlay-close registrar (e.g. so the Android back
|
|
36
|
+
* button can close the menu), the trigger runs controlled and registers its
|
|
37
|
+
* close function while open. Otherwise it's an uncontrolled pass-through.
|
|
38
|
+
*/
|
|
39
|
+
export const MenuTrigger = ({
|
|
40
|
+
onOpen,
|
|
41
|
+
onClose,
|
|
42
|
+
children,
|
|
43
|
+
}: MenuTriggerProps) => {
|
|
44
|
+
const registerClose = useOverlayCloseRegistrar();
|
|
45
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
46
|
+
|
|
47
|
+
const handleOpenChange = useCallback(
|
|
48
|
+
(open: boolean) => {
|
|
49
|
+
setIsOpen(open);
|
|
50
|
+
if (open) {
|
|
51
|
+
registerClose?.(() => {
|
|
52
|
+
setIsOpen(false);
|
|
53
|
+
registerClose(null);
|
|
54
|
+
});
|
|
55
|
+
onOpen?.();
|
|
56
|
+
} else {
|
|
57
|
+
registerClose?.(null);
|
|
58
|
+
onClose?.();
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
[registerClose, onOpen, onClose],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
if (!registerClose) {
|
|
65
|
+
return (
|
|
66
|
+
<RACMenuTrigger
|
|
67
|
+
onOpenChange={(open) => (open ? onOpen?.() : onClose?.())}
|
|
68
|
+
>
|
|
69
|
+
{children}
|
|
70
|
+
</RACMenuTrigger>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<RACMenuTrigger isOpen={isOpen} onOpenChange={handleOpenChange}>
|
|
76
|
+
{children}
|
|
77
|
+
</RACMenuTrigger>
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export interface MenuListProps {
|
|
82
|
+
children: ReactNode;
|
|
83
|
+
/** Placement of the dropdown relative to the trigger. */
|
|
84
|
+
placement?: PopoverProps["placement"];
|
|
85
|
+
/** Per-instance style overrides, merged after the recipe. */
|
|
86
|
+
css?: SystemStyleObject;
|
|
87
|
+
className?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* MenuList — the dropdown surface: RAC's <Popover> (positioned card) wrapping a
|
|
92
|
+
* RAC <Menu> (the list). Place `MenuItem`s inside.
|
|
93
|
+
*/
|
|
94
|
+
export const MenuList = ({
|
|
95
|
+
children,
|
|
96
|
+
// Matches Chakra Menu's default "bottom-start".
|
|
97
|
+
placement = "bottom start",
|
|
98
|
+
css: cssProp,
|
|
99
|
+
className,
|
|
100
|
+
}: MenuListProps) => {
|
|
101
|
+
const slots = menu();
|
|
102
|
+
return (
|
|
103
|
+
<Popover
|
|
104
|
+
placement={placement}
|
|
105
|
+
className={cx(
|
|
106
|
+
slots.content,
|
|
107
|
+
cssProp ? css(cssProp) : undefined,
|
|
108
|
+
className,
|
|
109
|
+
)}
|
|
110
|
+
>
|
|
111
|
+
<RACMenu className={slots.list}>{children}</RACMenu>
|
|
112
|
+
</Popover>
|
|
113
|
+
);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export interface MenuItemProps
|
|
117
|
+
extends Omit<RACMenuItemProps, "className" | "children"> {
|
|
118
|
+
/** Icon rendered before the label, matching Chakra's MenuItem `icon`. */
|
|
119
|
+
icon?: ReactNode;
|
|
120
|
+
/** Per-instance style overrides, merged after the recipe. */
|
|
121
|
+
css?: SystemStyleObject;
|
|
122
|
+
className?: string;
|
|
123
|
+
children?: ReactNode;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* MenuItem — a RAC <MenuItem> styled with the `menu` recipe's `item` slot.
|
|
128
|
+
* Use `onAction` for the click behaviour (RAC closes the menu automatically).
|
|
129
|
+
*/
|
|
130
|
+
export const MenuItem = ({
|
|
131
|
+
icon,
|
|
132
|
+
css: cssProp,
|
|
133
|
+
className,
|
|
134
|
+
children,
|
|
135
|
+
...rest
|
|
136
|
+
}: MenuItemProps) => {
|
|
137
|
+
const slots = menu();
|
|
138
|
+
return (
|
|
139
|
+
<RACMenuItem
|
|
140
|
+
className={cx(slots.item, cssProp ? css(cssProp) : undefined, className)}
|
|
141
|
+
{...rest}
|
|
142
|
+
>
|
|
143
|
+
{icon ? (
|
|
144
|
+
<>
|
|
145
|
+
<span className={slots.icon}>{icon}</span>
|
|
146
|
+
<span className={slots.label}>{children}</span>
|
|
147
|
+
</>
|
|
148
|
+
) : (
|
|
149
|
+
children
|
|
150
|
+
)}
|
|
151
|
+
</RACMenuItem>
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export interface MenuDividerProps {
|
|
156
|
+
css?: SystemStyleObject;
|
|
157
|
+
className?: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Horizontal rule separating groups of menu items. */
|
|
161
|
+
export const MenuDivider = ({ css: cssProp, className }: MenuDividerProps) => {
|
|
162
|
+
const slots = menu();
|
|
163
|
+
return (
|
|
164
|
+
<Separator
|
|
165
|
+
className={cx(
|
|
166
|
+
slots.divider,
|
|
167
|
+
cssProp ? css(cssProp) : undefined,
|
|
168
|
+
className,
|
|
169
|
+
)}
|
|
170
|
+
/>
|
|
171
|
+
);
|
|
172
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
* Dialog slot recipe — Chakra's default Modal parts (light mode), plus this
|
|
10
|
+
* app's overrides: a full-viewport overlay (the iOS WKWebView 100% fix) and a
|
|
11
|
+
* `full` size with safe-area insets and the brand status-bar gradient.
|
|
12
|
+
*
|
|
13
|
+
* A config recipe (rather than an atomic `sva`) so the `size` variant accepts
|
|
14
|
+
* responsive values, e.g. `{ base: "full", md: "4xl" }`. Consumed by the
|
|
15
|
+
* shared-ui Modal, which maps the slots onto react-aria-components'
|
|
16
|
+
* ModalOverlay / Modal / Dialog.
|
|
17
|
+
*
|
|
18
|
+
* Registered in the base preset (base-preset.ts).
|
|
19
|
+
*/
|
|
20
|
+
// Appearance of a normal (non-full) modal box. Restated by every non-full size
|
|
21
|
+
// variant so that, when `size` is responsive (e.g. { base: "full", md: "4xl" }),
|
|
22
|
+
// the larger breakpoint fully overrides the `full` variant rather than leaking
|
|
23
|
+
// its margin:0 / border-radius:0 / full-height / gradient into desktop. Panda
|
|
24
|
+
// applies the base-breakpoint variant value unconditionally, so symmetric
|
|
25
|
+
// property sets across size values are required.
|
|
26
|
+
const dialogBox = {
|
|
27
|
+
my: "16",
|
|
28
|
+
mx: "2",
|
|
29
|
+
borderRadius: "md",
|
|
30
|
+
background: "white",
|
|
31
|
+
minHeight: "auto",
|
|
32
|
+
padding: "0",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// The `full` variant also styles these slots; same symmetry requirement.
|
|
36
|
+
const dialogSlots = {
|
|
37
|
+
header: { pl: "6" },
|
|
38
|
+
body: { overflowY: "visible" },
|
|
39
|
+
closeTrigger: { top: "2" },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const dialog = defineSlotRecipe({
|
|
43
|
+
className: "dialog",
|
|
44
|
+
slots: [
|
|
45
|
+
"overlay",
|
|
46
|
+
"content",
|
|
47
|
+
"inner",
|
|
48
|
+
"header",
|
|
49
|
+
"body",
|
|
50
|
+
"footer",
|
|
51
|
+
"closeTrigger",
|
|
52
|
+
],
|
|
53
|
+
base: {
|
|
54
|
+
overlay: {
|
|
55
|
+
position: "fixed",
|
|
56
|
+
inset: 0,
|
|
57
|
+
w: "100%",
|
|
58
|
+
h: "100%",
|
|
59
|
+
bg: "blackAlpha.600",
|
|
60
|
+
zIndex: "modal",
|
|
61
|
+
display: "flex",
|
|
62
|
+
justifyContent: "center",
|
|
63
|
+
alignItems: "flex-start",
|
|
64
|
+
overflow: "auto",
|
|
65
|
+
overscrollBehaviorY: "none",
|
|
66
|
+
// Fade the backdrop in/out. RAC toggles data-entering/data-exiting and
|
|
67
|
+
// waits for the transition before unmounting.
|
|
68
|
+
opacity: 1,
|
|
69
|
+
transition: "opacity 0.2s ease-out",
|
|
70
|
+
"&[data-entering]": { opacity: 0 },
|
|
71
|
+
"&[data-exiting]": { opacity: 0 },
|
|
72
|
+
_motionReduce: { transition: "none" },
|
|
73
|
+
},
|
|
74
|
+
content: {
|
|
75
|
+
position: "relative",
|
|
76
|
+
color: "inherit",
|
|
77
|
+
boxShadow: "lg",
|
|
78
|
+
width: "100%",
|
|
79
|
+
display: "flex",
|
|
80
|
+
flexDirection: "column",
|
|
81
|
+
outline: "none",
|
|
82
|
+
// Approximate Chakra's fade + scale enter/exit.
|
|
83
|
+
opacity: 1,
|
|
84
|
+
transform: "scale(1)",
|
|
85
|
+
transition: "opacity 0.2s ease-out, transform 0.2s ease-out",
|
|
86
|
+
"&[data-entering]": { opacity: 0, transform: "scale(0.95)" },
|
|
87
|
+
"&[data-exiting]": { opacity: 0, transform: "scale(0.95)" },
|
|
88
|
+
_motionReduce: { transition: "none" },
|
|
89
|
+
},
|
|
90
|
+
inner: {
|
|
91
|
+
outline: "none",
|
|
92
|
+
display: "flex",
|
|
93
|
+
flexDirection: "column",
|
|
94
|
+
width: "100%",
|
|
95
|
+
flex: "1 1 auto",
|
|
96
|
+
},
|
|
97
|
+
header: {
|
|
98
|
+
px: "6",
|
|
99
|
+
py: "4",
|
|
100
|
+
fontSize: "xl",
|
|
101
|
+
fontWeight: "semibold",
|
|
102
|
+
flexShrink: 0,
|
|
103
|
+
},
|
|
104
|
+
body: { px: "6", py: "2", flex: "1" },
|
|
105
|
+
footer: {
|
|
106
|
+
px: "6",
|
|
107
|
+
py: "4",
|
|
108
|
+
display: "flex",
|
|
109
|
+
alignItems: "center",
|
|
110
|
+
justifyContent: "flex-end",
|
|
111
|
+
// Chakra had no footer gap (call sites wrapped buttons in HStacks);
|
|
112
|
+
// baked in as the house style. Override via css for tighter layouts.
|
|
113
|
+
gap: "5",
|
|
114
|
+
flexShrink: 0,
|
|
115
|
+
},
|
|
116
|
+
closeTrigger: { position: "absolute", top: "2", insetEnd: "3" },
|
|
117
|
+
},
|
|
118
|
+
variants: {
|
|
119
|
+
// Chakra's `isCentered`: vertically centre the dialog in the viewport
|
|
120
|
+
// rather than the default top alignment.
|
|
121
|
+
centered: {
|
|
122
|
+
true: {
|
|
123
|
+
overlay: { alignItems: "center" },
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
size: {
|
|
127
|
+
xs: { ...dialogSlots, content: { ...dialogBox, maxWidth: "xs" } },
|
|
128
|
+
sm: { ...dialogSlots, content: { ...dialogBox, maxWidth: "sm" } },
|
|
129
|
+
md: { ...dialogSlots, content: { ...dialogBox, maxWidth: "md" } },
|
|
130
|
+
lg: { ...dialogSlots, content: { ...dialogBox, maxWidth: "lg" } },
|
|
131
|
+
xl: { ...dialogSlots, content: { ...dialogBox, maxWidth: "xl" } },
|
|
132
|
+
"2xl": { ...dialogSlots, content: { ...dialogBox, maxWidth: "2xl" } },
|
|
133
|
+
"3xl": { ...dialogSlots, content: { ...dialogBox, maxWidth: "3xl" } },
|
|
134
|
+
"4xl": { ...dialogSlots, content: { ...dialogBox, maxWidth: "4xl" } },
|
|
135
|
+
"5xl": { ...dialogSlots, content: { ...dialogBox, maxWidth: "5xl" } },
|
|
136
|
+
"6xl": { ...dialogSlots, content: { ...dialogBox, maxWidth: "6xl" } },
|
|
137
|
+
full: {
|
|
138
|
+
content: {
|
|
139
|
+
maxWidth: "100vw",
|
|
140
|
+
minHeight: "100dvh",
|
|
141
|
+
my: "0",
|
|
142
|
+
mx: "0",
|
|
143
|
+
borderRadius: "0",
|
|
144
|
+
paddingTop: "env(safe-area-inset-top)",
|
|
145
|
+
paddingBottom: "env(safe-area-inset-bottom)",
|
|
146
|
+
paddingLeft: "env(safe-area-inset-left)",
|
|
147
|
+
paddingRight: "env(safe-area-inset-right)",
|
|
148
|
+
// brand colour in the status-bar area, white below (matches ActionBar)
|
|
149
|
+
background:
|
|
150
|
+
"linear-gradient(to bottom, token(colors.statusBarBg) env(safe-area-inset-top), white env(safe-area-inset-top))",
|
|
151
|
+
},
|
|
152
|
+
header: {
|
|
153
|
+
pl: "calc(var(--window-controls-left, 0px) + token(spacing.6))",
|
|
154
|
+
},
|
|
155
|
+
body: { flex: "1", overflowY: "auto" },
|
|
156
|
+
closeTrigger: {
|
|
157
|
+
top: "calc(env(safe-area-inset-top) + token(spacing.2))",
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
defaultVariants: { size: "md" },
|
|
163
|
+
});
|
package/src/Modal.tsx
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import { createContext, ReactNode, RefObject, useContext } from "react";
|
|
7
|
+
import {
|
|
8
|
+
Button as RACButton,
|
|
9
|
+
Dialog,
|
|
10
|
+
Heading as RACHeading,
|
|
11
|
+
Modal as RACModal,
|
|
12
|
+
ModalOverlay,
|
|
13
|
+
} from "react-aria-components";
|
|
14
|
+
import { css, cx } from "styled-system/css";
|
|
15
|
+
import { dialog } from "styled-system/recipes";
|
|
16
|
+
import { ConditionalValue, SystemStyleObject } from "styled-system/types";
|
|
17
|
+
import { useIntl } from "react-intl";
|
|
18
|
+
import { CloseIcon } from "./CloseIcon";
|
|
19
|
+
import { uiMessage } from "./messages";
|
|
20
|
+
import { UnmountCallback } from "./UnmountCallback";
|
|
21
|
+
|
|
22
|
+
type DialogSlots = ReturnType<typeof dialog>;
|
|
23
|
+
|
|
24
|
+
const SlotContext = createContext<{ slots: DialogSlots; onClose: () => void }>({
|
|
25
|
+
slots: dialog({}),
|
|
26
|
+
onClose: () => undefined,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const useDialog = () => useContext(SlotContext);
|
|
30
|
+
|
|
31
|
+
export type ModalSize = ConditionalValue<
|
|
32
|
+
| "xs"
|
|
33
|
+
| "sm"
|
|
34
|
+
| "md"
|
|
35
|
+
| "lg"
|
|
36
|
+
| "xl"
|
|
37
|
+
| "2xl"
|
|
38
|
+
| "3xl"
|
|
39
|
+
| "4xl"
|
|
40
|
+
| "5xl"
|
|
41
|
+
| "6xl"
|
|
42
|
+
| "full"
|
|
43
|
+
>;
|
|
44
|
+
|
|
45
|
+
export interface ModalProps {
|
|
46
|
+
isOpen: boolean;
|
|
47
|
+
onClose: () => void;
|
|
48
|
+
size?: ModalSize;
|
|
49
|
+
/** Allow closing by clicking the backdrop (default true; Escape always closes). */
|
|
50
|
+
isDismissable?: boolean;
|
|
51
|
+
/** Disable the enter/exit transitions (Chakra's motionPreset="none"). */
|
|
52
|
+
motionless?: boolean;
|
|
53
|
+
/** Prevent Escape closing the dialog (Chakra's closeOnEsc={false}). */
|
|
54
|
+
isKeyboardDismissDisabled?: boolean;
|
|
55
|
+
/** Style overrides for the dialog box (Chakra's ModalContent props). */
|
|
56
|
+
contentCss?: SystemStyleObject;
|
|
57
|
+
/**
|
|
58
|
+
* Style overrides for the backdrop (Chakra's ModalOverlay props), e.g. a
|
|
59
|
+
* transparent backdrop when something else provides the dimming.
|
|
60
|
+
*/
|
|
61
|
+
overlayCss?: SystemStyleObject;
|
|
62
|
+
/**
|
|
63
|
+
* Use "alertdialog" for confirmations that interrupt the user (Chakra's
|
|
64
|
+
* AlertDialog).
|
|
65
|
+
*/
|
|
66
|
+
role?: "dialog" | "alertdialog";
|
|
67
|
+
/** Vertically centre the dialog (Chakra's `isCentered`). */
|
|
68
|
+
isCentered?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Called after the dialog has fully closed (exit transition done and the
|
|
71
|
+
* dialog removed). Matches Chakra's `onCloseComplete`.
|
|
72
|
+
*/
|
|
73
|
+
onCloseComplete?: () => void;
|
|
74
|
+
/**
|
|
75
|
+
* Element to focus when the dialog closes, instead of the element that was
|
|
76
|
+
* focused when it opened. Matches Chakra's `finalFocusRef`.
|
|
77
|
+
*/
|
|
78
|
+
finalFocusRef?: RefObject<HTMLElement>;
|
|
79
|
+
/**
|
|
80
|
+
* Accessible name for dialogs without a ModalHeader (which otherwise
|
|
81
|
+
* provides the label). One of the two is required — react-aria warns in
|
|
82
|
+
* dev builds when a dialog has neither. An explicit `aria-label` wins
|
|
83
|
+
* over a ModalHeader.
|
|
84
|
+
*/
|
|
85
|
+
"aria-label"?: string;
|
|
86
|
+
children: ReactNode;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Modal — a focus-trapping dialog. Collapses Chakra's
|
|
91
|
+
* Modal/ModalOverlay/ModalContent into a single shell; place ModalHeader,
|
|
92
|
+
* ModalBody and ModalFooter inside.
|
|
93
|
+
*/
|
|
94
|
+
export const Modal = ({
|
|
95
|
+
isOpen,
|
|
96
|
+
onClose,
|
|
97
|
+
size,
|
|
98
|
+
isDismissable = true,
|
|
99
|
+
motionless,
|
|
100
|
+
isKeyboardDismissDisabled,
|
|
101
|
+
contentCss,
|
|
102
|
+
overlayCss,
|
|
103
|
+
role,
|
|
104
|
+
isCentered,
|
|
105
|
+
onCloseComplete,
|
|
106
|
+
finalFocusRef,
|
|
107
|
+
"aria-label": ariaLabel,
|
|
108
|
+
children,
|
|
109
|
+
}: ModalProps) => {
|
|
110
|
+
const slots = dialog({ size, centered: isCentered });
|
|
111
|
+
const motionlessClass = motionless
|
|
112
|
+
? css({
|
|
113
|
+
transition: "none",
|
|
114
|
+
"&[data-entering]": { opacity: 1, transform: "none" },
|
|
115
|
+
"&[data-exiting]": { opacity: 1, transform: "none" },
|
|
116
|
+
})
|
|
117
|
+
: undefined;
|
|
118
|
+
const handleUnmount = () => {
|
|
119
|
+
onCloseComplete?.();
|
|
120
|
+
const el = finalFocusRef?.current;
|
|
121
|
+
if (el) {
|
|
122
|
+
// After RAC's own focus restoration, which also runs on unmount.
|
|
123
|
+
requestAnimationFrame(() => el.focus());
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
return (
|
|
127
|
+
<ModalOverlay
|
|
128
|
+
isOpen={isOpen}
|
|
129
|
+
onOpenChange={(open) => {
|
|
130
|
+
if (!open) {
|
|
131
|
+
onClose();
|
|
132
|
+
}
|
|
133
|
+
}}
|
|
134
|
+
isDismissable={isDismissable}
|
|
135
|
+
isKeyboardDismissDisabled={isKeyboardDismissDisabled}
|
|
136
|
+
className={cx(
|
|
137
|
+
slots.overlay,
|
|
138
|
+
motionlessClass,
|
|
139
|
+
overlayCss ? css(overlayCss) : undefined,
|
|
140
|
+
)}
|
|
141
|
+
>
|
|
142
|
+
<UnmountCallback callback={handleUnmount} />
|
|
143
|
+
<RACModal
|
|
144
|
+
className={cx(
|
|
145
|
+
slots.content,
|
|
146
|
+
motionlessClass,
|
|
147
|
+
contentCss ? css(contentCss) : undefined,
|
|
148
|
+
)}
|
|
149
|
+
>
|
|
150
|
+
<Dialog role={role} aria-label={ariaLabel} className={slots.inner}>
|
|
151
|
+
<SlotContext.Provider value={{ slots, onClose }}>
|
|
152
|
+
{children}
|
|
153
|
+
</SlotContext.Provider>
|
|
154
|
+
</Dialog>
|
|
155
|
+
</RACModal>
|
|
156
|
+
</ModalOverlay>
|
|
157
|
+
);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
interface SlotProps {
|
|
161
|
+
children?: ReactNode;
|
|
162
|
+
css?: SystemStyleObject;
|
|
163
|
+
className?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Modal title. Rendered as RAC's labelling heading for the dialog. */
|
|
167
|
+
export const ModalHeader = ({
|
|
168
|
+
children,
|
|
169
|
+
css: cssProp,
|
|
170
|
+
className,
|
|
171
|
+
level,
|
|
172
|
+
}: SlotProps & {
|
|
173
|
+
/** Heading element level (default 3, like RAC). */ level?: number;
|
|
174
|
+
}) => {
|
|
175
|
+
const { slots } = useDialog();
|
|
176
|
+
return (
|
|
177
|
+
<RACHeading
|
|
178
|
+
slot="title"
|
|
179
|
+
level={level}
|
|
180
|
+
className={cx(
|
|
181
|
+
slots.header,
|
|
182
|
+
cssProp ? css(cssProp) : undefined,
|
|
183
|
+
className,
|
|
184
|
+
)}
|
|
185
|
+
>
|
|
186
|
+
{children}
|
|
187
|
+
</RACHeading>
|
|
188
|
+
);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export const ModalBody = ({ children, css: cssProp, className }: SlotProps) => {
|
|
192
|
+
const { slots } = useDialog();
|
|
193
|
+
return (
|
|
194
|
+
<div
|
|
195
|
+
className={cx(slots.body, cssProp ? css(cssProp) : undefined, className)}
|
|
196
|
+
>
|
|
197
|
+
{children}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export const ModalFooter = ({
|
|
203
|
+
children,
|
|
204
|
+
css: cssProp,
|
|
205
|
+
className,
|
|
206
|
+
}: SlotProps) => {
|
|
207
|
+
const { slots } = useDialog();
|
|
208
|
+
return (
|
|
209
|
+
<div
|
|
210
|
+
className={cx(
|
|
211
|
+
slots.footer,
|
|
212
|
+
cssProp ? css(cssProp) : undefined,
|
|
213
|
+
className,
|
|
214
|
+
)}
|
|
215
|
+
>
|
|
216
|
+
{children}
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
export interface ModalCloseButtonProps {
|
|
222
|
+
/** Accessible name; defaults to the localized close label. */
|
|
223
|
+
"aria-label"?: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* ModalCloseButton — the X in the dialog's top corner (Chakra's
|
|
228
|
+
* ModalCloseButton at its default md size). Closes via the Modal's onClose.
|
|
229
|
+
*/
|
|
230
|
+
export const ModalCloseButton = ({
|
|
231
|
+
"aria-label": ariaLabel,
|
|
232
|
+
}: ModalCloseButtonProps) => {
|
|
233
|
+
const intl = useIntl();
|
|
234
|
+
const { slots, onClose } = useDialog();
|
|
235
|
+
return (
|
|
236
|
+
<RACButton
|
|
237
|
+
aria-label={ariaLabel ?? intl.formatMessage(uiMessage("ui.close-action"))}
|
|
238
|
+
onPress={onClose}
|
|
239
|
+
className={cx(
|
|
240
|
+
slots.closeTrigger,
|
|
241
|
+
css({
|
|
242
|
+
display: "inline-flex",
|
|
243
|
+
alignItems: "center",
|
|
244
|
+
justifyContent: "center",
|
|
245
|
+
width: "8",
|
|
246
|
+
height: "8",
|
|
247
|
+
fontSize: "xs",
|
|
248
|
+
borderRadius: "md",
|
|
249
|
+
cursor: "pointer",
|
|
250
|
+
bg: "transparent",
|
|
251
|
+
border: "none",
|
|
252
|
+
color: "inherit",
|
|
253
|
+
outline: "none",
|
|
254
|
+
transitionProperty: "background-color, box-shadow",
|
|
255
|
+
transitionDuration: "normal",
|
|
256
|
+
_hover: { bg: "blackAlpha.100" },
|
|
257
|
+
_active: { bg: "blackAlpha.200" },
|
|
258
|
+
_focusVisible: { focusShadow: "outline" },
|
|
259
|
+
}),
|
|
260
|
+
)}
|
|
261
|
+
>
|
|
262
|
+
<CloseIcon />
|
|
263
|
+
</RACButton>
|
|
264
|
+
);
|
|
265
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import { forwardRef, SelectHTMLAttributes } from "react";
|
|
7
|
+
import { css, cx } from "styled-system/css";
|
|
8
|
+
import { input } from "styled-system/recipes";
|
|
9
|
+
import { SystemStyleObject } from "styled-system/types";
|
|
10
|
+
|
|
11
|
+
export interface NativeSelectProps
|
|
12
|
+
extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "className"> {
|
|
13
|
+
/**
|
|
14
|
+
* Suppress the dropdown chevron, e.g. when an adjacent control provides the
|
|
15
|
+
* affordance. Without the chevron a bare <select> element is rendered (no
|
|
16
|
+
* wrapper), so it can participate directly in e.g. an attached ButtonGroup.
|
|
17
|
+
*/
|
|
18
|
+
hideChevron?: boolean;
|
|
19
|
+
/** Per-instance style overrides, merged after the recipe. */
|
|
20
|
+
css?: SystemStyleObject;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* NativeSelect — a native select styled like Chakra's Select field (md,
|
|
26
|
+
* outline). The recipe's `appearance: none` removes the platform chevron, so
|
|
27
|
+
* one is drawn back in by default (Chakra Select's glyph, `currentColor`).
|
|
28
|
+
*/
|
|
29
|
+
export const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
|
|
30
|
+
function NativeSelect(
|
|
31
|
+
{ hideChevron = false, css: cssProp, className, ...rest },
|
|
32
|
+
ref,
|
|
33
|
+
) {
|
|
34
|
+
const select = (
|
|
35
|
+
<select
|
|
36
|
+
ref={ref}
|
|
37
|
+
className={cx(
|
|
38
|
+
input(),
|
|
39
|
+
css(
|
|
40
|
+
{ cursor: "pointer" },
|
|
41
|
+
// Room for the chevron overlay.
|
|
42
|
+
hideChevron ? undefined : { paddingRight: "8" },
|
|
43
|
+
cssProp,
|
|
44
|
+
),
|
|
45
|
+
className,
|
|
46
|
+
)}
|
|
47
|
+
{...rest}
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
if (hideChevron) {
|
|
51
|
+
return select;
|
|
52
|
+
}
|
|
53
|
+
return (
|
|
54
|
+
<span className={css({ position: "relative", display: "inline-flex" })}>
|
|
55
|
+
{select}
|
|
56
|
+
{/* Chakra Select's chevron. */}
|
|
57
|
+
<svg
|
|
58
|
+
viewBox="0 0 24 24"
|
|
59
|
+
aria-hidden
|
|
60
|
+
className={css({
|
|
61
|
+
position: "absolute",
|
|
62
|
+
right: "2",
|
|
63
|
+
top: "50%",
|
|
64
|
+
transform: "translateY(-50%)",
|
|
65
|
+
width: "5",
|
|
66
|
+
height: "5",
|
|
67
|
+
pointerEvents: "none",
|
|
68
|
+
fill: "currentColor",
|
|
69
|
+
})}
|
|
70
|
+
>
|
|
71
|
+
<path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" />
|
|
72
|
+
</svg>
|
|
73
|
+
</span>
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
);
|