@gtivr4/a1-design-system-react 0.6.1 → 0.7.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtivr4/a1-design-system-react",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "React components for the A1 token-driven design system.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -783,3 +783,41 @@ html.a1-theme-dark.a1-theme-accessible {
783
783
  --semantic-color-border-subtle: var(--base-color-neutral-500);
784
784
  --semantic-color-border-default: var(--base-color-neutral-400);
785
785
  }
786
+
787
+ /* ─── Fresh theme — gradient on body, surface tokens on html ─────────────────
788
+ Light mode: mint gradient. Dark mode: dark teal gradient.
789
+ CSS custom properties stay on html so they inherit throughout the document.
790
+ background-image targets body so it doesn't paint behind the html scroll area. */
791
+
792
+ html.a1-theme-fresh body {
793
+ background-image: linear-gradient(207.43deg, #D7FFF8 0%, #CDF5EE 57.44%);
794
+ background-attachment: fixed;
795
+ background-color: var(--semantic-color-surface-page);
796
+ }
797
+
798
+ /* Dark mode surface tokens — stay on html for inheritance (0,2,1 beats html.a1-theme-dark 0,1,1) */
799
+ html.a1-theme-fresh.a1-theme-dark,
800
+ html.a1-theme-dark.a1-theme-fresh {
801
+ --semantic-color-surface-page: #03453A;
802
+ --semantic-color-surface-panel: #046756;
803
+ --semantic-color-surface-raised: #057868;
804
+ }
805
+
806
+ html.a1-theme-fresh.a1-theme-dark body,
807
+ html.a1-theme-dark.a1-theme-fresh body {
808
+ background-image: linear-gradient(207.43deg, #046756 0%, #03453A 57.44%);
809
+ background-color: #03453A;
810
+ }
811
+
812
+ @media (prefers-color-scheme: dark) {
813
+ html.a1-theme-fresh {
814
+ --semantic-color-surface-page: #03453A;
815
+ --semantic-color-surface-panel: #046756;
816
+ --semantic-color-surface-raised: #057868;
817
+ }
818
+
819
+ html.a1-theme-fresh body {
820
+ background-image: linear-gradient(207.43deg, #046756 0%, #03453A 57.44%);
821
+ background-color: #03453A;
822
+ }
823
+ }
@@ -5,6 +5,8 @@ export interface ButtonContainerProps extends React.HTMLAttributes<HTMLDivElemen
5
5
  align?: "start" | "center" | "end";
6
6
  /** Default size passed to child Button elements that do not set their own size. */
7
7
  size?: "sm" | "md" | "lg";
8
+ /** When true, Button children stretch to fill remaining row space while IconButton children keep their natural square size. Always renders as a row — does not collapse to column on narrow containers. Default: false */
9
+ fillButtons?: boolean;
8
10
  children?: React.ReactNode;
9
11
  }
10
12
 
@@ -20,6 +20,7 @@ function applyButtonSize(children, size) {
20
20
  export function ButtonContainer({
21
21
  align = "start",
22
22
  size,
23
+ fillButtons = false,
23
24
  className = "",
24
25
  children,
25
26
  ...props
@@ -30,6 +31,7 @@ export function ButtonContainer({
30
31
  "a1-button-container",
31
32
  `a1-button-container--${resolvedAlign}`,
32
33
  resolvedSize && `a1-button-container--${resolvedSize}`,
34
+ fillButtons && "a1-button-container--fill-buttons",
33
35
  className
34
36
  ]
35
37
  .filter(Boolean)
@@ -54,3 +54,23 @@
54
54
  .a1-button-container--lg .a1-button-container__inner {
55
55
  gap: var(--base-spacing-16) var(--base-spacing-24);
56
56
  }
57
+
58
+ /* ── Fill buttons ────────────────────────────────────────────────────────── */
59
+ /* Buttons stretch to fill remaining space; icon buttons stay their natural
60
+ square size. Always row — does not collapse to column on narrow containers. */
61
+
62
+ .a1-button-container--fill-buttons .a1-button-container__inner {
63
+ flex-direction: row;
64
+ flex-wrap: nowrap;
65
+ align-items: center;
66
+ }
67
+
68
+ .a1-button-container--fill-buttons .a1-button-container__inner > .a1-button {
69
+ flex: 1 1 0;
70
+ min-inline-size: 0;
71
+ width: auto;
72
+ }
73
+
74
+ .a1-button-container--fill-buttons .a1-button-container__inner > .a1-icon-button {
75
+ flex: 0 0 auto;
76
+ }
@@ -78,6 +78,12 @@ export interface DataTableProps extends React.HTMLAttributes<HTMLDivElement> {
78
78
  emptyTitle?: string;
79
79
  emptyDescription?: string;
80
80
  emptyIcon?: string;
81
+ /**
82
+ * Custom full-width no-padding rows inserted into the table body.
83
+ * Each entry specifies `content` (ReactNode) and an optional `afterRow` index (0-based,
84
+ * default 0 = before all data rows). Multiple entries with the same `afterRow` stack in order.
85
+ */
86
+ notices?: Array<{ content: React.ReactNode; afterRow?: number }>;
81
87
  }
82
88
 
83
89
  export declare function DataTable(props: DataTableProps): React.ReactElement;
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState } from "react";
1
+ import { Fragment, useEffect, useRef, useState } from "react";
2
2
  import { Button } from "../button/Button.jsx";
3
3
  import { SelectField } from "../field/SelectField.jsx";
4
4
  import { Icon } from "../icon/Icon.jsx";
@@ -197,6 +197,7 @@ export function DataTable({
197
197
  zebra = false,
198
198
  scrollable = false,
199
199
  caption,
200
+ notices = [],
200
201
  page,
201
202
  defaultPage = 1,
202
203
  pageSize,
@@ -661,39 +662,54 @@ export function DataTable({
661
662
  </tr>
662
663
  </thead>
663
664
  <tbody>
664
- {visibleRowEntries.map(({ row, index: rowIndex, id: rowId, supportsRowClickSelection }) => {
665
- const isSelected = selectedRowIdSet.has(rowId);
666
- return (
667
- <tr
668
- key={rowId}
669
- data-selected={isSelected ? "true" : undefined}
670
- data-selectable-row={supportsRowClickSelection ? "true" : undefined}
671
- onClick={(event) => handleRowClick(rowId, supportsRowClickSelection, event)}
672
- >
673
- {selectable && (
674
- <td
675
- className="a1-data-table__select-cell"
676
- data-label="Select"
677
- >
678
- <SelectionCheckbox
679
- checked={isSelected}
680
- label={`Select row ${rowIndex + 1}`}
681
- onChange={(checked) => toggleRowSelected(rowId, checked)}
682
- />
683
- </td>
684
- )}
685
- {columns.map((col) => (
686
- <td
687
- key={col.key}
688
- data-label={col.label}
689
- data-align={getAlign(col)}
665
+ {(() => {
666
+ const noticeColSpan = selectable ? columns.length + 1 : columns.length;
667
+ const noticeMap = {};
668
+ notices.forEach(({ content, afterRow = 0 }) => {
669
+ if (!noticeMap[afterRow]) noticeMap[afterRow] = [];
670
+ noticeMap[afterRow].push(content);
671
+ });
672
+ return visibleRowEntries.map(({ row, index: rowIndex, id: rowId, supportsRowClickSelection }, i) => {
673
+ const isSelected = selectedRowIdSet.has(rowId);
674
+ const noticesHere = noticeMap[i] ?? [];
675
+ return (
676
+ <Fragment key={rowId}>
677
+ {noticesHere.map((content, j) => (
678
+ <tr key={j} className="a1-data-table__notice-row">
679
+ <td className="a1-data-table__notice-cell" colSpan={noticeColSpan}>{content}</td>
680
+ </tr>
681
+ ))}
682
+ <tr
683
+ data-selected={isSelected ? "true" : undefined}
684
+ data-selectable-row={supportsRowClickSelection ? "true" : undefined}
685
+ onClick={(event) => handleRowClick(rowId, supportsRowClickSelection, event)}
690
686
  >
691
- {renderCell(col, row[col.key])}
692
- </td>
693
- ))}
694
- </tr>
695
- );
696
- })}
687
+ {selectable && (
688
+ <td
689
+ className="a1-data-table__select-cell"
690
+ data-label="Select"
691
+ >
692
+ <SelectionCheckbox
693
+ checked={isSelected}
694
+ label={`Select row ${rowIndex + 1}`}
695
+ onChange={(checked) => toggleRowSelected(rowId, checked)}
696
+ />
697
+ </td>
698
+ )}
699
+ {columns.map((col) => (
700
+ <td
701
+ key={col.key}
702
+ data-label={col.label}
703
+ data-align={getAlign(col)}
704
+ >
705
+ {renderCell(col, row[col.key])}
706
+ </td>
707
+ ))}
708
+ </tr>
709
+ </Fragment>
710
+ );
711
+ });
712
+ })()}
697
713
  </tbody>
698
714
  </table>
699
715
  )}
@@ -201,6 +201,17 @@
201
201
  border-bottom: none;
202
202
  }
203
203
 
204
+ /* ── Notice row (no-padding custom content spanning all columns) ─────────── */
205
+
206
+ /* Double-class specificity (0-2-0) beats density td rules (0-1-1) */
207
+ .a1-data-table .a1-data-table__notice-cell {
208
+ padding: 0;
209
+ }
210
+
211
+ .a1-data-table--zebra tbody .a1-data-table__notice-row td {
212
+ background: transparent;
213
+ }
214
+
204
215
  /* ── Zebra striping ──────────────────────────────────────────────────────── */
205
216
 
206
217
  .a1-data-table--zebra tbody tr:nth-child(even) td {
@@ -0,0 +1,11 @@
1
+ import * as React from "react";
2
+ import { TextFieldProps } from "./TextField.d.ts";
3
+
4
+ export interface NumberFieldProps extends Omit<TextFieldProps, "type"> {
5
+ /** Non-editable prefix rendered before the value at full input size and color (e.g. "$"). */
6
+ prefix?: string;
7
+ /** Non-editable unit label rendered after the value at smaller, muted size (e.g. "lbs", "km", "ft"). */
8
+ unit?: string;
9
+ }
10
+
11
+ export declare function NumberField(props: NumberFieldProps): React.ReactElement;
@@ -1,11 +1,63 @@
1
+ import { useRef, useCallback, useState, forwardRef } from "react";
1
2
  import { TextField } from "./TextField.jsx";
2
3
 
3
- export function NumberField({ className = "", ...props }) {
4
+ export const NumberField = forwardRef(function NumberField(
5
+ { unit, prefix, className = "", onChange, value, defaultValue, style, ...props },
6
+ forwardedRef,
7
+ ) {
8
+ const localRef = useRef(null);
9
+
10
+ const mergedRef = useCallback((el) => {
11
+ localRef.current = el;
12
+ if (typeof forwardedRef === "function") forwardedRef(el);
13
+ else if (forwardedRef) forwardedRef.current = el;
14
+ }, [forwardedRef]);
15
+
16
+ const [uncontrolledLength, setUncontrolledLength] = useState(
17
+ () => Math.max(String(defaultValue ?? "").length, 1),
18
+ );
19
+
20
+ const inputLength = value !== undefined
21
+ ? Math.max(String(value).length || 1, 1)
22
+ : uncontrolledLength;
23
+
24
+ function handleChange(e) {
25
+ if (value === undefined) {
26
+ setUncontrolledLength(Math.max(e.target.value.length || 1, 1));
27
+ }
28
+ onChange?.(e);
29
+ }
30
+
31
+ const overlay = (prefix || unit) ? (
32
+ <>
33
+ {prefix && <span className="a1-field__prefix" aria-hidden="true">{prefix}</span>}
34
+ {unit && (
35
+ <span
36
+ className="a1-field__unit"
37
+ aria-hidden="true"
38
+ onClick={() => localRef.current?.focus()}
39
+ >
40
+ {unit}
41
+ </span>
42
+ )}
43
+ </>
44
+ ) : null;
45
+
4
46
  return (
5
47
  <TextField
48
+ ref={mergedRef}
6
49
  type="number"
7
- className={className}
50
+ className={[
51
+ className,
52
+ prefix && "a1-field--has-prefix",
53
+ unit && "a1-field--has-unit",
54
+ ].filter(Boolean).join(" ")}
55
+ inputOverlay={overlay}
56
+ onChange={unit ? handleChange : onChange}
57
+ value={value}
58
+ defaultValue={defaultValue}
59
+ style={unit ? { "--a1-field-number-width": `${inputLength}ch`, ...style } : style}
8
60
  {...props}
9
61
  />
10
62
  );
11
- }
63
+ });
@@ -279,6 +279,137 @@
279
279
  width: auto;
280
280
  }
281
281
 
282
+ /* ─── Number field prefix (e.g. "$") and unit (e.g. "lbs") ─────────────────── */
283
+ /* The control container becomes the visual field; the input becomes borderless.
284
+ Prefix is reordered before the input via CSS order. Unit follows the input
285
+ as a compact flex child so it sits immediately beside the value. */
286
+
287
+ .a1-field--has-prefix .a1-field__control,
288
+ .a1-field--has-unit .a1-field__control {
289
+ display: flex;
290
+ align-items: center;
291
+ background: var(--a1-field-background);
292
+ border: var(--component-field-border-width) solid var(--a1-field-border-color);
293
+ border-radius: var(--a1-field-border-radius);
294
+ transition:
295
+ border-color var(--semantic-motion-duration-fast),
296
+ background var(--semantic-motion-duration-fast);
297
+ overflow: hidden;
298
+ }
299
+
300
+ /* Input loses its own border/background; container provides them */
301
+ .a1-field--has-prefix .a1-field__input,
302
+ .a1-field--has-unit .a1-field__input {
303
+ border: none;
304
+ background: transparent;
305
+ border-radius: 0;
306
+ outline: none;
307
+ /* suppress the native hover/active border — container handles it */
308
+ box-shadow: none;
309
+ }
310
+
311
+ .a1-field--has-prefix .a1-field__input:hover,
312
+ .a1-field--has-unit .a1-field__input:hover,
313
+ .a1-field--has-prefix .a1-field__input:active,
314
+ .a1-field--has-unit .a1-field__input:active {
315
+ background: transparent;
316
+ border-color: transparent;
317
+ }
318
+
319
+ /* Focus ring moves to container */
320
+ .a1-field--has-prefix .a1-field__control:focus-within,
321
+ .a1-field--has-unit .a1-field__control:focus-within {
322
+ outline: var(--a1-field-focus-ring-width) solid var(--a1-field-focus-ring-color);
323
+ outline-offset: var(--a1-field-focus-ring-offset);
324
+ border-color: var(--semantic-color-action-background);
325
+ }
326
+
327
+ /* Hover on container */
328
+ .a1-field--has-prefix .a1-field__control:hover:not(:focus-within),
329
+ .a1-field--has-unit .a1-field__control:hover:not(:focus-within) {
330
+ background: var(--a1-field-hover-background);
331
+ border-color: var(--a1-field-hover-border-color);
332
+ }
333
+
334
+ /* Disabled: suppress hover on container */
335
+ .a1-field--disabled.a1-field--has-prefix .a1-field__control,
336
+ .a1-field--disabled.a1-field--has-unit .a1-field__control {
337
+ background: var(--semantic-color-surface-raised);
338
+ border-color: var(--semantic-color-border-subtle);
339
+ cursor: not-allowed;
340
+ }
341
+
342
+ /* Error: container */
343
+ .a1-field--error.a1-field--has-prefix .a1-field__control,
344
+ .a1-field--error.a1-field--has-unit .a1-field__control {
345
+ border-color: var(--semantic-color-status-error-border);
346
+ border-left-width: var(--a1-field-accent-border-width);
347
+ border-left-color: var(--semantic-color-status-error-background);
348
+ }
349
+
350
+ .a1-field--error.a1-field--has-prefix .a1-field__control:focus-within,
351
+ .a1-field--error.a1-field--has-unit .a1-field__control:focus-within {
352
+ border-color: var(--semantic-color-status-error-background);
353
+ outline-color: var(--semantic-color-status-error-background);
354
+ }
355
+
356
+ /* Required: container */
357
+ .a1-field--required.a1-field--has-prefix .a1-field__control,
358
+ .a1-field--required.a1-field--has-unit .a1-field__control {
359
+ border-color: var(--semantic-color-status-info-border);
360
+ border-left-width: var(--a1-field-accent-border-width);
361
+ border-left-color: var(--semantic-color-status-info-background);
362
+ }
363
+
364
+ /* ─── Prefix: same size/color as value, visually before the input ────────── */
365
+
366
+ .a1-field__prefix {
367
+ order: -1; /* renders after input in DOM; flex order places it first */
368
+ flex-shrink: 0;
369
+ padding-inline-start: var(--a1-field-padding-inline);
370
+ padding-inline-end: var(--base-spacing-2);
371
+ font-family: var(--component-paragraph-font-family);
372
+ font-size: var(--a1-field-font-size);
373
+ font-weight: var(--base-font-weight-regular);
374
+ color: var(--semantic-color-text-default);
375
+ line-height: 1;
376
+ pointer-events: none;
377
+ user-select: none;
378
+ }
379
+
380
+ .a1-field--has-prefix .a1-field__input {
381
+ flex: 1;
382
+ min-width: 0;
383
+ padding-inline-start: 0;
384
+ }
385
+
386
+ /* ─── Unit: smaller/muted, immediately after the value ───────────────────── */
387
+ /* Unit fills remaining space so clicks anywhere right of the value focus the input. */
388
+
389
+ .a1-field__unit {
390
+ flex: 1;
391
+ padding-inline-start: var(--base-spacing-4);
392
+ padding-inline-end: var(--a1-field-padding-inline);
393
+ font-family: var(--component-paragraph-font-family);
394
+ font-size: var(--semantic-font-size-body-xs);
395
+ color: var(--semantic-color-text-muted);
396
+ line-height: 1;
397
+ pointer-events: auto;
398
+ user-select: none;
399
+ cursor: text;
400
+ }
401
+
402
+ /* Input shrinks to its value so the unit sits right beside it.
403
+ JS sets --a1-field-number-width per keystroke. field-sizing: content
404
+ auto-sizes in Chrome 123+ / Firefox 128+ regardless. */
405
+ .a1-field--has-unit .a1-field__input {
406
+ flex: 0 0 auto;
407
+ width: calc(var(--a1-field-number-width, 4ch) + var(--a1-field-padding-inline));
408
+ min-width: 2ch;
409
+ padding-inline-end: 0;
410
+ field-sizing: content;
411
+ }
412
+
282
413
  /* ─── Mask overlay (PhoneField, ZipField) ─────────────────────────────────── */
283
414
 
284
415
  .a1-field__mask-overlay {
@@ -7,6 +7,8 @@ export interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonEl
7
7
  label: string;
8
8
  /** Visual style. Default: "tertiary" */
9
9
  variant?: "tertiary" | "secondary" | "destructive" | "success";
10
+ /** Button size. "lg" matches Button's large touch target (3.5rem) and icon size, suitable for pairing with large Buttons. Default: "md" */
11
+ size?: "md" | "lg";
10
12
  disabled?: boolean;
11
13
  onClick?: React.MouseEventHandler<HTMLButtonElement>;
12
14
  }
@@ -2,20 +2,24 @@ import "./icon-button.css";
2
2
  import { Icon } from "../icon/Icon.jsx";
3
3
 
4
4
  const variants = ["tertiary", "secondary", "destructive", "success"];
5
+ const sizes = ["md", "lg"];
5
6
 
6
7
  export function IconButton({
7
8
  icon,
8
9
  label,
9
10
  variant = "tertiary",
11
+ size,
10
12
  disabled = false,
11
13
  onClick,
12
14
  className = "",
13
15
  ...props
14
16
  }) {
15
17
  const resolvedVariant = variants.includes(variant) ? variant : "tertiary";
18
+ const resolvedSize = sizes.includes(size) ? size : null;
16
19
  const classes = [
17
20
  "a1-icon-button",
18
21
  `a1-icon-button--${resolvedVariant}`,
22
+ resolvedSize === "lg" && "a1-icon-button--large",
19
23
  className,
20
24
  ].filter(Boolean).join(" ");
21
25
 
@@ -8,7 +8,7 @@
8
8
  align-items: center;
9
9
  justify-content: center;
10
10
  flex-shrink: 0;
11
- height: var(--component-icon-button-size);
11
+ height: var(--a1-icon-button-size, var(--component-icon-button-size));
12
12
  aspect-ratio: 1;
13
13
  padding: 0;
14
14
  border-radius: var(--component-icon-button-border-radius);
@@ -33,8 +33,17 @@
33
33
  }
34
34
 
35
35
  .a1-icon-button .a1-icon {
36
- font-size: var(--component-icon-button-icon-size);
37
- --a1-icon-opsz: var(--component-icon-button-icon-optical-size);
36
+ font-size: var(--a1-icon-button-icon-size, var(--component-icon-button-icon-size));
37
+ --a1-icon-opsz: var(--a1-icon-button-icon-opsz, var(--component-icon-button-icon-optical-size));
38
+ }
39
+
40
+ /* ── Size: large ─────────────────────────────────────────────────────────── */
41
+ /* Touch target matches Button lg (3.5rem); icon matches Button lg's icon. */
42
+
43
+ .a1-icon-button--large {
44
+ --a1-icon-button-size: var(--component-button-large-height);
45
+ --a1-icon-button-icon-size: var(--component-button-icon-size);
46
+ --a1-icon-button-icon-opsz: var(--component-button-icon-optical-size);
38
47
  }
39
48
 
40
49
  .a1-icon-button:focus-visible {
@@ -14,7 +14,7 @@ export interface SectionProps extends React.HTMLAttributes<HTMLElement> {
14
14
  /** Background surface treatment */
15
15
  surface?: "page" | "panel" | "raised";
16
16
  /** Gap between direct children */
17
- gap?: "xs" | "sm" | "md" | "lg";
17
+ gap?: "xs" | "sm" | "md" | "lg" | "xl";
18
18
  /** Gradient overlay colour */
19
19
  gradient?: "accent" | "highlight" | "info" | "success" | "warn";
20
20
  /** Gradient origin. Default: "center" */
@@ -4,7 +4,7 @@ import "./section.css";
4
4
 
5
5
  const VALID_PADDING = ["lg", "md", "sm", "xs", "none"];
6
6
  const VALID_SURFACES = ["page", "panel", "raised"];
7
- const VALID_GAPS = ["xs", "sm", "md", "lg"];
7
+ const VALID_GAPS = ["xs", "sm", "md", "lg", "xl"];
8
8
  const VALID_GRADIENTS = ["accent", "highlight", "info", "success", "warn"];
9
9
  const VALID_GRADIENT_POSITIONS = [
10
10
  "top",
@@ -68,7 +68,8 @@
68
68
  .a1-section--gap-xs,
69
69
  .a1-section--gap-sm,
70
70
  .a1-section--gap-md,
71
- .a1-section--gap-lg {
71
+ .a1-section--gap-lg,
72
+ .a1-section--gap-xl {
72
73
  display: grid;
73
74
  justify-items: var(--a1-section-justify-items);
74
75
  }
@@ -77,6 +78,7 @@
77
78
  .a1-section--gap-sm { gap: var(--semantic-spacing-gap-sm); }
78
79
  .a1-section--gap-md { gap: var(--semantic-spacing-gap-md); }
79
80
  .a1-section--gap-lg { gap: var(--semantic-spacing-gap-lg); }
81
+ .a1-section--gap-xl { gap: var(--semantic-spacing-gap-xl); }
80
82
 
81
83
  /* ── Height ────────────────────────────────────────────────────────────────── */
82
84
 
@@ -4,7 +4,7 @@ type Breakpoints = "xs" | "sm" | "md" | "lg" | "xl";
4
4
  type Direction = "column" | "column-reverse" | "row" | "row-reverse";
5
5
  type Justify = "start" | "center" | "end" | "between" | "around" | "evenly";
6
6
  type Align = "stretch" | "start" | "center" | "end" | "baseline";
7
- type SemanticGap = "xs" | "sm" | "md" | "lg";
7
+ type SemanticGap = "xs" | "sm" | "md" | "lg" | "xl";
8
8
  type SpacingToken = 1 | 2 | 4 | 6 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 64 | 96 | 128;
9
9
 
10
10
  export interface StackProps extends React.HTMLAttributes<HTMLElement> {
@@ -4,7 +4,7 @@ import { resolveSpacing } from "../structure-utils.js";
4
4
  const directions = ["column", "column-reverse", "row", "row-reverse"];
5
5
  const alignments = ["stretch", "start", "center", "end", "baseline"];
6
6
  const justifications = ["start", "center", "end", "between", "around", "evenly"];
7
- const semanticGaps = ["xs", "sm", "md", "lg"];
7
+ const semanticGaps = ["xs", "sm", "md", "lg", "xl"];
8
8
  const breakpoints = ["xs", "sm", "md", "lg", "xl"];
9
9
 
10
10
  function resolveGap(gap) {
@@ -0,0 +1,17 @@
1
+ import * as React from "react";
2
+
3
+ export interface StepTrackerProps extends React.HTMLAttributes<HTMLDivElement> {
4
+ /** Total number of steps. */
5
+ steps: number;
6
+ /** 1-indexed position of the current step. Default: 1. */
7
+ currentStep?: number;
8
+ /**
9
+ * Horizontal alignment of the tracker within its container.
10
+ * "left" | "center" | "right" — groups items together.
11
+ * "full" — active pill expands to fill remaining space.
12
+ * Default: "left".
13
+ */
14
+ align?: "left" | "center" | "right" | "full";
15
+ }
16
+
17
+ export declare function StepTracker(props: StepTrackerProps): React.ReactElement;
@@ -0,0 +1,41 @@
1
+ import "./step-tracker.css";
2
+
3
+ const ALIGNS = ["left", "center", "right", "full"];
4
+
5
+ export function StepTracker({
6
+ steps,
7
+ currentStep = 1,
8
+ align = "left",
9
+ className = "",
10
+ ...props
11
+ }) {
12
+ const resolvedAlign = ALIGNS.includes(align) ? align : "left";
13
+ const total = Math.max(1, steps);
14
+ const current = Math.min(total, Math.max(1, currentStep));
15
+
16
+ const classes = [
17
+ "a1-step-tracker",
18
+ resolvedAlign !== "left" && `a1-step-tracker--${resolvedAlign}`,
19
+ className,
20
+ ].filter(Boolean).join(" ");
21
+
22
+ return (
23
+ <div
24
+ className={classes}
25
+ role="img"
26
+ aria-label={`Step ${current} of ${total}`}
27
+ {...props}
28
+ >
29
+ {Array.from({ length: total }, (_, i) => (
30
+ <div
31
+ key={i}
32
+ className={[
33
+ "a1-step-tracker__step",
34
+ i + 1 === current && "a1-step-tracker__step--current",
35
+ ].filter(Boolean).join(" ")}
36
+ aria-hidden="true"
37
+ />
38
+ ))}
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,37 @@
1
+ .a1-step-tracker {
2
+ --a1-st-dot-size: var(--component-step-tracker-dot-size);
3
+ --a1-st-active-width: var(--component-step-tracker-active-width);
4
+ --a1-st-dot-color: var(--semantic-color-border-strong);
5
+ --a1-st-active-color: var(--semantic-color-text-default);
6
+ --a1-st-gap: var(--component-step-tracker-gap);
7
+
8
+ display: flex;
9
+ align-items: center;
10
+ gap: var(--a1-st-gap);
11
+ }
12
+
13
+ /* ─── Alignment ───────────────────────────────────────────────────────────── */
14
+
15
+ .a1-step-tracker--center { justify-content: center; }
16
+ .a1-step-tracker--right { justify-content: flex-end; }
17
+
18
+ /* full: dots stay fixed, active pill expands to fill remaining space */
19
+ .a1-step-tracker--full .a1-step-tracker__step--current {
20
+ flex: 1;
21
+ }
22
+
23
+ /* ─── Step ────────────────────────────────────────────────────────────────── */
24
+
25
+ .a1-step-tracker__step {
26
+ block-size: var(--a1-st-dot-size);
27
+ inline-size: var(--a1-st-dot-size);
28
+ border-radius: 9999px;
29
+ background: var(--a1-st-dot-color);
30
+ flex-shrink: 0;
31
+ transition: inline-size var(--semantic-motion-duration-fast, 150ms) var(--semantic-motion-easing-standard, ease);
32
+ }
33
+
34
+ .a1-step-tracker__step--current {
35
+ inline-size: var(--a1-st-active-width);
36
+ background: var(--a1-st-active-color);
37
+ }
package/src/index.js CHANGED
@@ -7,6 +7,7 @@ export { Notification } from "./components/notification/Notification.jsx";
7
7
  export { Snackbar } from "./components/snackbar/Snackbar.jsx";
8
8
  export { StatusBar } from "./components/status-bar/StatusBar.jsx";
9
9
  export { CircularProgress } from "./components/circular-progress/CircularProgress.jsx";
10
+ export { StepTracker } from "./components/step-tracker/StepTracker.jsx";
10
11
  export { Bleed } from "./components/bleed/Bleed.jsx";
11
12
  export { IconButton } from "./components/icon-button/IconButton.jsx";
12
13
  export { Button } from "./components/button/Button.jsx";export { ButtonContainer } from "./components/button-container/ButtonContainer.jsx";
package/src/themes.css CHANGED
@@ -295,10 +295,10 @@ html.a1-theme-catlympics.a1-theme-light, html.a1-theme-light.a1-theme-catlympics
295
295
 
296
296
  /* ────────────────────────────────────────────────────────────
297
297
  Fresh
298
- A crisp, airy theme with sky-blue accents, Nunito body text (ExtraBold headings), Baskerville display typography, and a cool mint gradient background. Apply [data-theme='fresh'] to <html>.
298
+ A crisp, airy theme with sky-blue accents, Nunito body text (ExtraBold headings), Baskerville display typography, and a cool mint gradient background. Apply class="a1-theme-fresh" to <html>.
299
299
  ──────────────────────────────────────────────────────────── */
300
300
 
301
- [data-theme='fresh'] {
301
+ html.a1-theme-fresh {
302
302
  --base-color-accent-0: #FAFCFF;
303
303
  --base-color-accent-50: #ECF3FE;
304
304
  --base-color-accent-100: #D8E8FD;
@@ -432,8 +432,6 @@ html.a1-theme-catlympics.a1-theme-light, html.a1-theme-light.a1-theme-catlympics
432
432
  --component-paragraph-font-weight: var(--theme-a1-fresh-font-weight-body);
433
433
  --component-heading-font-weight-heading: var(--theme-a1-fresh-font-weight-heading);
434
434
  --component-heading-font-weight-display: var(--theme-a1-fresh-font-weight-display);
435
- background-image: linear-gradient(207.43deg, #D7FFF8 0%, #CDF5EE 57.44%);
436
- background-attachment: fixed;
437
435
  }
438
436
 
439
437
 
package/src/tokens.css CHANGED
@@ -234,6 +234,7 @@
234
234
  --semantic-spacing-gap-sm: 0.75rem;
235
235
  --semantic-spacing-gap-md: 1rem;
236
236
  --semantic-spacing-gap-lg: 1.5rem;
237
+ --semantic-spacing-gap-xl: 2.5rem;
237
238
  --semantic-font-size-body-xs: 0.75rem;
238
239
  --semantic-font-size-body-sm: 0.875rem;
239
240
  --semantic-font-size-body-md: 1rem;
@@ -679,6 +680,11 @@
679
680
  --component-status-bar-fill-background: #7c3aed;
680
681
  --component-status-bar-label-gap: 0.5rem;
681
682
  --component-status-bar-indeterminate-duration: 1400ms;
683
+ --component-step-tracker-dot-size: 0.5rem;
684
+ --component-step-tracker-dot-color: #64748b;
685
+ --component-step-tracker-active-width: 2rem;
686
+ --component-step-tracker-active-color: #060b14;
687
+ --component-step-tracker-gap: 0.375rem;
682
688
  --component-switch-track-width: 2.5rem;
683
689
  --component-switch-track-height: 1.375rem;
684
690
  --component-switch-thumb-size: 1rem;