@gtivr4/a1-design-system-react 0.15.0 → 0.18.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 (41) hide show
  1. package/package.json +3 -2
  2. package/src/color-scheme.css +2 -0
  3. package/src/components/accordion/accordion.css +6 -0
  4. package/src/components/autocomplete/Autocomplete.d.ts +53 -0
  5. package/src/components/autocomplete/Autocomplete.jsx +380 -0
  6. package/src/components/autocomplete/autocomplete.css +346 -0
  7. package/src/components/banner/Banner.d.ts +9 -2
  8. package/src/components/banner/Banner.jsx +32 -6
  9. package/src/components/banner/banner.css +81 -0
  10. package/src/components/bottom-sheet/BottomSheet.d.ts +22 -0
  11. package/src/components/bottom-sheet/BottomSheet.jsx +154 -0
  12. package/src/components/bottom-sheet/bottom-sheet.css +113 -0
  13. package/src/components/code/Code.jsx +6 -1
  14. package/src/components/data-table/DataTable.jsx +11 -1
  15. package/src/components/data-table/data-table.css +19 -0
  16. package/src/components/figure/Figure.d.ts +7 -0
  17. package/src/components/figure/Figure.jsx +23 -2
  18. package/src/components/figure/figure.css +25 -0
  19. package/src/components/grid/Grid.d.ts +1 -1
  20. package/src/components/grid/Grid.jsx +2 -0
  21. package/src/components/grid/grid.css +5 -0
  22. package/src/components/page-layout/page-layout.css +10 -4
  23. package/src/components/page-nav/PageNav.jsx +29 -8
  24. package/src/components/page-nav/page-nav.css +13 -0
  25. package/src/components/paragraph/Paragraph.d.ts +2 -0
  26. package/src/components/paragraph/Paragraph.jsx +4 -0
  27. package/src/components/paragraph/paragraph.css +6 -6
  28. package/src/components/segmented-control/SegmentedControl.d.ts +8 -0
  29. package/src/components/segmented-control/SegmentedControl.jsx +16 -3
  30. package/src/components/segmented-control/segmented.css +31 -1
  31. package/src/components/slider/slider.css +10 -2
  32. package/src/components/split-button/SplitButton.jsx +3 -1
  33. package/src/components/tabs/tabs.css +3 -0
  34. package/src/components/toolbar/Toolbar.d.ts +7 -0
  35. package/src/components/toolbar/Toolbar.jsx +13 -5
  36. package/src/components/top-header/top-header.css +2 -0
  37. package/src/components/tree-menu/TreeMenu.jsx +11 -7
  38. package/src/index.d.ts +71 -0
  39. package/src/index.js +2 -0
  40. package/src/themes.css +293 -0
  41. package/src/tokens.css +22 -1
@@ -0,0 +1,113 @@
1
+ /* BottomSheet — fixed bottom panel, no scrim, separation via shadow. Drag handle
2
+ resizes between detents; content scrolls internally. xs + sm only. */
3
+
4
+ .a1-bottom-sheet {
5
+ position: fixed;
6
+ inset-inline: 0;
7
+ inset-block-end: 0;
8
+ z-index: var(--component-bottom-sheet-z-index, 200);
9
+
10
+ display: flex;
11
+ flex-direction: column;
12
+ block-size: var(--a1-bottom-sheet-height, var(--component-bottom-sheet-header-height, 3.5rem));
13
+ max-block-size: 96dvh;
14
+ min-block-size: var(--component-bottom-sheet-header-height, 3.5rem);
15
+
16
+ background: var(--component-bottom-sheet-background, var(--semantic-color-surface-card));
17
+ border-start-start-radius: var(--component-bottom-sheet-border-radius, var(--base-radius-lg));
18
+ border-start-end-radius: var(--component-bottom-sheet-border-radius, var(--base-radius-lg));
19
+ /* Strong 1px hairline along the top edge (and up the rounded corners). */
20
+ border-block-start: var(--component-divider-size-sm) solid var(--semantic-color-border-strong);
21
+ border-inline: var(--component-divider-size-sm) solid var(--semantic-color-border-strong);
22
+ /* Upward shadow only — separates the sheet from page content without a scrim. */
23
+ box-shadow:
24
+ 0 calc(-1 * var(--base-spacing-2)) var(--base-spacing-8)
25
+ color-mix(in srgb, var(--semantic-color-text-default) 12%, transparent),
26
+ var(--semantic-shadow-xl);
27
+
28
+ padding-block-end: env(safe-area-inset-bottom, 0px);
29
+ transition: block-size var(--semantic-motion-duration-fast) var(--semantic-motion-easing-standard);
30
+ }
31
+
32
+ .a1-bottom-sheet--dragging {
33
+ transition: none;
34
+ }
35
+
36
+ /* ── Header (drag handle + title) ──────────────────────────────────────────── */
37
+
38
+ .a1-bottom-sheet__header {
39
+ flex: none;
40
+ display: flex;
41
+ flex-direction: column;
42
+ align-items: stretch;
43
+ gap: var(--base-spacing-8);
44
+ padding: var(--base-spacing-8) var(--component-bottom-sheet-padding, var(--base-spacing-16));
45
+ min-block-size: var(--component-bottom-sheet-header-height, 3.5rem);
46
+ cursor: grab;
47
+ touch-action: none;
48
+ user-select: none;
49
+ }
50
+
51
+ .a1-bottom-sheet--dragging .a1-bottom-sheet__header {
52
+ cursor: grabbing;
53
+ }
54
+
55
+ .a1-bottom-sheet__handle {
56
+ align-self: center;
57
+ flex: none;
58
+ inline-size: var(--component-bottom-sheet-handle-width, var(--base-spacing-40));
59
+ block-size: var(--component-bottom-sheet-handle-height, var(--base-spacing-4));
60
+ padding: 0;
61
+ border: none;
62
+ border-radius: 999px;
63
+ background: var(--semantic-color-border-strong);
64
+ cursor: grab;
65
+ }
66
+
67
+ .a1-bottom-sheet__handle:focus-visible {
68
+ outline: var(--component-button-focus-ring-width, 2px) solid var(--semantic-color-text-accent);
69
+ outline-offset: var(--base-spacing-4);
70
+ }
71
+
72
+ .a1-bottom-sheet__title {
73
+ font-size: var(--semantic-font-size-body-md);
74
+ font-weight: var(--semantic-font-weight-heading);
75
+ color: var(--semantic-color-text-default);
76
+ white-space: nowrap;
77
+ overflow: hidden;
78
+ text-overflow: ellipsis;
79
+ }
80
+
81
+ /* ── Scrollable content ────────────────────────────────────────────────────── */
82
+
83
+ .a1-bottom-sheet__content {
84
+ flex: 1 1 auto;
85
+ min-block-size: 0;
86
+ overflow-y: auto;
87
+ overscroll-behavior: contain;
88
+ -webkit-overflow-scrolling: touch;
89
+ padding:
90
+ 0
91
+ var(--component-bottom-sheet-padding, var(--base-spacing-16))
92
+ var(--base-spacing-16);
93
+ }
94
+
95
+ /* When collapsed the content area has no height — it's clipped to the header. */
96
+ .a1-bottom-sheet--collapsed .a1-bottom-sheet__content {
97
+ overflow: hidden;
98
+ }
99
+
100
+ /* ── Spacer: reserves the collapsed footprint in document flow ─────────────── */
101
+
102
+ .a1-bottom-sheet__spacer {
103
+ block-size: var(--component-bottom-sheet-header-height, 3.5rem);
104
+ }
105
+
106
+ /* ── xs + sm only ──────────────────────────────────────────────────────────── */
107
+
108
+ @media (--bp-md-up) {
109
+ .a1-bottom-sheet,
110
+ .a1-bottom-sheet__spacer {
111
+ display: none;
112
+ }
113
+ }
@@ -77,6 +77,7 @@ export function Code({
77
77
 
78
78
  const copyLabel = useLabel("code.copyCode", "Copy code");
79
79
  const copiedLabel = useLabel("code.copied", "Copied");
80
+ const editLabel = useLabel("code.editCode", "Edit code");
80
81
  const textToCopy = useMemo(
81
82
  () => copyText || (editable ? editableValue : textFromChildren(Children.toArray(children))),
82
83
  [children, copyText, editable, editableValue],
@@ -113,6 +114,10 @@ export function Code({
113
114
  ]
114
115
  .filter(Boolean)
115
116
  .join(" ");
117
+ const editableProps = {
118
+ ...props,
119
+ ...(!props["aria-label"] && !props["aria-labelledby"] ? { "aria-label": editLabel } : null),
120
+ };
116
121
 
117
122
  if (!shouldRenderBlock) {
118
123
  return (
@@ -145,7 +150,7 @@ export function Code({
145
150
  value={editableValue}
146
151
  onChange={handleTextareaChange}
147
152
  spellCheck={false}
148
- {...props}
153
+ {...editableProps}
149
154
  />
150
155
  ) : (
151
156
  <pre className="a1-code-block__pre">
@@ -12,7 +12,7 @@ import "./data-table.css";
12
12
  * columns: Array<{
13
13
  * key: string,
14
14
  * label: string,
15
- * type?: "text" | "number" | "currency" | "date" | "badge" | "avatar" | "link" | "actions",
15
+ * type?: "text" | "number" | "currency" | "date" | "badge" | "avatar" | "image" | "link" | "actions",
16
16
  * align?: "start" | "center" | "end",
17
17
  * width?: string,
18
18
  * sortable?: boolean,
@@ -29,6 +29,7 @@ import "./data-table.css";
29
29
  // Estimated minimum content width per column type at a "neutral" padding level
30
30
  const COL_BASE_WIDTH = {
31
31
  avatar: 160, // avatar circle + name text
32
+ image: 72, // small thumbnail
32
33
  date: 110, // "Jan 12, 2026"
33
34
  actions: 120, // one or two compact buttons
34
35
  link: 120, // linked text
@@ -455,6 +456,15 @@ export function DataTable({
455
456
  );
456
457
  }
457
458
 
459
+ case "image": {
460
+ // value is an image URL, or `{ src, alt }`.
461
+ const src = value && typeof value === "object" ? value.src : value;
462
+ const alt = value && typeof value === "object" ? (value.alt ?? "") : "";
463
+ return src
464
+ ? <img className="a1-data-table__thumb" src={src} alt={alt} loading="lazy" />
465
+ : <span className="a1-data-table__thumb a1-data-table__thumb--empty" aria-hidden="true" />;
466
+ }
467
+
458
468
  case "badge": {
459
469
  const status = col.statusMap?.[value] ?? "neutral";
460
470
  const compact = activeDensity === "compact";
@@ -286,6 +286,25 @@
286
286
  font-size: var(--component-notification-font-size);
287
287
  }
288
288
 
289
+ /* Image (thumbnail) cell type. */
290
+ .a1-data-table__thumb {
291
+ display: block;
292
+ inline-size: 2.5rem;
293
+ block-size: 2.5rem;
294
+ object-fit: cover;
295
+ border-radius: var(--base-radius-sm);
296
+ background: var(--semantic-color-surface-raised);
297
+ }
298
+
299
+ .a1-data-table--compact .a1-data-table__thumb {
300
+ inline-size: 2rem;
301
+ block-size: 2rem;
302
+ }
303
+
304
+ .a1-data-table__thumb--empty {
305
+ border: var(--component-divider-size-sm) dashed var(--semantic-color-border-subtle);
306
+ }
307
+
289
308
  .a1-data-table__actions {
290
309
  display: inline-flex;
291
310
  align-items: center;
@@ -54,6 +54,13 @@ export interface FigureProps extends React.HTMLAttributes<HTMLElement> {
54
54
  * Pass `true` for symmetric bleed or a numeric spacing token for inline-only.
55
55
  */
56
56
  bleed?: boolean | SpacingToken;
57
+ /**
58
+ * Show a tokenized placeholder pattern when `src` is missing or fails to load
59
+ * (e.g. a deleted image). Default: true. Set false to render the bare `<img>`.
60
+ */
61
+ placeholder?: boolean;
62
+ /** Material Symbols icon shown in the placeholder. Default: "image" */
63
+ placeholderIcon?: string;
57
64
  /** Extra class names on the `<figure>` element */
58
65
  className?: string;
59
66
  /** Extra class names on the `<img>` element */
@@ -1,6 +1,7 @@
1
1
  import "./figure.css";
2
- import { useState } from "react";
2
+ import { useEffect, useState } from "react";
3
3
  import { Bleed } from "../bleed/Bleed.jsx";
4
+ import { Icon } from "../icon/Icon.jsx";
4
5
 
5
6
  function isCropRect(rect) {
6
7
  return (
@@ -35,6 +36,8 @@ export function Figure({
35
36
  marginTop,
36
37
  marginBottom,
37
38
  bleed,
39
+ placeholder = true,
40
+ placeholderIcon = "image",
38
41
  className = "",
39
42
  imgClassName = "",
40
43
  style,
@@ -46,6 +49,12 @@ export function Figure({
46
49
  const cropped = isCropRect(cropRect);
47
50
  const [naturalRatio, setNaturalRatio] = useState(null);
48
51
 
52
+ // Show a tokenized placeholder pattern when there's no source or it fails to
53
+ // load (e.g. a deleted library image). Reset the error flag when src changes.
54
+ const [errored, setErrored] = useState(false);
55
+ useEffect(() => { setErrored(false); }, [src]);
56
+ const showPlaceholder = placeholder && (!src || errored);
57
+
49
58
  const classes = [
50
59
  "a1-figure",
51
60
  radius != null && rounded.includes(radius) && `a1-figure--rounded-${radius}`,
@@ -70,6 +79,7 @@ export function Figure({
70
79
  alt={alt}
71
80
  className={["a1-figure__img", imgClassName].filter(Boolean).join(" ")}
72
81
  style={imgStyle}
82
+ onError={() => setErrored(true)}
73
83
  onLoad={cropped ? (e) => {
74
84
  const { naturalWidth, naturalHeight } = e.currentTarget;
75
85
  if (naturalWidth && naturalHeight) setNaturalRatio(naturalWidth / naturalHeight);
@@ -77,7 +87,18 @@ export function Figure({
77
87
  />
78
88
  );
79
89
 
80
- const media = cropped ? (
90
+ const placeholderEl = (
91
+ <div
92
+ className={["a1-figure__img", "a1-figure__placeholder", imgClassName].filter(Boolean).join(" ")}
93
+ style={imgStyle}
94
+ role="img"
95
+ aria-label={alt || undefined}
96
+ >
97
+ <Icon name={placeholderIcon} className="a1-figure__placeholder-icon" aria-hidden="true" />
98
+ </div>
99
+ );
100
+
101
+ const media = showPlaceholder ? placeholderEl : cropped ? (
81
102
  <div
82
103
  className="a1-figure__crop"
83
104
  style={{
@@ -167,3 +167,28 @@
167
167
  .a1-figure--caption-center .a1-figure__caption {
168
168
  text-align: center;
169
169
  }
170
+
171
+ /* ─── Placeholder ─────────────────────────────────────────────────────────────
172
+ Shown when the image is missing or fails to load: a tokenized diagonal-stripe
173
+ pattern with a centered icon. Shares .a1-figure__img sizing, so aspect-ratio
174
+ and size variants apply; a 4/3 fallback box is used when no ratio is set. */
175
+ .a1-figure__placeholder {
176
+ display: flex;
177
+ align-items: center;
178
+ justify-content: center;
179
+ aspect-ratio: 4 / 3;
180
+ background-color: var(--semantic-color-surface-raised);
181
+ background-image: repeating-linear-gradient(
182
+ 45deg,
183
+ var(--semantic-color-border-subtle) 0,
184
+ var(--semantic-color-border-subtle) 1px,
185
+ transparent 1px,
186
+ transparent 10px
187
+ );
188
+ color: var(--semantic-color-text-muted);
189
+ }
190
+
191
+ .a1-figure__placeholder-icon {
192
+ font-size: var(--base-spacing-48);
193
+ opacity: 0.6;
194
+ }
@@ -1,7 +1,7 @@
1
1
  import * as React from "react";
2
2
 
3
3
  type Breakpoints = "xs" | "sm" | "md" | "lg" | "xl";
4
- type GapKey = "xs" | "sm" | "md" | "lg" | "xl" | "xxl" | 1 | 2 | 4 | 6 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 64 | 96 | 128;
4
+ type GapKey = "none" | "xs" | "sm" | "md" | "lg" | "xl" | "xxl" | 1 | 2 | 4 | 6 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 64 | 96 | 128;
5
5
  type ColSpan = number | "full";
6
6
 
7
7
  export interface GridProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -14,6 +14,8 @@ const breakpoints = ["xs", "sm", "md", "lg", "xl"];
14
14
 
15
15
  function resolveGap(key) {
16
16
  if (key == null) return undefined;
17
+ // "none" removes the gap (matches the shared resolveSpacing convention).
18
+ if (key === "none") return "0";
17
19
  if (gapSizes[key]) return gapSizes[key];
18
20
 
19
21
  const n = Number(key);
@@ -34,6 +34,7 @@
34
34
  .a1-grid--xs-3 { --a1-grid-cols: 3; }
35
35
  .a1-grid--xs-4 { --a1-grid-cols: 4; }
36
36
  .a1-grid--xs-6 { --a1-grid-cols: 6; }
37
+ .a1-grid--xs-8 { --a1-grid-cols: 8; }
37
38
  .a1-grid--xs-12 { --a1-grid-cols: 12; }
38
39
 
39
40
  @media (--bp-sm-up) {
@@ -42,6 +43,7 @@
42
43
  .a1-grid--sm-3 { --a1-grid-cols: 3; }
43
44
  .a1-grid--sm-4 { --a1-grid-cols: 4; }
44
45
  .a1-grid--sm-6 { --a1-grid-cols: 6; }
46
+ .a1-grid--sm-8 { --a1-grid-cols: 8; }
45
47
  .a1-grid--sm-12 { --a1-grid-cols: 12; }
46
48
  }
47
49
 
@@ -51,6 +53,7 @@
51
53
  .a1-grid--md-3 { --a1-grid-cols: 3; }
52
54
  .a1-grid--md-4 { --a1-grid-cols: 4; }
53
55
  .a1-grid--md-6 { --a1-grid-cols: 6; }
56
+ .a1-grid--md-8 { --a1-grid-cols: 8; }
54
57
  .a1-grid--md-12 { --a1-grid-cols: 12; }
55
58
  }
56
59
 
@@ -60,6 +63,7 @@
60
63
  .a1-grid--lg-3 { --a1-grid-cols: 3; }
61
64
  .a1-grid--lg-4 { --a1-grid-cols: 4; }
62
65
  .a1-grid--lg-6 { --a1-grid-cols: 6; }
66
+ .a1-grid--lg-8 { --a1-grid-cols: 8; }
63
67
  .a1-grid--lg-12 { --a1-grid-cols: 12; }
64
68
  }
65
69
 
@@ -69,6 +73,7 @@
69
73
  .a1-grid--xl-3 { --a1-grid-cols: 3; }
70
74
  .a1-grid--xl-4 { --a1-grid-cols: 4; }
71
75
  .a1-grid--xl-6 { --a1-grid-cols: 6; }
76
+ .a1-grid--xl-8 { --a1-grid-cols: 8; }
72
77
  .a1-grid--xl-12 { --a1-grid-cols: 12; }
73
78
  }
74
79
 
@@ -117,10 +117,16 @@
117
117
  overflow-y: hidden;
118
118
  }
119
119
 
120
- .a1-page-layout--viewport-height .a1-page-layout__sidebar .a1-side-nav {
121
- position: relative;
122
- top: auto;
123
- height: 100%;
120
+ /* Only at lg+ is the sidebar persistent and in flow; pin the SideNav to fill the
121
+ sidebar height so its footer stays anchored. At md and below the SideNav must
122
+ keep its fixed-overlay behaviour (see side-nav.css) so the sidebar slot
123
+ collapses and the main column expands to fill the freed space. */
124
+ @media (--bp-lg-up) {
125
+ .a1-page-layout--viewport-height .a1-page-layout__sidebar .a1-side-nav {
126
+ position: relative;
127
+ top: auto;
128
+ height: 100%;
129
+ }
124
130
  }
125
131
 
126
132
  .a1-page-layout--viewport-height .a1-page-layout__content {
@@ -2,6 +2,22 @@ import { useEffect, useRef, useState } from "react";
2
2
  import { Card } from "../card/Card.jsx";
3
3
  import "./page-nav.css";
4
4
 
5
+ // Find the nearest scrollable ancestor of a node, or null when the page scrolls
6
+ // on the document/window itself. Needed because the host may scroll a nested
7
+ // container (e.g. a viewport-height PageLayout) rather than the window — in which
8
+ // case window scroll never fires and the progress/active state would freeze.
9
+ function getScrollParent(node) {
10
+ let el = node?.parentElement;
11
+ while (el && el !== document.body && el !== document.documentElement) {
12
+ const overflowY = getComputedStyle(el).overflowY;
13
+ if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight) {
14
+ return el;
15
+ }
16
+ el = el.parentElement;
17
+ }
18
+ return null;
19
+ }
20
+
5
21
  export function PageNav({
6
22
  sections = [],
7
23
  label = "On this page",
@@ -12,22 +28,27 @@ export function PageNav({
12
28
  const [progress, setProgress] = useState(0);
13
29
  const intersectingIds = useRef(new Set());
14
30
 
15
- // Reading progress: track document scroll position
31
+ // Reading progress: track the scroll position of the actual scroll container
32
+ // (the window, or a nested scrollable ancestor like a viewport-height layout).
16
33
  useEffect(() => {
34
+ const scroller = getScrollParent(sections.length ? document.getElementById(sections[0].id) : null);
35
+ const target = scroller ?? window;
17
36
  function update() {
18
- const el = document.documentElement;
37
+ const el = scroller ?? document.documentElement;
19
38
  const total = el.scrollHeight - el.clientHeight;
20
39
  setProgress(total > 0 ? (el.scrollTop / total) * 100 : 0);
21
40
  }
22
- window.addEventListener("scroll", update, { passive: true });
41
+ target.addEventListener("scroll", update, { passive: true });
23
42
  update();
24
- return () => window.removeEventListener("scroll", update);
25
- }, []);
43
+ return () => target.removeEventListener("scroll", update);
44
+ }, [sections]);
26
45
 
27
- // Active section: observe each section element entering/leaving the viewport
46
+ // Active section: observe each section element entering/leaving the scroll
47
+ // container (root = the nested scroller, or the viewport when null).
28
48
  useEffect(() => {
29
49
  if (!sections.length) return;
30
50
 
51
+ const scroller = getScrollParent(document.getElementById(sections[0].id));
31
52
  const observer = new IntersectionObserver(
32
53
  (entries) => {
33
54
  entries.forEach((entry) => {
@@ -43,9 +64,9 @@ export function PageNav({
43
64
  if (first) setActiveId(first.id);
44
65
  },
45
66
  // -8% top offset keeps the active section stable once it clears the
46
- // header; -88% bottom offset means only the top 12% of the viewport
67
+ // header; -88% bottom offset means only the top 12% of the scroll area
47
68
  // is treated as "current".
48
- { rootMargin: "-8% 0px -88% 0px", threshold: 0 }
69
+ { root: scroller ?? null, rootMargin: "-8% 0px -88% 0px", threshold: 0 }
49
70
  );
50
71
 
51
72
  sections.forEach(({ id }) => {
@@ -163,3 +163,16 @@
163
163
  border-color: var(--semantic-color-action-background);
164
164
  }
165
165
  }
166
+
167
+ /* ── Desktop: stick within its column as the reader scrolls ────────────────── */
168
+ /* `--a1-page-nav-top` lets a consumer offset for a sticky header (default 16px);
169
+ a long nav scrolls internally rather than running past the viewport. */
170
+ @media (min-width: 769px) {
171
+ .a1-page-nav.a1-card {
172
+ position: sticky;
173
+ top: var(--a1-page-nav-top, var(--base-spacing-16));
174
+ align-self: start;
175
+ max-block-size: calc(100vh - var(--a1-page-nav-top, var(--base-spacing-16)) - var(--base-spacing-16));
176
+ overflow: hidden auto;
177
+ }
178
+ }
@@ -17,6 +17,8 @@ export interface ParagraphProps extends React.HTMLAttributes<HTMLParagraphElemen
17
17
  textWrap?: "balance";
18
18
  /** Horizontal text alignment. "start"/"end" are logical aliases for LTR/RTL-safe alignment. */
19
19
  align?: "left" | "center" | "right" | "start" | "end";
20
+ /** Font weight. Omit to inherit the body default. */
21
+ weight?: "regular" | "medium" | "semibold" | "bold";
20
22
  children?: React.ReactNode;
21
23
  }
22
24
 
@@ -5,6 +5,7 @@ const colors = ["default", "muted"];
5
5
  const breakpoints = ["xs", "sm", "md", "lg", "xl"];
6
6
  const textWraps = ["balance"];
7
7
  const aligns = ["left", "center", "right", "start", "end"];
8
+ const weights = ["regular", "medium", "semibold", "bold"];
8
9
 
9
10
  function isResponsiveSize(size) {
10
11
  return size && typeof size === "object" && !Array.isArray(size);
@@ -31,6 +32,7 @@ export function Paragraph({
31
32
  color = "default",
32
33
  textWrap,
33
34
  align,
35
+ weight,
34
36
  className = "",
35
37
  style,
36
38
  ...props
@@ -39,6 +41,7 @@ export function Paragraph({
39
41
  const resolvedColor = colors.includes(color) ? color : "default";
40
42
  const resolvedTextWrap = textWraps.includes(textWrap) ? textWrap : null;
41
43
  const resolvedAlign = aligns.includes(align) ? align : null;
44
+ const resolvedWeight = weights.includes(weight) ? weight : null;
42
45
  const responsiveStyle = getResponsiveSizeStyle(size);
43
46
  const resolvedStyle = Object.keys(responsiveStyle).length
44
47
  ? { ...responsiveStyle, ...style }
@@ -50,6 +53,7 @@ export function Paragraph({
50
53
  resolvedColor !== "default" && `a1-paragraph--${resolvedColor}`,
51
54
  resolvedTextWrap && `a1-paragraph--wrap-${resolvedTextWrap}`,
52
55
  resolvedAlign && `a1-paragraph--align-${resolvedAlign}`,
56
+ resolvedWeight && `a1-paragraph--weight-${resolvedWeight}`,
53
57
  className
54
58
  ]
55
59
  .filter(Boolean)
@@ -2,7 +2,7 @@
2
2
  margin: 0;
3
3
  font-family: var(--component-paragraph-font-family);
4
4
  font-size: var(--a1-paragraph-responsive-size, var(--a1-paragraph-size));
5
- font-weight: var(--component-paragraph-font-weight);
5
+ font-weight: var(--a1-paragraph-weight, var(--component-paragraph-font-weight));
6
6
  line-height: var(--component-paragraph-font-line-height);
7
7
  color: var(--a1-paragraph-color, var(--semantic-color-text-default));
8
8
  }
@@ -39,6 +39,11 @@
39
39
 
40
40
  .a1-paragraph--muted { --a1-paragraph-color: var(--semantic-color-text-muted); }
41
41
 
42
+ .a1-paragraph--weight-regular { --a1-paragraph-weight: var(--base-font-weight-regular); }
43
+ .a1-paragraph--weight-medium { --a1-paragraph-weight: var(--base-font-weight-medium); }
44
+ .a1-paragraph--weight-semibold { --a1-paragraph-weight: var(--base-font-weight-semibold); }
45
+ .a1-paragraph--weight-bold { --a1-paragraph-weight: var(--base-font-weight-bold); }
46
+
42
47
  /* Text wrap */
43
48
  .a1-paragraph--wrap-balance { text-wrap: balance; }
44
49
 
@@ -48,8 +53,3 @@
48
53
  .a1-paragraph--align-right { text-align: end; }
49
54
  .a1-paragraph--align-start { text-align: start; }
50
55
  .a1-paragraph--align-end { text-align: end; }
51
-
52
- .a1-paragraph + .a1-paragraph,
53
- .a1-paragraph + .a1-heading {
54
- margin-top: 1.5em;
55
- }
@@ -18,6 +18,14 @@ export interface SegmentedControlProps extends React.HTMLAttributes<HTMLDivEleme
18
18
  fullWidth?: boolean;
19
19
  /** Height scale. Default: "md" */
20
20
  size?: "sm" | "md" | "lg";
21
+ /**
22
+ * Label display. `"all"` (default) shows every option's label. `"selected"`
23
+ * shows the label only on the selected option; the rest render icon-only
24
+ * (using each option's `ariaLabel`/`label` for its accessible name). Options
25
+ * without an icon always show their label so they never render blank.
26
+ * "none" hides every label (fully icon-only).
27
+ */
28
+ labelMode?: "all" | "selected" | "none";
21
29
  }
22
30
 
23
31
  export declare function SegmentedControl(props: SegmentedControlProps): React.ReactElement;
@@ -11,6 +11,7 @@ export function SegmentedControl({
11
11
  onChange,
12
12
  fullWidth = false,
13
13
  size,
14
+ labelMode = "all",
14
15
  ...props
15
16
  }) {
16
17
  const items = options.map(normalize);
@@ -52,8 +53,20 @@ export function SegmentedControl({
52
53
  onKeyDown={handleKeyDown}
53
54
  >
54
55
  {items.map((opt) => {
55
- const iconOnly = Boolean(opt.icon) && !opt.label;
56
56
  const isSelected = value === opt.value;
57
+ // labelMode controls which segments show their text label:
58
+ // "all" (default) — every segment.
59
+ // "selected" — only the selected segment.
60
+ // "none" — none of them (fully icon-only).
61
+ // An option with no icon always shows its label so it never goes blank.
62
+ const wantsLabel =
63
+ Boolean(opt.label) &&
64
+ (labelMode === "none"
65
+ ? !opt.icon
66
+ : labelMode === "selected"
67
+ ? (isSelected || !opt.icon)
68
+ : true);
69
+ const iconOnly = Boolean(opt.icon) && !wantsLabel;
57
70
 
58
71
  return (
59
72
  <button
@@ -61,7 +74,7 @@ export function SegmentedControl({
61
74
  role="radio"
62
75
  type="button"
63
76
  aria-checked={isSelected}
64
- aria-label={iconOnly ? (opt.ariaLabel ?? opt.value) : undefined}
77
+ aria-label={iconOnly ? (opt.ariaLabel ?? opt.label ?? opt.value) : undefined}
65
78
  tabIndex={isSelected ? 0 : -1}
66
79
  className={[
67
80
  "a1-segment",
@@ -72,7 +85,7 @@ export function SegmentedControl({
72
85
  onClick={() => onChange?.(opt.value)}
73
86
  >
74
87
  {opt.icon && <Icon name={opt.icon} className="a1-segment__icon" />}
75
- {opt.label}
88
+ {wantsLabel ? opt.label : null}
76
89
  </button>
77
90
  );
78
91
  })}
@@ -13,6 +13,9 @@
13
13
 
14
14
  .a1-segmented--full-width {
15
15
  display: flex;
16
+ /* Fill the container so the equal-width segments actually stretch — `display:
17
+ flex` alone leaves it content-width inside a centering flex/grid parent. */
18
+ inline-size: 100%;
16
19
  }
17
20
 
18
21
  /* ─── Segment button ──────────────────────────────────────────────────────── */
@@ -58,10 +61,27 @@
58
61
  font-size: var(--semantic-font-size-body-sm);
59
62
  }
60
63
 
64
+ /* Small: tighter padding and a font size one step down from the default. */
65
+ .a1-segmented--sm .a1-segment {
66
+ padding: var(--component-segmented-segment-padding-block-sm)
67
+ var(--component-segmented-segment-padding-inline-sm);
68
+ font-size: var(--semantic-font-size-body-xs);
69
+ /* Tighter icon↔label gap at sm. */
70
+ gap: var(--base-spacing-4);
71
+ }
72
+
73
+ /* Large: roomier padding and a font size one step up. */
74
+ .a1-segmented--lg .a1-segment {
75
+ padding: var(--component-segmented-segment-padding-block-lg)
76
+ var(--component-segmented-segment-padding-inline-lg);
77
+ font-size: var(--semantic-font-size-body-md);
78
+ }
79
+
61
80
  /* ─── Icon ────────────────────────────────────────────────────────────────── */
62
81
 
63
82
  .a1-segment__icon {
64
- font-size: 1em;
83
+ /* One step larger than the label so the glyph reads clearly at the md/lg sizes. */
84
+ font-size: 1.25em;
65
85
  }
66
86
 
67
87
  /* Icon-only: match inline padding to block padding so the segment is square */
@@ -69,6 +89,16 @@
69
89
  padding-inline: var(--component-segmented-segment-padding-block);
70
90
  }
71
91
 
92
+ /* Small: a larger icon and tighter horizontal padding per segment (so an
93
+ icon-only sm strip stays compact). */
94
+ .a1-segmented--sm .a1-segment__icon {
95
+ font-size: 1.5em;
96
+ }
97
+
98
+ .a1-segmented--sm .a1-segment--icon-only {
99
+ padding-inline: var(--component-segmented-segment-padding-block-sm);
100
+ }
101
+
72
102
  /* ─── Dark mode ───────────────────────────────────────────────────────────── */
73
103
 
74
104
  .a1-theme-dark .a1-segment:not([aria-checked="true"]),