@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.
- package/package.json +3 -2
- package/src/color-scheme.css +2 -0
- package/src/components/accordion/accordion.css +6 -0
- package/src/components/autocomplete/Autocomplete.d.ts +53 -0
- package/src/components/autocomplete/Autocomplete.jsx +380 -0
- package/src/components/autocomplete/autocomplete.css +346 -0
- package/src/components/banner/Banner.d.ts +9 -2
- package/src/components/banner/Banner.jsx +32 -6
- package/src/components/banner/banner.css +81 -0
- package/src/components/bottom-sheet/BottomSheet.d.ts +22 -0
- package/src/components/bottom-sheet/BottomSheet.jsx +154 -0
- package/src/components/bottom-sheet/bottom-sheet.css +113 -0
- package/src/components/code/Code.jsx +6 -1
- package/src/components/data-table/DataTable.jsx +11 -1
- package/src/components/data-table/data-table.css +19 -0
- package/src/components/figure/Figure.d.ts +7 -0
- package/src/components/figure/Figure.jsx +23 -2
- package/src/components/figure/figure.css +25 -0
- package/src/components/grid/Grid.d.ts +1 -1
- package/src/components/grid/Grid.jsx +2 -0
- package/src/components/grid/grid.css +5 -0
- package/src/components/page-layout/page-layout.css +10 -4
- package/src/components/page-nav/PageNav.jsx +29 -8
- package/src/components/page-nav/page-nav.css +13 -0
- package/src/components/paragraph/Paragraph.d.ts +2 -0
- package/src/components/paragraph/Paragraph.jsx +4 -0
- package/src/components/paragraph/paragraph.css +6 -6
- package/src/components/segmented-control/SegmentedControl.d.ts +8 -0
- package/src/components/segmented-control/SegmentedControl.jsx +16 -3
- package/src/components/segmented-control/segmented.css +31 -1
- package/src/components/slider/slider.css +10 -2
- package/src/components/split-button/SplitButton.jsx +3 -1
- package/src/components/tabs/tabs.css +3 -0
- package/src/components/toolbar/Toolbar.d.ts +7 -0
- package/src/components/toolbar/Toolbar.jsx +13 -5
- package/src/components/top-header/top-header.css +2 -0
- package/src/components/tree-menu/TreeMenu.jsx +11 -7
- package/src/index.d.ts +71 -0
- package/src/index.js +2 -0
- package/src/themes.css +293 -0
- 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
|
-
{...
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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
|
-
|
|
41
|
+
target.addEventListener("scroll", update, { passive: true });
|
|
23
42
|
update();
|
|
24
|
-
return () =>
|
|
25
|
-
}, []);
|
|
43
|
+
return () => target.removeEventListener("scroll", update);
|
|
44
|
+
}, [sections]);
|
|
26
45
|
|
|
27
|
-
// Active section: observe each section element entering/leaving the
|
|
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
|
|
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
|
-
|
|
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"]),
|