@microbit/ui 0.1.0-alpha.10
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 +220 -0
- package/lang/ui.ca.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.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 +58 -0
- package/postcss-legacy-safari.cjs +96 -0
- package/reset.css +35 -0
- package/src/Button.recipe.ts +162 -0
- package/src/Button.tsx +69 -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 +105 -0
- package/src/Checkbox.tsx +76 -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/Divider.tsx +52 -0
- package/src/Drawer.recipe.ts +98 -0
- package/src/Drawer.tsx +138 -0
- package/src/Fade.tsx +48 -0
- package/src/Heading.recipe.ts +52 -0
- package/src/Heading.tsx +14 -0
- package/src/Icon.tsx +55 -0
- package/src/IconButton.tsx +44 -0
- package/src/Image.tsx +11 -0
- package/src/Input.recipe.ts +72 -0
- package/src/Input.tsx +40 -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/Menu.recipe.ts +119 -0
- package/src/Menu.tsx +261 -0
- package/src/Modal.recipe.ts +163 -0
- package/src/Modal.tsx +279 -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 +107 -0
- package/src/Radio.tsx +89 -0
- package/src/SharedUIProvider.tsx +55 -0
- package/src/Slide.tsx +54 -0
- package/src/Slider.recipe.ts +102 -0
- package/src/Slider.tsx +163 -0
- package/src/Spinner.tsx +69 -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 +89 -0
- package/src/Toast.recipe.ts +98 -0
- package/src/Toast.tsx +171 -0
- package/src/Tooltip.tsx +91 -0
- package/src/UnmountCallback.tsx +18 -0
- package/src/VisuallyHidden.tsx +14 -0
- package/src/base-preset.ts +294 -0
- package/src/base-tokens.ts +1017 -0
- package/src/button-icon.ts +23 -0
- package/src/hooks/useBreakpointValue.ts +63 -0
- package/src/hooks/useClipboard.ts +64 -0
- package/src/hooks/useMediaQuery.ts +28 -0
- package/src/hooks/usePrevious.ts +18 -0
- package/src/index.ts +55 -0
- package/src/messages.ts +17 -0
- package/src/system.ts +36 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
import { forwardRef, ReactNode } from "react";
|
|
7
|
+
import {
|
|
8
|
+
Link as RACLink,
|
|
9
|
+
LinkProps as RACLinkProps,
|
|
10
|
+
} from "react-aria-components";
|
|
11
|
+
import { css, cx } from "styled-system/css";
|
|
12
|
+
import { button, ButtonVariantProps } from "styled-system/recipes";
|
|
13
|
+
import { SystemStyleObject } from "styled-system/types";
|
|
14
|
+
import { buttonIcon } from "./button-icon";
|
|
15
|
+
|
|
16
|
+
export interface LinkButtonProps
|
|
17
|
+
extends Omit<RACLinkProps, "className" | "children">,
|
|
18
|
+
ButtonVariantProps {
|
|
19
|
+
/** Per-instance style overrides, merged after the recipe. */
|
|
20
|
+
css?: SystemStyleObject;
|
|
21
|
+
className?: string;
|
|
22
|
+
/** Icon rendered before the label, matching Chakra's `leftIcon`. */
|
|
23
|
+
leftIcon?: ReactNode;
|
|
24
|
+
/** Icon rendered after the label, matching Chakra's `rightIcon`. */
|
|
25
|
+
rightIcon?: ReactNode;
|
|
26
|
+
children?: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Anchors pick up underline styling that buttons never have.
|
|
30
|
+
const linkReset = css.raw({
|
|
31
|
+
textDecoration: "none",
|
|
32
|
+
_hover: { textDecoration: "none" },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* LinkButton — a navigation link that looks like a Button (Chakra's
|
|
37
|
+
* `Button as="a"`). react-aria-components <Link> renders a real anchor
|
|
38
|
+
* (`href`, `target`, new-tab/middle-click semantics preserved) with the same
|
|
39
|
+
* interaction data attributes as Button, so the `button` recipe's
|
|
40
|
+
* hover/press/focus/disabled states apply unchanged.
|
|
41
|
+
*
|
|
42
|
+
* Use for navigation that should read as a call to action (e.g. an external
|
|
43
|
+
* help page presented as a dialog's primary action); use Button for
|
|
44
|
+
* in-page actions.
|
|
45
|
+
*/
|
|
46
|
+
export const LinkButton = forwardRef<HTMLAnchorElement, LinkButtonProps>(
|
|
47
|
+
function LinkButton(
|
|
48
|
+
{
|
|
49
|
+
variant,
|
|
50
|
+
size,
|
|
51
|
+
css: cssProp,
|
|
52
|
+
className,
|
|
53
|
+
leftIcon,
|
|
54
|
+
rightIcon,
|
|
55
|
+
children,
|
|
56
|
+
...rest
|
|
57
|
+
},
|
|
58
|
+
ref,
|
|
59
|
+
) {
|
|
60
|
+
return (
|
|
61
|
+
<RACLink
|
|
62
|
+
ref={ref}
|
|
63
|
+
className={cx(
|
|
64
|
+
button({ variant, size }),
|
|
65
|
+
css(linkReset, cssProp),
|
|
66
|
+
className,
|
|
67
|
+
)}
|
|
68
|
+
{...rest}
|
|
69
|
+
>
|
|
70
|
+
{leftIcon ? (
|
|
71
|
+
<span className={buttonIcon({ side: "left" })}>{leftIcon}</span>
|
|
72
|
+
) : null}
|
|
73
|
+
{children}
|
|
74
|
+
{rightIcon ? (
|
|
75
|
+
<span className={buttonIcon({ side: "right" })}>{rightIcon}</span>
|
|
76
|
+
) : null}
|
|
77
|
+
</RACLink>
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
);
|
package/src/List.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
* Unstyled list, matching Chakra's <List>. The marker removal is explicit —
|
|
10
|
+
* Chakra's List set listStyleType itself, and coexisting apps run without
|
|
11
|
+
* Panda's preflight (margins/padding are zeroed by whichever reset is
|
|
12
|
+
* active).
|
|
13
|
+
*/
|
|
14
|
+
export const List = styled("ul", {
|
|
15
|
+
base: { listStyleType: "none" },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** List item, matching Chakra's <ListItem>. */
|
|
19
|
+
export const ListItem = styled("li", {
|
|
20
|
+
base: {},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** Bulleted list matching Chakra's <UnorderedList>. */
|
|
24
|
+
export const UnorderedList = styled("ul", {
|
|
25
|
+
base: { listStyleType: "disc", marginStart: "1em" },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** Numbered list matching Chakra's <OrderedList>. */
|
|
29
|
+
export const OrderedList = styled("ol", {
|
|
30
|
+
base: { listStyleType: "decimal", marginStart: "1em" },
|
|
31
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
* Menu slot recipe — Chakra's default Menu parts (light mode). `content` is the
|
|
10
|
+
* dropdown card (react-aria-components' Popover), `list` the RAC Menu, `item` a
|
|
11
|
+
* MenuItem, `icon` the leading-icon wrapper.
|
|
12
|
+
*
|
|
13
|
+
* A config slot recipe (rather than an atomic `sva`) for consistency with
|
|
14
|
+
* `dialog` and so presets can override it later if brands diverge.
|
|
15
|
+
* No variants, so it needs no `staticCss` entry.
|
|
16
|
+
*
|
|
17
|
+
* Registered in the base preset (base-preset.ts).
|
|
18
|
+
*/
|
|
19
|
+
export const menu = defineSlotRecipe({
|
|
20
|
+
className: "menu",
|
|
21
|
+
slots: [
|
|
22
|
+
"content",
|
|
23
|
+
"list",
|
|
24
|
+
"item",
|
|
25
|
+
"icon",
|
|
26
|
+
"label",
|
|
27
|
+
"divider",
|
|
28
|
+
"group",
|
|
29
|
+
"groupTitle",
|
|
30
|
+
"itemIndicator",
|
|
31
|
+
],
|
|
32
|
+
base: {
|
|
33
|
+
content: {
|
|
34
|
+
bg: "white",
|
|
35
|
+
color: "inherit",
|
|
36
|
+
minWidth: "3xs",
|
|
37
|
+
py: "2",
|
|
38
|
+
zIndex: "dropdown",
|
|
39
|
+
borderRadius: "md",
|
|
40
|
+
borderWidth: "1px",
|
|
41
|
+
borderColor: "gray.200",
|
|
42
|
+
boxShadow: "sm",
|
|
43
|
+
// Approximate Chakra's menu fade/scale. RAC toggles data-entering/
|
|
44
|
+
// data-exiting on the Popover and waits for the transition before unmount.
|
|
45
|
+
transformOrigin: "top",
|
|
46
|
+
opacity: 1,
|
|
47
|
+
transform: "scale(1)",
|
|
48
|
+
transition: "opacity 0.1s ease-out, transform 0.1s ease-out",
|
|
49
|
+
"&[data-entering]": { opacity: 0, transform: "scale(0.95)" },
|
|
50
|
+
"&[data-exiting]": { opacity: 0, transform: "scale(0.95)" },
|
|
51
|
+
_motionReduce: { transition: "none" },
|
|
52
|
+
},
|
|
53
|
+
list: {
|
|
54
|
+
outline: "none",
|
|
55
|
+
},
|
|
56
|
+
item: {
|
|
57
|
+
display: "flex",
|
|
58
|
+
alignItems: "center",
|
|
59
|
+
py: "1.5",
|
|
60
|
+
px: "3",
|
|
61
|
+
cursor: "pointer",
|
|
62
|
+
color: "inherit",
|
|
63
|
+
textDecoration: "none",
|
|
64
|
+
outline: "none",
|
|
65
|
+
transitionProperty: "background",
|
|
66
|
+
transitionDuration: "ultra-fast",
|
|
67
|
+
transitionTimingFunction: "ease-in",
|
|
68
|
+
// RAC highlights the active item (keyboard or pointer) with data-focused;
|
|
69
|
+
// data-pressed is the pressed state — mirrors Chakra's _focus/_active.
|
|
70
|
+
"&[data-focused]": { bg: "gray.100" },
|
|
71
|
+
"&[data-pressed]": { bg: "gray.200" },
|
|
72
|
+
"&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
|
|
73
|
+
},
|
|
74
|
+
label: {
|
|
75
|
+
// Chakra wraps an icon-item's children in a flex:1 span, so block
|
|
76
|
+
// children (e.g. two stacked <Text>s) lay out vertically rather than as
|
|
77
|
+
// flex-row siblings of the icon.
|
|
78
|
+
flex: "1",
|
|
79
|
+
},
|
|
80
|
+
icon: {
|
|
81
|
+
display: "inline-flex",
|
|
82
|
+
alignItems: "center",
|
|
83
|
+
justifyContent: "center",
|
|
84
|
+
flexShrink: 0,
|
|
85
|
+
marginEnd: "0.75rem",
|
|
86
|
+
// Chakra's MenuIcon shrinks glyphs to 0.8em; items passing an explicitly
|
|
87
|
+
// sized icon (e.g. h/w) override this.
|
|
88
|
+
fontSize: "0.8em",
|
|
89
|
+
},
|
|
90
|
+
divider: {
|
|
91
|
+
border: 0,
|
|
92
|
+
borderBottom: "1px solid",
|
|
93
|
+
borderColor: "gray.200",
|
|
94
|
+
my: "2",
|
|
95
|
+
opacity: 0.6,
|
|
96
|
+
},
|
|
97
|
+
group: {},
|
|
98
|
+
// Chakra's MenuGroup/MenuOptionGroup title.
|
|
99
|
+
groupTitle: {
|
|
100
|
+
display: "block",
|
|
101
|
+
mx: "4",
|
|
102
|
+
my: "2",
|
|
103
|
+
fontWeight: "semibold",
|
|
104
|
+
fontSize: "sm",
|
|
105
|
+
},
|
|
106
|
+
// Check glyph slot for MenuItemOption: space always reserved (as Chakra
|
|
107
|
+
// does), visible only on the selected item (RAC sets data-selected on it).
|
|
108
|
+
itemIndicator: {
|
|
109
|
+
display: "inline-flex",
|
|
110
|
+
alignItems: "center",
|
|
111
|
+
justifyContent: "center",
|
|
112
|
+
flexShrink: 0,
|
|
113
|
+
marginEnd: "0.75rem",
|
|
114
|
+
fontSize: "0.8em",
|
|
115
|
+
opacity: 0,
|
|
116
|
+
"[data-selected] &": { opacity: 1 },
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
});
|
package/src/Menu.tsx
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
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
|
+
Header as RACHeader,
|
|
9
|
+
Menu as RACMenu,
|
|
10
|
+
MenuItem as RACMenuItem,
|
|
11
|
+
MenuItemProps as RACMenuItemProps,
|
|
12
|
+
MenuSection as RACMenuSection,
|
|
13
|
+
MenuTrigger as RACMenuTrigger,
|
|
14
|
+
Popover,
|
|
15
|
+
PopoverProps,
|
|
16
|
+
Separator,
|
|
17
|
+
} from "react-aria-components";
|
|
18
|
+
import { RiCheckLine } from "react-icons/ri";
|
|
19
|
+
import { css, cx } from "styled-system/css";
|
|
20
|
+
import { menu } from "styled-system/recipes";
|
|
21
|
+
import { SystemStyleObject } from "styled-system/types";
|
|
22
|
+
import { Icon } from "./Icon";
|
|
23
|
+
import { useOverlayCloseRegistrar } from "./SharedUIProvider";
|
|
24
|
+
|
|
25
|
+
export interface MenuTriggerProps {
|
|
26
|
+
/** Called when the menu opens. */
|
|
27
|
+
onOpen?: () => void;
|
|
28
|
+
/** Called when the menu closes. */
|
|
29
|
+
onClose?: () => void;
|
|
30
|
+
/** The trigger element followed by a `MenuList` (react-aria pattern). */
|
|
31
|
+
children: ReactNode;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* MenuTrigger — react-aria-components' <MenuTrigger>. Its first child is the
|
|
36
|
+
* trigger (e.g. a Button); the second is a `MenuList`. RAC returns focus to the
|
|
37
|
+
* trigger when the menu closes.
|
|
38
|
+
*
|
|
39
|
+
* When the app installs an overlay-close registrar (e.g. so the Android back
|
|
40
|
+
* button can close the menu), the trigger runs controlled and registers its
|
|
41
|
+
* close function while open. Otherwise it's an uncontrolled pass-through.
|
|
42
|
+
*/
|
|
43
|
+
export const MenuTrigger = ({
|
|
44
|
+
onOpen,
|
|
45
|
+
onClose,
|
|
46
|
+
children,
|
|
47
|
+
}: MenuTriggerProps) => {
|
|
48
|
+
const registerClose = useOverlayCloseRegistrar();
|
|
49
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
50
|
+
|
|
51
|
+
const handleOpenChange = useCallback(
|
|
52
|
+
(open: boolean) => {
|
|
53
|
+
setIsOpen(open);
|
|
54
|
+
if (open) {
|
|
55
|
+
registerClose?.(() => {
|
|
56
|
+
setIsOpen(false);
|
|
57
|
+
registerClose(null);
|
|
58
|
+
});
|
|
59
|
+
onOpen?.();
|
|
60
|
+
} else {
|
|
61
|
+
registerClose?.(null);
|
|
62
|
+
onClose?.();
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
[registerClose, onOpen, onClose],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
if (!registerClose) {
|
|
69
|
+
return (
|
|
70
|
+
<RACMenuTrigger
|
|
71
|
+
onOpenChange={(open) => (open ? onOpen?.() : onClose?.())}
|
|
72
|
+
>
|
|
73
|
+
{children}
|
|
74
|
+
</RACMenuTrigger>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<RACMenuTrigger isOpen={isOpen} onOpenChange={handleOpenChange}>
|
|
80
|
+
{children}
|
|
81
|
+
</RACMenuTrigger>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export interface MenuListProps {
|
|
86
|
+
children: ReactNode;
|
|
87
|
+
/** Placement of the dropdown relative to the trigger. */
|
|
88
|
+
placement?: PopoverProps["placement"];
|
|
89
|
+
/** Per-instance style overrides, merged after the recipe. */
|
|
90
|
+
css?: SystemStyleObject;
|
|
91
|
+
className?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* MenuList — the dropdown surface: RAC's <Popover> (positioned card) wrapping a
|
|
96
|
+
* RAC <Menu> (the list). Place `MenuItem`s inside.
|
|
97
|
+
*/
|
|
98
|
+
export const MenuList = ({
|
|
99
|
+
children,
|
|
100
|
+
// Matches Chakra Menu's default "bottom-start".
|
|
101
|
+
placement = "bottom start",
|
|
102
|
+
css: cssProp,
|
|
103
|
+
className,
|
|
104
|
+
}: MenuListProps) => {
|
|
105
|
+
const slots = menu();
|
|
106
|
+
return (
|
|
107
|
+
<Popover
|
|
108
|
+
placement={placement}
|
|
109
|
+
className={cx(
|
|
110
|
+
slots.content,
|
|
111
|
+
cssProp ? css(cssProp) : undefined,
|
|
112
|
+
className,
|
|
113
|
+
)}
|
|
114
|
+
>
|
|
115
|
+
<RACMenu className={slots.list}>{children}</RACMenu>
|
|
116
|
+
</Popover>
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export interface MenuItemProps
|
|
121
|
+
extends Omit<RACMenuItemProps, "className" | "children"> {
|
|
122
|
+
/** Icon rendered before the label, matching Chakra's MenuItem `icon`. */
|
|
123
|
+
icon?: ReactNode;
|
|
124
|
+
/** Per-instance style overrides, merged after the recipe. */
|
|
125
|
+
css?: SystemStyleObject;
|
|
126
|
+
className?: string;
|
|
127
|
+
children?: ReactNode;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* MenuItem — a RAC <MenuItem> styled with the `menu` recipe's `item` slot.
|
|
132
|
+
* Use `onAction` for the click behaviour (RAC closes the menu automatically).
|
|
133
|
+
*/
|
|
134
|
+
export const MenuItem = ({
|
|
135
|
+
icon,
|
|
136
|
+
css: cssProp,
|
|
137
|
+
className,
|
|
138
|
+
children,
|
|
139
|
+
...rest
|
|
140
|
+
}: MenuItemProps) => {
|
|
141
|
+
const slots = menu();
|
|
142
|
+
return (
|
|
143
|
+
<RACMenuItem
|
|
144
|
+
className={cx(slots.item, cssProp ? css(cssProp) : undefined, className)}
|
|
145
|
+
{...rest}
|
|
146
|
+
>
|
|
147
|
+
{icon ? (
|
|
148
|
+
<>
|
|
149
|
+
<span className={slots.icon}>{icon}</span>
|
|
150
|
+
<span className={slots.label}>{children}</span>
|
|
151
|
+
</>
|
|
152
|
+
) : (
|
|
153
|
+
children
|
|
154
|
+
)}
|
|
155
|
+
</RACMenuItem>
|
|
156
|
+
);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export interface MenuOptionGroupProps {
|
|
160
|
+
/** Group heading shown above the options (Chakra's `title`). */
|
|
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
|
+
/** `MenuItemOption` children. */
|
|
167
|
+
children: ReactNode;
|
|
168
|
+
css?: SystemStyleObject;
|
|
169
|
+
className?: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* MenuOptionGroup — a single-select (radio) group of `MenuItemOption`s within
|
|
174
|
+
* a menu, replacing Chakra's `MenuOptionGroup type="radio"`. Selection is
|
|
175
|
+
* section-scoped (RAC MenuSection), so a menu can mix action items and option
|
|
176
|
+
* groups.
|
|
177
|
+
*/
|
|
178
|
+
export const MenuOptionGroup = ({
|
|
179
|
+
title,
|
|
180
|
+
value,
|
|
181
|
+
onChange,
|
|
182
|
+
children,
|
|
183
|
+
css: cssProp,
|
|
184
|
+
className,
|
|
185
|
+
}: MenuOptionGroupProps) => {
|
|
186
|
+
const slots = menu();
|
|
187
|
+
return (
|
|
188
|
+
<RACMenuSection
|
|
189
|
+
className={cx(slots.group, cssProp ? css(cssProp) : undefined, className)}
|
|
190
|
+
selectionMode="single"
|
|
191
|
+
selectedKeys={value != null ? [value] : []}
|
|
192
|
+
onSelectionChange={(keys) => {
|
|
193
|
+
if (keys !== "all") {
|
|
194
|
+
const key = keys.values().next().value;
|
|
195
|
+
if (key != null) {
|
|
196
|
+
onChange?.(String(key));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}}
|
|
200
|
+
>
|
|
201
|
+
{title != null && (
|
|
202
|
+
<RACHeader className={slots.groupTitle}>{title}</RACHeader>
|
|
203
|
+
)}
|
|
204
|
+
{children}
|
|
205
|
+
</RACMenuSection>
|
|
206
|
+
);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export interface MenuItemOptionProps
|
|
210
|
+
extends Omit<RACMenuItemProps, "className" | "children" | "id" | "value"> {
|
|
211
|
+
/** This option's value within its `MenuOptionGroup`. */
|
|
212
|
+
value: string;
|
|
213
|
+
css?: SystemStyleObject;
|
|
214
|
+
className?: string;
|
|
215
|
+
children?: ReactNode;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* MenuItemOption — a selectable option inside a `MenuOptionGroup`, with a
|
|
220
|
+
* check indicator on the selected item (Chakra's MenuItemOption).
|
|
221
|
+
*/
|
|
222
|
+
export const MenuItemOption = ({
|
|
223
|
+
value,
|
|
224
|
+
css: cssProp,
|
|
225
|
+
className,
|
|
226
|
+
children,
|
|
227
|
+
...rest
|
|
228
|
+
}: MenuItemOptionProps) => {
|
|
229
|
+
const slots = menu();
|
|
230
|
+
return (
|
|
231
|
+
<RACMenuItem
|
|
232
|
+
id={value}
|
|
233
|
+
className={cx(slots.item, cssProp ? css(cssProp) : undefined, className)}
|
|
234
|
+
{...rest}
|
|
235
|
+
>
|
|
236
|
+
<span className={slots.itemIndicator} aria-hidden>
|
|
237
|
+
<Icon as={RiCheckLine} />
|
|
238
|
+
</span>
|
|
239
|
+
<span className={slots.label}>{children}</span>
|
|
240
|
+
</RACMenuItem>
|
|
241
|
+
);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
export interface MenuDividerProps {
|
|
245
|
+
css?: SystemStyleObject;
|
|
246
|
+
className?: string;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Horizontal rule separating groups of menu items. */
|
|
250
|
+
export const MenuDivider = ({ css: cssProp, className }: MenuDividerProps) => {
|
|
251
|
+
const slots = menu();
|
|
252
|
+
return (
|
|
253
|
+
<Separator
|
|
254
|
+
className={cx(
|
|
255
|
+
slots.divider,
|
|
256
|
+
cssProp ? css(cssProp) : undefined,
|
|
257
|
+
className,
|
|
258
|
+
)}
|
|
259
|
+
/>
|
|
260
|
+
);
|
|
261
|
+
};
|
|
@@ -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
|
+
});
|