@gtivr4/a1-design-system-react 0.1.0 → 0.2.4

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 (111) hide show
  1. package/guidelines/Guidelines.md +228 -0
  2. package/package.json +4 -1
  3. package/src/breakpoints.css +29 -0
  4. package/src/color-scheme.css +586 -24
  5. package/src/components/accordion/Accordion.jsx +80 -0
  6. package/src/components/accordion/accordion.css +118 -0
  7. package/src/components/banner/Banner.jsx +66 -0
  8. package/src/components/banner/banner.css +205 -0
  9. package/src/components/bleed/Bleed.jsx +27 -0
  10. package/src/components/bleed/bleed.css +5 -0
  11. package/src/components/blockquote/Blockquote.jsx +40 -0
  12. package/src/components/blockquote/blockquote.css +166 -0
  13. package/src/components/breadcrumb/Breadcrumb.jsx +82 -0
  14. package/src/components/breadcrumb/breadcrumb.css +133 -0
  15. package/src/components/button/button.css +42 -12
  16. package/src/components/button-container/ButtonContainer.jsx +20 -1
  17. package/src/components/button-container/button-container.css +19 -1
  18. package/src/components/calendar/Calendar.jsx +383 -0
  19. package/src/components/calendar/calendar.css +225 -0
  20. package/src/components/card/Card.jsx +50 -12
  21. package/src/components/card/card.css +178 -14
  22. package/src/components/checkbox-group/CheckboxGroup.jsx +120 -0
  23. package/src/components/checkbox-group/checkbox-group.css +304 -0
  24. package/src/components/cluster/Cluster.jsx +52 -0
  25. package/src/components/cluster/cluster.css +9 -0
  26. package/src/components/code/Code.jsx +135 -0
  27. package/src/components/code/code.css +60 -0
  28. package/src/components/data-table/DataTable.jsx +721 -0
  29. package/src/components/data-table/DataTableFilters.jsx +339 -0
  30. package/src/components/data-table/data-table-filters.css +259 -0
  31. package/src/components/data-table/data-table.css +425 -0
  32. package/src/components/dialog/Dialog.jsx +45 -2
  33. package/src/components/dialog/dialog.css +13 -4
  34. package/src/components/divider/Divider.jsx +64 -0
  35. package/src/components/divider/divider.css +170 -0
  36. package/src/components/field/CreditCardField.jsx +131 -0
  37. package/src/components/field/DateField.jsx +11 -0
  38. package/src/components/field/NumberField.jsx +11 -0
  39. package/src/components/field/PhoneField.jsx +107 -0
  40. package/src/components/field/SelectField.jsx +86 -0
  41. package/src/components/field/TextField.jsx +83 -0
  42. package/src/components/field/TextareaField.jsx +147 -0
  43. package/src/components/field/TimeField.jsx +11 -0
  44. package/src/components/field/ZipField.jsx +114 -0
  45. package/src/components/field/credit-card.css +30 -0
  46. package/src/components/field/field.css +380 -0
  47. package/src/components/field/textarea-field.css +185 -0
  48. package/src/components/field-row/FieldRow.jsx +23 -0
  49. package/src/components/field-row/field-row.css +51 -0
  50. package/src/components/fieldset/Fieldset.jsx +49 -0
  51. package/src/components/fieldset/fieldset.css +75 -0
  52. package/src/components/figure/Figure.jsx +63 -0
  53. package/src/components/figure/figure.css +97 -0
  54. package/src/components/grid/Grid.jsx +36 -2
  55. package/src/components/grid/grid.css +129 -4
  56. package/src/components/heading/Heading.jsx +41 -1
  57. package/src/components/heading/heading.css +65 -4
  58. package/src/components/icon/icon.css +1 -0
  59. package/src/components/icon-button/icon-button.css +1 -0
  60. package/src/components/inline/inline.css +51 -0
  61. package/src/components/inline-editable/InlineEditable.jsx +77 -0
  62. package/src/components/inline-editable/inline-editable.css +47 -0
  63. package/src/components/inset/Inset.jsx +27 -0
  64. package/src/components/inset/inset.css +6 -0
  65. package/src/components/labels/Labels.jsx +5 -5
  66. package/src/components/link/Link.jsx +2 -3
  67. package/src/components/link/link.css +30 -1
  68. package/src/components/list/List.jsx +92 -0
  69. package/src/components/list/list.css +178 -0
  70. package/src/components/menu/Menu.jsx +243 -10
  71. package/src/components/menu/menu.css +157 -17
  72. package/src/components/message/Message.jsx +25 -50
  73. package/src/components/message/message.css +50 -33
  74. package/src/components/notification/Notification.jsx +1 -1
  75. package/src/components/page-layout/PageLayout.jsx +16 -1
  76. package/src/components/page-layout/page-layout.css +97 -4
  77. package/src/components/page-nav/PageNav.jsx +110 -0
  78. package/src/components/page-nav/page-nav.css +167 -0
  79. package/src/components/paragraph/Paragraph.jsx +35 -2
  80. package/src/components/paragraph/paragraph.css +38 -1
  81. package/src/components/radio-group/RadioGroup.jsx +121 -0
  82. package/src/components/radio-group/radio-group.css +268 -0
  83. package/src/components/section/Section.jsx +108 -0
  84. package/src/components/section/section.css +280 -0
  85. package/src/components/segmented-control/SegmentedControl.jsx +4 -0
  86. package/src/components/segmented-control/segmented.css +13 -0
  87. package/src/components/side-nav/SideNav.jsx +29 -9
  88. package/src/components/side-nav/scrim.css +1 -1
  89. package/src/components/side-nav/side-nav.css +70 -32
  90. package/src/components/snackbar/Snackbar.jsx +56 -0
  91. package/src/components/snackbar/snackbar.css +113 -0
  92. package/src/components/spacer/Spacer.jsx +36 -0
  93. package/src/components/spacer/spacer.css +44 -0
  94. package/src/components/stack/Stack.jsx +100 -0
  95. package/src/components/stack/stack.css +37 -0
  96. package/src/components/switch/Switch.jsx +114 -0
  97. package/src/components/switch/switch.css +276 -0
  98. package/src/components/system-banner/SystemBanner.jsx +57 -0
  99. package/src/components/system-banner/system-banner.css +118 -0
  100. package/src/components/tabs/Tabs.jsx +96 -28
  101. package/src/components/tabs/tabs.css +352 -15
  102. package/src/components/token-select/TokenSelect.jsx +159 -0
  103. package/src/components/token-select/token-select.css +110 -0
  104. package/src/components/top-header/TopHeader.jsx +641 -0
  105. package/src/components/top-header/top-header.css +337 -0
  106. package/src/illustrations/ComponentThumbnails.jsx +227 -0
  107. package/src/index.js +41 -5
  108. package/src/themes.css +256 -5
  109. package/src/tokens.css +919 -0
  110. package/src/utilities/spacing.css +8 -0
  111. package/src/utilities/sr-only.css +16 -0
@@ -0,0 +1,92 @@
1
+ import { createContext, useContext } from "react";
2
+ import "./list.css";
3
+ import { Icon } from "../icon/Icon.jsx";
4
+
5
+ const sizes = ["xs", "sm", "md", "lg", "xl"];
6
+ const colors = ["default", "muted"];
7
+ const variants = ["unordered", "ordered", "icon", "divider"];
8
+ const spacings = ["sm", "md", "lg"];
9
+ const breakpoints = ["xs", "sm", "md", "lg", "xl"];
10
+
11
+ function isResponsiveSize(size) {
12
+ return size && typeof size === "object" && !Array.isArray(size);
13
+ }
14
+
15
+ function resolveBaseSize(size) {
16
+ if (!isResponsiveSize(size)) return sizes.includes(size) ? size : "md";
17
+ return sizes.includes(size.xs) ? size.xs : "md";
18
+ }
19
+
20
+ function getResponsiveSizeStyle(size) {
21
+ if (!isResponsiveSize(size)) return {};
22
+ return breakpoints.slice(1).reduce((style, bp) => {
23
+ if (sizes.includes(size[bp])) {
24
+ style[`--a1-list-size-${bp}`] = `var(--semantic-font-size-body-${size[bp]})`;
25
+ }
26
+ return style;
27
+ }, {});
28
+ }
29
+
30
+ const ListContext = createContext({ icon: null, as: "ul", variant: "unordered" });
31
+
32
+ export function List({
33
+ as: Component = "ul",
34
+ size = "md",
35
+ color = "default",
36
+ icon = null,
37
+ variant: variantProp,
38
+ marginBottom,
39
+ className = "",
40
+ style,
41
+ ...props
42
+ }) {
43
+ const resolvedSize = resolveBaseSize(size);
44
+ const resolvedColor = colors.includes(color) ? color : "default";
45
+ const isOrdered = Component === "ol";
46
+
47
+ const variant = variants.includes(variantProp)
48
+ ? variantProp
49
+ : isOrdered ? "ordered" : icon != null ? "icon" : "unordered";
50
+
51
+ const responsiveStyle = getResponsiveSizeStyle(size);
52
+ const resolvedStyle = Object.keys(responsiveStyle).length
53
+ ? { ...responsiveStyle, ...style }
54
+ : style;
55
+
56
+ const classes = [
57
+ "a1-list",
58
+ `a1-list--${resolvedSize}`,
59
+ `a1-list--${variant}`,
60
+ resolvedColor !== "default" && `a1-list--${resolvedColor}`,
61
+ spacings.includes(marginBottom) && `a1-list--mb-${marginBottom}`,
62
+ className,
63
+ ]
64
+ .filter(Boolean)
65
+ .join(" ");
66
+
67
+ return (
68
+ <ListContext.Provider value={{ icon, as: Component, variant }}>
69
+ <Component className={classes} style={resolvedStyle} {...props} />
70
+ </ListContext.Provider>
71
+ );
72
+ }
73
+
74
+ export function ListItem({ icon: itemIcon, className = "", children, ...props }) {
75
+ const { icon: listIcon, variant } = useContext(ListContext);
76
+ // undefined means "not passed" — fall back to list icon. null means explicit "no icon".
77
+ const resolvedIcon = itemIcon !== undefined ? itemIcon : listIcon;
78
+ const isDivider = variant === "divider";
79
+
80
+ return (
81
+ <li className={["a1-list-item", className].filter(Boolean).join(" ")} {...props}>
82
+ {!isDivider && (
83
+ resolvedIcon != null ? (
84
+ <Icon name={resolvedIcon} className="a1-list-item__marker" />
85
+ ) : (
86
+ <span className="a1-list-item__marker" aria-hidden="true" />
87
+ )
88
+ )}
89
+ <span className="a1-list-item__content">{children}</span>
90
+ </li>
91
+ );
92
+ }
@@ -0,0 +1,178 @@
1
+ /* ── Container ──────────────────────────────────────────────────────────────── */
2
+
3
+ .a1-list--mb-sm { margin-bottom: var(--base-spacing-8); }
4
+ .a1-list--mb-md { margin-bottom: var(--base-spacing-16); }
5
+ .a1-list--mb-lg { margin-bottom: var(--base-spacing-24); }
6
+
7
+ .a1-list {
8
+ margin: 0;
9
+ padding: 0;
10
+ list-style: none;
11
+ font-family: var(--component-paragraph-font-family);
12
+ font-size: var(--a1-list-responsive-size, var(--a1-list-size, var(--semantic-font-size-body-md)));
13
+ font-weight: var(--component-paragraph-font-weight);
14
+ line-height: var(--component-paragraph-font-line-height);
15
+ color: var(--a1-list-color, var(--semantic-color-text-default));
16
+ display: flex;
17
+ flex-direction: column;
18
+ gap: var(--a1-list-gap, var(--base-spacing-4));
19
+ }
20
+
21
+ @media (--bp-sm-up) {
22
+ .a1-list {
23
+ --a1-list-responsive-size: var(--a1-list-size-sm, var(--a1-list-size));
24
+ }
25
+ }
26
+
27
+ @media (--bp-md-up) {
28
+ .a1-list {
29
+ --a1-list-responsive-size: var(--a1-list-size-md, var(--a1-list-size-sm, var(--a1-list-size)));
30
+ }
31
+ }
32
+
33
+ @media (--bp-lg-up) {
34
+ .a1-list {
35
+ --a1-list-responsive-size: var(--a1-list-size-lg, var(--a1-list-size-md, var(--a1-list-size-sm, var(--a1-list-size))));
36
+ }
37
+ }
38
+
39
+ @media (--bp-xl) {
40
+ .a1-list {
41
+ --a1-list-responsive-size: var(--a1-list-size-xl, var(--a1-list-size-lg, var(--a1-list-size-md, var(--a1-list-size-sm, var(--a1-list-size)))));
42
+ }
43
+ }
44
+
45
+ /* ── Size scale ─────────────────────────────────────────────────────────────── */
46
+
47
+ .a1-list--xs { --a1-list-size: var(--semantic-font-size-body-xs); --a1-list-gap: var(--base-spacing-4); }
48
+ .a1-list--sm { --a1-list-size: var(--semantic-font-size-body-sm); --a1-list-gap: var(--base-spacing-4); }
49
+ .a1-list--md { --a1-list-size: var(--semantic-font-size-body-md); --a1-list-gap: var(--base-spacing-8); }
50
+ .a1-list--lg { --a1-list-size: var(--semantic-font-size-body-lg); --a1-list-gap: var(--base-spacing-12); }
51
+ .a1-list--xl { --a1-list-size: var(--semantic-font-size-body-xl); --a1-list-gap: var(--base-spacing-16); }
52
+
53
+ /* ── Color ──────────────────────────────────────────────────────────────────── */
54
+
55
+ .a1-list--muted { --a1-list-color: var(--semantic-color-text-muted); }
56
+
57
+ /* ── Item ───────────────────────────────────────────────────────────────────── */
58
+
59
+ .a1-list-item {
60
+ display: flex;
61
+ align-items: flex-start;
62
+ gap: 0.25em;
63
+ }
64
+
65
+ .a1-list-item__marker {
66
+ flex-shrink: 0;
67
+ }
68
+
69
+ .a1-list-item__content {
70
+ flex: 1 1 auto;
71
+ min-width: 0;
72
+ }
73
+
74
+ /* Nested list: add top breathing room when a list follows text inside an item */
75
+ .a1-list-item__content > .a1-list {
76
+ margin-top: var(--a1-list-gap, var(--base-spacing-4));
77
+ }
78
+
79
+ /* ── Unordered — bullet hierarchy ───────────────────────────────────────────── */
80
+
81
+ /*
82
+ * Direct-child selectors scope each level's marker width to its own items only,
83
+ * preventing ordered-list counter styles from bleeding into nested unordered lists.
84
+ *
85
+ * margin-top positions the bullet at the cap-height of the first text line.
86
+ * For line-height 1.5: half-leading = 0.25em above em-square top;
87
+ * cap-height midpoint ≈ 0.35em into em-square → center at ~0.60em from line top.
88
+ * margin-top = center - half-bullet-height = 0.60em - 0.20em = 0.40em.
89
+ */
90
+
91
+ /* Level 1 — filled disc */
92
+ .a1-list--unordered > .a1-list-item > .a1-list-item__marker {
93
+ width: 1em;
94
+ }
95
+
96
+ .a1-list--unordered > .a1-list-item > .a1-list-item__marker::before {
97
+ content: "";
98
+ display: block;
99
+ width: 0.45em;
100
+ height: 0.45em;
101
+ border-radius: 50%;
102
+ background: currentColor;
103
+ opacity: 0.65;
104
+ margin-top: 0.48em;
105
+ }
106
+
107
+ /* Level 2 — open circle */
108
+ .a1-list--unordered .a1-list--unordered > .a1-list-item > .a1-list-item__marker::before {
109
+ background: transparent;
110
+ border: 0.09em solid currentColor;
111
+ opacity: 0.65;
112
+ }
113
+
114
+ /* Level 3 — filled square */
115
+ .a1-list--unordered .a1-list--unordered .a1-list--unordered > .a1-list-item > .a1-list-item__marker::before {
116
+ background: currentColor;
117
+ border: none;
118
+ border-radius: 0;
119
+ opacity: 0.65;
120
+ }
121
+
122
+ /* Level 4+ — bordered square */
123
+ .a1-list--unordered .a1-list--unordered .a1-list--unordered .a1-list--unordered > .a1-list-item > .a1-list-item__marker::before {
124
+ background: transparent;
125
+ border: 0.09em solid currentColor;
126
+ border-radius: 0;
127
+ opacity: 0.65;
128
+ }
129
+
130
+ /* ── Ordered — CSS counter ──────────────────────────────────────────────────── */
131
+
132
+ .a1-list--ordered {
133
+ counter-reset: a1-list;
134
+ }
135
+
136
+ /* Direct-child selector: only increment items that directly belong to this ordered list */
137
+ .a1-list--ordered > .a1-list-item {
138
+ counter-increment: a1-list;
139
+ }
140
+
141
+ .a1-list--ordered > .a1-list-item > .a1-list-item__marker {
142
+ min-width: 1.5em;
143
+ text-align: right;
144
+ color: var(--semantic-color-text-muted);
145
+ font-variant-numeric: tabular-nums;
146
+ }
147
+
148
+ .a1-list--ordered > .a1-list-item > .a1-list-item__marker::before {
149
+ content: counter(a1-list) ".";
150
+ }
151
+
152
+ /* ── Icon ───────────────────────────────────────────────────────────────────── */
153
+
154
+ /*
155
+ * margin-top aligns the icon's optical center with the cap-height of the first
156
+ * text line, matching the disc bullet position. Material Symbols glyphs sit
157
+ * centrally within the em square, so offset = half-leading = ~0.25em.
158
+ */
159
+ .a1-list--icon .a1-list-item__marker {
160
+ font-size: 1em;
161
+ color: var(--semantic-color-text-accent);
162
+ margin-top: 0.25em;
163
+ }
164
+
165
+ /* ── Divider ─────────────────────────────────────────────────────────────────── */
166
+
167
+ .a1-list--divider {
168
+ gap: 0;
169
+ }
170
+
171
+ .a1-list--divider > .a1-list-item {
172
+ padding: var(--a1-list-gap, var(--base-spacing-4)) 0;
173
+ border-bottom: 1px solid var(--semantic-color-border-subtle);
174
+ }
175
+
176
+ .a1-list--divider > .a1-list-item:first-child {
177
+ border-top: 1px solid var(--semantic-color-border-subtle);
178
+ }
@@ -1,45 +1,278 @@
1
- import { useEffect, useRef } from "react";
1
+ import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
2
2
  import "./menu.css";
3
+ import { Divider } from "../divider/Divider.jsx";
4
+ import { Icon } from "../icon/Icon.jsx";
5
+ import { IconButton } from "../icon-button/IconButton.jsx";
3
6
 
4
- export function Menu({ open, onClose, "aria-label": ariaLabel, children }) {
7
+ const variants = ["default", "destructive"];
8
+ const xsQuery = "(max-width: 480px)";
9
+ const viewportMargin = 8;
10
+ const focusableSelector = [
11
+ "a[href]",
12
+ "button:not([disabled])",
13
+ "input:not([disabled])",
14
+ "select:not([disabled])",
15
+ "textarea:not([disabled])",
16
+ "[tabindex]:not([tabindex='-1'])",
17
+ ].join(",");
18
+
19
+ function getFocusableElements(container) {
20
+ return [...container.querySelectorAll(focusableSelector)].filter((element) => {
21
+ if (element.getAttribute("aria-disabled") === "true") return false;
22
+ return element.getClientRects().length > 0;
23
+ });
24
+ }
25
+
26
+ /* ── Menu ────────────────────────────────────────────────────────────────── */
27
+
28
+ export function Menu({
29
+ open,
30
+ onClose,
31
+ anchorRef,
32
+ "aria-label": ariaLabel,
33
+ className = "",
34
+ children,
35
+ }) {
5
36
  const ref = useRef(null);
37
+ const fallbackAnchorRef = useRef(null);
38
+ const modalRef = useRef(false);
6
39
 
7
- useEffect(() => {
40
+ const updatePosition = useCallback(() => {
41
+ const el = ref.current;
42
+ if (!el?.open) return;
43
+
44
+ if (window.matchMedia(xsQuery).matches) {
45
+ el.style.removeProperty("--a1-menu-top");
46
+ el.style.removeProperty("--a1-menu-left");
47
+ el.style.removeProperty("--a1-menu-max-height");
48
+ return;
49
+ }
50
+
51
+ const anchor = anchorRef?.current ?? fallbackAnchorRef.current;
52
+ const anchorRect = anchor?.getBoundingClientRect?.();
53
+ const menuRect = el.getBoundingClientRect();
54
+ const width = menuRect.width || 260;
55
+ const height = menuRect.height || 0;
56
+ const viewportWidth = window.innerWidth;
57
+ const viewportHeight = window.innerHeight;
58
+
59
+ const preferredLeft = anchorRect
60
+ ? anchorRect.left
61
+ : viewportWidth - width - viewportMargin;
62
+ const left = Math.min(
63
+ Math.max(viewportMargin, preferredLeft),
64
+ Math.max(viewportMargin, viewportWidth - width - viewportMargin),
65
+ );
66
+
67
+ const belowTop = anchorRect ? anchorRect.bottom + viewportMargin : viewportMargin;
68
+ const aboveTop = anchorRect ? anchorRect.top - height - viewportMargin : viewportMargin;
69
+ const belowSpace = viewportHeight - belowTop - viewportMargin;
70
+ const aboveSpace = anchorRect ? anchorRect.top - viewportMargin * 2 : 0;
71
+ const shouldPlaceAbove = height > belowSpace && aboveSpace > belowSpace;
72
+ const unclampedTop = shouldPlaceAbove ? aboveTop : belowTop;
73
+ const top = Math.min(
74
+ Math.max(viewportMargin, unclampedTop),
75
+ Math.max(viewportMargin, viewportHeight - height - viewportMargin),
76
+ );
77
+ const maxHeight = shouldPlaceAbove
78
+ ? Math.max(120, anchorRect.top - viewportMargin * 2)
79
+ : Math.max(120, viewportHeight - top - viewportMargin);
80
+
81
+ el.style.setProperty("--a1-menu-top", `${Math.round(top)}px`);
82
+ el.style.setProperty("--a1-menu-left", `${Math.round(left)}px`);
83
+ el.style.setProperty("--a1-menu-max-height", `${Math.floor(maxHeight)}px`);
84
+ }, [anchorRef]);
85
+
86
+ const openDialog = useCallback(() => {
8
87
  const el = ref.current;
9
88
  if (!el) return;
89
+
90
+ const shouldModal = window.matchMedia(xsQuery).matches;
91
+ if (el.open && modalRef.current === shouldModal) {
92
+ updatePosition();
93
+ return;
94
+ }
95
+
96
+ if (el.open) el.close();
97
+ if (shouldModal) {
98
+ el.showModal();
99
+ } else {
100
+ el.show();
101
+ }
102
+ modalRef.current = shouldModal;
103
+ updatePosition();
104
+ requestAnimationFrame(() => {
105
+ getFocusableElements(el)[0]?.focus();
106
+ });
107
+ }, [updatePosition]);
108
+
109
+ useLayoutEffect(() => {
110
+ const el = ref.current;
111
+ if (!el) return;
112
+
10
113
  if (open) {
11
- if (!el.open) el.showModal();
114
+ fallbackAnchorRef.current = anchorRef?.current ?? document.activeElement;
115
+ openDialog();
12
116
  } else if (el.open) {
13
117
  el.close();
118
+ modalRef.current = false;
119
+ el.style.removeProperty("--a1-menu-top");
120
+ el.style.removeProperty("--a1-menu-left");
121
+ el.style.removeProperty("--a1-menu-max-height");
14
122
  }
15
- }, [open]);
123
+ }, [anchorRef, open, openDialog]);
124
+
125
+ useEffect(() => {
126
+ if (!open) return undefined;
127
+
128
+ const onViewportChange = () => openDialog();
129
+ const onScroll = () => updatePosition();
130
+ window.addEventListener("resize", onViewportChange);
131
+ window.addEventListener("scroll", onScroll, true);
132
+ return () => {
133
+ window.removeEventListener("resize", onViewportChange);
134
+ window.removeEventListener("scroll", onScroll, true);
135
+ };
136
+ }, [open, openDialog, updatePosition]);
16
137
 
17
138
  useEffect(() => {
18
139
  const el = ref.current;
19
140
  if (!el) return;
20
141
  const onCancel = (e) => { e.preventDefault(); onClose?.(); };
21
- // Backdrop clicks hit the dialog element directly
22
- const onClick = (e) => { if (e.target === el) onClose?.(); };
142
+ const onClick = (e) => { if (e.target === el) onClose?.(); };
23
143
  el.addEventListener("cancel", onCancel);
24
- el.addEventListener("click", onClick);
144
+ el.addEventListener("click", onClick);
25
145
  return () => {
26
146
  el.removeEventListener("cancel", onCancel);
27
- el.removeEventListener("click", onClick);
147
+ el.removeEventListener("click", onClick);
28
148
  };
29
149
  }, [onClose]);
30
150
 
151
+ useEffect(() => {
152
+ if (!open) return undefined;
153
+
154
+ const onKeyDown = (e) => {
155
+ const el = ref.current;
156
+ if (!el?.open) return;
157
+
158
+ if (e.key === "Escape") {
159
+ e.preventDefault();
160
+ onClose?.();
161
+ return;
162
+ }
163
+
164
+ if (e.key !== "Tab") return;
165
+
166
+ const focusableElements = getFocusableElements(el);
167
+ if (focusableElements.length === 0) {
168
+ e.preventDefault();
169
+ el.focus();
170
+ return;
171
+ }
172
+
173
+ const firstElement = focusableElements[0];
174
+ const lastElement = focusableElements[focusableElements.length - 1];
175
+
176
+ if (!el.contains(document.activeElement)) {
177
+ e.preventDefault();
178
+ (e.shiftKey ? lastElement : firstElement).focus();
179
+ } else if (e.shiftKey && document.activeElement === firstElement) {
180
+ e.preventDefault();
181
+ lastElement.focus();
182
+ } else if (!e.shiftKey && document.activeElement === lastElement) {
183
+ e.preventDefault();
184
+ firstElement.focus();
185
+ }
186
+ };
187
+
188
+ document.addEventListener("keydown", onKeyDown);
189
+ return () => document.removeEventListener("keydown", onKeyDown);
190
+ }, [onClose, open]);
191
+
192
+ useEffect(() => {
193
+ if (!open) return undefined;
194
+
195
+ const onPointerDown = (e) => {
196
+ const el = ref.current;
197
+ const anchor = anchorRef?.current ?? fallbackAnchorRef.current;
198
+ if (!el || modalRef.current) return;
199
+ if (el.contains(e.target) || anchor?.contains?.(e.target)) return;
200
+ onClose?.();
201
+ };
202
+
203
+ document.addEventListener("pointerdown", onPointerDown, true);
204
+ return () => document.removeEventListener("pointerdown", onPointerDown, true);
205
+ }, [anchorRef, onClose, open]);
206
+
31
207
  return (
32
- <dialog ref={ref} className="a1-menu" aria-label={ariaLabel}>
208
+ <dialog
209
+ ref={ref}
210
+ className={["a1-menu", className].filter(Boolean).join(" ")}
211
+ aria-label={ariaLabel}
212
+ tabIndex={-1}
213
+ >
214
+ <IconButton
215
+ icon="close"
216
+ label="Close menu"
217
+ className="a1-menu__close"
218
+ onClick={onClose}
219
+ />
33
220
  {children}
34
221
  </dialog>
35
222
  );
36
223
  }
37
224
 
225
+ /* ── MenuSection ─────────────────────────────────────────────────────────── */
226
+
38
227
  export function MenuSection({ label, children }) {
39
228
  return (
40
229
  <div className="a1-menu__section">
230
+ <Divider className="a1-menu__section-divider" space="xs" />
41
231
  {label && <p className="a1-menu__section-label">{label}</p>}
42
232
  {children}
43
233
  </div>
44
234
  );
45
235
  }
236
+
237
+ /* ── MenuItem ────────────────────────────────────────────────────────────── */
238
+
239
+ export function MenuItem({
240
+ children,
241
+ icon,
242
+ shortcut,
243
+ variant = "default",
244
+ active = false,
245
+ disabled = false,
246
+ href,
247
+ onClick,
248
+ className = "",
249
+ ...props
250
+ }) {
251
+ const Component = href ? "a" : "button";
252
+ const resolvedVariant = variants.includes(variant) ? variant : "default";
253
+
254
+ const classes = [
255
+ "a1-menu-item",
256
+ resolvedVariant !== "default" && `a1-menu-item--${resolvedVariant}`,
257
+ active && "a1-menu-item--active",
258
+ className,
259
+ ].filter(Boolean).join(" ");
260
+
261
+ return (
262
+ <Component
263
+ className={classes}
264
+ href={disabled ? undefined : href}
265
+ type={!href ? "button" : undefined}
266
+ disabled={!href ? disabled : undefined}
267
+ aria-disabled={href && disabled ? true : undefined}
268
+ aria-current={active ? "page" : undefined}
269
+ tabIndex={href && disabled ? -1 : undefined}
270
+ onClick={disabled ? undefined : onClick}
271
+ {...props}
272
+ >
273
+ {icon && <Icon name={icon} className="a1-menu-item__icon" />}
274
+ <span className="a1-menu-item__label">{children}</span>
275
+ {shortcut && <kbd className="a1-menu-item__shortcut" aria-hidden="true">{shortcut}</kbd>}
276
+ </Component>
277
+ );
278
+ }