@gtivr4/a1-design-system-react 0.15.0 → 0.19.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 (43) 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.d.ts +4 -0
  14. package/src/components/code/Code.jsx +45 -2
  15. package/src/components/code/code.css +23 -0
  16. package/src/components/data-table/DataTable.jsx +11 -1
  17. package/src/components/data-table/data-table.css +19 -0
  18. package/src/components/figure/Figure.d.ts +7 -0
  19. package/src/components/figure/Figure.jsx +23 -2
  20. package/src/components/figure/figure.css +25 -0
  21. package/src/components/grid/Grid.d.ts +3 -1
  22. package/src/components/grid/Grid.jsx +10 -0
  23. package/src/components/grid/grid.css +11 -0
  24. package/src/components/page-layout/page-layout.css +10 -4
  25. package/src/components/page-nav/PageNav.jsx +29 -8
  26. package/src/components/page-nav/page-nav.css +13 -0
  27. package/src/components/paragraph/Paragraph.d.ts +2 -0
  28. package/src/components/paragraph/Paragraph.jsx +4 -0
  29. package/src/components/paragraph/paragraph.css +6 -6
  30. package/src/components/segmented-control/SegmentedControl.d.ts +8 -0
  31. package/src/components/segmented-control/SegmentedControl.jsx +16 -3
  32. package/src/components/segmented-control/segmented.css +31 -1
  33. package/src/components/slider/slider.css +10 -2
  34. package/src/components/split-button/SplitButton.jsx +3 -1
  35. package/src/components/tabs/tabs.css +3 -0
  36. package/src/components/toolbar/Toolbar.d.ts +7 -0
  37. package/src/components/toolbar/Toolbar.jsx +13 -5
  38. package/src/components/top-header/top-header.css +2 -0
  39. package/src/components/tree-menu/TreeMenu.jsx +11 -7
  40. package/src/index.d.ts +71 -0
  41. package/src/index.js +2 -0
  42. package/src/themes.css +293 -0
  43. 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
+ }
@@ -13,6 +13,10 @@ export interface CodeProps extends React.HTMLAttributes<HTMLElement> {
13
13
  editable?: boolean;
14
14
  /** Called with the current string value whenever the editable textarea changes. */
15
15
  onChangeValue?: (value: string) => void;
16
+ /** Cap a long read-only block to `collapsedLines` with a fade + Show more/less toggle (the toggle appears only when the content overflows). Block, non-editable only. Default: false */
17
+ collapsible?: boolean;
18
+ /** Approximate number of lines shown when collapsed. Default: 14 */
19
+ collapsedLines?: number;
16
20
  children?: React.ReactNode;
17
21
  }
18
22
 
@@ -56,12 +56,17 @@ export function Code({
56
56
  copyText,
57
57
  editable = false,
58
58
  onChangeValue,
59
+ collapsible = false,
60
+ collapsedLines = 14,
59
61
  className = "",
60
62
  children,
61
63
  ...props
62
64
  }) {
63
65
  const resolvedVariant = variants.includes(variant) ? variant : "inline";
64
66
  const [copied, setCopied] = useState(false);
67
+ const [expanded, setExpanded] = useState(false);
68
+ const [overflows, setOverflows] = useState(false);
69
+ const preRef = useRef(null);
65
70
  const [editableValue, setEditableValue] = useState(() =>
66
71
  textFromChildren(Children.toArray(children))
67
72
  );
@@ -77,11 +82,16 @@ export function Code({
77
82
 
78
83
  const copyLabel = useLabel("code.copyCode", "Copy code");
79
84
  const copiedLabel = useLabel("code.copied", "Copied");
85
+ const editLabel = useLabel("code.editCode", "Edit code");
86
+ const showMoreLabel = useLabel("code.showMore", "Show more");
87
+ const showLessLabel = useLabel("code.showLess", "Show less");
80
88
  const textToCopy = useMemo(
81
89
  () => copyText || (editable ? editableValue : textFromChildren(Children.toArray(children))),
82
90
  [children, copyText, editable, editableValue],
83
91
  );
84
92
  const shouldRenderBlock = resolvedVariant === "block" || copyCode || editable;
93
+ // Collapsible only applies to a read-only block (not the editable textarea).
94
+ const collapses = collapsible && !editable && shouldRenderBlock;
85
95
 
86
96
  useEffect(() => {
87
97
  return () => {
@@ -89,6 +99,16 @@ export function Code({
89
99
  };
90
100
  }, []);
91
101
 
102
+ // Detect whether the (collapsed) content actually overflows the cap, so the
103
+ // toggle only appears when it's needed. scrollHeight reports the full content
104
+ // height even while clipped, so this is accurate in either state.
105
+ useEffect(() => {
106
+ if (!collapses) { setOverflows(false); return; }
107
+ if (expanded) return; // measured while collapsed; keep so "Show less" stays
108
+ const el = preRef.current;
109
+ if (el) setOverflows(el.scrollHeight - el.clientHeight > 4);
110
+ }, [collapses, expanded, children, collapsedLines]);
111
+
92
112
  function handleTextareaChange(e) {
93
113
  setEditableValue(e.target.value);
94
114
  onChangeValue?.(e.target.value);
@@ -113,6 +133,10 @@ export function Code({
113
133
  ]
114
134
  .filter(Boolean)
115
135
  .join(" ");
136
+ const editableProps = {
137
+ ...props,
138
+ ...(!props["aria-label"] && !props["aria-labelledby"] ? { "aria-label": editLabel } : null),
139
+ };
116
140
 
117
141
  if (!shouldRenderBlock) {
118
142
  return (
@@ -122,12 +146,15 @@ export function Code({
122
146
  );
123
147
  }
124
148
 
149
+ const collapsed = collapses && overflows && !expanded;
150
+
125
151
  return (
126
152
  <div
127
153
  className={[
128
154
  "a1-code-block",
129
155
  copyCode && "a1-code-block--copyable",
130
156
  editable && "a1-code-block--editable",
157
+ collapsed && "a1-code-block--collapsed",
131
158
  className,
132
159
  ]
133
160
  .filter(Boolean)
@@ -145,10 +172,14 @@ export function Code({
145
172
  value={editableValue}
146
173
  onChange={handleTextareaChange}
147
174
  spellCheck={false}
148
- {...props}
175
+ {...editableProps}
149
176
  />
150
177
  ) : (
151
- <pre className="a1-code-block__pre">
178
+ <pre
179
+ ref={preRef}
180
+ className="a1-code-block__pre"
181
+ style={collapses ? { "--a1-code-collapsed-max": `${collapsedLines * 1.6}em` } : undefined}
182
+ >
152
183
  <code className={codeClasses} {...props}>
153
184
  {children}
154
185
  </code>
@@ -166,6 +197,18 @@ export function Code({
166
197
  {copied ? copiedLabel : copyLabel}
167
198
  </Button>
168
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>
211
+ )}
169
212
  </div>
170
213
  );
171
214
  }
@@ -87,3 +87,26 @@
87
87
  .a1-code-block__copy {
88
88
  margin: 0;
89
89
  }
90
+
91
+ /* Collapsible block: cap the height with a fade and an Expand/Collapse toggle. */
92
+ .a1-code-block--collapsed .a1-code-block__pre {
93
+ position: relative;
94
+ max-block-size: var(--a1-code-collapsed-max, 22rem);
95
+ overflow-y: hidden;
96
+ }
97
+
98
+ .a1-code-block--collapsed .a1-code-block__pre::after {
99
+ content: "";
100
+ position: absolute;
101
+ inset-inline: 0;
102
+ inset-block-end: 0;
103
+ block-size: var(--base-spacing-48, 3rem);
104
+ background: linear-gradient(to top, var(--semantic-color-surface-panel), transparent);
105
+ pointer-events: none;
106
+ border-end-start-radius: var(--base-radius-md);
107
+ border-end-end-radius: var(--base-radius-md);
108
+ }
109
+
110
+ .a1-code-block__toggle {
111
+ margin: 0;
112
+ }
@@ -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> {
@@ -20,6 +20,8 @@ export interface GridProps extends React.HTMLAttributes<HTMLDivElement> {
20
20
  layout?: "default" | "bento";
21
21
  /** CSS value for `grid-auto-rows` */
22
22
  autoRows?: string;
23
+ /** Cross-axis (vertical) alignment of items within their row. Omit to inherit the grid default ("stretch" = equal-height items filling the row height). */
24
+ alignItems?: "start" | "center" | "end" | "stretch";
23
25
  children?: React.ReactNode;
24
26
  }
25
27
 
@@ -11,9 +11,12 @@ const gapSizes = {
11
11
  };
12
12
  const layouts = ["default", "bento"];
13
13
  const breakpoints = ["xs", "sm", "md", "lg", "xl"];
14
+ const alignments = ["start", "center", "end", "stretch"];
14
15
 
15
16
  function resolveGap(key) {
16
17
  if (key == null) return undefined;
18
+ // "none" removes the gap (matches the shared resolveSpacing convention).
19
+ if (key === "none") return "0";
17
20
  if (gapSizes[key]) return gapSizes[key];
18
21
 
19
22
  const n = Number(key);
@@ -27,6 +30,7 @@ export function Grid({
27
30
  columnGap,
28
31
  layout = "default",
29
32
  autoRows,
33
+ alignItems,
30
34
  className = "",
31
35
  children,
32
36
  ...props
@@ -38,6 +42,12 @@ export function Grid({
38
42
  classes.push(`a1-grid--${resolvedLayout}`);
39
43
  }
40
44
 
45
+ // Cross-axis (vertical) alignment of items in their row. Omit to inherit the
46
+ // grid default (stretch = equal-height items filling the row).
47
+ if (alignments.includes(alignItems)) {
48
+ classes.push(`a1-grid--align-${alignItems}`);
49
+ }
50
+
41
51
  let inlineCols;
42
52
  if (typeof columns === "number") {
43
53
  inlineCols = columns;
@@ -9,6 +9,12 @@
9
9
  align-items: stretch;
10
10
  }
11
11
 
12
+ /* Cross-axis (vertical) alignment of items within their row. */
13
+ .a1-grid--align-start { align-items: start; }
14
+ .a1-grid--align-center { align-items: center; }
15
+ .a1-grid--align-end { align-items: end; }
16
+ .a1-grid--align-stretch { align-items: stretch; }
17
+
12
18
  .a1-grid--bento > .a1-grid-item {
13
19
  min-height: 0;
14
20
  }
@@ -34,6 +40,7 @@
34
40
  .a1-grid--xs-3 { --a1-grid-cols: 3; }
35
41
  .a1-grid--xs-4 { --a1-grid-cols: 4; }
36
42
  .a1-grid--xs-6 { --a1-grid-cols: 6; }
43
+ .a1-grid--xs-8 { --a1-grid-cols: 8; }
37
44
  .a1-grid--xs-12 { --a1-grid-cols: 12; }
38
45
 
39
46
  @media (--bp-sm-up) {
@@ -42,6 +49,7 @@
42
49
  .a1-grid--sm-3 { --a1-grid-cols: 3; }
43
50
  .a1-grid--sm-4 { --a1-grid-cols: 4; }
44
51
  .a1-grid--sm-6 { --a1-grid-cols: 6; }
52
+ .a1-grid--sm-8 { --a1-grid-cols: 8; }
45
53
  .a1-grid--sm-12 { --a1-grid-cols: 12; }
46
54
  }
47
55
 
@@ -51,6 +59,7 @@
51
59
  .a1-grid--md-3 { --a1-grid-cols: 3; }
52
60
  .a1-grid--md-4 { --a1-grid-cols: 4; }
53
61
  .a1-grid--md-6 { --a1-grid-cols: 6; }
62
+ .a1-grid--md-8 { --a1-grid-cols: 8; }
54
63
  .a1-grid--md-12 { --a1-grid-cols: 12; }
55
64
  }
56
65
 
@@ -60,6 +69,7 @@
60
69
  .a1-grid--lg-3 { --a1-grid-cols: 3; }
61
70
  .a1-grid--lg-4 { --a1-grid-cols: 4; }
62
71
  .a1-grid--lg-6 { --a1-grid-cols: 6; }
72
+ .a1-grid--lg-8 { --a1-grid-cols: 8; }
63
73
  .a1-grid--lg-12 { --a1-grid-cols: 12; }
64
74
  }
65
75
 
@@ -69,6 +79,7 @@
69
79
  .a1-grid--xl-3 { --a1-grid-cols: 3; }
70
80
  .a1-grid--xl-4 { --a1-grid-cols: 4; }
71
81
  .a1-grid--xl-6 { --a1-grid-cols: 6; }
82
+ .a1-grid--xl-8 { --a1-grid-cols: 8; }
72
83
  .a1-grid--xl-12 { --a1-grid-cols: 12; }
73
84
  }
74
85
 
@@ -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