@gtivr4/a1-design-system-react 0.19.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.19.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",
@@ -25,6 +25,15 @@ export interface CardProps extends React.HTMLAttributes<HTMLElement> {
25
25
  * Default: "action"
26
26
  */
27
27
  heroColor?: "action" | "neutral" | "info" | "success" | "warn" | "error" | (string & {});
28
+ /** Badge label overlaid on the hero (only renders when `iconDisplay="hero"`). */
29
+ heroBadge?: React.ReactNode;
30
+ /** Status colour of the hero badge. Default: "neutral" */
31
+ heroBadgeStatus?: "neutral" | "info" | "success" | "warn" | "error";
32
+ /** Placement of the hero badge on a 3×3 grid ("{top|middle|bottom}-{start|center|end}"). Default: "top-end" */
33
+ heroBadgePosition?:
34
+ | "top-start" | "top-center" | "top-end"
35
+ | "middle-start" | "middle-center" | "middle-end"
36
+ | "bottom-start" | "bottom-center" | "bottom-end";
28
37
  children?: React.ReactNode;
29
38
  }
30
39
 
@@ -1,5 +1,6 @@
1
1
  import "./card.css";
2
2
  import { Icon } from "../icon/Icon.jsx";
3
+ import { MessageBadge } from "../message/Message.jsx";
3
4
 
4
5
  const HERO_COLORS = {
5
6
  action: "var(--semantic-color-action-background)",
@@ -12,6 +13,14 @@ const HERO_COLORS = {
12
13
 
13
14
  const VALID_ICON_DISPLAY = ["none", "default", "hero"];
14
15
 
16
+ // 3×3 placement of a hero badge: "{block}-{inline}" where block ∈ top|middle|bottom
17
+ // and inline ∈ start|center|end.
18
+ const VALID_HERO_BADGE_POSITIONS = [
19
+ "top-start", "top-center", "top-end",
20
+ "middle-start", "middle-center", "middle-end",
21
+ "bottom-start", "bottom-center", "bottom-end",
22
+ ];
23
+
15
24
  export function Card({
16
25
  as,
17
26
  bare = false,
@@ -20,6 +29,9 @@ export function Card({
20
29
  icon,
21
30
  iconDisplay = "default",
22
31
  heroColor = "action",
32
+ heroBadge,
33
+ heroBadgeStatus = "neutral",
34
+ heroBadgePosition = "top-end",
23
35
  className = "",
24
36
  children,
25
37
  ...props
@@ -47,12 +59,22 @@ export function Card({
47
59
  ? { type: "button" }
48
60
  : {};
49
61
 
62
+ const badgePos = VALID_HERO_BADGE_POSITIONS.includes(heroBadgePosition)
63
+ ? heroBadgePosition
64
+ : "top-end";
65
+ const [badgeBlock, badgeInline] = badgePos.split("-");
66
+
50
67
  return (
51
68
  <Component className={classes} href={href} {...interactiveProps} {...props}>
52
69
  <div className="a1-card__layout">
53
70
  {resolvedDisplay === "hero" && (
54
71
  <div className="a1-card__hero" style={{ "--a1-card-hero-bg": heroBg }}>
55
72
  <Icon name={icon} aria-hidden="true" />
73
+ {heroBadge && (
74
+ <span className={`a1-card__hero-badge a1-card__hero-badge--${badgeBlock} a1-card__hero-badge--${badgeInline}`}>
75
+ <MessageBadge status={heroBadgeStatus} size="sm">{heroBadge}</MessageBadge>
76
+ </span>
77
+ )}
56
78
  </div>
57
79
  )}
58
80
  {resolvedDisplay === "default" && (
@@ -79,6 +79,7 @@ button.a1-card--navigation {
79
79
 
80
80
  .a1-card__hero {
81
81
  /* Bleed out to the card edges on all four sides then add inner padding */
82
+ position: relative;
82
83
  margin-top: calc(-1 * var(--component-card-padding));
83
84
  margin-inline: calc(-1 * var(--component-card-padding));
84
85
  margin-bottom: var(--component-card-padding);
@@ -91,8 +92,26 @@ button.a1-card--navigation {
91
92
  --a1-icon-opsz: 48;
92
93
  }
93
94
 
95
+ /* Hero badge — overlaid on the hero, placed via a 3×3 grid. */
96
+ .a1-card__hero-badge {
97
+ position: absolute;
98
+ z-index: 1;
99
+ }
100
+
101
+ .a1-card__hero-badge--top { inset-block-start: var(--base-spacing-8); }
102
+ .a1-card__hero-badge--bottom { inset-block-end: var(--base-spacing-8); }
103
+ .a1-card__hero-badge--middle { inset-block-start: 50%; }
104
+ .a1-card__hero-badge--start { inset-inline-start: var(--base-spacing-8); }
105
+ .a1-card__hero-badge--end { inset-inline-end: var(--base-spacing-8); }
106
+ .a1-card__hero-badge--center { inset-inline-start: 50%; }
107
+
108
+ /* Centre transforms for the middle/centre axes (combine when both). */
109
+ .a1-card__hero-badge--middle:not(.a1-card__hero-badge--center) { transform: translateY(-50%); }
110
+ .a1-card__hero-badge--center:not(.a1-card__hero-badge--middle) { transform: translateX(-50%); }
111
+ .a1-card__hero-badge--middle.a1-card__hero-badge--center { transform: translate(-50%, -50%); }
112
+
94
113
  /* Higher specificity (0,2,0) beats .a1-icon (0,1,0) so font-size is not overridden by inherit */
95
- .a1-card__hero .a1-icon {
114
+ .a1-card__hero > .a1-icon {
96
115
  font-size: var(--base-spacing-64);
97
116
  color: var(--semantic-color-text-inverse);
98
117
  }
@@ -120,7 +139,7 @@ button.a1-card--navigation {
120
139
  border-end-end-radius: 0;
121
140
  }
122
141
 
123
- .a1-card__hero .a1-icon {
142
+ .a1-card__hero > .a1-icon {
124
143
  font-size: var(--base-spacing-128);
125
144
  }
126
145
 
@@ -146,7 +146,11 @@ export function Code({
146
146
  );
147
147
  }
148
148
 
149
- const collapsed = collapses && overflows && !expanded;
149
+ // Cap the height whenever collapsible + not expanded (so the overflow check has
150
+ // a clamped height to measure against); `clipped` adds the fade only when the
151
+ // content actually overflows the cap.
152
+ const collapsed = collapses && !expanded;
153
+ const clipped = collapsed && overflows;
150
154
 
151
155
  return (
152
156
  <div
@@ -155,6 +159,7 @@ export function Code({
155
159
  copyCode && "a1-code-block--copyable",
156
160
  editable && "a1-code-block--editable",
157
161
  collapsed && "a1-code-block--collapsed",
162
+ clipped && "a1-code-block--clipped",
158
163
  className,
159
164
  ]
160
165
  .filter(Boolean)
@@ -185,29 +190,33 @@ export function Code({
185
190
  </code>
186
191
  </pre>
187
192
  )}
188
- {copyCode && (
189
- <Button
190
- className="a1-code-block__copy"
191
- icon="content_copy"
192
- size="sm"
193
- variant="tertiary"
194
- onClick={handleCopy}
195
- type="button"
196
- >
197
- {copied ? copiedLabel : copyLabel}
198
- </Button>
199
- )}
200
- {collapses && overflows && (
201
- <Button
202
- className="a1-code-block__toggle"
203
- icon={expanded ? "expand_less" : "expand_more"}
204
- size="sm"
205
- variant="tertiary"
206
- onClick={() => setExpanded((v) => !v)}
207
- type="button"
208
- >
209
- {expanded ? showLessLabel : showMoreLabel}
210
- </Button>
193
+ {(copyCode || (collapses && overflows)) && (
194
+ <div className="a1-code-block__actions">
195
+ {copyCode && (
196
+ <Button
197
+ className="a1-code-block__copy"
198
+ icon="content_copy"
199
+ size="sm"
200
+ variant="tertiary"
201
+ onClick={handleCopy}
202
+ type="button"
203
+ >
204
+ {copied ? copiedLabel : copyLabel}
205
+ </Button>
206
+ )}
207
+ {collapses && overflows && (
208
+ <Button
209
+ className="a1-code-block__toggle"
210
+ icon={expanded ? "expand_less" : "expand_more"}
211
+ size="sm"
212
+ variant="tertiary"
213
+ onClick={() => setExpanded((v) => !v)}
214
+ type="button"
215
+ >
216
+ {expanded ? showLessLabel : showMoreLabel}
217
+ </Button>
218
+ )}
219
+ </div>
211
220
  )}
212
221
  </div>
213
222
  );
@@ -95,7 +95,7 @@
95
95
  overflow-y: hidden;
96
96
  }
97
97
 
98
- .a1-code-block--collapsed .a1-code-block__pre::after {
98
+ .a1-code-block--clipped .a1-code-block__pre::after {
99
99
  content: "";
100
100
  position: absolute;
101
101
  inset-inline: 0;
@@ -110,3 +110,10 @@
110
110
  .a1-code-block__toggle {
111
111
  margin: 0;
112
112
  }
113
+
114
+ /* Copy + Show more/less sit inline on one row. */
115
+ .a1-code-block__actions {
116
+ display: flex;
117
+ align-items: center;
118
+ gap: var(--base-spacing-8);
119
+ }
@@ -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 {
@@ -213,6 +213,13 @@
213
213
  color: var(--semantic-color-text-muted);
214
214
  }
215
215
 
216
+ /* When the parent is the current/selected item (a child page is active), its
217
+ leading icon matches the selected text colour instead of staying muted. */
218
+ .a1-top-header__flyout-trigger.a1-menu-item--active .a1-top-header__flyout-icon,
219
+ .a1-top-header__flyout-trigger[aria-current="page"] .a1-top-header__flyout-icon {
220
+ color: currentColor;
221
+ }
222
+
216
223
  .a1-top-header__flyout-chevron {
217
224
  margin-inline-start: auto;
218
225
  flex-shrink: 0;