@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtivr4/a1-design-system-react",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "React components for the A1 token-driven design system.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -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
- if (value !== panelValue) return null;
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
- className={`a1-tab-panel a1-tab-panel--${variant}`}
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
- /* ─── Responsive tab labels (xs/sm: icon-only for inactive tabs) ─────────── */
482
-
483
- @media (--bp-sm-down) {
484
- .a1-tab {
485
- position: relative;
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
- /* Visually hide label on inactive tabs — kept in DOM for accessibility */
489
- .a1-tab[aria-selected="false"] .a1-tab__label {
490
- position: absolute;
491
- width: 1px;
492
- height: 1px;
493
- padding: 0;
494
- margin: -1px;
495
- overflow: hidden;
496
- clip: rect(0, 0, 0, 0);
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 {