@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.
- package/README.md +32 -0
- package/package.json +40 -0
- package/src/color-scheme.css +213 -0
- package/src/components/button/Button.jsx +45 -0
- package/src/components/button/button.css +135 -0
- package/src/components/button-container/ButtonContainer.jsx +27 -0
- package/src/components/button-container/button-container.css +38 -0
- package/src/components/card/Card.jsx +29 -0
- package/src/components/card/card.css +37 -0
- package/src/components/dialog/Dialog.jsx +44 -0
- package/src/components/dialog/dialog.css +58 -0
- package/src/components/grid/Grid.jsx +77 -0
- package/src/components/grid/grid.css +86 -0
- package/src/components/heading/Heading.jsx +69 -0
- package/src/components/heading/heading.css +76 -0
- package/src/components/icon/Icon.jsx +32 -0
- package/src/components/icon/icon.css +10 -0
- package/src/components/icon-button/IconButton.jsx +34 -0
- package/src/components/icon-button/icon-button.css +196 -0
- package/src/components/inverse/Inverse.jsx +18 -0
- package/src/components/labels/Labels.jsx +29 -0
- package/src/components/link/Link.jsx +41 -0
- package/src/components/link/link.css +50 -0
- package/src/components/menu/Menu.jsx +45 -0
- package/src/components/menu/menu.css +45 -0
- package/src/components/message/Message.jsx +103 -0
- package/src/components/message/message.css +226 -0
- package/src/components/notification/Notification.jsx +55 -0
- package/src/components/notification/notification.css +69 -0
- package/src/components/page-layout/PageLayout.jsx +40 -0
- package/src/components/page-layout/page-layout.css +61 -0
- package/src/components/pagination/Pagination.jsx +64 -0
- package/src/components/pagination/pagination.css +85 -0
- package/src/components/paragraph/Paragraph.jsx +26 -0
- package/src/components/paragraph/paragraph.css +16 -0
- package/src/components/segmented-control/SegmentedControl.jsx +77 -0
- package/src/components/segmented-control/segmented.css +76 -0
- package/src/components/side-nav/SideNav.jsx +208 -0
- package/src/components/side-nav/scrim.css +17 -0
- package/src/components/side-nav/side-nav.css +283 -0
- package/src/components/tabs/Tabs.jsx +102 -0
- package/src/components/tabs/tabs.css +135 -0
- package/src/index.js +20 -0
- package/src/themes.css +186 -0
- 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
|
+
}
|