@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,346 @@
1
+ /* ─── Autocomplete (combobox) ─────────────────────────────────────────────────
2
+ Styled to match the A1 field family, with its own dropdown listbox + chips.
3
+ Size is driven by --a1-ac-* variables set per size modifier. */
4
+
5
+ .a1-autocomplete {
6
+ --a1-ac-height: var(--component-field-default-height);
7
+ --a1-ac-padding-inline: var(--component-field-default-padding-inline);
8
+ --a1-ac-font-size: var(--semantic-font-size-body-md);
9
+ --a1-ac-label-size: var(--semantic-font-size-body-sm);
10
+ --a1-ac-radius: var(--base-radius-md);
11
+ --a1-ac-accent-width: var(--component-field-required-border-width);
12
+
13
+ position: relative;
14
+ display: flex;
15
+ flex-direction: column;
16
+ gap: var(--base-spacing-4);
17
+ font-family: var(--component-paragraph-font-family);
18
+ }
19
+
20
+ .a1-autocomplete--compact {
21
+ --a1-ac-height: var(--component-field-compact-height);
22
+ --a1-ac-padding-inline: var(--component-field-compact-padding-inline);
23
+ --a1-ac-font-size: var(--semantic-font-size-body-sm);
24
+ --a1-ac-label-size: var(--semantic-font-size-body-xs);
25
+ --a1-ac-radius: var(--base-radius-sm);
26
+ --a1-ac-accent-width: var(--component-field-compact-accent-border-width);
27
+ }
28
+
29
+ .a1-autocomplete--comfortable {
30
+ --a1-ac-height: var(--component-field-comfortable-height);
31
+ --a1-ac-padding-inline: var(--component-field-comfortable-padding-inline);
32
+ --a1-ac-radius: var(--base-radius-lg);
33
+ }
34
+
35
+ /* ─── Label ───────────────────────────────────────────────────────────────── */
36
+
37
+ .a1-autocomplete__label {
38
+ display: flex;
39
+ align-items: center;
40
+ gap: var(--base-spacing-6);
41
+ flex-wrap: wrap;
42
+ font-size: var(--a1-ac-label-size);
43
+ font-weight: var(--component-field-label-font-weight);
44
+ color: var(--semantic-color-text-default);
45
+ line-height: var(--semantic-font-line-height-body);
46
+ }
47
+
48
+ .a1-autocomplete--disabled .a1-autocomplete__label {
49
+ color: var(--semantic-color-text-muted);
50
+ }
51
+
52
+ .a1-autocomplete__asterisk {
53
+ color: var(--semantic-color-status-error-background);
54
+ }
55
+
56
+ /* ─── Control (input box) ─────────────────────────────────────────────────── */
57
+
58
+ .a1-autocomplete__control {
59
+ display: flex;
60
+ flex-wrap: wrap;
61
+ align-items: center;
62
+ gap: var(--base-spacing-4);
63
+ min-block-size: var(--a1-ac-height);
64
+ padding-inline: var(--a1-ac-padding-inline);
65
+ padding-block: var(--base-spacing-2);
66
+ background: var(--semantic-color-surface-field);
67
+ border: var(--component-field-border-width) solid var(--semantic-color-border-strong);
68
+ border-radius: var(--a1-ac-radius);
69
+ cursor: text;
70
+ transition: border-color var(--semantic-motion-duration-fast), background var(--semantic-motion-duration-fast);
71
+ }
72
+
73
+ .a1-autocomplete__control:hover {
74
+ border-color: var(--semantic-color-border-strong);
75
+ }
76
+
77
+ .a1-autocomplete__control:focus-within {
78
+ outline: var(--component-field-focus-ring-width) solid var(--component-field-focus-ring-color);
79
+ outline-offset: var(--component-field-focus-ring-offset);
80
+ border-color: var(--semantic-color-action-background);
81
+ }
82
+
83
+ /* Required: info-tinted border with a thick leading accent, matching the field family. */
84
+ .a1-autocomplete--required .a1-autocomplete__control {
85
+ border-color: var(--semantic-color-status-info-border);
86
+ border-inline-start-width: var(--a1-ac-accent-width);
87
+ border-inline-start-color: var(--semantic-color-status-info-background);
88
+ }
89
+
90
+ .a1-autocomplete--required .a1-autocomplete__control:focus-within {
91
+ border-color: var(--semantic-color-status-info-background);
92
+ }
93
+
94
+ /* Error takes precedence over required. */
95
+ .a1-autocomplete--error .a1-autocomplete__control {
96
+ border-color: var(--semantic-color-status-error-border);
97
+ border-inline-start-width: var(--a1-ac-accent-width);
98
+ border-inline-start-color: var(--semantic-color-status-error-background);
99
+ }
100
+
101
+ .a1-autocomplete--disabled .a1-autocomplete__control {
102
+ background: var(--semantic-color-surface-raised);
103
+ border-color: var(--semantic-color-border-subtle);
104
+ cursor: not-allowed;
105
+ }
106
+
107
+ .a1-autocomplete__input {
108
+ flex: 1;
109
+ min-inline-size: 4ch;
110
+ border: none;
111
+ background: transparent;
112
+ outline: none;
113
+ color: var(--semantic-color-text-default);
114
+ font-family: inherit;
115
+ font-size: var(--a1-ac-font-size);
116
+ line-height: var(--semantic-font-line-height-body);
117
+ padding-block: var(--base-spacing-2);
118
+ }
119
+
120
+ .a1-autocomplete__input:disabled {
121
+ cursor: not-allowed;
122
+ color: var(--semantic-color-text-muted);
123
+ }
124
+
125
+ .a1-autocomplete__chevron {
126
+ flex-shrink: 0;
127
+ color: var(--semantic-color-text-muted);
128
+ font-size: var(--base-spacing-20);
129
+ }
130
+
131
+ .a1-autocomplete__clear {
132
+ flex-shrink: 0;
133
+ display: inline-flex;
134
+ align-items: center;
135
+ justify-content: center;
136
+ border: none;
137
+ background: transparent;
138
+ color: var(--semantic-color-text-muted);
139
+ cursor: pointer;
140
+ border-radius: var(--base-radius-sm);
141
+ padding: var(--base-spacing-2);
142
+ font-size: var(--base-spacing-16);
143
+ }
144
+
145
+ .a1-autocomplete__clear:hover {
146
+ color: var(--semantic-color-text-default);
147
+ }
148
+
149
+ /* ─── Multi-select chips ──────────────────────────────────────────────────── */
150
+
151
+ .a1-autocomplete__chip {
152
+ display: inline-flex;
153
+ align-items: center;
154
+ gap: var(--base-spacing-4);
155
+ padding-block: var(--base-spacing-2);
156
+ padding-inline: var(--base-spacing-8);
157
+ background: var(--semantic-color-surface-panel);
158
+ border: var(--component-divider-size-sm) solid var(--semantic-color-border-subtle);
159
+ border-radius: var(--base-radius-sm);
160
+ font-size: var(--semantic-font-size-body-xs);
161
+ color: var(--semantic-color-text-default);
162
+ white-space: nowrap;
163
+ }
164
+
165
+ .a1-autocomplete__chip-remove {
166
+ display: inline-flex;
167
+ align-items: center;
168
+ border: none;
169
+ background: transparent;
170
+ color: var(--semantic-color-text-muted);
171
+ cursor: pointer;
172
+ padding: 0;
173
+ font-size: var(--base-spacing-16);
174
+ }
175
+
176
+ .a1-autocomplete__chip-remove:hover {
177
+ color: var(--semantic-color-status-error-text);
178
+ }
179
+
180
+ /* ─── Listbox dropdown ────────────────────────────────────────────────────── */
181
+
182
+ .a1-autocomplete__listbox {
183
+ position: absolute;
184
+ z-index: var(--component-menu-z-index, 1000);
185
+ inset-block-start: calc(100% + var(--base-spacing-2));
186
+ inset-inline: 0;
187
+ margin: 0;
188
+ padding: var(--base-spacing-4);
189
+ list-style: none;
190
+ max-block-size: 16rem;
191
+ overflow-y: auto;
192
+ background: var(--semantic-color-surface-page);
193
+ border: var(--component-divider-size-sm) solid var(--semantic-color-border-default);
194
+ border-radius: var(--base-radius-md);
195
+ box-shadow: var(--semantic-shadow-md);
196
+ }
197
+
198
+ /* Portaled to <body>: positioned via inline fixed top/left/width so it escapes
199
+ any clipping ancestor (e.g. an Accordion's overflow:hidden panel). */
200
+ .a1-autocomplete__listbox--floating {
201
+ inset-block-start: auto;
202
+ inset-inline: auto;
203
+ margin-block-start: var(--base-spacing-2);
204
+ }
205
+
206
+ .a1-autocomplete__option {
207
+ display: flex;
208
+ align-items: center;
209
+ gap: var(--base-spacing-8);
210
+ padding-block: var(--base-spacing-8);
211
+ padding-inline: var(--base-spacing-8);
212
+ border-radius: var(--base-radius-sm);
213
+ font-size: var(--semantic-font-size-body-sm);
214
+ color: var(--semantic-color-text-default);
215
+ cursor: pointer;
216
+ }
217
+
218
+ /* Colour swatch shown beside an option / chip / the selected value (colour variant).
219
+ Sized to fill the row height (`--a1-autocomplete-swatch-size`, set per context)
220
+ and always bordered so a light swatch can't bleed into the surface. */
221
+ .a1-autocomplete__swatch {
222
+ flex: none;
223
+ inline-size: var(--a1-autocomplete-swatch-size, var(--base-spacing-24));
224
+ block-size: var(--a1-autocomplete-swatch-size, var(--base-spacing-24));
225
+ border-radius: var(--base-radius-sm);
226
+ border: var(--component-divider-size-sm) solid var(--semantic-color-border-strong);
227
+ }
228
+
229
+ /* In the option list the swatch can be generous (fills the option line). */
230
+ .a1-autocomplete__option .a1-autocomplete__swatch {
231
+ --a1-autocomplete-swatch-size: var(--base-spacing-24);
232
+ }
233
+
234
+ /* On the selected value in the control: match the input text line. */
235
+ .a1-autocomplete__swatch--leading {
236
+ --a1-autocomplete-swatch-size: var(--base-spacing-24);
237
+ margin-inline: var(--base-spacing-8) 0;
238
+ }
239
+
240
+ /* Inside a multi-select chip: scale down to the chip height. */
241
+ .a1-autocomplete__chip .a1-autocomplete__swatch {
242
+ --a1-autocomplete-swatch-size: var(--base-spacing-16);
243
+ }
244
+
245
+ .a1-autocomplete__option-label {
246
+ flex: 1;
247
+ overflow: hidden;
248
+ text-overflow: ellipsis;
249
+ white-space: nowrap;
250
+ }
251
+
252
+ .a1-autocomplete__option--active {
253
+ background: var(--semantic-color-surface-panel);
254
+ }
255
+
256
+ .a1-autocomplete__option[aria-selected="true"] {
257
+ color: var(--semantic-color-action-background);
258
+ }
259
+
260
+ .a1-autocomplete__option--create {
261
+ color: var(--semantic-color-action-background);
262
+ font-weight: var(--semantic-font-weight-heading);
263
+ }
264
+
265
+ /* Multi-select: a checkbox on each option makes multi-selection obvious. Matches
266
+ the CheckboxGroup checkbox exactly (size, border, radius, fill, SVG tick). */
267
+ .a1-autocomplete__checkbox {
268
+ flex-shrink: 0;
269
+ inline-size: var(--component-checkbox-group-box-size);
270
+ block-size: var(--component-checkbox-group-box-size);
271
+ border: var(--component-field-border-width) solid var(--semantic-color-border-strong);
272
+ border-radius: var(--base-radius-sm);
273
+ background-color: var(--semantic-color-surface-field);
274
+ }
275
+
276
+ .a1-autocomplete__checkbox--on {
277
+ background-color: var(--semantic-color-status-info-background);
278
+ border-color: var(--semantic-color-status-info-background);
279
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpolyline points='2,6 5,9.5 10,3' fill='none' stroke='%23fafcff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
280
+ background-repeat: no-repeat;
281
+ background-position: center;
282
+ background-size: var(--component-checkbox-group-icon-size);
283
+ }
284
+
285
+ .a1-autocomplete__option-check,
286
+ .a1-autocomplete__option-icon {
287
+ flex-shrink: 0;
288
+ font-size: var(--base-spacing-20);
289
+ }
290
+
291
+ .a1-autocomplete__empty {
292
+ padding: var(--base-spacing-8);
293
+ font-size: var(--semantic-font-size-body-sm);
294
+ color: var(--semantic-color-text-muted);
295
+ }
296
+
297
+ /* ─── Group headings ──────────────────────────────────────────────────────────
298
+ Inserted before the first option of each group. Sticky so the current
299
+ category stays visible while scrolling a long grouped list. */
300
+ .a1-autocomplete__group {
301
+ position: sticky;
302
+ inset-block-start: calc(-1 * var(--base-spacing-4));
303
+ padding-block: var(--base-spacing-6) var(--base-spacing-2);
304
+ padding-inline: var(--base-spacing-8);
305
+ background: var(--semantic-color-surface-page);
306
+ font-size: var(--semantic-font-size-body-xs);
307
+ font-weight: var(--component-field-label-font-weight);
308
+ color: var(--semantic-color-text-muted);
309
+ }
310
+
311
+ /* "Showing the first N — keep typing" footer when the list is capped. */
312
+ .a1-autocomplete__more {
313
+ padding: var(--base-spacing-8);
314
+ font-size: var(--semantic-font-size-body-xs);
315
+ color: var(--semantic-color-text-muted);
316
+ text-align: center;
317
+ }
318
+
319
+ /* Leading glyph for the selected value in the control (icon options). */
320
+ .a1-autocomplete__leading-icon {
321
+ flex: none;
322
+ margin-inline-start: var(--base-spacing-4);
323
+ color: var(--semantic-color-text-muted);
324
+ font-size: var(--base-spacing-20);
325
+ }
326
+
327
+ .a1-autocomplete__chip-icon {
328
+ flex: none;
329
+ font-size: var(--base-spacing-16);
330
+ }
331
+
332
+ /* ─── Messages ────────────────────────────────────────────────────────────── */
333
+
334
+ .a1-autocomplete__message {
335
+ margin: 0;
336
+ font-size: var(--semantic-font-size-body-xs);
337
+ line-height: var(--semantic-font-line-height-body);
338
+ }
339
+
340
+ .a1-autocomplete__message--hint {
341
+ color: var(--semantic-color-text-muted);
342
+ }
343
+
344
+ .a1-autocomplete__message--error {
345
+ color: var(--semantic-color-status-error-text);
346
+ }
@@ -1,12 +1,19 @@
1
1
  import * as React from "react";
2
2
 
3
3
  export interface BannerProps {
4
- /** Layout style. "inline" sits within content; "system" spans full width. Default: "inline" */
5
- variant?: "inline" | "system";
4
+ /** Layout style. "inline" sits within content; "system" spans full width; "calendar" shows a date callout in place of the status icon. Default: "inline" */
5
+ variant?: "inline" | "system" | "calendar";
6
6
  /** Semantic status colour. Default: "neutral" */
7
7
  status?: "neutral" | "info" | "success" | "warn" | "error";
8
8
  /** Bold title text shown before the body */
9
9
  title?: string;
10
+ /** Small overline label shown above the title. Calendar variant only. */
11
+ eyebrow?: string;
12
+ /**
13
+ * Date shown in the calendar callout. Accepts a Date, an ISO date string, or
14
+ * `{ month, day }` for full control of the displayed month/day. Calendar variant only.
15
+ */
16
+ date?: Date | string | { month: string | number; day: string | number };
10
17
  /** Override the default status icon with any Material Symbols name */
11
18
  icon?: string;
12
19
  /** Action element (e.g. a Button) rendered at the trailing end */
@@ -19,12 +19,27 @@ const SYSTEM_ICONS = {
19
19
  };
20
20
 
21
21
  const STATUSES = ["neutral", "info", "success", "warn", "error"];
22
- const VARIANTS = ["inline", "system"];
22
+ const VARIANTS = ["inline", "system", "calendar"];
23
+
24
+ // Derive the month/day for the calendar callout. `date` may be a Date, an
25
+ // ISO-ish string, or an object `{ month, day }`. Month uses the locale's short
26
+ // name (sentence case — never uppercased).
27
+ function dateParts(date) {
28
+ if (!date) return null;
29
+ if (typeof date === "object" && !(date instanceof Date)) {
30
+ return { month: String(date.month ?? ""), day: String(date.day ?? "") };
31
+ }
32
+ const d = date instanceof Date ? date : new Date(date);
33
+ if (Number.isNaN(d.getTime())) return { month: "", day: String(date) };
34
+ return { month: d.toLocaleDateString(undefined, { month: "short" }), day: String(d.getDate()) };
35
+ }
23
36
 
24
37
  export function Banner({
25
38
  variant = "inline",
26
39
  status = "neutral",
27
40
  title,
41
+ eyebrow,
42
+ date,
28
43
  icon,
29
44
  action,
30
45
  onDismiss,
@@ -34,21 +49,32 @@ export function Banner({
34
49
  }) {
35
50
  const resolvedVariant = VARIANTS.includes(variant) ? variant : "inline";
36
51
  const resolvedStatus = STATUSES.includes(status) ? status : "neutral";
52
+ const isCalendar = resolvedVariant === "calendar";
37
53
  const resolvedIcon = icon ?? (resolvedVariant === "system" ? SYSTEM_ICONS : INLINE_ICONS)[resolvedStatus];
54
+ const dp = isCalendar ? dateParts(date) : null;
38
55
 
39
56
  return (
40
57
  <div
41
58
  className={`a1-banner a1-banner--${resolvedVariant} a1-banner--${resolvedStatus}${className ? ` ${className}` : ""}`}
42
- role="alert"
43
- aria-live="polite"
59
+ // A calendar/event callout isn't an alert — keep it a quiet region.
60
+ role={isCalendar ? "group" : "alert"}
61
+ {...(isCalendar ? {} : { "aria-live": "polite" })}
44
62
  {...rest}
45
63
  >
46
64
  <div className="a1-banner__inner">
47
- <span className="a1-banner__icon" aria-hidden="true">
48
- <Icon name={resolvedIcon} />
49
- </span>
65
+ {isCalendar && dp ? (
66
+ <span className="a1-banner__date" aria-hidden="true">
67
+ {dp.month && <span className="a1-banner__date-month">{dp.month}</span>}
68
+ {dp.day && <span className="a1-banner__date-day">{dp.day}</span>}
69
+ </span>
70
+ ) : (
71
+ <span className="a1-banner__icon" aria-hidden="true">
72
+ <Icon name={resolvedIcon} />
73
+ </span>
74
+ )}
50
75
 
51
76
  <div className="a1-banner__content">
77
+ {isCalendar && eyebrow && <span className="a1-banner__eyebrow">{eyebrow}</span>}
52
78
  {title && <span className="a1-banner__title">{title}</span>}
53
79
  {children && <span className="a1-banner__body">{children}</span>}
54
80
  </div>
@@ -203,3 +203,84 @@
203
203
  --a1-icon-button-border-hover: transparent;
204
204
  --a1-icon-button-border-pressed: transparent;
205
205
  }
206
+
207
+ /* ─── Calendar variant ─────────────────────────────────────────────────────────
208
+ An event/date callout: a tokenized date block replaces the status icon, and an
209
+ eyebrow sits above a larger title. Neutral defaults to the action colour for the
210
+ date block; status modifiers still drive the accent so a calendar callout can be
211
+ tinted to a status. Shares the inline surface treatment. */
212
+
213
+ .a1-banner--calendar.a1-banner--neutral {
214
+ --a1-banner-accent: var(--semantic-color-action-background);
215
+ }
216
+
217
+ /* The date block puts inverse (white) text on the accent. warn.500 only hits
218
+ 3.18:1 — too low for the small month label — so use warn.text, matching the
219
+ system variant's bg fix. */
220
+ .a1-banner--calendar.a1-banner--warn {
221
+ --a1-banner-accent: var(--semantic-color-status-warn-text);
222
+ }
223
+
224
+ .a1-banner--calendar .a1-banner__inner {
225
+ align-items: center;
226
+ padding: var(--component-message-banner-padding);
227
+ border: var(--component-message-banner-border-width) solid var(--a1-banner-border);
228
+ border-radius: var(--component-message-banner-border-radius);
229
+ background: var(--a1-banner-surface);
230
+ }
231
+
232
+ .a1-banner__date {
233
+ flex-shrink: 0;
234
+ display: flex;
235
+ flex-direction: column;
236
+ align-items: center;
237
+ justify-content: center;
238
+ gap: var(--base-spacing-2);
239
+ min-inline-size: var(--base-spacing-48);
240
+ padding-block: var(--base-spacing-8);
241
+ padding-inline: var(--base-spacing-8);
242
+ background: var(--a1-banner-accent);
243
+ color: var(--semantic-color-text-inverse);
244
+ border-radius: var(--base-radius-md);
245
+ line-height: 1;
246
+ }
247
+
248
+ .a1-banner__date-month {
249
+ font-family: var(--component-paragraph-font-family);
250
+ font-size: var(--semantic-font-size-body-2xs);
251
+ font-weight: var(--component-message-banner-title-font-weight);
252
+ line-height: 1;
253
+ }
254
+
255
+ .a1-banner__date-day {
256
+ font-family: var(--component-heading-font-family);
257
+ font-size: var(--semantic-font-size-heading-md);
258
+ font-weight: var(--semantic-font-weight-heading);
259
+ line-height: 1;
260
+ }
261
+
262
+ .a1-banner--calendar .a1-banner__content {
263
+ flex-direction: column;
264
+ gap: var(--base-spacing-2);
265
+ }
266
+
267
+ .a1-banner__eyebrow {
268
+ font-family: var(--component-paragraph-font-family);
269
+ font-size: var(--semantic-font-size-body-2xs);
270
+ font-weight: var(--semantic-font-weight-heading);
271
+ line-height: var(--semantic-font-line-height-body);
272
+ color: var(--semantic-color-text-muted);
273
+ }
274
+
275
+ .a1-banner--calendar .a1-banner__title {
276
+ font-size: var(--semantic-font-size-heading-sm);
277
+ color: var(--semantic-color-text-default);
278
+ }
279
+
280
+ .a1-banner--calendar .a1-banner__body {
281
+ color: var(--semantic-color-text-muted);
282
+ }
283
+
284
+ .a1-banner--calendar .a1-banner__action {
285
+ align-self: center;
286
+ }
@@ -0,0 +1,22 @@
1
+ import * as React from "react";
2
+
3
+ export interface BottomSheetProps
4
+ extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
5
+ /** First line shown in the header — the only content visible when collapsed. */
6
+ title?: string;
7
+ /**
8
+ * Expanded heights as fractions of the viewport height (0–1), smallest first.
9
+ * The collapsed state (header only) is always available as snap index 0.
10
+ * Default: [0.5, 0.92].
11
+ */
12
+ detents?: number[];
13
+ /** Controlled snap index. 0 = collapsed, then one index per `detents` entry. */
14
+ detent?: number;
15
+ /** Uncontrolled initial snap index. Default: 1 (the first detent). */
16
+ defaultDetent?: number;
17
+ /** Called with the next snap index when the detent changes. */
18
+ onDetentChange?: (index: number) => void;
19
+ children?: React.ReactNode;
20
+ }
21
+
22
+ export declare function BottomSheet(props: BottomSheetProps): React.ReactElement;
@@ -0,0 +1,154 @@
1
+ import { useCallback, useId, useRef, useState } from "react";
2
+ import "./bottom-sheet.css";
3
+
4
+ const DEFAULT_DETENTS = [0.5, 0.92];
5
+
6
+ /**
7
+ * BottomSheet — a fixed panel attached to the bottom of the viewport that
8
+ * overlays content with no scrim (separation via shadow). A drag handle in the
9
+ * header resizes it between detents: a collapsed state (header / first line of
10
+ * the title only) and one or more expanded heights. Content scrolls internally;
11
+ * there is no footer. Only rendered at xs and sm breakpoints. The component
12
+ * reserves the collapsed footprint in flow (via an invisible spacer) so page
13
+ * content can always be scrolled clear of the sheet.
14
+ *
15
+ * `detents` are expanded heights as fractions of the viewport height (0–1).
16
+ * Snap indices: 0 = collapsed, then one per detent (controlled via `detent`).
17
+ */
18
+ export function BottomSheet({
19
+ title,
20
+ detents = DEFAULT_DETENTS,
21
+ detent,
22
+ defaultDetent = 1,
23
+ onDetentChange,
24
+ children,
25
+ className = "",
26
+ "aria-label": ariaLabel,
27
+ ...props
28
+ }) {
29
+ const id = useId();
30
+ const titleId = `${id}-title`;
31
+ const contentId = `${id}-content`;
32
+ const snapCount = detents.length + 1; // collapsed + each detent
33
+
34
+ const isControlled = detent !== undefined;
35
+ const [internalIndex, setInternalIndex] = useState(() =>
36
+ Math.min(Math.max(defaultDetent, 0), snapCount - 1)
37
+ );
38
+ const index = isControlled ? Math.min(Math.max(detent, 0), snapCount - 1) : internalIndex;
39
+
40
+ const [dragPx, setDragPx] = useState(null);
41
+ const headerRef = useRef(null);
42
+ const sheetRef = useRef(null);
43
+ const dragRef = useRef(null);
44
+
45
+ const commit = useCallback((next) => {
46
+ const clamped = Math.min(Math.max(next, 0), snapCount - 1);
47
+ if (!isControlled) setInternalIndex(clamped);
48
+ onDetentChange?.(clamped);
49
+ }, [isControlled, onDetentChange, snapCount]);
50
+
51
+ function collapsedPx() { return headerRef.current?.offsetHeight ?? 56; }
52
+ function snapPixels() {
53
+ const vh = window.innerHeight;
54
+ return [collapsedPx(), ...detents.map((d) => Math.round(d * vh))];
55
+ }
56
+
57
+ function onPointerDown(e) {
58
+ if (e.button != null && e.button !== 0) return;
59
+ dragRef.current = {
60
+ startY: e.clientY,
61
+ startHeight: sheetRef.current?.offsetHeight ?? collapsedPx(),
62
+ moved: false,
63
+ };
64
+ e.currentTarget.setPointerCapture?.(e.pointerId);
65
+ }
66
+ function onPointerMove(e) {
67
+ if (!dragRef.current) return;
68
+ const delta = dragRef.current.startY - e.clientY; // dragging up grows the sheet
69
+ if (Math.abs(delta) > 4) dragRef.current.moved = true;
70
+ const max = Math.round(window.innerHeight * 0.96);
71
+ const next = Math.min(Math.max(dragRef.current.startHeight + delta, collapsedPx()), max);
72
+ setDragPx(next);
73
+ }
74
+ function onPointerUp(e) {
75
+ const drag = dragRef.current;
76
+ dragRef.current = null;
77
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
78
+ const current = dragPx;
79
+ setDragPx(null);
80
+ if (!drag) return;
81
+ if (!drag.moved) {
82
+ // A tap toggles between collapsed and the largest detent.
83
+ commit(index === 0 ? snapCount - 1 : 0);
84
+ return;
85
+ }
86
+ // Snap to the nearest detent.
87
+ const points = snapPixels();
88
+ let nearest = 0;
89
+ let best = Infinity;
90
+ points.forEach((p, i) => {
91
+ const d = Math.abs(p - (current ?? drag.startHeight));
92
+ if (d < best) { best = d; nearest = i; }
93
+ });
94
+ commit(nearest);
95
+ }
96
+
97
+ function onHandleKeyDown(e) {
98
+ if (e.key === "ArrowUp") { e.preventDefault(); commit(index + 1); }
99
+ else if (e.key === "ArrowDown") { e.preventDefault(); commit(index - 1); }
100
+ else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); commit(index === 0 ? snapCount - 1 : 0); }
101
+ }
102
+
103
+ const heightValue = dragPx != null
104
+ ? `${dragPx}px`
105
+ : index === 0
106
+ ? "var(--component-bottom-sheet-header-height, 3.5rem)"
107
+ : `${detents[index - 1] * 100}dvh`;
108
+
109
+ const classes = [
110
+ "a1-bottom-sheet",
111
+ index === 0 && "a1-bottom-sheet--collapsed",
112
+ dragPx != null && "a1-bottom-sheet--dragging",
113
+ className,
114
+ ].filter(Boolean).join(" ");
115
+
116
+ return (
117
+ <>
118
+ {/* Reserves the collapsed footprint in document flow so the page's last
119
+ content can always scroll clear of the fixed sheet. */}
120
+ <div className="a1-bottom-sheet__spacer" aria-hidden="true" />
121
+ <section
122
+ ref={sheetRef}
123
+ className={classes}
124
+ style={{ "--a1-bottom-sheet-height": heightValue }}
125
+ role="region"
126
+ aria-labelledby={title ? titleId : undefined}
127
+ aria-label={title ? undefined : ariaLabel}
128
+ {...props}
129
+ >
130
+ <div
131
+ ref={headerRef}
132
+ className="a1-bottom-sheet__header"
133
+ onPointerDown={onPointerDown}
134
+ onPointerMove={onPointerMove}
135
+ onPointerUp={onPointerUp}
136
+ onPointerCancel={onPointerUp}
137
+ >
138
+ <button
139
+ type="button"
140
+ className="a1-bottom-sheet__handle"
141
+ aria-label="Resize sheet"
142
+ aria-expanded={index > 0}
143
+ aria-controls={contentId}
144
+ onKeyDown={onHandleKeyDown}
145
+ />
146
+ {title && <span id={titleId} className="a1-bottom-sheet__title">{title}</span>}
147
+ </div>
148
+ <div id={contentId} className="a1-bottom-sheet__content">
149
+ {children}
150
+ </div>
151
+ </section>
152
+ </>
153
+ );
154
+ }