@gtivr4/a1-design-system-react 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 (45) hide show
  1. package/README.md +32 -0
  2. package/package.json +40 -0
  3. package/src/color-scheme.css +213 -0
  4. package/src/components/button/Button.jsx +45 -0
  5. package/src/components/button/button.css +135 -0
  6. package/src/components/button-container/ButtonContainer.jsx +27 -0
  7. package/src/components/button-container/button-container.css +38 -0
  8. package/src/components/card/Card.jsx +29 -0
  9. package/src/components/card/card.css +37 -0
  10. package/src/components/dialog/Dialog.jsx +44 -0
  11. package/src/components/dialog/dialog.css +58 -0
  12. package/src/components/grid/Grid.jsx +77 -0
  13. package/src/components/grid/grid.css +86 -0
  14. package/src/components/heading/Heading.jsx +69 -0
  15. package/src/components/heading/heading.css +76 -0
  16. package/src/components/icon/Icon.jsx +32 -0
  17. package/src/components/icon/icon.css +10 -0
  18. package/src/components/icon-button/IconButton.jsx +34 -0
  19. package/src/components/icon-button/icon-button.css +196 -0
  20. package/src/components/inverse/Inverse.jsx +18 -0
  21. package/src/components/labels/Labels.jsx +29 -0
  22. package/src/components/link/Link.jsx +41 -0
  23. package/src/components/link/link.css +50 -0
  24. package/src/components/menu/Menu.jsx +45 -0
  25. package/src/components/menu/menu.css +45 -0
  26. package/src/components/message/Message.jsx +103 -0
  27. package/src/components/message/message.css +226 -0
  28. package/src/components/notification/Notification.jsx +55 -0
  29. package/src/components/notification/notification.css +69 -0
  30. package/src/components/page-layout/PageLayout.jsx +40 -0
  31. package/src/components/page-layout/page-layout.css +61 -0
  32. package/src/components/pagination/Pagination.jsx +64 -0
  33. package/src/components/pagination/pagination.css +85 -0
  34. package/src/components/paragraph/Paragraph.jsx +26 -0
  35. package/src/components/paragraph/paragraph.css +16 -0
  36. package/src/components/segmented-control/SegmentedControl.jsx +77 -0
  37. package/src/components/segmented-control/segmented.css +76 -0
  38. package/src/components/side-nav/SideNav.jsx +208 -0
  39. package/src/components/side-nav/scrim.css +17 -0
  40. package/src/components/side-nav/side-nav.css +283 -0
  41. package/src/components/tabs/Tabs.jsx +102 -0
  42. package/src/components/tabs/tabs.css +135 -0
  43. package/src/index.js +20 -0
  44. package/src/themes.css +186 -0
  45. package/src/utilities/spacing.css +230 -0
@@ -0,0 +1,61 @@
1
+ .a1-page-layout {
2
+ display: flex;
3
+ flex-direction: column;
4
+ min-height: 100vh;
5
+ background: var(--semantic-color-surface-page);
6
+ color: var(--semantic-color-text-default);
7
+ }
8
+
9
+ .a1-page-layout__header {
10
+ flex-shrink: 0;
11
+ }
12
+
13
+ .a1-page-layout--sticky-header .a1-page-layout__header {
14
+ position: sticky;
15
+ top: 0;
16
+ z-index: 100;
17
+ }
18
+
19
+ .a1-page-layout__body {
20
+ display: flex;
21
+ flex: 1 1 auto;
22
+ min-height: 0;
23
+ gap: var(--component-page-layout-gap);
24
+ }
25
+
26
+ .a1-page-layout--sidebar-start .a1-page-layout__body {
27
+ flex-direction: row;
28
+ }
29
+
30
+ .a1-page-layout--sidebar-end .a1-page-layout__body {
31
+ flex-direction: row-reverse;
32
+ }
33
+
34
+ .a1-page-layout__sidebar {
35
+ flex-shrink: 0;
36
+ width: var(--component-page-layout-sidebar-width);
37
+ }
38
+
39
+ .a1-page-layout__sidebar:has(.a1-side-nav) {
40
+ display: flex;
41
+ width: auto;
42
+ }
43
+
44
+ .a1-page-layout__main {
45
+ flex: 1 1 0;
46
+ min-width: 0;
47
+ }
48
+
49
+ .a1-page-layout__footer {
50
+ flex-shrink: 0;
51
+ }
52
+
53
+ @media (max-width: 640px) {
54
+ .a1-page-layout__body {
55
+ flex-direction: column !important;
56
+ }
57
+
58
+ .a1-page-layout__sidebar {
59
+ width: 100%;
60
+ }
61
+ }
@@ -0,0 +1,64 @@
1
+ import "./pagination.css";
2
+ import { Icon } from "../icon/Icon.jsx";
3
+ import { IconButton } from "../icon-button/IconButton.jsx";
4
+
5
+ function getPageItems(page, totalPages, siblings) {
6
+ const left = Math.max(2, page - siblings);
7
+ const right = Math.min(totalPages - 1, page + siblings);
8
+
9
+ const items = [1];
10
+ if (left > 2) items.push("start-ellipsis");
11
+ for (let i = left; i <= right; i++) items.push(i);
12
+ if (right < totalPages - 1) items.push("end-ellipsis");
13
+ if (totalPages > 1) items.push(totalPages);
14
+
15
+ return items;
16
+ }
17
+
18
+ export function Pagination({
19
+ page,
20
+ totalPages,
21
+ onChange,
22
+ siblings = 1,
23
+ size = "md",
24
+ }) {
25
+ const items = getPageItems(page, totalPages, siblings);
26
+
27
+ return (
28
+ <nav aria-label="Pagination" className={`a1-pagination a1-pagination--${size}`}>
29
+ <IconButton
30
+ icon="chevron_left"
31
+ label="Previous page"
32
+ onClick={() => onChange?.(page - 1)}
33
+ disabled={page <= 1}
34
+ className="a1-pagination__item"
35
+ />
36
+
37
+ {items.map((item) =>
38
+ typeof item === "string" ? (
39
+ <span key={item} className="a1-pagination__ellipsis" aria-hidden="true">
40
+
41
+ </span>
42
+ ) : (
43
+ <button
44
+ key={item}
45
+ className="a1-pagination__item"
46
+ onClick={() => item !== page && onChange?.(item)}
47
+ aria-label={`Page ${item}`}
48
+ aria-current={item === page ? "page" : undefined}
49
+ >
50
+ {item}
51
+ </button>
52
+ )
53
+ )}
54
+
55
+ <IconButton
56
+ icon="chevron_right"
57
+ label="Next page"
58
+ onClick={() => onChange?.(page + 1)}
59
+ disabled={page >= totalPages}
60
+ className="a1-pagination__item"
61
+ />
62
+ </nav>
63
+ );
64
+ }
@@ -0,0 +1,85 @@
1
+ /* ─── Pagination ──────────────────────────────────────────────────────────── */
2
+
3
+ .a1-pagination {
4
+ display: inline-flex;
5
+ align-items: center;
6
+ gap: var(--component-pagination-gap);
7
+ }
8
+
9
+ /* ─── Item (shared between page buttons and prev/next) ────────────────────── */
10
+
11
+ .a1-pagination__item {
12
+ display: inline-flex;
13
+ align-items: center;
14
+ justify-content: center;
15
+ box-sizing: border-box;
16
+ min-width: var(--a1-pagination-size);
17
+ height: var(--a1-pagination-size);
18
+ padding: 0 var(--base-spacing-4);
19
+ border: var(--component-pagination-border-width) solid transparent;
20
+ border-radius: var(--base-radius-control);
21
+ background: transparent;
22
+ color: var(--semantic-color-text-default);
23
+ font-family: var(--component-paragraph-font-family);
24
+ font-size: var(--a1-pagination-font-size);
25
+ font-weight: var(--component-pagination-font-weight-default);
26
+ line-height: 1;
27
+ cursor: pointer;
28
+ user-select: none;
29
+ transition: background var(--semantic-motion-duration-fast), border-color var(--semantic-motion-duration-fast), color var(--semantic-motion-duration-fast);
30
+ }
31
+
32
+ .a1-pagination__item:hover:not([aria-current="page"]):not(:disabled) {
33
+ background: var(--semantic-color-surface-panel);
34
+ border-color: var(--semantic-color-border-default);
35
+ }
36
+
37
+ .a1-pagination__item:focus-visible {
38
+ outline: var(--component-pagination-focus-ring-width) solid var(--semantic-color-action-background);
39
+ outline-offset: var(--component-pagination-focus-ring-offset);
40
+ }
41
+
42
+ .a1-pagination__item[aria-current="page"] {
43
+ background: var(--semantic-color-action-background);
44
+ border-color: var(--semantic-color-action-background);
45
+ color: var(--semantic-color-action-foreground);
46
+ font-weight: var(--component-pagination-font-weight-active);
47
+ cursor: default;
48
+ }
49
+
50
+ .a1-pagination__item:disabled {
51
+ opacity: var(--component-pagination-disabled-opacity);
52
+ cursor: not-allowed;
53
+ }
54
+
55
+ /* Prev / next icons inherit item size */
56
+ .a1-pagination__item .a1-icon {
57
+ font-size: calc(var(--a1-pagination-size) * 0.5);
58
+ }
59
+
60
+ /* ─── Ellipsis ────────────────────────────────────────────────────────────── */
61
+
62
+ .a1-pagination__ellipsis {
63
+ display: inline-flex;
64
+ align-items: flex-end;
65
+ justify-content: center;
66
+ width: var(--a1-pagination-size);
67
+ height: var(--a1-pagination-size);
68
+ padding-bottom: var(--component-pagination-ellipsis-padding-bottom);
69
+ color: var(--semantic-color-text-muted);
70
+ font-size: var(--a1-pagination-font-size);
71
+ font-family: var(--component-paragraph-font-family);
72
+ user-select: none;
73
+ }
74
+
75
+ /* ─── Sizes ───────────────────────────────────────────────────────────────── */
76
+
77
+ .a1-pagination--md {
78
+ --a1-pagination-size: var(--component-pagination-item-size);
79
+ --a1-pagination-font-size: var(--semantic-font-size-body-sm);
80
+ }
81
+
82
+ .a1-pagination--sm {
83
+ --a1-pagination-size: var(--component-pagination-item-size-sm);
84
+ --a1-pagination-font-size: var(--semantic-font-size-body-xs);
85
+ }
@@ -0,0 +1,26 @@
1
+ import "./paragraph.css";
2
+
3
+ const sizes = ["xs", "sm", "md", "lg", "xl"];
4
+ const colors = ["default", "muted"];
5
+
6
+ export function Paragraph({
7
+ as: Component = "p",
8
+ size = "md",
9
+ color = "default",
10
+ className = "",
11
+ ...props
12
+ }) {
13
+ const resolvedSize = sizes.includes(size) ? size : "md";
14
+ const resolvedColor = colors.includes(color) ? color : "default";
15
+
16
+ const classes = [
17
+ "a1-paragraph",
18
+ `a1-paragraph--${resolvedSize}`,
19
+ resolvedColor !== "default" && `a1-paragraph--${resolvedColor}`,
20
+ className
21
+ ]
22
+ .filter(Boolean)
23
+ .join(" ");
24
+
25
+ return <Component className={classes} {...props} />;
26
+ }
@@ -0,0 +1,16 @@
1
+ .a1-paragraph {
2
+ margin: 0;
3
+ font-family: var(--component-paragraph-font-family);
4
+ font-size: var(--a1-paragraph-size);
5
+ font-weight: var(--component-paragraph-font-weight);
6
+ line-height: var(--component-paragraph-font-line-height);
7
+ color: var(--a1-paragraph-color, var(--semantic-color-text-default));
8
+ }
9
+
10
+ .a1-paragraph--xs { --a1-paragraph-size: var(--semantic-font-size-body-xs); }
11
+ .a1-paragraph--sm { --a1-paragraph-size: var(--semantic-font-size-body-sm); }
12
+ .a1-paragraph--md { --a1-paragraph-size: var(--semantic-font-size-body-md); }
13
+ .a1-paragraph--lg { --a1-paragraph-size: var(--semantic-font-size-body-lg); }
14
+ .a1-paragraph--xl { --a1-paragraph-size: var(--semantic-font-size-body-xl); }
15
+
16
+ .a1-paragraph--muted { --a1-paragraph-color: var(--semantic-color-text-muted); }
@@ -0,0 +1,77 @@
1
+ import "./segmented.css";
2
+ import { Icon } from "../icon/Icon.jsx";
3
+
4
+ function normalize(opt) {
5
+ return typeof opt === "string" ? { value: opt, label: opt } : opt;
6
+ }
7
+
8
+ export function SegmentedControl({
9
+ options = [],
10
+ value,
11
+ onChange,
12
+ fullWidth = false,
13
+ }) {
14
+ const items = options.map(normalize);
15
+
16
+ const handleKeyDown = (e) => {
17
+ const els = Array.from(e.currentTarget.querySelectorAll('[role="radio"]'));
18
+ const idx = els.indexOf(document.activeElement);
19
+ if (idx === -1) return;
20
+
21
+ let next = -1;
22
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") {
23
+ next = (idx + 1) % els.length;
24
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
25
+ next = (idx - 1 + els.length) % els.length;
26
+ } else if (e.key === "Home") {
27
+ next = 0;
28
+ } else if (e.key === "End") {
29
+ next = els.length - 1;
30
+ }
31
+
32
+ if (next !== -1) {
33
+ e.preventDefault();
34
+ els[next].focus();
35
+ onChange?.(items[next].value);
36
+ }
37
+ };
38
+
39
+ return (
40
+ <div
41
+ role="radiogroup"
42
+ className={[
43
+ "a1-segmented",
44
+ fullWidth && "a1-segmented--full-width",
45
+ ]
46
+ .filter(Boolean)
47
+ .join(" ")}
48
+ onKeyDown={handleKeyDown}
49
+ >
50
+ {items.map((opt) => {
51
+ const iconOnly = Boolean(opt.icon) && !opt.label;
52
+ const isSelected = value === opt.value;
53
+
54
+ return (
55
+ <button
56
+ key={opt.value}
57
+ role="radio"
58
+ type="button"
59
+ aria-checked={isSelected}
60
+ aria-label={iconOnly ? (opt.ariaLabel ?? opt.value) : undefined}
61
+ tabIndex={isSelected ? 0 : -1}
62
+ className={[
63
+ "a1-segment",
64
+ iconOnly && "a1-segment--icon-only",
65
+ ]
66
+ .filter(Boolean)
67
+ .join(" ")}
68
+ onClick={() => onChange?.(opt.value)}
69
+ >
70
+ {opt.icon && <Icon name={opt.icon} className="a1-segment__icon" />}
71
+ {opt.label}
72
+ </button>
73
+ );
74
+ })}
75
+ </div>
76
+ );
77
+ }
@@ -0,0 +1,76 @@
1
+ /* ─── Segmented control ───────────────────────────────────────────────────── */
2
+
3
+ .a1-segmented {
4
+ display: inline-flex;
5
+ align-items: stretch;
6
+ background: var(--semantic-color-surface-raised);
7
+ border: var(--component-segmented-border-width) solid var(--semantic-color-border-default);
8
+ border-radius: var(--base-radius-control);
9
+ padding: var(--component-segmented-padding);
10
+ gap: var(--component-segmented-gap);
11
+ }
12
+
13
+ .a1-segmented--full-width {
14
+ display: flex;
15
+ }
16
+
17
+ /* ─── Segment button ──────────────────────────────────────────────────────── */
18
+
19
+ .a1-segment {
20
+ display: inline-flex;
21
+ align-items: center;
22
+ justify-content: center;
23
+ gap: var(--base-spacing-8);
24
+ border: none;
25
+ /* Inner radius sits flush inside the container */
26
+ border-radius: calc(var(--base-radius-control) - var(--component-segmented-padding));
27
+ background: transparent;
28
+ color: var(--semantic-color-text-muted);
29
+ font-family: var(--component-paragraph-font-family);
30
+ font-weight: var(--component-segmented-font-weight-default);
31
+ white-space: nowrap;
32
+ cursor: pointer;
33
+ transition: background var(--semantic-motion-duration-fast), color var(--semantic-motion-duration-fast), box-shadow var(--semantic-motion-duration-fast);
34
+ }
35
+
36
+ .a1-segment:hover:not([aria-checked="true"]) {
37
+ color: var(--semantic-color-text-default);
38
+ background: var(--semantic-color-surface-panel);
39
+ }
40
+
41
+ .a1-segment[aria-checked="true"] {
42
+ background: var(--semantic-color-surface-page);
43
+ color: var(--semantic-color-text-default);
44
+ font-weight: var(--component-segmented-font-weight-active);
45
+ box-shadow: var(--semantic-shadow-xs);
46
+ }
47
+
48
+ .a1-segment:focus-visible {
49
+ outline: var(--component-segmented-focus-ring-width) solid var(--semantic-color-action-background);
50
+ outline-offset: var(--component-segmented-focus-ring-offset);
51
+ }
52
+
53
+ /* ─── Padding & size ──────────────────────────────────────────────────────── */
54
+
55
+ .a1-segment {
56
+ padding: var(--component-segmented-segment-padding-block)
57
+ var(--component-segmented-segment-padding-inline);
58
+ font-size: var(--semantic-font-size-body-sm);
59
+ }
60
+
61
+ /* ─── Icon ────────────────────────────────────────────────────────────────── */
62
+
63
+ .a1-segment__icon {
64
+ font-size: 1em;
65
+ }
66
+
67
+ /* Icon-only: match inline padding to block padding so the segment is square */
68
+ .a1-segment--icon-only {
69
+ padding-inline: var(--component-segmented-segment-padding-block);
70
+ }
71
+
72
+ /* ─── Full width ──────────────────────────────────────────────────────────── */
73
+
74
+ .a1-segmented--full-width .a1-segment {
75
+ flex: 1;
76
+ }
@@ -0,0 +1,208 @@
1
+ import { createContext, useContext, useEffect, useState } from "react";
2
+ import "./scrim.css";
3
+ import "./side-nav.css";
4
+ import { Icon } from "../icon/Icon.jsx";
5
+ import { IconButton } from "../icon-button/IconButton.jsx";
6
+
7
+ const DepthCtx = createContext(0);
8
+ const SideNavCtx = createContext({ collapsed: false, onExpand: null });
9
+
10
+ /**
11
+ * A leaf navigation item — renders an icon and label as a link or button.
12
+ * In collapsed state (lg+), the label is hidden and used as a native tooltip.
13
+ * @param {object} props
14
+ * @param {"a"|"button"|string} [props.as="a"] - Underlying HTML element
15
+ * @param {string} [props.icon] - Material Symbols icon name (recommended for collapsed nav)
16
+ * @param {string} props.label - Visible label text
17
+ * @param {boolean} [props.active] - Marks this item as the current page
18
+ * @param {string} [props.className]
19
+ */
20
+ export function SideNavItem({ as: Component = "a", icon, label, active, className = "", ...props }) {
21
+ const depth = useContext(DepthCtx);
22
+ const { collapsed } = useContext(SideNavCtx);
23
+
24
+ const classes = [
25
+ "a1-side-nav-item",
26
+ active && "a1-side-nav-item--active",
27
+ className,
28
+ ].filter(Boolean).join(" ");
29
+
30
+ return (
31
+ <Component
32
+ className={classes}
33
+ style={{ "--a1-side-nav-depth": collapsed ? 0 : depth }}
34
+ aria-current={active ? "page" : undefined}
35
+ title={collapsed ? label : undefined}
36
+ {...props}
37
+ >
38
+ {icon && <Icon name={icon} className="a1-side-nav-item__icon" />}
39
+ <span className="a1-side-nav-item__label">{label}</span>
40
+ </Component>
41
+ );
42
+ }
43
+
44
+ /**
45
+ * An expandable group — a trigger that reveals nested SideNavItems or SideNavGroups.
46
+ * When the sidebar is collapsed (lg+), clicking the trigger expands the sidebar instead.
47
+ * Supports uncontrolled (`defaultOpen`) and controlled (`open` + `onOpenChange`) usage.
48
+ * @param {object} props
49
+ * @param {string} [props.icon] - Material Symbols icon name (recommended for collapsed nav)
50
+ * @param {string} props.label - Trigger label text
51
+ * @param {boolean} [props.defaultOpen=false] - Initial expanded state (uncontrolled)
52
+ * @param {boolean} [props.open] - Controlled expanded state
53
+ * @param {function} [props.onOpenChange] - Called with next boolean when toggled
54
+ * @param {React.ReactNode} props.children - Nested SideNavItems or SideNavGroups
55
+ * @param {string} [props.className]
56
+ */
57
+ export function SideNavGroup({ icon, label, defaultOpen = false, open: controlledOpen, onOpenChange, children, className = "", ...props }) {
58
+ const depth = useContext(DepthCtx);
59
+ const { collapsed, onExpand } = useContext(SideNavCtx);
60
+ const isControlled = controlledOpen !== undefined;
61
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
62
+ const isOpen = isControlled ? controlledOpen : internalOpen;
63
+
64
+ function toggle() {
65
+ if (collapsed) { onExpand?.(); return; }
66
+ if (!isControlled) setInternalOpen(v => !v);
67
+ onOpenChange?.(!isOpen);
68
+ }
69
+
70
+ const triggerClasses = [
71
+ "a1-side-nav-item",
72
+ "a1-side-nav-group__trigger",
73
+ isOpen && "a1-side-nav-group__trigger--open",
74
+ className,
75
+ ].filter(Boolean).join(" ");
76
+
77
+ const childrenClasses = [
78
+ "a1-side-nav-group__children",
79
+ isOpen && "a1-side-nav-group__children--open",
80
+ ].filter(Boolean).join(" ");
81
+
82
+ return (
83
+ <div className="a1-side-nav-group" {...props}>
84
+ <button
85
+ type="button"
86
+ className={triggerClasses}
87
+ style={{ "--a1-side-nav-depth": collapsed ? 0 : depth }}
88
+ aria-expanded={collapsed ? undefined : isOpen}
89
+ title={collapsed ? label : undefined}
90
+ onClick={toggle}
91
+ >
92
+ {icon && <Icon name={icon} className="a1-side-nav-item__icon" />}
93
+ <span className="a1-side-nav-item__label">{label}</span>
94
+ <Icon name="chevron_right" className="a1-side-nav-group__chevron" />
95
+ </button>
96
+ <div className={childrenClasses}>
97
+ <DepthCtx.Provider value={depth + 1}>
98
+ <div className="a1-side-nav-group__children-inner">
99
+ {children}
100
+ </div>
101
+ </DepthCtx.Provider>
102
+ </div>
103
+ </div>
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Side navigation shell with responsive controls, header, nav area, and footer slots.
109
+ *
110
+ * Responsive behavior:
111
+ * - xs (≤480px): full-viewport-width fixed overlay; built-in close (✕) button
112
+ * - sm/md (481–1024px): fixed-width overlay with scrim; built-in close (✕) button
113
+ * - lg/xl (≥1025px): persistent in the document flow; built-in collapse (‹/›) toggle
114
+ *
115
+ * The close/collapse button is rendered inline with the header content.
116
+ *
117
+ * @param {object} props
118
+ * @param {React.ReactNode | ((collapsed: boolean) => React.ReactNode)} [props.header]
119
+ * Header content. Pass a render function to receive the current collapsed state,
120
+ * e.g. `header={(collapsed) => <MyLogo collapsed={collapsed} />}`.
121
+ * @param {React.ReactNode} [props.footer] - Below nav items; hidden when collapsed
122
+ * @param {React.ReactNode} props.children - SideNavItem and SideNavGroup elements
123
+ * @param {boolean} [props.open=false] - Controls overlay visibility on xs/sm/md
124
+ * @param {function} [props.onClose] - Called when scrim, Escape, or the close button is triggered
125
+ * @param {boolean} [props.defaultCollapsed=false] - Initial collapsed state for lg/xl (uncontrolled)
126
+ * @param {boolean} [props.collapsed] - Controlled collapsed state for lg/xl
127
+ * @param {function} [props.onCollapsedChange] - Called with next boolean when collapsed state changes
128
+ * @param {"start"|"end"} [props.placement="start"] - Side of the viewport/layout where the nav appears
129
+ * @param {string} [props.className]
130
+ */
131
+ export function SideNav({
132
+ header, footer, children,
133
+ open = false, onClose,
134
+ collapsed: controlledCollapsed, defaultCollapsed = false, onCollapsedChange,
135
+ placement = "start",
136
+ className = "", ...props
137
+ }) {
138
+ const isCollapsedControlled = controlledCollapsed !== undefined;
139
+ const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed);
140
+ const isCollapsed = isCollapsedControlled ? controlledCollapsed : internalCollapsed;
141
+
142
+ function toggleCollapse() {
143
+ if (!isCollapsedControlled) setInternalCollapsed(v => !v);
144
+ onCollapsedChange?.(!isCollapsed);
145
+ }
146
+
147
+ function handleExpand() {
148
+ if (!isCollapsedControlled) setInternalCollapsed(false);
149
+ onCollapsedChange?.(false);
150
+ }
151
+
152
+ useEffect(() => {
153
+ if (!open) return;
154
+ const handler = (e) => { if (e.key === "Escape") onClose?.(); };
155
+ document.addEventListener("keydown", handler);
156
+ return () => document.removeEventListener("keydown", handler);
157
+ }, [open, onClose]);
158
+
159
+ const resolvedHeader = typeof header === "function" ? header(isCollapsed) : header;
160
+
161
+ const navClasses = [
162
+ "a1-side-nav",
163
+ `a1-side-nav--placement-${placement}`,
164
+ open && "a1-side-nav--open",
165
+ isCollapsed && "a1-side-nav--collapsed",
166
+ className,
167
+ ].filter(Boolean).join(" ");
168
+
169
+ const collapseIcon = placement === "end"
170
+ ? (isCollapsed ? "chevron_left" : "chevron_right")
171
+ : (isCollapsed ? "chevron_right" : "chevron_left");
172
+
173
+ return (
174
+ <>
175
+ <div
176
+ className={`a1-scrim a1-side-nav__scrim${open ? " a1-scrim--visible" : ""}`}
177
+ aria-hidden="true"
178
+ onClick={onClose}
179
+ />
180
+ <nav className={navClasses} {...props}>
181
+ {/* Header row: logo/content + inline close or collapse button */}
182
+ <div className="a1-side-nav__header-row">
183
+ {resolvedHeader && (
184
+ <div className="a1-side-nav__header-content">{resolvedHeader}</div>
185
+ )}
186
+ <IconButton
187
+ icon="close"
188
+ label="Close navigation"
189
+ className="a1-side-nav__close-btn"
190
+ onClick={onClose}
191
+ />
192
+ <IconButton
193
+ icon={collapseIcon}
194
+ label={isCollapsed ? "Expand navigation" : "Collapse navigation"}
195
+ className="a1-side-nav__collapse-btn"
196
+ onClick={toggleCollapse}
197
+ />
198
+ </div>
199
+
200
+ <SideNavCtx.Provider value={{ collapsed: isCollapsed, onExpand: handleExpand }}>
201
+ <div className="a1-side-nav__nav">{children}</div>
202
+ </SideNavCtx.Provider>
203
+
204
+ {footer && <div className="a1-side-nav__footer">{footer}</div>}
205
+ </nav>
206
+ </>
207
+ );
208
+ }
@@ -0,0 +1,17 @@
1
+ /* ── Scrim overlay — shared by Dialog (::backdrop) and SideNav ────────── */
2
+
3
+ .a1-scrim {
4
+ position: fixed;
5
+ inset: 0;
6
+ z-index: 199;
7
+ background: var(--component-scrim-color);
8
+ backdrop-filter: blur(var(--component-scrim-blur));
9
+ opacity: 0;
10
+ pointer-events: none;
11
+ transition: opacity 250ms ease;
12
+ }
13
+
14
+ .a1-scrim--visible {
15
+ opacity: 1;
16
+ pointer-events: auto;
17
+ }