@gtivr4/a1-design-system-react 0.20.0 → 0.21.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/package.json
CHANGED
|
@@ -67,6 +67,31 @@ export function Dialog({
|
|
|
67
67
|
return () => el.removeEventListener("cancel", handleCancel);
|
|
68
68
|
}, [onClose]);
|
|
69
69
|
|
|
70
|
+
// Dismissable dialogs (those with an onClose) also close when the backdrop — the
|
|
71
|
+
// area outside the dialog box — is clicked, matching Escape. Require both the press
|
|
72
|
+
// and the release to land on the backdrop so a drag that starts inside the dialog
|
|
73
|
+
// (e.g. selecting text and releasing past the edge) doesn't dismiss it.
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const el = ref.current;
|
|
76
|
+
if (!open || !el || !onClose) return;
|
|
77
|
+
const onBackdrop = (e) => {
|
|
78
|
+
const r = el.getBoundingClientRect();
|
|
79
|
+
return e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom;
|
|
80
|
+
};
|
|
81
|
+
let downOnBackdrop = false;
|
|
82
|
+
const handleMouseDown = (e) => { downOnBackdrop = e.target === el && onBackdrop(e); };
|
|
83
|
+
const handleClick = (e) => {
|
|
84
|
+
if (downOnBackdrop && e.target === el && onBackdrop(e)) onClose();
|
|
85
|
+
downOnBackdrop = false;
|
|
86
|
+
};
|
|
87
|
+
el.addEventListener("mousedown", handleMouseDown);
|
|
88
|
+
el.addEventListener("click", handleClick);
|
|
89
|
+
return () => {
|
|
90
|
+
el.removeEventListener("mousedown", handleMouseDown);
|
|
91
|
+
el.removeEventListener("click", handleClick);
|
|
92
|
+
};
|
|
93
|
+
}, [open, onClose]);
|
|
94
|
+
|
|
70
95
|
useEffect(() => {
|
|
71
96
|
const el = ref.current;
|
|
72
97
|
if (!open || !el) return;
|
|
@@ -21,6 +21,22 @@ export interface TabsProps {
|
|
|
21
21
|
level?: 1 | 2;
|
|
22
22
|
/** Size variant. Default: undefined (standard) */
|
|
23
23
|
size?: "compact";
|
|
24
|
+
/**
|
|
25
|
+
* Keep every panel mounted, stacked in one cell, so the panel area always reserves
|
|
26
|
+
* the **tallest** panel's height — switching tabs won't change the container height.
|
|
27
|
+
* Opt-in (default `false`); use inside a Dialog/overlay so it doesn't resize and move
|
|
28
|
+
* its targets. On a page, leave it off so tabs size to the active panel.
|
|
29
|
+
* Default: false
|
|
30
|
+
*/
|
|
31
|
+
equalHeight?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Label display. `"all"` (default) shows every tab's label at all breakpoints.
|
|
34
|
+
* `"selected"` shows the label only on the **active** tab; inactive tabs render
|
|
35
|
+
* **icon-only** (the label stays in the DOM for the accessible name). Pair with a
|
|
36
|
+
* `Tab` `icon` so inactive tabs aren't blank, and give **every** `Tab` a label.
|
|
37
|
+
* Mirrors `ToolbarGroup`'s `labelMode`. Default: "all"
|
|
38
|
+
*/
|
|
39
|
+
labelMode?: "all" | "selected";
|
|
24
40
|
className?: string;
|
|
25
41
|
children?: React.ReactNode;
|
|
26
42
|
}
|
|
@@ -6,11 +6,11 @@ const TabsContext = createContext(null);
|
|
|
6
6
|
|
|
7
7
|
/* ─── Tabs ─────────────────────────────────────────────────────────────────── */
|
|
8
8
|
|
|
9
|
-
export function Tabs({ children, value, onChange, variant = "line", level = 1, size, className = "" }) {
|
|
9
|
+
export function Tabs({ children, value, onChange, variant = "line", level = 1, size, equalHeight = false, labelMode = "all", className = "" }) {
|
|
10
10
|
const uid = useId();
|
|
11
11
|
return (
|
|
12
|
-
<TabsContext.Provider value={{ value, onChange, variant, level, size, uid }}>
|
|
13
|
-
<div className={["a1-tabs", `a1-tabs--level-${level}`, size && `a1-tabs--${size}`, className].filter(Boolean).join(" ")}>
|
|
12
|
+
<TabsContext.Provider value={{ value, onChange, variant, level, size, uid, equalHeight, labelMode }}>
|
|
13
|
+
<div className={["a1-tabs", `a1-tabs--level-${level}`, size && `a1-tabs--${size}`, equalHeight && "a1-tabs--equal-height", labelMode === "selected" && "a1-tabs--label-selected", className].filter(Boolean).join(" ")}>
|
|
14
14
|
{children}
|
|
15
15
|
</div>
|
|
16
16
|
</TabsContext.Provider>
|
|
@@ -20,7 +20,7 @@ export function Tabs({ children, value, onChange, variant = "line", level = 1, s
|
|
|
20
20
|
/* ─── TabList ───────────────────────────────────────────────────────────────── */
|
|
21
21
|
|
|
22
22
|
export function TabList({ children }) {
|
|
23
|
-
const { variant } = useContext(TabsContext);
|
|
23
|
+
const { variant, value } = useContext(TabsContext);
|
|
24
24
|
const scrollRef = useRef(null);
|
|
25
25
|
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
|
26
26
|
const [canScrollRight, setCanScrollRight] = useState(false);
|
|
@@ -49,6 +49,24 @@ export function TabList({ children }) {
|
|
|
49
49
|
};
|
|
50
50
|
}, [checkScroll, enableScroll]);
|
|
51
51
|
|
|
52
|
+
// Keep the selected tab in view when it changes (and on mount) by adjusting only
|
|
53
|
+
// the strip's own horizontal scroll — never scrollIntoView, which would also move
|
|
54
|
+
// the page vertically.
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!enableScroll) return;
|
|
57
|
+
const el = scrollRef.current;
|
|
58
|
+
const active = el?.querySelector('[role="tab"][aria-selected="true"]');
|
|
59
|
+
if (!el || !active) return;
|
|
60
|
+
const pad = 16;
|
|
61
|
+
const elRect = el.getBoundingClientRect();
|
|
62
|
+
const aRect = active.getBoundingClientRect();
|
|
63
|
+
if (aRect.left < elRect.left) {
|
|
64
|
+
el.scrollBy({ left: aRect.left - elRect.left - pad, behavior: "smooth" });
|
|
65
|
+
} else if (aRect.right > elRect.right) {
|
|
66
|
+
el.scrollBy({ left: aRect.right - elRect.right + pad, behavior: "smooth" });
|
|
67
|
+
}
|
|
68
|
+
}, [value, enableScroll]);
|
|
69
|
+
|
|
52
70
|
const scrollBy = (dir) => {
|
|
53
71
|
scrollRef.current?.scrollBy({ left: dir * 200, behavior: "smooth" });
|
|
54
72
|
};
|
|
@@ -154,15 +172,25 @@ export function Tab({ children, value: tabValue, count, icon, iconPosition = "st
|
|
|
154
172
|
/* ─── TabPanel ──────────────────────────────────────────────────────────────── */
|
|
155
173
|
|
|
156
174
|
export function TabPanel({ children, value: panelValue }) {
|
|
157
|
-
const { value, variant, uid } = useContext(TabsContext);
|
|
158
|
-
|
|
175
|
+
const { value, variant, uid, equalHeight } = useContext(TabsContext);
|
|
176
|
+
const isActive = value === panelValue;
|
|
177
|
+
// Default: unmount inactive panels (the panel area sizes to the active tab). With
|
|
178
|
+
// equalHeight, keep every panel mounted but stacked + hidden, so the area always
|
|
179
|
+
// reserves the tallest panel's height and the container never resizes on switch.
|
|
180
|
+
if (!equalHeight && !isActive) return null;
|
|
159
181
|
|
|
160
182
|
return (
|
|
161
183
|
<div
|
|
162
184
|
role="tabpanel"
|
|
163
185
|
id={`${uid}-panel-${panelValue}`}
|
|
164
186
|
aria-labelledby={`${uid}-tab-${panelValue}`}
|
|
165
|
-
|
|
187
|
+
aria-hidden={equalHeight && !isActive ? true : undefined}
|
|
188
|
+
className={[
|
|
189
|
+
"a1-tab-panel",
|
|
190
|
+
`a1-tab-panel--${variant}`,
|
|
191
|
+
equalHeight && "a1-tab-panel--stacked",
|
|
192
|
+
equalHeight && !isActive && "a1-tab-panel--inactive",
|
|
193
|
+
].filter(Boolean).join(" ")}
|
|
166
194
|
>
|
|
167
195
|
{children}
|
|
168
196
|
</div>
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
box-sizing: border-box;
|
|
5
5
|
display: flex;
|
|
6
6
|
flex-direction: column;
|
|
7
|
+
/* Never force a flex/grid parent wider than its share: allow shrinking and cap
|
|
8
|
+
at the parent width so an overflowing tab strip scrolls instead of pushing it. */
|
|
9
|
+
min-width: 0;
|
|
10
|
+
max-width: 100%;
|
|
7
11
|
}
|
|
8
12
|
|
|
9
13
|
/* ─── Tab list wrapper ────────────────────────────────────────────────────── */
|
|
@@ -17,6 +21,8 @@
|
|
|
17
21
|
.a1-tab-list-wrapper {
|
|
18
22
|
display: flex;
|
|
19
23
|
align-items: stretch;
|
|
24
|
+
min-width: 0;
|
|
25
|
+
max-width: 100%;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
.a1-tab-list-wrapper--line {
|
|
@@ -63,6 +69,11 @@
|
|
|
63
69
|
/* Line variant gets overflow scroll; the tab list scrolls, wrapper border stays full-width */
|
|
64
70
|
.a1-tab-list--scrollable {
|
|
65
71
|
flex: 1;
|
|
72
|
+
/* min-width: 0 lets this flex item shrink below the tabs' intrinsic (min-content)
|
|
73
|
+
width — without it the strip grows to fit every tab and forces a horizontal
|
|
74
|
+
page/container overflow instead of scrolling internally. This is what makes the
|
|
75
|
+
overflow-x scroll (and the scroll-arrow buttons) actually engage. */
|
|
76
|
+
min-width: 0;
|
|
66
77
|
overflow-x: auto;
|
|
67
78
|
/* Setting overflow-x alone makes the browser compute overflow-y to `auto`,
|
|
68
79
|
which lets a sub-pixel of height add a stray vertical scroll. Pin it. */
|
|
@@ -478,24 +489,23 @@
|
|
|
478
489
|
border-bottom-left-radius: var(--base-radius-control);
|
|
479
490
|
}
|
|
480
491
|
|
|
481
|
-
/* ───
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
492
|
+
/* ─── Label on selected only (opt-in via labelMode="selected") ─────────────────
|
|
493
|
+
* Inactive tabs render icon-only; the selected tab shows its label. The label stays
|
|
494
|
+
* in the DOM (visually hidden) so the accessible name is preserved. Pair with a Tab
|
|
495
|
+
* `icon` so inactive tabs aren't blank, and give every Tab a label. */
|
|
496
|
+
.a1-tabs--label-selected .a1-tab {
|
|
497
|
+
position: relative;
|
|
498
|
+
}
|
|
487
499
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
white-space: nowrap;
|
|
498
|
-
}
|
|
500
|
+
.a1-tabs--label-selected .a1-tab[aria-selected="false"] .a1-tab__label {
|
|
501
|
+
position: absolute;
|
|
502
|
+
width: 1px;
|
|
503
|
+
height: 1px;
|
|
504
|
+
padding: 0;
|
|
505
|
+
margin: -1px;
|
|
506
|
+
overflow: hidden;
|
|
507
|
+
clip: rect(0, 0, 0, 0);
|
|
508
|
+
white-space: nowrap;
|
|
499
509
|
}
|
|
500
510
|
|
|
501
511
|
/* ─── Responsive font sizes ───────────────────────────────────────────────── */
|
|
@@ -545,6 +555,34 @@
|
|
|
545
555
|
padding: var(--base-spacing-24) 0;
|
|
546
556
|
}
|
|
547
557
|
|
|
558
|
+
/* ─── Equal-height panels (opt-in) ──────────────────────────────────────────────
|
|
559
|
+
* Keep every panel mounted and stacked in one grid cell so the panel area always
|
|
560
|
+
* reserves the *tallest* panel's height — switching tabs no longer changes the
|
|
561
|
+
* container height. Use inside a Dialog/overlay so the dialog doesn't resize and
|
|
562
|
+
* move its targets. Inactive panels stay laid out (so they contribute height) but
|
|
563
|
+
* are visually hidden and removed from the a11y tree (aria-hidden), which also
|
|
564
|
+
* takes their focusable descendants out of the tab order.
|
|
565
|
+
*/
|
|
566
|
+
.a1-tabs--equal-height {
|
|
567
|
+
display: grid;
|
|
568
|
+
grid-template-columns: minmax(0, 1fr);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
.a1-tabs--equal-height > .a1-tab-list-wrapper {
|
|
572
|
+
grid-row: 1;
|
|
573
|
+
grid-column: 1;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
.a1-tab-panel--stacked {
|
|
577
|
+
grid-row: 2;
|
|
578
|
+
grid-column: 1;
|
|
579
|
+
min-width: 0;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
.a1-tab-panel--inactive {
|
|
583
|
+
visibility: hidden;
|
|
584
|
+
}
|
|
585
|
+
|
|
548
586
|
/* ─── Compact size ──────────────────────────────────────────────────────────── */
|
|
549
587
|
|
|
550
588
|
.a1-tabs--compact .a1-tab {
|