@gtivr4/a1-design-system-react 0.14.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 (57) hide show
  1. package/package.json +3 -2
  2. package/src/color-scheme.css +2 -0
  3. package/src/components/accordion/Accordion.d.ts +8 -0
  4. package/src/components/accordion/Accordion.jsx +9 -1
  5. package/src/components/accordion/accordion.css +46 -6
  6. package/src/components/autocomplete/Autocomplete.d.ts +53 -0
  7. package/src/components/autocomplete/Autocomplete.jsx +380 -0
  8. package/src/components/autocomplete/autocomplete.css +346 -0
  9. package/src/components/banner/Banner.d.ts +9 -2
  10. package/src/components/banner/Banner.jsx +32 -6
  11. package/src/components/banner/banner.css +81 -0
  12. package/src/components/bottom-sheet/BottomSheet.d.ts +22 -0
  13. package/src/components/bottom-sheet/BottomSheet.jsx +154 -0
  14. package/src/components/bottom-sheet/bottom-sheet.css +113 -0
  15. package/src/components/button/button.css +7 -3
  16. package/src/components/code/Code.jsx +6 -1
  17. package/src/components/data-table/DataTable.jsx +11 -1
  18. package/src/components/data-table/data-table.css +19 -0
  19. package/src/components/figure/Figure.d.ts +37 -4
  20. package/src/components/figure/Figure.jsx +78 -9
  21. package/src/components/figure/figure.css +105 -8
  22. package/src/components/grid/Grid.d.ts +1 -1
  23. package/src/components/grid/Grid.jsx +2 -0
  24. package/src/components/grid/grid.css +5 -0
  25. package/src/components/icon-button/IconButton.d.ts +2 -2
  26. package/src/components/icon-button/IconButton.jsx +3 -2
  27. package/src/components/icon-button/icon-button.css +11 -1
  28. package/src/components/menu/Menu.jsx +12 -0
  29. package/src/components/menu/menu.css +17 -6
  30. package/src/components/page-layout/page-layout.css +10 -4
  31. package/src/components/page-nav/PageNav.jsx +29 -8
  32. package/src/components/page-nav/page-nav.css +13 -0
  33. package/src/components/paragraph/Paragraph.d.ts +2 -0
  34. package/src/components/paragraph/Paragraph.jsx +4 -0
  35. package/src/components/paragraph/paragraph.css +6 -6
  36. package/src/components/section/Section.d.ts +6 -0
  37. package/src/components/section/Section.jsx +19 -0
  38. package/src/components/section/section.css +33 -10
  39. package/src/components/segmented-control/SegmentedControl.d.ts +8 -0
  40. package/src/components/segmented-control/SegmentedControl.jsx +16 -3
  41. package/src/components/segmented-control/segmented.css +31 -1
  42. package/src/components/slider/Slider.d.ts +71 -0
  43. package/src/components/slider/Slider.jsx +243 -0
  44. package/src/components/slider/slider.css +238 -0
  45. package/src/components/split-button/SplitButton.d.ts +39 -0
  46. package/src/components/split-button/SplitButton.jsx +94 -0
  47. package/src/components/split-button/split-button.css +40 -0
  48. package/src/components/tabs/tabs.css +3 -0
  49. package/src/components/toolbar/Toolbar.d.ts +131 -0
  50. package/src/components/toolbar/Toolbar.jsx +335 -0
  51. package/src/components/toolbar/toolbar.css +229 -0
  52. package/src/components/top-header/top-header.css +2 -0
  53. package/src/components/tree-menu/TreeMenu.jsx +11 -7
  54. package/src/index.d.ts +71 -0
  55. package/src/index.js +15 -1
  56. package/src/themes.css +293 -0
  57. package/src/tokens.css +26 -3
@@ -37,12 +37,22 @@
37
37
  --a1-icon-opsz: var(--a1-icon-button-icon-opsz, var(--component-icon-button-icon-optical-size));
38
38
  }
39
39
 
40
+ /* ── Size: small ─────────────────────────────────────────────────────────── */
41
+ /* 24×24px total — the WCAG 2.2 target-size (AA) minimum; for dense toolbars. */
42
+
43
+ .a1-icon-button--small {
44
+ --a1-icon-button-size: var(--base-spacing-24);
45
+ --a1-icon-button-icon-size: var(--base-spacing-16);
46
+ }
47
+
40
48
  /* ── Size: large ─────────────────────────────────────────────────────────── */
41
49
  /* Touch target matches Button lg (3.5rem); icon matches Button lg's icon. */
42
50
 
43
51
  .a1-icon-button--large {
44
52
  --a1-icon-button-size: var(--component-button-large-height);
45
- --a1-icon-button-icon-size: var(--component-button-icon-size);
53
+ /* Icon is two steps up the icon scale (20 → 24 → 32px) so it reads at the
54
+ larger touch target. */
55
+ --a1-icon-button-icon-size: var(--base-spacing-32);
46
56
  --a1-icon-button-icon-opsz: var(--component-button-icon-optical-size);
47
57
  }
48
58
 
@@ -81,6 +81,18 @@ export function Menu({
81
81
  el.style.setProperty("--a1-menu-top", `${Math.round(top)}px`);
82
82
  el.style.setProperty("--a1-menu-left", `${Math.round(left)}px`);
83
83
  el.style.setProperty("--a1-menu-max-height", `${Math.floor(maxHeight)}px`);
84
+
85
+ // `position: fixed` resolves against the nearest transformed/filtered
86
+ // ancestor's box, not the viewport — e.g. an `overlay` Toolbar positioned
87
+ // with a CSS transform. Measure where the menu actually landed and correct
88
+ // by the containing-block offset so it stays anchored to the viewport.
89
+ const placed = el.getBoundingClientRect();
90
+ const dx = left - placed.left;
91
+ const dy = top - placed.top;
92
+ if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
93
+ el.style.setProperty("--a1-menu-left", `${Math.round(left + dx)}px`);
94
+ el.style.setProperty("--a1-menu-top", `${Math.round(top + dy)}px`);
95
+ }
84
96
  }, [anchorRef]);
85
97
 
86
98
  const openDialog = useCallback(() => {
@@ -104,16 +104,27 @@
104
104
  background: transparent;
105
105
  }
106
106
 
107
- /* Active / current-page indicator */
107
+ /* Active / current-page indicator — matches the TreeMenu selected pattern:
108
+ a solid action-background fill for a clear, unambiguous highlight. */
108
109
  .a1-menu-item--active {
109
- color: var(--semantic-color-text-default);
110
- background: var(--semantic-color-action-surface);
111
- border-inline-start: 2px solid var(--semantic-color-action-background);
112
- padding-inline-start: calc(var(--base-spacing-8) - 2px);
110
+ color: var(--semantic-color-action-foreground);
111
+ background: var(--semantic-color-action-background);
112
+ }
113
+
114
+ .a1-menu-item--active:hover {
115
+ background: var(--semantic-color-action-background-hover);
116
+ }
117
+
118
+ .a1-menu-item--active:active {
119
+ background: var(--semantic-color-action-background-pressed);
120
+ }
121
+
122
+ .a1-menu-item--active:focus-visible {
123
+ outline-color: var(--semantic-color-action-foreground);
113
124
  }
114
125
 
115
126
  .a1-menu-item--active .a1-menu-item__icon {
116
- color: var(--semantic-color-action-background);
127
+ color: var(--semantic-color-action-foreground);
117
128
  }
118
129
 
119
130
  /* Destructive variant */
@@ -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
- }
@@ -33,6 +33,12 @@ export interface SectionProps extends React.HTMLAttributes<HTMLElement> {
33
33
  borderStyle?: "solid" | "dashed" | "dotted";
34
34
  /** Border color tone. Uses the same variants as Divider. Default: "subtle" */
35
35
  borderVariant?: "subtle" | "strong" | "accent";
36
+ /**
37
+ * Which sides the border is drawn on (requires `borderSize`). `"all"` (default)
38
+ * draws all four sides; pass an array to draw only those sides, e.g.
39
+ * `["top", "bottom"]`. An empty array draws no border.
40
+ */
41
+ borderSides?: "all" | ("top" | "right" | "bottom" | "left")[];
36
42
  /** Border radius scale. */
37
43
  radius?: "none" | "sm" | "md" | "lg" | "xl";
38
44
  children?: React.ReactNode;
@@ -23,8 +23,19 @@ const VALID_ALIGNMENTS = ["left", "center", "right"];
23
23
  const VALID_BORDER_SIZES = ["xs", "sm", "md", "lg"];
24
24
  const VALID_BORDER_STYLES = ["solid", "dashed", "dotted"];
25
25
  const VALID_BORDER_VARIANTS = ["subtle", "strong", "accent"];
26
+ const VALID_BORDER_SIDES = ["top", "right", "bottom", "left"];
26
27
  const VALID_RADII = ["none", "sm", "md", "lg", "xl"];
27
28
 
29
+ // Resolve which sides get the border. `null` means all sides (the default);
30
+ // an array means only those sides (an empty array means no border).
31
+ function resolveBorderSides(borderSides) {
32
+ if (borderSides == null || borderSides === "all") return null;
33
+ const arr = Array.isArray(borderSides) ? borderSides : [borderSides];
34
+ const sides = VALID_BORDER_SIDES.filter((side) => arr.includes(side));
35
+ if (sides.length === VALID_BORDER_SIDES.length) return null; // all four = all
36
+ return sides;
37
+ }
38
+
28
39
  export function Section({
29
40
  as: Component = "section",
30
41
  padding = "md",
@@ -39,6 +50,7 @@ export function Section({
39
50
  borderSize,
40
51
  borderStyle = "solid",
41
52
  borderVariant = "subtle",
53
+ borderSides,
42
54
  radius,
43
55
  className = "",
44
56
  children,
@@ -98,6 +110,13 @@ export function Section({
98
110
 
99
111
  if (borderSize && VALID_BORDER_SIZES.includes(borderSize)) {
100
112
  classes.push(`a1-section--border-${borderSize}`);
113
+
114
+ // Per-side borders. Omitted / "all" draws all four sides (default).
115
+ const sides = resolveBorderSides(borderSides);
116
+ if (sides) {
117
+ classes.push("a1-section--border-sided");
118
+ sides.forEach((side) => classes.push(`a1-section--border-side-${side}`));
119
+ }
101
120
  }
102
121
 
103
122
  if (borderStyle && VALID_BORDER_STYLES.includes(borderStyle)) {
@@ -13,7 +13,15 @@
13
13
  --a1-section-border-size: 0;
14
14
  --a1-section-border-style: solid;
15
15
  --a1-section-border-color: transparent;
16
- border: var(--a1-section-border-size) var(--a1-section-border-style) var(--a1-section-border-color);
16
+ /* Per-side widths default to the full border size (all sides). The sided
17
+ modifier zeroes them so individual sides can be opted back in. */
18
+ --a1-section-border-top: var(--a1-section-border-size);
19
+ --a1-section-border-right: var(--a1-section-border-size);
20
+ --a1-section-border-bottom: var(--a1-section-border-size);
21
+ --a1-section-border-left: var(--a1-section-border-size);
22
+ border-style: var(--a1-section-border-style);
23
+ border-color: var(--a1-section-border-color);
24
+ border-width: var(--a1-section-border-top) var(--a1-section-border-right) var(--a1-section-border-bottom) var(--a1-section-border-left);
17
25
  }
18
26
 
19
27
  .a1-section.a1-inverse {
@@ -42,6 +50,18 @@
42
50
  .a1-section--border-strong { --a1-section-border-color: var(--semantic-color-border-strong); }
43
51
  .a1-section--border-accent { --a1-section-border-color: var(--semantic-color-text-accent); }
44
52
 
53
+ /* Per-side borders: start from no sides, then opt each chosen side back in. */
54
+ .a1-section--border-sided {
55
+ --a1-section-border-top: 0;
56
+ --a1-section-border-right: 0;
57
+ --a1-section-border-bottom: 0;
58
+ --a1-section-border-left: 0;
59
+ }
60
+ .a1-section--border-side-top { --a1-section-border-top: var(--a1-section-border-size); }
61
+ .a1-section--border-side-right { --a1-section-border-right: var(--a1-section-border-size); }
62
+ .a1-section--border-side-bottom { --a1-section-border-bottom: var(--a1-section-border-size); }
63
+ .a1-section--border-side-left { --a1-section-border-left: var(--a1-section-border-size); }
64
+
45
65
  /* ── Radius ────────────────────────────────────────────────────────────────── */
46
66
 
47
67
  .a1-section--radius-none { border-radius: 0; }
@@ -92,21 +112,24 @@
92
112
 
93
113
  /* ── Content gap ──────────────────────────────────────────────────────────── */
94
114
 
95
- .a1-section--gap-xs,
96
- .a1-section--gap-sm,
97
- .a1-section--gap-md,
98
- .a1-section--gap-lg,
99
- .a1-section--gap-xl {
100
- display: grid;
101
- justify-items: var(--a1-section-justify-items);
102
- }
103
-
115
+ /* A gap is the grid gap only when the section is a grid — i.e. when it is
116
+ aligned (or height-hero). Without alignment the section flows as block
117
+ (block children fill the width; inline content keeps its natural size) and
118
+ the gap becomes vertical rhythm via margins, since `gap` has no effect on
119
+ display:block. This keeps natural-width content (a Button, a Badge) from
120
+ being stretched by the grid when no alignment is requested. */
104
121
  .a1-section--gap-xs { gap: var(--semantic-spacing-gap-xs); }
105
122
  .a1-section--gap-sm { gap: var(--semantic-spacing-gap-sm); }
106
123
  .a1-section--gap-md { gap: var(--semantic-spacing-gap-md); }
107
124
  .a1-section--gap-lg { gap: var(--semantic-spacing-gap-lg); }
108
125
  .a1-section--gap-xl { gap: var(--semantic-spacing-gap-xl); }
109
126
 
127
+ .a1-section--gap-xs:not([class*="-align-"]):not(.a1-section--height-hero) > * + * { margin-block-start: var(--semantic-spacing-gap-xs); }
128
+ .a1-section--gap-sm:not([class*="-align-"]):not(.a1-section--height-hero) > * + * { margin-block-start: var(--semantic-spacing-gap-sm); }
129
+ .a1-section--gap-md:not([class*="-align-"]):not(.a1-section--height-hero) > * + * { margin-block-start: var(--semantic-spacing-gap-md); }
130
+ .a1-section--gap-lg:not([class*="-align-"]):not(.a1-section--height-hero) > * + * { margin-block-start: var(--semantic-spacing-gap-lg); }
131
+ .a1-section--gap-xl:not([class*="-align-"]):not(.a1-section--height-hero) > * + * { margin-block-start: var(--semantic-spacing-gap-xl); }
132
+
110
133
  /* ── Height ────────────────────────────────────────────────────────────────── */
111
134
 
112
135
  .a1-section--height-screen {
@@ -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"]),
@@ -0,0 +1,71 @@
1
+ import * as React from "react";
2
+
3
+ /**
4
+ * A detent (snap stop): a bare value, or a value with an optional display `label`
5
+ * and/or `icon` (a Material Symbols name shown in the label row instead of text).
6
+ * Provide a `label` alongside an `icon` to give screen readers a text alternative.
7
+ */
8
+ export type SliderDetent = number | { value: number; label?: React.ReactNode; icon?: string };
9
+
10
+ export interface SliderProps
11
+ extends Omit<
12
+ React.InputHTMLAttributes<HTMLInputElement>,
13
+ "value" | "defaultValue" | "onChange" | "min" | "max" | "step"
14
+ > {
15
+ /** Controlled value (in the value domain — a detent value when `detents` is set). */
16
+ value?: number;
17
+ /** Uncontrolled initial value. Defaults to the first detent, or `min`. */
18
+ defaultValue?: number;
19
+ /** Called with the new value on every change. */
20
+ onChange?: (value: number) => void;
21
+ /** Minimum (continuous mode). Default: 0 */
22
+ min?: number;
23
+ /** Maximum (continuous mode). Default: 100 */
24
+ max?: number;
25
+ /** Step granularity (continuous mode). Default: 1 */
26
+ step?: number;
27
+ /**
28
+ * Optional snap stops. Pass numbers, or `{ value, label }` to show labels under
29
+ * the track (e.g. `[{value:0,label:'None'},{value:1,label:'XS'},…]`). The thumb
30
+ * snaps between detents and the keyboard moves one detent at a time.
31
+ */
32
+ detents?: SliderDetent[];
33
+ /**
34
+ * Visible field label rendered above the control and associated with it (also
35
+ * the accessible name). Sized to match the field family per `size`. Use
36
+ * `aria-label` / `aria-labelledby` instead for an invisible name.
37
+ */
38
+ label?: React.ReactNode;
39
+ /**
40
+ * Density, matching the field family. Default: "default". Scales the label,
41
+ * detent labels, track, and thumb so a Slider sits naturally beside fields.
42
+ */
43
+ size?: "compact" | "default" | "comfortable";
44
+ /**
45
+ * Selection colour. "default" uses the action colour; "subtle" uses neutrals
46
+ * for the fill, thumb, and active detent. Default: "default"
47
+ */
48
+ variant?: "default" | "subtle";
49
+ /** Show the floating value bubble while dragging/focused. Default: true */
50
+ showValue?: boolean;
51
+ /** Preferred bubble side; it flips to stay in the viewport. Default: "above" */
52
+ valuePosition?: "above" | "below";
53
+ /** Format the bubble + `aria-valuetext`. Defaults to the detent label or the number. */
54
+ formatValue?: (value: number) => React.ReactNode;
55
+ /**
56
+ * An alternate label shown in the value bubble (visual only — `aria-valuetext`
57
+ * is unchanged). A node is used as-is; a function receives the current value
58
+ * and the active detent. When omitted, the bubble keeps its current content
59
+ * (the formatted value, detent label/icon, or the raw value). Useful for a
60
+ * longer spoken-out size name (e.g. "Small") while the detent stays "SM".
61
+ */
62
+ bubbleLabel?: React.ReactNode | ((value: number, detent: { value: number; label?: React.ReactNode; icon?: string } | null) => React.ReactNode);
63
+ /** Disable the slider. Default: false */
64
+ disabled?: boolean;
65
+ /** Form field name. */
66
+ name?: string;
67
+ id?: string;
68
+ className?: string;
69
+ }
70
+
71
+ export declare function Slider(props: SliderProps): React.ReactElement;