@microbit/ui 0.1.0-alpha.8 → 0.1.0

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.
Files changed (114) hide show
  1. package/LICENSE.md +8 -0
  2. package/README.md +171 -20
  3. package/lang/ui.ar.json +62 -0
  4. package/lang/ui.ca.json +43 -3
  5. package/lang/ui.cy.json +62 -0
  6. package/lang/ui.de.json +44 -4
  7. package/lang/ui.en-us.json +40 -0
  8. package/lang/ui.en.json +40 -0
  9. package/lang/ui.es-es.json +43 -3
  10. package/lang/ui.fr.json +44 -4
  11. package/lang/ui.ga-ie.json +43 -3
  12. package/lang/ui.it.json +62 -0
  13. package/lang/ui.ja.json +44 -4
  14. package/lang/ui.ko.json +44 -4
  15. package/lang/ui.lo.json +62 -0
  16. package/lang/ui.lol.json +40 -0
  17. package/lang/ui.nl.json +44 -4
  18. package/lang/ui.pl.json +44 -4
  19. package/lang/ui.pt-br.json +44 -4
  20. package/lang/ui.vi.json +62 -0
  21. package/lang/ui.zh-cn.json +44 -4
  22. package/lang/ui.zh-tw.json +44 -4
  23. package/package.json +12 -10
  24. package/reset.css +12 -0
  25. package/src/Avatar.recipe.ts +191 -0
  26. package/src/Avatar.tsx +278 -0
  27. package/src/Breadcrumb.recipe.ts +45 -0
  28. package/src/Breadcrumb.tsx +114 -0
  29. package/src/Button.recipe.ts +88 -50
  30. package/src/Button.tsx +67 -14
  31. package/src/ButtonGroup.tsx +37 -15
  32. package/src/Card.recipe.ts +1 -2
  33. package/src/Card.tsx +2 -1
  34. package/src/Checkbox.recipe.ts +49 -14
  35. package/src/Checkbox.tsx +111 -32
  36. package/src/CheckboxGroup.tsx +70 -0
  37. package/src/CloseButton.tsx +3 -3
  38. package/src/CloseIcon.tsx +6 -4
  39. package/src/Code.tsx +1 -1
  40. package/src/Collapse.tsx +13 -14
  41. package/src/ComboBox.tsx +246 -0
  42. package/src/Divider.tsx +40 -7
  43. package/src/Drawer.recipe.ts +21 -10
  44. package/src/Drawer.tsx +3 -4
  45. package/src/ExternalLink.tsx +43 -0
  46. package/src/Fade.tsx +18 -4
  47. package/src/Field.recipe.ts +114 -0
  48. package/src/Field.tsx +187 -0
  49. package/src/GridList.recipe.ts +57 -0
  50. package/src/GridList.tsx +81 -0
  51. package/src/Heading.recipe.ts +20 -1
  52. package/src/Heading.tsx +3 -3
  53. package/src/Icon.tsx +23 -7
  54. package/src/IconButton.tsx +8 -18
  55. package/src/Image.tsx +1 -1
  56. package/src/Input.recipe.ts +41 -28
  57. package/src/Input.tsx +23 -7
  58. package/src/InputGroup.tsx +26 -12
  59. package/src/Kbd.tsx +1 -1
  60. package/src/Link.tsx +3 -3
  61. package/src/LinkBox.tsx +2 -3
  62. package/src/LinkButton.tsx +14 -13
  63. package/src/List.tsx +5 -7
  64. package/src/ListBox.recipe.ts +43 -0
  65. package/src/ListBox.tsx +88 -0
  66. package/src/Menu.recipe.ts +22 -19
  67. package/src/Menu.tsx +54 -28
  68. package/src/Modal.recipe.ts +17 -9
  69. package/src/Modal.tsx +123 -24
  70. package/src/MoreMenuButton.tsx +63 -0
  71. package/src/NativeSelect.tsx +41 -14
  72. package/src/NativeSelectField.tsx +84 -0
  73. package/src/NumberField.recipe.ts +56 -15
  74. package/src/NumberField.tsx +74 -22
  75. package/src/PopoverArrow.tsx +1 -2
  76. package/src/ProgressBar.tsx +3 -5
  77. package/src/Radio.recipe.ts +108 -0
  78. package/src/Radio.tsx +62 -0
  79. package/src/RadioGroup.tsx +68 -0
  80. package/src/Select.recipe.ts +211 -0
  81. package/src/Select.tsx +194 -0
  82. package/src/SharedUIProvider.tsx +63 -7
  83. package/src/Skeleton.tsx +146 -0
  84. package/src/Slide.tsx +2 -2
  85. package/src/Slider.recipe.ts +27 -16
  86. package/src/Slider.tsx +20 -12
  87. package/src/Spinner.tsx +11 -9
  88. package/src/Svg.tsx +2 -3
  89. package/src/Switch.recipe.ts +57 -11
  90. package/src/Switch.tsx +39 -7
  91. package/src/Text.recipe.ts +26 -0
  92. package/src/Text.tsx +6 -2
  93. package/src/TextField.tsx +39 -40
  94. package/src/Toast.recipe.ts +52 -15
  95. package/src/Toast.tsx +147 -48
  96. package/src/Tooltip.recipe.ts +32 -0
  97. package/src/Tooltip.tsx +68 -28
  98. package/src/TooltipButton.tsx +229 -0
  99. package/src/UnmountCallback.tsx +2 -2
  100. package/src/VisuallyHidden.tsx +1 -1
  101. package/src/base-preset.ts +329 -84
  102. package/src/base-tokens.ts +0 -3
  103. package/src/button-icon.ts +4 -5
  104. package/src/data-attrs.ts +16 -0
  105. package/src/dense-preset.ts +107 -0
  106. package/src/hooks/useBreakpointValue.ts +4 -4
  107. package/src/hooks/useClipboard.ts +43 -7
  108. package/src/hooks/useDisclosure.ts +31 -0
  109. package/src/hooks/useMediaQuery.ts +3 -4
  110. package/src/hooks/usePrevious.ts +1 -4
  111. package/src/index.ts +20 -3
  112. package/src/rac-locale.ts +33 -0
  113. package/src/system.ts +22 -1
  114. package/src/TextField.recipe.ts +0 -54
@@ -7,8 +7,8 @@ import { styled } from "styled-system/jsx";
7
7
 
8
8
  /**
9
9
  * InputGroup — relative wrapper for an Input with addon elements. Pad the
10
- * input (`pl`/`pr`) to make room for the elements. Replaces Chakra's
11
- * InputGroup.
10
+ * input (`ps`/`pe`) to make room for the elements; the elements sit on the
11
+ * reading-order sides, so physical padding lands opposite them in RTL.
12
12
  */
13
13
  export const InputGroup = styled("div", {
14
14
  base: { position: "relative", width: "100%", display: "flex" },
@@ -16,33 +16,47 @@ export const InputGroup = styled("div", {
16
16
 
17
17
  // The element styles are written out twice rather than shared via a spread:
18
18
  // Panda's extractor only reliably evaluates inline literals.
19
+ // The size variants are square boxes matching the input recipe's height per
20
+ // size; pass the same `size` as the grouped Input.
19
21
 
20
- /** Element overlaying the start of an InputGroup (Chakra InputLeftElement). */
21
- export const InputLeftElement = styled("div", {
22
+ /** Element overlaying the start of an InputGroup. */
23
+ export const InputStartElement = styled("div", {
22
24
  base: {
23
25
  position: "absolute",
24
26
  top: 0,
25
- left: 0,
26
- width: "10",
27
- height: "10",
27
+ insetStart: 0,
28
28
  display: "flex",
29
29
  alignItems: "center",
30
30
  justifyContent: "center",
31
31
  zIndex: 2,
32
32
  },
33
+ variants: {
34
+ size: {
35
+ lg: { width: "12", height: "12" },
36
+ md: { width: "10", height: "10" },
37
+ sm: { width: "8", height: "8" },
38
+ },
39
+ },
40
+ defaultVariants: { size: "md" },
33
41
  });
34
42
 
35
- /** Element overlaying the end of an InputGroup (Chakra InputRightElement). */
36
- export const InputRightElement = styled("div", {
43
+ /** Element overlaying the end of an InputGroup. */
44
+ export const InputEndElement = styled("div", {
37
45
  base: {
38
46
  position: "absolute",
39
47
  top: 0,
40
- right: 0,
41
- width: "10",
42
- height: "10",
48
+ insetEnd: 0,
43
49
  display: "flex",
44
50
  alignItems: "center",
45
51
  justifyContent: "center",
46
52
  zIndex: 2,
47
53
  },
54
+ variants: {
55
+ size: {
56
+ lg: { width: "12", height: "12" },
57
+ md: { width: "10", height: "10" },
58
+ sm: { width: "8", height: "8" },
59
+ },
60
+ },
61
+ defaultVariants: { size: "md" },
48
62
  });
package/src/Kbd.tsx CHANGED
@@ -6,7 +6,7 @@
6
6
  import { styled } from "styled-system/jsx";
7
7
 
8
8
  /**
9
- * Kbd — keyboard-key chip matching Chakra's <Kbd> (light mode).
9
+ * Kbd — a keyboard-key chip (light mode).
10
10
  */
11
11
  export const Kbd = styled("kbd", {
12
12
  base: {
package/src/Link.tsx CHANGED
@@ -6,8 +6,8 @@
6
6
  import { styled } from "styled-system/jsx";
7
7
 
8
8
  /**
9
- * Link — anchor styled to match Chakra's <Link> base (no underline until hover,
10
- * focus ring on keyboard focus). Accepts Panda style props for colour etc.
9
+ * Link — a styled anchor (no underline until hover, focus ring on keyboard
10
+ * focus). Accepts Panda style props for colour etc.
11
11
  */
12
12
  export const Link = styled("a", {
13
13
  base: {
@@ -18,6 +18,6 @@ export const Link = styled("a", {
18
18
  "background-color, border-color, color, fill, stroke, opacity, box-shadow, transform",
19
19
  transitionDuration: "normal",
20
20
  _hover: { textDecoration: "underline" },
21
- _focusVisible: { focusShadow: "outline" },
21
+ _focusVisible: { focusRing: "outline" },
22
22
  },
23
23
  });
package/src/LinkBox.tsx CHANGED
@@ -12,7 +12,7 @@ import { SystemStyleObject } from "styled-system/types";
12
12
  /**
13
13
  * LinkBox — makes a whole box clickable via a nested LinkOverlay (or any
14
14
  * element with an inset `_before` overlay). Other interactive children must
15
- * be raised with `zIndex: 1`. Replaces Chakra's <LinkBox>.
15
+ * be raised with `zIndex: 1`.
16
16
  */
17
17
  export const LinkBox = styled("div", {
18
18
  base: { position: "relative" },
@@ -54,8 +54,7 @@ export interface LinkOverlayButtonProps
54
54
  * Deliberately a plain button, not a react-aria one: react-aria's usePress
55
55
  * cancels presses that land outside the button's bounding rect, which defeats
56
56
  * the overlay. `position: static` overrides the button recipe's base so the
57
- * overlay anchors to the LinkBox (Chakra's LinkOverlay did the same over its
58
- * Button).
57
+ * overlay anchors to the LinkBox.
59
58
  */
60
59
  export const LinkOverlayButton = forwardRef<
61
60
  HTMLButtonElement,
@@ -19,10 +19,10 @@ export interface LinkButtonProps
19
19
  /** Per-instance style overrides, merged after the recipe. */
20
20
  css?: SystemStyleObject;
21
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;
22
+ /** Icon rendered before the label. */
23
+ startIcon?: ReactNode;
24
+ /** Icon rendered after the label. */
25
+ endIcon?: ReactNode;
26
26
  children?: ReactNode;
27
27
  }
28
28
 
@@ -33,8 +33,8 @@ const linkReset = css.raw({
33
33
  });
34
34
 
35
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
36
+ * LinkButton — a navigation link that looks like a Button.
37
+ * react-aria-components <Link> renders a real anchor
38
38
  * (`href`, `target`, new-tab/middle-click semantics preserved) with the same
39
39
  * interaction data attributes as Button, so the `button` recipe's
40
40
  * hover/press/focus/disabled states apply unchanged.
@@ -48,10 +48,11 @@ export const LinkButton = forwardRef<HTMLAnchorElement, LinkButtonProps>(
48
48
  {
49
49
  variant,
50
50
  size,
51
+ tone,
51
52
  css: cssProp,
52
53
  className,
53
- leftIcon,
54
- rightIcon,
54
+ startIcon,
55
+ endIcon,
55
56
  children,
56
57
  ...rest
57
58
  },
@@ -61,18 +62,18 @@ export const LinkButton = forwardRef<HTMLAnchorElement, LinkButtonProps>(
61
62
  <RACLink
62
63
  ref={ref}
63
64
  className={cx(
64
- button({ variant, size }),
65
+ button({ variant, size, tone }),
65
66
  css(linkReset, cssProp),
66
67
  className,
67
68
  )}
68
69
  {...rest}
69
70
  >
70
- {leftIcon ? (
71
- <span className={buttonIcon({ side: "left" })}>{leftIcon}</span>
71
+ {startIcon ? (
72
+ <span className={buttonIcon({ side: "start" })}>{startIcon}</span>
72
73
  ) : null}
73
74
  {children}
74
- {rightIcon ? (
75
- <span className={buttonIcon({ side: "right" })}>{rightIcon}</span>
75
+ {endIcon ? (
76
+ <span className={buttonIcon({ side: "end" })}>{endIcon}</span>
76
77
  ) : null}
77
78
  </RACLink>
78
79
  );
package/src/List.tsx CHANGED
@@ -6,26 +6,24 @@
6
6
  import { styled } from "styled-system/jsx";
7
7
 
8
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).
9
+ * Unstyled list. The marker removal is explicit rather than relying on a
10
+ * reset.
13
11
  */
14
12
  export const List = styled("ul", {
15
13
  base: { listStyleType: "none" },
16
14
  });
17
15
 
18
- /** List item, matching Chakra's <ListItem>. */
16
+ /** List item. */
19
17
  export const ListItem = styled("li", {
20
18
  base: {},
21
19
  });
22
20
 
23
- /** Bulleted list matching Chakra's <UnorderedList>. */
21
+ /** Bulleted list. */
24
22
  export const UnorderedList = styled("ul", {
25
23
  base: { listStyleType: "disc", marginStart: "1em" },
26
24
  });
27
25
 
28
- /** Numbered list matching Chakra's <OrderedList>. */
26
+ /** Numbered list. */
29
27
  export const OrderedList = styled("ol", {
30
28
  base: { listStyleType: "decimal", marginStart: "1em" },
31
29
  });
@@ -0,0 +1,43 @@
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
+ * ListBox slot recipe — a standalone list of choosable options, single or
10
+ * multiple. Distinct from the `select` recipe's `list`/`option` slots, which
11
+ * style the same react-aria primitive inside a dropdown card: this one sits
12
+ * inline on the page, so it carries no surface of its own.
13
+ *
14
+ * An option is a leaf — if the rows need their own buttons or menus, they
15
+ * want `GridList` instead.
16
+ *
17
+ * Registered in the base preset (base-preset.ts), which also has the
18
+ * `staticCss` entry that keeps the runtime-prop variants generated.
19
+ */
20
+ export const listBox = defineSlotRecipe({
21
+ className: "list-box",
22
+ slots: ["root", "option"],
23
+ base: {
24
+ root: {
25
+ // The listbox holds the roving tab index, so it is focusable itself and
26
+ // would otherwise draw the platform ring around the whole list.
27
+ outline: "none",
28
+ },
29
+ option: {
30
+ display: "flex",
31
+ alignItems: "center",
32
+ cursor: "pointer",
33
+ outline: "none",
34
+ transitionProperty: "background",
35
+ transitionDuration: "ultra-fast",
36
+ transitionTimingFunction: "ease-in",
37
+ _hover: { bg: "gray.50" },
38
+ "&[data-selected]": { bg: "gray.100", _hover: { bg: "gray.100" } },
39
+ "&[data-focus-visible]": { focusRing: "outline" },
40
+ "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
41
+ },
42
+ },
43
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import {
7
+ ListBox as RACListBox,
8
+ ListBoxItem as RACListBoxItem,
9
+ ListBoxItemProps as RACListBoxItemProps,
10
+ ListBoxProps as RACListBoxProps,
11
+ } from "react-aria-components";
12
+ import { css, cx } from "styled-system/css";
13
+ import { listBox } from "styled-system/recipes";
14
+ import { SystemStyleObject } from "styled-system/types";
15
+
16
+ export interface ListBoxProps<T extends object>
17
+ extends Omit<RACListBoxProps<T>, "className" | "style" | "children"> {
18
+ /** `ListBoxOption`s, or a render function when `items` is given. */
19
+ children: RACListBoxProps<T>["children"];
20
+ /** Per-instance style overrides for the list, merged after the recipe. */
21
+ css?: SystemStyleObject;
22
+ className?: string;
23
+ }
24
+
25
+ /**
26
+ * ListBox — react-aria-components' <ListBox>, standing on the page rather
27
+ * than inside a dropdown (that is `Select`/`ComboBox`, which share their own
28
+ * recipe).
29
+ *
30
+ * Options are leaves: a button inside one is unreachable by keyboard, so rows
31
+ * carrying their own controls want `GridList`.
32
+ */
33
+ export const ListBox = <T extends object>({
34
+ css: cssProp,
35
+ className,
36
+ children,
37
+ ...rest
38
+ }: ListBoxProps<T>) => {
39
+ const slots = listBox();
40
+ return (
41
+ <RACListBox
42
+ {...rest}
43
+ className={cx(slots.root, cssProp ? css(cssProp) : undefined, className)}
44
+ >
45
+ {children}
46
+ </RACListBox>
47
+ );
48
+ };
49
+
50
+ export interface ListBoxOptionProps<T extends object = object>
51
+ extends Omit<RACListBoxItemProps<T>, "className" | "style" | "children"> {
52
+ /**
53
+ * The option's content. A function receives the option's state, for a row
54
+ * that draws its own selected marker rather than taking the recipe's
55
+ * background.
56
+ */
57
+ children?: RACListBoxItemProps<T>["children"];
58
+ /** Per-instance style overrides for the option, merged after the recipe. */
59
+ css?: SystemStyleObject;
60
+ className?: string;
61
+ }
62
+
63
+ /**
64
+ * An option in a `ListBox`.
65
+ *
66
+ * Give it a `textValue` where its children aren't a plain string: react-aria
67
+ * derives typeahead text from string children only.
68
+ */
69
+ export const ListBoxOption = <T extends object = object>({
70
+ css: cssProp,
71
+ className,
72
+ children,
73
+ ...rest
74
+ }: ListBoxOptionProps<T>) => {
75
+ const slots = listBox();
76
+ return (
77
+ <RACListBoxItem
78
+ {...rest}
79
+ className={cx(
80
+ slots.option,
81
+ cssProp ? css(cssProp) : undefined,
82
+ className,
83
+ )}
84
+ >
85
+ {children}
86
+ </RACListBoxItem>
87
+ );
88
+ };
@@ -6,9 +6,9 @@
6
6
  import { defineSlotRecipe } from "@pandacss/dev";
7
7
 
8
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.
9
+ * Menu slot recipe. `content` is the dropdown card (react-aria-components'
10
+ * Popover), `list` the RAC Menu, `item` a MenuItem, `icon` the leading-icon
11
+ * wrapper.
12
12
  *
13
13
  * A config slot recipe (rather than an atomic `sva`) for consistency with
14
14
  * `dialog` and so presets can override it later if brands diverge.
@@ -35,13 +35,17 @@ export const menu = defineSlotRecipe({
35
35
  color: "inherit",
36
36
  minWidth: "3xs",
37
37
  py: "2",
38
- zIndex: "dropdown",
38
+ // `popover` (1500), not `dropdown` (1000): a RAC Popover always portals
39
+ // to the body, so a menu opened from inside a Modal (zIndex `modal`,
40
+ // 1400) escapes the modal's stacking context and would paint behind it.
41
+ // Nothing else lives between 1400 and the toast/tooltip layer.
42
+ zIndex: "popover",
39
43
  borderRadius: "md",
40
44
  borderWidth: "1px",
41
45
  borderColor: "gray.200",
42
46
  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.
47
+ // Fade/scale enter/exit. RAC toggles data-entering/data-exiting on the
48
+ // Popover and waits for the transition before unmount.
45
49
  transformOrigin: "top",
46
50
  opacity: 1,
47
51
  transform: "scale(1)",
@@ -62,19 +66,18 @@ export const menu = defineSlotRecipe({
62
66
  color: "inherit",
63
67
  textDecoration: "none",
64
68
  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.
69
+ // data-focused is RAC's active item, either modality. The highlight
70
+ // is focus indication: no transition (it snaps with the ring, which
71
+ // keyboard nav adds; inset, the rows being full-bleed).
70
72
  "&[data-focused]": { bg: "gray.100" },
73
+ "&[data-focus-visible]": { focusRing: "outlineInset" },
71
74
  "&[data-pressed]": { bg: "gray.200" },
72
75
  "&[data-disabled]": { opacity: 0.4, cursor: "not-allowed" },
73
76
  },
74
77
  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
+ // A flex:1 wrapper around an icon-item's children, so block children
79
+ // (e.g. two stacked <Text>s) lay out vertically rather than as flex-row
80
+ // siblings of the icon.
78
81
  flex: "1",
79
82
  },
80
83
  icon: {
@@ -83,8 +86,8 @@ export const menu = defineSlotRecipe({
83
86
  justifyContent: "center",
84
87
  flexShrink: 0,
85
88
  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.
89
+ // Glyphs shrink to 0.8em; items passing an explicitly sized icon
90
+ // (e.g. h/w) override this.
88
91
  fontSize: "0.8em",
89
92
  },
90
93
  divider: {
@@ -95,7 +98,7 @@ export const menu = defineSlotRecipe({
95
98
  opacity: 0.6,
96
99
  },
97
100
  group: {},
98
- // Chakra's MenuGroup/MenuOptionGroup title.
101
+ // Title for a menu group or option group.
99
102
  groupTitle: {
100
103
  display: "block",
101
104
  mx: "4",
@@ -103,8 +106,8 @@ export const menu = defineSlotRecipe({
103
106
  fontWeight: "semibold",
104
107
  fontSize: "sm",
105
108
  },
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).
109
+ // Check glyph slot for MenuItemOption: space always reserved, visible
110
+ // only on the selected item (RAC sets data-selected on it).
108
111
  itemIndicator: {
109
112
  display: "inline-flex",
110
113
  alignItems: "center",
package/src/Menu.tsx CHANGED
@@ -97,7 +97,6 @@ export interface MenuListProps {
97
97
  */
98
98
  export const MenuList = ({
99
99
  children,
100
- // Matches Chakra Menu's default "bottom-start".
101
100
  placement = "bottom start",
102
101
  css: cssProp,
103
102
  className,
@@ -119,7 +118,7 @@ export const MenuList = ({
119
118
 
120
119
  export interface MenuItemProps
121
120
  extends Omit<RACMenuItemProps, "className" | "children"> {
122
- /** Icon rendered before the label, matching Chakra's MenuItem `icon`. */
121
+ /** Icon rendered before the label. */
123
122
  icon?: ReactNode;
124
123
  /** Per-instance style overrides, merged after the recipe. */
125
124
  css?: SystemStyleObject;
@@ -156,45 +155,72 @@ export const MenuItem = ({
156
155
  );
157
156
  };
158
157
 
159
- export interface MenuOptionGroupProps {
160
- /** Group heading shown above the options (Chakra's `title`). */
158
+ interface MenuOptionGroupBaseProps {
159
+ /** Group heading shown above the options. */
161
160
  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
161
  /** `MenuItemOption` children. */
167
162
  children: ReactNode;
168
163
  css?: SystemStyleObject;
169
164
  className?: string;
170
165
  }
171
166
 
167
+ export interface MenuOptionGroupRadioProps extends MenuOptionGroupBaseProps {
168
+ type?: "radio";
169
+ /** The selected `MenuItemOption`'s value. */
170
+ value?: string;
171
+ /** Called with the newly selected option's value. */
172
+ onChange?: (value: string) => void;
173
+ }
174
+
175
+ export interface MenuOptionGroupCheckboxProps extends MenuOptionGroupBaseProps {
176
+ type: "checkbox";
177
+ /** The checked `MenuItemOption`s' values. */
178
+ value?: string[];
179
+ /** Called with the full set of checked values after a toggle. */
180
+ onChange?: (value: string[]) => void;
181
+ }
182
+
183
+ export type MenuOptionGroupProps =
184
+ | MenuOptionGroupRadioProps
185
+ | MenuOptionGroupCheckboxProps;
186
+
172
187
  /**
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.
188
+ * MenuOptionGroup — a group of checkable `MenuItemOption`s within a menu.
189
+ * `type="radio"` (the default) is single-select and renders `menuitemradio`;
190
+ * `type="checkbox"` is multi-select and renders `menuitemcheckbox`, each
191
+ * option toggling independently.
192
+ *
193
+ * Selection is section-scoped (RAC MenuSection), so a menu can mix action items
194
+ * and option groups. An option's own `onAction` still fires on every press,
195
+ * including the press that deselects it — so a lone toggle can be driven either
196
+ * by the group's `onChange` or by the item's `onAction`.
197
+ *
198
+ * Choosing an option leaves the menu open (a plain `MenuItem` closes it).
177
199
  */
178
- export const MenuOptionGroup = ({
179
- title,
180
- value,
181
- onChange,
182
- children,
183
- css: cssProp,
184
- className,
185
- }: MenuOptionGroupProps) => {
200
+ export const MenuOptionGroup = (props: MenuOptionGroupProps) => {
201
+ const { title, children, css: cssProp, className } = props;
186
202
  const slots = menu();
203
+ const selectedKeys =
204
+ props.type === "checkbox"
205
+ ? props.value ?? []
206
+ : props.value != null
207
+ ? [props.value]
208
+ : [];
187
209
  return (
188
210
  <RACMenuSection
189
211
  className={cx(slots.group, cssProp ? css(cssProp) : undefined, className)}
190
- selectionMode="single"
191
- selectedKeys={value != null ? [value] : []}
212
+ selectionMode={props.type === "checkbox" ? "multiple" : "single"}
213
+ selectedKeys={selectedKeys}
192
214
  onSelectionChange={(keys) => {
193
- if (keys !== "all") {
194
- const key = keys.values().next().value;
195
- if (key != null) {
196
- onChange?.(String(key));
197
- }
215
+ if (keys === "all") {
216
+ return;
217
+ }
218
+ const values = [...keys].map(String);
219
+ if (props.type === "checkbox") {
220
+ props.onChange?.(values);
221
+ } else if (values.length > 0) {
222
+ // Radio: a press that clears the selection reports nothing.
223
+ props.onChange?.(values[0]);
198
224
  }
199
225
  }}
200
226
  >
@@ -217,7 +243,7 @@ export interface MenuItemOptionProps
217
243
 
218
244
  /**
219
245
  * MenuItemOption — a selectable option inside a `MenuOptionGroup`, with a
220
- * check indicator on the selected item (Chakra's MenuItemOption).
246
+ * check indicator on the selected item.
221
247
  */
222
248
  export const MenuItemOption = ({
223
249
  value,
@@ -6,9 +6,9 @@
6
6
  import { defineSlotRecipe } from "@pandacss/dev";
7
7
 
8
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.
9
+ * Dialog slot recipe. The overlay spans the full viewport (the iOS WKWebView
10
+ * 100% fix) and the `full` size adds safe-area insets and the brand
11
+ * status-bar gradient.
12
12
  *
13
13
  * A config recipe (rather than an atomic `sva`) so the `size` variant accepts
14
14
  * responsive values, e.g. `{ base: "full", md: "4xl" }`. Consumed by the
@@ -33,6 +33,9 @@ const dialogBox = {
33
33
  };
34
34
 
35
35
  // The `full` variant also styles these slots; same symmetry requirement.
36
+ // The header's padding stays physical to pair with that variant's
37
+ // window-controls inset, which is a screen position rather than a
38
+ // reading-order one; the base `px` covers the start side either way.
36
39
  const dialogSlots = {
37
40
  header: { pl: "6" },
38
41
  body: { overflowY: "visible" },
@@ -54,7 +57,13 @@ export const dialog = defineSlotRecipe({
54
57
  overlay: {
55
58
  position: "fixed",
56
59
  inset: 0,
57
- w: "100%",
60
+ // 100vw, not 100%: react-aria's scroll lock reserves the root
61
+ // scrollbar gutter (scrollbar-gutter: stable), which narrows the
62
+ // containing block for fixed elements — 100% leaves an uncovered
63
+ // strip where the page scrollbar was. Viewport units span the
64
+ // reserved gutter, so the backdrop (and a full-size dialog) reach
65
+ // the real viewport edge.
66
+ w: "100vw",
58
67
  h: "100%",
59
68
  bg: "blackAlpha.600",
60
69
  zIndex: "modal",
@@ -79,7 +88,7 @@ export const dialog = defineSlotRecipe({
79
88
  display: "flex",
80
89
  flexDirection: "column",
81
90
  outline: "none",
82
- // Approximate Chakra's fade + scale enter/exit.
91
+ // Fade + scale enter/exit.
83
92
  opacity: 1,
84
93
  transform: "scale(1)",
85
94
  transition: "opacity 0.2s ease-out, transform 0.2s ease-out",
@@ -108,16 +117,15 @@ export const dialog = defineSlotRecipe({
108
117
  display: "flex",
109
118
  alignItems: "center",
110
119
  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.
120
+ // The gap is house style; override via css for tighter layouts.
113
121
  gap: "5",
114
122
  flexShrink: 0,
115
123
  },
116
124
  closeTrigger: { position: "absolute", top: "2", insetEnd: "3" },
117
125
  },
118
126
  variants: {
119
- // Chakra's `isCentered`: vertically centre the dialog in the viewport
120
- // rather than the default top alignment.
127
+ // Vertically centre the dialog in the viewport rather than the default
128
+ // top alignment.
121
129
  centered: {
122
130
  true: {
123
131
  overlay: { alignItems: "center" },