@gtivr4/a1-design-system-react 0.3.1 → 0.4.1

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.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "React components for the A1 token-driven design system.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -42,6 +42,12 @@ export function Calendar({
42
42
  dimPast = true,
43
43
  variant = "scroll",
44
44
  todayButton = false,
45
+ selectable = false,
46
+ selectedDate,
47
+ defaultSelectedDate,
48
+ onChange,
49
+ minDate,
50
+ maxDate,
45
51
  className = "",
46
52
  ...props
47
53
  }) {
@@ -63,6 +69,35 @@ export function Calendar({
63
69
  const [viewMonth, setViewMonth] = useState(centerMonth);
64
70
  const currentMonthRef = useRef(null);
65
71
 
72
+ // Selection — controlled when selectedDate is provided, otherwise internal state
73
+ const isControlled = selectedDate !== undefined;
74
+ const [internalSelected, setInternalSelected] = useState(defaultSelectedDate ?? null);
75
+ const selected = isControlled ? selectedDate : internalSelected;
76
+
77
+ function isSameDay(d, y, m, day) {
78
+ return d instanceof Date && d.getFullYear() === y && d.getMonth() === m && d.getDate() === day;
79
+ }
80
+
81
+ function isDayDisabled(y, m, day) {
82
+ const date = new Date(y, m, day);
83
+ if (minDate) {
84
+ const min = new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate());
85
+ if (date < min) return true;
86
+ }
87
+ if (maxDate) {
88
+ const max = new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate());
89
+ if (date > max) return true;
90
+ }
91
+ return false;
92
+ }
93
+
94
+ function handleDayClick(y, m, day) {
95
+ if (isDayDisabled(y, m, day)) return;
96
+ const date = new Date(y, m, day);
97
+ if (!isControlled) setInternalSelected(date);
98
+ onChange?.(date);
99
+ }
100
+
66
101
  // ── Localised strings ─────────────────────────────────────────
67
102
 
68
103
  // Month names
@@ -210,15 +245,30 @@ export function Calendar({
210
245
  dimPast &&
211
246
  new Date(year, month, day) < new Date(todayYear, todayMonth, todayDay);
212
247
 
248
+ const isSelected = isSameDay(selected, year, month, day);
249
+ const isDisabled = isDayDisabled(year, month, day);
250
+
213
251
  return (
214
252
  <td
215
253
  key={di}
216
254
  className={[
217
255
  "a1-calendar__day",
218
- isToday && "a1-calendar__day--today",
219
- isPast && "a1-calendar__day--past",
256
+ isToday && "a1-calendar__day--today",
257
+ isPast && "a1-calendar__day--past",
258
+ isSelected && "a1-calendar__day--selected",
259
+ isDisabled && "a1-calendar__day--disabled",
220
260
  ].filter(Boolean).join(" ")}
221
261
  aria-current={isToday ? "date" : undefined}
262
+ aria-selected={isSelected ? true : undefined}
263
+ aria-disabled={isDisabled ? true : undefined}
264
+ tabIndex={selectable && !isDisabled ? 0 : undefined}
265
+ onClick={selectable && !isDisabled ? () => handleDayClick(year, month, day) : undefined}
266
+ onKeyDown={selectable && !isDisabled ? (e) => {
267
+ if (e.key === "Enter" || e.key === " ") {
268
+ e.preventDefault();
269
+ handleDayClick(year, month, day);
270
+ }
271
+ } : undefined}
222
272
  >
223
273
  <span className="a1-calendar__day-number" aria-hidden="true">
224
274
  {day}
@@ -235,17 +285,27 @@ export function Calendar({
235
285
 
236
286
  // ── Paginated variant ─────────────────────────────────────────
237
287
  if (variant === "paginated") {
238
- const yearStart = todayYear - 10;
239
- const yearEnd = todayYear + 15;
288
+ const minYear = minDate ? minDate.getFullYear() : todayYear - 10;
289
+ const minMon = minDate ? minDate.getMonth() : 0;
290
+ const maxYear = maxDate ? maxDate.getFullYear() : todayYear + 15;
291
+ const maxMon = maxDate ? maxDate.getMonth() : 11;
292
+
293
+ const yearStart = minDate ? minDate.getFullYear() : todayYear - 10;
294
+ const yearEnd = maxDate ? maxDate.getFullYear() : todayYear + 15;
240
295
  const years = Array.from({ length: yearEnd - yearStart + 1 }, (_, i) => yearStart + i);
241
296
 
297
+ const prevAtMin = viewYear === minYear && viewMonth === minMon;
298
+ const nextAtMax = viewYear === maxYear && viewMonth === maxMon;
299
+
242
300
  const goPrev = () => {
301
+ if (prevAtMin) return;
243
302
  const p = addMonths(viewYear, viewMonth, -1);
244
303
  setViewYear(p.year);
245
304
  setViewMonth(p.month);
246
305
  };
247
306
 
248
307
  const goNext = () => {
308
+ if (nextAtMax) return;
249
309
  const n = addMonths(viewYear, viewMonth, 1);
250
310
  setViewYear(n.year);
251
311
  setViewMonth(n.month);
@@ -263,19 +323,19 @@ export function Calendar({
263
323
 
264
324
  return (
265
325
  <div
266
- className={["a1-calendar", "a1-calendar--paginated", className].filter(Boolean).join(" ")}
326
+ className={["a1-calendar", "a1-calendar--paginated", selectable && "a1-calendar--selectable", className].filter(Boolean).join(" ")}
267
327
  {...props}
268
328
  >
269
329
  <div className="a1-calendar__inner">
270
330
  <div className="a1-calendar__nav">
271
331
 
272
332
  <span className="a1-calendar__nav-wide">
273
- <Button variant="tertiary" size="sm" icon={isRtl ? "chevron_right" : "chevron_left"} iconPosition="start" onClick={goPrev}>
333
+ <Button variant="tertiary" size="sm" icon={isRtl ? "chevron_right" : "chevron_left"} iconPosition="start" onClick={goPrev} disabled={prevAtMin}>
274
334
  {prevMonthLabel}
275
335
  </Button>
276
336
  </span>
277
337
  <span className="a1-calendar__nav-narrow">
278
- <IconButton icon={isRtl ? "chevron_right" : "chevron_left"} label={prevMonthLabel} variant="tertiary" onClick={goPrev} />
338
+ <IconButton icon={isRtl ? "chevron_right" : "chevron_left"} label={prevMonthLabel} variant="tertiary" onClick={goPrev} disabled={prevAtMin} />
279
339
  </span>
280
340
 
281
341
  <div className="a1-calendar__nav-label">
@@ -286,9 +346,12 @@ export function Calendar({
286
346
  value={viewMonth}
287
347
  onChange={e => setViewMonth(Number(e.target.value))}
288
348
  >
289
- {MONTHS.map((name, i) => (
290
- <option key={i} value={i}>{name}</option>
291
- ))}
349
+ {MONTHS.map((name, i) => {
350
+ const disabled =
351
+ (viewYear === minYear && i < minMon) ||
352
+ (viewYear === maxYear && i > maxMon);
353
+ return <option key={i} value={i} disabled={disabled}>{name}</option>;
354
+ })}
292
355
  </SelectField>
293
356
  <SelectField
294
357
  className="a1-calendar__nav-field"
@@ -327,12 +390,12 @@ export function Calendar({
327
390
  </div>
328
391
 
329
392
  <span className="a1-calendar__nav-wide">
330
- <Button variant="tertiary" size="sm" icon={isRtl ? "chevron_left" : "chevron_right"} iconPosition="end" onClick={goNext}>
393
+ <Button variant="tertiary" size="sm" icon={isRtl ? "chevron_left" : "chevron_right"} iconPosition="end" onClick={goNext} disabled={nextAtMax}>
331
394
  {nextMonthLabel}
332
395
  </Button>
333
396
  </span>
334
397
  <span className="a1-calendar__nav-narrow">
335
- <IconButton icon={isRtl ? "chevron_left" : "chevron_right"} label={nextMonthLabel} variant="tertiary" onClick={goNext} />
398
+ <IconButton icon={isRtl ? "chevron_left" : "chevron_right"} label={nextMonthLabel} variant="tertiary" onClick={goNext} disabled={nextAtMax} />
336
399
  </span>
337
400
 
338
401
  </div>
@@ -351,7 +414,7 @@ export function Calendar({
351
414
 
352
415
  return (
353
416
  <div
354
- className={["a1-calendar", className].filter(Boolean).join(" ")}
417
+ className={["a1-calendar", selectable && "a1-calendar--selectable", className].filter(Boolean).join(" ")}
355
418
  {...props}
356
419
  >
357
420
  <div className="a1-calendar__inner">
@@ -110,6 +110,50 @@
110
110
  background: var(--semantic-color-surface-raised);
111
111
  }
112
112
 
113
+ /* ── Selected — full-cell fill (display and selectable modes) ─── */
114
+
115
+ .a1-calendar__day--selected {
116
+ background: var(--semantic-color-action-background);
117
+ color: var(--semantic-color-text-inverse);
118
+ }
119
+
120
+ /* ── Disabled (out of range) — same surface as past dates ──────── */
121
+
122
+ .a1-calendar__day--disabled {
123
+ background: var(--semantic-color-surface-raised);
124
+ }
125
+
126
+ /* ── Selectable mode (opt-in via selectable prop) ──────────────── */
127
+
128
+ .a1-calendar--selectable .a1-calendar__day:not(.a1-calendar__day--empty):not(.a1-calendar__day--disabled) {
129
+ cursor: pointer;
130
+ }
131
+ .a1-calendar--selectable .a1-calendar__day--disabled {
132
+ cursor: not-allowed;
133
+ }
134
+
135
+ /* Today — neutral ring instead of action fill (yields to selected) */
136
+ .a1-calendar--selectable .a1-calendar__day--today:not(.a1-calendar__day--selected) {
137
+ background: transparent;
138
+ color: var(--semantic-color-text-default);
139
+ }
140
+ .a1-calendar--selectable .a1-calendar__day--today:not(.a1-calendar__day--selected) .a1-calendar__day-number {
141
+ outline: 2px solid var(--semantic-color-text-default);
142
+ outline-offset: -1px;
143
+ }
144
+
145
+ /* Full-cell hover for non-selected, non-disabled days */
146
+ .a1-calendar--selectable .a1-calendar__day:not(.a1-calendar__day--empty):not(.a1-calendar__day--disabled):not(.a1-calendar__day--selected):hover {
147
+ background: var(--semantic-color-surface-panel);
148
+ color: var(--semantic-color-text-default);
149
+ }
150
+
151
+ /* Focus ring */
152
+ .a1-calendar--selectable .a1-calendar__day:focus-visible {
153
+ outline: var(--component-field-focus-ring-width) solid var(--component-field-focus-ring-color);
154
+ outline-offset: -2px;
155
+ }
156
+
113
157
  /* ── Paginated variant — nav bar ─────────────────────────── */
114
158
 
115
159
  .a1-calendar__nav {
@@ -178,6 +222,33 @@
178
222
  color: var(--semantic-color-text-inverse);
179
223
  }
180
224
 
225
+ /* Selected: revert to circle in narrow view */
226
+ .a1-calendar__day--selected {
227
+ background: transparent;
228
+ color: var(--semantic-color-text-default);
229
+ }
230
+ .a1-calendar__day--selected .a1-calendar__day-number {
231
+ background: var(--semantic-color-action-background);
232
+ color: var(--semantic-color-text-inverse);
233
+ }
234
+
235
+ /* Selectable: today ring (suppress action-bg circle from non-selectable narrow rule) */
236
+ .a1-calendar--selectable .a1-calendar__day--today:not(.a1-calendar__day--selected) .a1-calendar__day-number {
237
+ background: transparent;
238
+ color: var(--semantic-color-text-default);
239
+ outline: 2px solid var(--semantic-color-text-default);
240
+ outline-offset: -1px;
241
+ }
242
+
243
+ /* Selectable: hover as circle in narrow */
244
+ .a1-calendar--selectable .a1-calendar__day:not(.a1-calendar__day--empty):not(.a1-calendar__day--disabled):not(.a1-calendar__day--selected):hover {
245
+ background: transparent;
246
+ color: var(--semantic-color-text-default);
247
+ }
248
+ .a1-calendar--selectable .a1-calendar__day:not(.a1-calendar__day--empty):not(.a1-calendar__day--disabled):not(.a1-calendar__day--selected):hover .a1-calendar__day-number {
249
+ background: var(--semantic-color-surface-panel);
250
+ }
251
+
181
252
  .a1-calendar__nav {
182
253
  padding-block: var(--component-calendar-heading-padding-block-compact);
183
254
  }
@@ -16,6 +16,7 @@ export function ChoiceGroup({
16
16
  size = "default",
17
17
  columns,
18
18
  multiple = false,
19
+ inlineIcon = false,
19
20
  required = false,
20
21
  name,
21
22
  options = [],
@@ -76,6 +77,7 @@ export function ChoiceGroup({
76
77
  "a1-choice-group",
77
78
  resolvedSize !== "default" && `a1-choice-group--${resolvedSize}`,
78
79
  multiple ? "a1-choice-group--multiple" : "a1-choice-group--single",
80
+ inlineIcon && "a1-choice-group--inline-icon",
79
81
  isFixedColumns && "a1-choice-group--fixed-columns",
80
82
  responsiveClass,
81
83
  error && "a1-choice-group--error",
@@ -317,6 +317,17 @@
317
317
  background-size: 80%;
318
318
  }
319
319
 
320
+ /* ─── Inline icon layout ────────────────────────────────────────────────────── */
321
+
322
+ .a1-choice-group--inline-icon .a1-choice-item {
323
+ flex-direction: row;
324
+ align-items: center;
325
+ }
326
+
327
+ .a1-choice-group--inline-icon .a1-choice-item__icon {
328
+ flex-shrink: 0;
329
+ }
330
+
320
331
  /* ─── Error state ───────────────────────────────────────────────────────────── */
321
332
 
322
333
  .a1-choice-group--error
@@ -22,8 +22,8 @@ import "./data-table.css";
22
22
  * }>
23
23
  * rows: Array<Record<string, any>>
24
24
  * getRowId?: (row: Record<string, any>, index: number) => string | number
25
- * density?: "auto" | "comfortable" | "default" | "compact"
26
- * "auto" (default) — switches between densities based on available width and column definitions
25
+ * size?: "comfortable" | "default" | "compact"
26
+ * omit (default) — switches between densities automatically based on available container width
27
27
  */
28
28
 
29
29
  // Estimated minimum content width per column type at a "neutral" padding level
@@ -193,7 +193,7 @@ function SelectionCheckbox({ checked, indeterminate = false, label, onChange })
193
193
  export function DataTable({
194
194
  columns = [],
195
195
  rows = [],
196
- density = "default",
196
+ size,
197
197
  zebra = false,
198
198
  scrollable = false,
199
199
  caption,
@@ -238,14 +238,14 @@ export function DataTable({
238
238
  const [internalSearchColumn, setInternalSearchColumn] = useState(defaultSearchColumn);
239
239
  const [internalSelectedRowIds, setInternalSelectedRowIds] = useState(() => normalizeRowIds(defaultSelectedRowIds));
240
240
 
241
- const isAuto = density === "auto";
241
+ const isAuto = size === undefined;
242
242
  const isSortControlled = sort !== undefined;
243
243
  const isPageControlled = page !== undefined;
244
244
  const isFilterControlled = filterValue !== undefined;
245
245
  const isSearchControlled = searchValue !== undefined;
246
246
  const isSearchColumnControlled = searchColumn !== undefined;
247
247
  const isSelectionControlled = selectedRowIds !== undefined;
248
- const activeDensity = isAuto ? autoDensity : density;
248
+ const activeDensity = isAuto ? autoDensity : size;
249
249
  const activeSort = isSortControlled ? normalizeSort(sort) : internalSort;
250
250
  const activePage = isPageControlled ? page : internalPage;
251
251
  const activeFilterValue = isFilterControlled
@@ -6,7 +6,7 @@ import { FieldsetContext } from "../fieldset/FieldsetContext.js";
6
6
  import "./field.css";
7
7
 
8
8
  const SIZES = ["comfortable", "default", "compact"];
9
- const LABEL_POSITIONS = ["above", "side"];
9
+ const LABEL_POSITIONS = ["above", "before"];
10
10
 
11
11
  export const SelectField = forwardRef(function SelectField({
12
12
  label,
@@ -5,7 +5,7 @@ import { FieldsetContext } from "../fieldset/FieldsetContext.js";
5
5
  import "./field.css";
6
6
 
7
7
  const SIZES = ["comfortable", "default", "compact"];
8
- const LABEL_POSITIONS = ["above", "side"];
8
+ const LABEL_POSITIONS = ["above", "before"];
9
9
 
10
10
  export const TextField = forwardRef(function TextField({
11
11
  label,
@@ -6,7 +6,7 @@ import "./field.css";
6
6
  import "./textarea-field.css";
7
7
 
8
8
  const SIZES = ["comfortable", "default", "compact"];
9
- const LABEL_POSITIONS = ["above", "side"];
9
+ const LABEL_POSITIONS = ["above", "before"];
10
10
  const ROW_SIZES = { sm: 2, md: 4, lg: 8, xl: 12 };
11
11
 
12
12
  function resolveRows(rows) {
@@ -336,9 +336,9 @@
336
336
  color: var(--semantic-color-status-error-background);
337
337
  }
338
338
 
339
- /* ─── Side label layout ────────────────────────────────────────────────────── */
339
+ /* ─── Before label layout ───────────────────────────────────────────────────── */
340
340
 
341
- .a1-field--label-side {
341
+ .a1-field--label-before {
342
342
  display: grid;
343
343
  grid-template-columns: var(--a1-field-side-label-width) 1fr;
344
344
  column-gap: var(--base-spacing-16);
@@ -346,12 +346,12 @@
346
346
  align-items: start;
347
347
  }
348
348
 
349
- .a1-field--label-side .a1-field__label {
349
+ .a1-field--label-before .a1-field__label {
350
350
  padding-top: calc((var(--a1-field-height) - 1lh) / 2);
351
351
  padding-bottom: 0;
352
352
  }
353
353
 
354
- .a1-field--label-side .a1-field__message {
354
+ .a1-field--label-before .a1-field__message {
355
355
  grid-column: 1;
356
356
  grid-row: 2;
357
357
  /* Fine-tuned margin: overrides the base margin-top added for the above-label
@@ -359,22 +359,22 @@
359
359
  margin-top: calc((var(--a1-field-label-size) * 1.5 - var(--a1-field-height)) / 2 + var(--base-spacing-2));
360
360
  }
361
361
 
362
- /* ─── Side label → stacked on xs/sm ───────────────────────────────────────── */
362
+ /* ─── Before label → stacked on xs/sm ─────────────────────────────────────── */
363
363
 
364
364
  @media (--bp-sm-down) {
365
- .a1-field--label-side {
365
+ .a1-field--label-before {
366
366
  display: flex;
367
367
  flex-direction: column;
368
368
  gap: var(--a1-field-gap); /* restore: grid overrode row-gap to base-spacing-4 */
369
369
  align-items: stretch; /* restore: grid set align-items: start */
370
370
  }
371
371
 
372
- .a1-field--label-side .a1-field__label {
372
+ .a1-field--label-before .a1-field__label {
373
373
  padding-top: 0;
374
374
  padding-bottom: var(--a1-field-gap);
375
375
  }
376
376
 
377
- .a1-field--label-side .a1-field__message {
377
+ .a1-field--label-before .a1-field__message {
378
378
  margin-top: var(--a1-field-gap);
379
379
  }
380
380
  }
@@ -5,9 +5,9 @@ import "./field-row.css";
5
5
  export function FieldRow({ children, className = "", ...props }) {
6
6
  const ctx = useContext(FieldsetContext);
7
7
 
8
- // Side-label fields already use an internal two-column grid;
8
+ // Before-label fields already use an internal two-column grid;
9
9
  // stacking the row prevents layout conflicts.
10
- const stacked = ctx?.labelPosition === "side";
10
+ const stacked = ctx?.labelPosition === "before";
11
11
 
12
12
  const classes = [
13
13
  "a1-field-row",
@@ -2,7 +2,7 @@ import { FieldsetContext } from "./FieldsetContext.js";
2
2
  import "./fieldset.css";
3
3
 
4
4
  const SIZES = ["comfortable", "default", "compact"];
5
- const LABEL_POSITIONS = ["above", "side"];
5
+ const LABEL_POSITIONS = ["above", "before"];
6
6
 
7
7
  export function Fieldset({
8
8
  legend,
@@ -2,6 +2,7 @@ import "./grid.css";
2
2
 
3
3
  const SPACING_KEYS = [1, 2, 4, 6, 8, 12, 16, 20, 24, 32, 40, 64, 96, 128];
4
4
  const gapSizes = {
5
+ xs: "var(--base-spacing-4)",
5
6
  sm: "var(--base-spacing-8)",
6
7
  md: "var(--base-spacing-16)",
7
8
  lg: "var(--base-spacing-24)",
@@ -7,7 +7,7 @@ const margins = ["sm", "md", "lg"];
7
7
  const levels = ["h1", "h2", "h3", "h4", "h5", "h6"];
8
8
  const breakpoints = ["xs", "sm", "md", "lg", "xl"];
9
9
  const textWraps = ["balance"];
10
- const aligns = ["left", "center", "right"];
10
+ const aligns = ["left", "center", "right", "start", "end"];
11
11
 
12
12
  const levelDefaults = { h1: "xl", h2: "lg", h3: "md", h4: "sm", h5: "xs", h6: "xs" };
13
13
 
@@ -88,6 +88,8 @@
88
88
  .a1-heading--align-left { text-align: start; }
89
89
  .a1-heading--align-center { text-align: center; }
90
90
  .a1-heading--align-right { text-align: end; }
91
+ .a1-heading--align-start { text-align: start; }
92
+ .a1-heading--align-end { text-align: end; }
91
93
 
92
94
  /* Expressive marks */
93
95
  .a1-heading-mark {
@@ -1,6 +1,6 @@
1
1
  import "./notification.css";
2
2
 
3
- const variants = ["default", "error", "success", "warn", "info"];
3
+ const statuses = ["neutral", "error", "success", "warn", "info"];
4
4
  const positions = ["top-right", "top-left", "bottom-right", "bottom-left"];
5
5
 
6
6
  function formatCount(n, max) {
@@ -15,11 +15,11 @@ export function Notification({
15
15
  count,
16
16
  label,
17
17
  dot = false,
18
- variant = "default",
18
+ status = "neutral",
19
19
  position = "top-right",
20
20
  max = 99,
21
21
  }) {
22
- const resolvedVariant = variants.includes(variant) ? variant : "default";
22
+ const resolvedStatus = statuses.includes(status) ? status : "neutral";
23
23
  const resolvedPosition = positions.includes(position) ? position : "top-right";
24
24
 
25
25
  const isDot = dot || (count === undefined && label === undefined);
@@ -35,7 +35,7 @@ export function Notification({
35
35
 
36
36
  const classes = [
37
37
  "a1-notification",
38
- `a1-notification--${resolvedVariant}`,
38
+ `a1-notification--${resolvedStatus}`,
39
39
  `a1-notification--${resolvedPosition}`,
40
40
  isDot && "a1-notification--dot",
41
41
  ]
@@ -43,7 +43,7 @@
43
43
 
44
44
  /* ── Variants ───────────────────────────────────────────────────────────── */
45
45
 
46
- .a1-notification--default {
46
+ .a1-notification--neutral {
47
47
  --a1-notification-background: var(--base-color-neutral-600);
48
48
  --a1-notification-foreground: var(--base-color-neutral-0);
49
49
  }
@@ -4,7 +4,7 @@ const sizes = ["xs", "sm", "md", "lg", "xl"];
4
4
  const colors = ["default", "muted"];
5
5
  const breakpoints = ["xs", "sm", "md", "lg", "xl"];
6
6
  const textWraps = ["balance"];
7
- const aligns = ["left", "center", "right"];
7
+ const aligns = ["left", "center", "right", "start", "end"];
8
8
 
9
9
  function isResponsiveSize(size) {
10
10
  return size && typeof size === "object" && !Array.isArray(size);
@@ -46,6 +46,8 @@
46
46
  .a1-paragraph--align-left { text-align: start; }
47
47
  .a1-paragraph--align-center { text-align: center; }
48
48
  .a1-paragraph--align-right { text-align: end; }
49
+ .a1-paragraph--align-start { text-align: start; }
50
+ .a1-paragraph--align-end { text-align: end; }
49
51
 
50
52
  .a1-paragraph + .a1-paragraph,
51
53
  .a1-paragraph + .a1-heading {
@@ -31,7 +31,7 @@ export function Section({
31
31
  inverse = false,
32
32
  contentWidth,
33
33
  height,
34
- alignment,
34
+ align,
35
35
  className = "",
36
36
  children,
37
37
  ...props
@@ -72,14 +72,14 @@ export function Section({
72
72
  classes.push(`a1-section--height-${height}`);
73
73
  }
74
74
 
75
- if (typeof alignment === "string") {
76
- if (VALID_ALIGNMENTS.includes(alignment)) {
77
- classes.push(`a1-section--align-${alignment}`);
75
+ if (typeof align === "string") {
76
+ if (VALID_ALIGNMENTS.includes(align)) {
77
+ classes.push(`a1-section--align-${align}`);
78
78
  }
79
- } else if (alignment && typeof alignment === "object") {
80
- for (const [bp, align] of Object.entries(alignment)) {
81
- if (VALID_ALIGNMENTS.includes(align)) {
82
- classes.push(`a1-section--${bp}-align-${align}`);
79
+ } else if (align && typeof align === "object") {
80
+ for (const [bp, alignVal] of Object.entries(align)) {
81
+ if (VALID_ALIGNMENTS.includes(alignVal)) {
82
+ classes.push(`a1-section--${bp}-align-${alignVal}`);
83
83
  }
84
84
  }
85
85
  }
@@ -111,7 +111,7 @@
111
111
  /* ── Alignment ─────────────────────────────────────────────────────────────── */
112
112
  /*
113
113
  * Aligns direct children as layout items.
114
- * Use the object prop for responsive changes: alignment={{ xs: "center", lg: "left" }}
114
+ * Use the object prop for responsive changes: align={{ xs: "center", lg: "left" }}
115
115
  */
116
116
 
117
117
  .a1-section[class*="-align-"] {
@@ -206,10 +206,11 @@
206
206
 
207
207
  /* ── Padding — static (with built-in responsive scaling) ───────────────────── */
208
208
  /*
209
- * Three tiers scale across three breakpoints:
209
+ * Four tiers scale across three breakpoints:
210
210
  * lg 96/64 → 96/40 at ≤1024 → 64/24 at ≤640
211
211
  * md 64/40 → 40/24 at ≤1024 → 32/16 at ≤640
212
212
  * sm 32/24 → 24/16 at ≤1024 → 16/12 at ≤640
213
+ * xs 16/16 → 12/12 at ≤1024 → 8/8 at ≤640
213
214
  * Block (top/bottom) / Inline (left/right)
214
215
  */
215
216
 
@@ -42,7 +42,6 @@
42
42
  .a1-segment[aria-checked="true"] {
43
43
  background: var(--semantic-color-surface-page);
44
44
  color: var(--semantic-color-text-default);
45
- font-weight: var(--component-segmented-font-weight-active);
46
45
  box-shadow: var(--semantic-shadow-xs);
47
46
  }
48
47
 
@@ -0,0 +1,92 @@
1
+ import { useId, useState, useEffect } from "react";
2
+ import { Button } from "../button/Button.jsx";
3
+ import { useLabel } from "../labels/Labels.jsx";
4
+ import "./status-bar.css";
5
+
6
+ const SIZES = ["sm", "md", "lg"];
7
+ const POSITIONS = ["above", "below", "before", "after"];
8
+
9
+ export function StatusBar({
10
+ value = 0,
11
+ max = 100,
12
+ label,
13
+ labelPosition = "above",
14
+ size = "md",
15
+ indeterminate = false,
16
+ className = "",
17
+ ...props
18
+ }) {
19
+ const labelId = useId();
20
+
21
+ const pauseLabel = useLabel("statusBar.pause", "Pause");
22
+ const playLabel = useLabel("statusBar.play", "Play");
23
+
24
+ const [paused, setPaused] = useState(false);
25
+ const [showPause, setShowPause] = useState(false);
26
+
27
+ useEffect(() => {
28
+ if (!indeterminate) {
29
+ setShowPause(false);
30
+ setPaused(false);
31
+ return;
32
+ }
33
+ const t = setTimeout(() => setShowPause(true), 3000);
34
+ return () => clearTimeout(t);
35
+ }, [indeterminate]);
36
+
37
+ const resolvedSize = SIZES.includes(size) ? size : "md";
38
+ const resolvedPosition = POSITIONS.includes(labelPosition) ? labelPosition : "above";
39
+
40
+ const pct = Math.min(100, Math.max(0, max > 0 ? (value / max) * 100 : 0));
41
+ const isLabelFirst = resolvedPosition === "above" || resolvedPosition === "before";
42
+
43
+ const classes = [
44
+ "a1-status-bar",
45
+ resolvedSize !== "md" && `a1-status-bar--${resolvedSize}`,
46
+ `a1-status-bar--${resolvedPosition}`,
47
+ indeterminate && "a1-status-bar--indeterminate",
48
+ indeterminate && paused && "a1-status-bar--paused",
49
+ className,
50
+ ].filter(Boolean).join(" ");
51
+
52
+ const labelEl = label ? (
53
+ <span id={labelId} className="a1-status-bar__label">{label}</span>
54
+ ) : null;
55
+
56
+ return (
57
+ <div className={classes}>
58
+ {isLabelFirst && labelEl}
59
+ <div className="a1-status-bar__bar-row">
60
+ <div
61
+ className="a1-status-bar__track"
62
+ role="progressbar"
63
+ aria-valuenow={indeterminate ? undefined : value}
64
+ aria-valuemin={0}
65
+ aria-valuemax={max}
66
+ aria-labelledby={label ? labelId : undefined}
67
+ {...props}
68
+ >
69
+ <div
70
+ className={[
71
+ "a1-status-bar__fill",
72
+ indeterminate && "a1-status-bar__fill--indeterminate",
73
+ ].filter(Boolean).join(" ")}
74
+ style={indeterminate ? undefined : { inlineSize: `${pct}%` }}
75
+ />
76
+ </div>
77
+ {showPause && (
78
+ <Button
79
+ size="sm"
80
+ variant="secondary"
81
+ icon={paused ? "play_arrow" : "pause"}
82
+ className="a1-status-bar__pause"
83
+ onClick={() => setPaused(p => !p)}
84
+ >
85
+ {paused ? playLabel : pauseLabel}
86
+ </Button>
87
+ )}
88
+ </div>
89
+ {!isLabelFirst && labelEl}
90
+ </div>
91
+ );
92
+ }
@@ -0,0 +1,146 @@
1
+ /* ─── StatusBar ─────────────────────────────────────────────────────────────── */
2
+
3
+ .a1-status-bar {
4
+ --a1-sb-height: var(--component-status-bar-md-height);
5
+ --a1-sb-track-bg: var(--component-status-bar-track-background);
6
+ --a1-sb-fill-bg: var(--component-status-bar-fill-background);
7
+ --a1-sb-radius: var(--component-status-bar-border-radius);
8
+ --a1-sb-border-width: var(--component-status-bar-border-width);
9
+ --a1-sb-border-color: var(--component-status-bar-border-color);
10
+ --a1-sb-label-gap: var(--component-status-bar-label-gap);
11
+
12
+ display: flex;
13
+ flex-direction: column;
14
+ gap: var(--a1-sb-label-gap);
15
+ inline-size: 100%;
16
+ }
17
+
18
+ /* ─── Sizes ─────────────────────────────────────────────────────────────────── */
19
+
20
+ .a1-status-bar--sm { --a1-sb-height: var(--component-status-bar-sm-height); }
21
+ .a1-status-bar--lg { --a1-sb-height: var(--component-status-bar-lg-height); }
22
+
23
+ /* ─── Label positions ───────────────────────────────────────────────────────── */
24
+
25
+ /* below: column layout, DOM order is [bar-row, label] so no CSS change needed */
26
+
27
+ .a1-status-bar--before,
28
+ .a1-status-bar--after {
29
+ flex-direction: row;
30
+ align-items: center;
31
+ }
32
+
33
+ .a1-status-bar--before .a1-status-bar__bar-row,
34
+ .a1-status-bar--after .a1-status-bar__bar-row {
35
+ flex: 1;
36
+ min-inline-size: 0;
37
+ }
38
+
39
+ .a1-status-bar--before .a1-status-bar__label,
40
+ .a1-status-bar--after .a1-status-bar__label {
41
+ flex-shrink: 0;
42
+ }
43
+
44
+ /* ─── Bar row (track + optional pause button) ───────────────────────────────── */
45
+
46
+ .a1-status-bar__bar-row {
47
+ display: flex;
48
+ flex-direction: row;
49
+ align-items: center;
50
+ gap: var(--base-spacing-8);
51
+ }
52
+
53
+ .a1-status-bar__bar-row .a1-status-bar__track {
54
+ flex: 1;
55
+ min-inline-size: 0;
56
+ inline-size: auto;
57
+ }
58
+
59
+ /* ─── Label ─────────────────────────────────────────────────────────────────── */
60
+
61
+ .a1-status-bar__label {
62
+ display: block;
63
+ font-family: var(--component-paragraph-font-family);
64
+ font-size: var(--semantic-font-size-body-sm);
65
+ font-weight: var(--base-font-weight-regular);
66
+ color: var(--semantic-color-text-default);
67
+ line-height: var(--semantic-font-line-height-body);
68
+ }
69
+
70
+ /* ─── Track ─────────────────────────────────────────────────────────────────── */
71
+
72
+ .a1-status-bar__track {
73
+ position: relative;
74
+ display: flex; /* flex direction inherits writing mode — fills from inline-start in RTL */
75
+ block-size: var(--a1-sb-height);
76
+ inline-size: 100%;
77
+ background-color: var(--a1-sb-track-bg);
78
+ border-radius: var(--a1-sb-radius);
79
+ border: var(--a1-sb-border-width) solid var(--a1-sb-border-color);
80
+ overflow: hidden;
81
+ }
82
+
83
+ /* ─── Fill ──────────────────────────────────────────────────────────────────── */
84
+
85
+ /* Flex child — grows from inline-start, which in RTL means it grows from the right */
86
+ .a1-status-bar__fill {
87
+ flex-shrink: 0;
88
+ block-size: 100%;
89
+ inline-size: 0;
90
+ background-color: var(--a1-sb-fill-bg);
91
+ border-radius: inherit;
92
+ transition: inline-size var(--semantic-motion-duration-normal) var(--semantic-motion-easing-standard);
93
+ }
94
+
95
+ /* ─── Indeterminate (loading) ───────────────────────────────────────────────── */
96
+
97
+ /* Absolute positioning takes the fill out of flex flow for the sweep animation */
98
+ .a1-status-bar__fill--indeterminate {
99
+ position: absolute;
100
+ inset-block: 0;
101
+ left: 0;
102
+ inline-size: 35%;
103
+ animation: a1-sb-indeterminate var(--component-status-bar-indeterminate-duration) ease-in-out infinite;
104
+ }
105
+
106
+ /* RTL: anchor to the physical right and reverse the sweep direction */
107
+ [dir="rtl"] .a1-status-bar__fill--indeterminate {
108
+ left: auto;
109
+ right: 0;
110
+ animation-direction: reverse;
111
+ }
112
+
113
+ .a1-status-bar--paused .a1-status-bar__fill--indeterminate {
114
+ animation-play-state: paused;
115
+ }
116
+
117
+ @keyframes a1-sb-indeterminate {
118
+ 0% { transform: translateX(-115%); }
119
+ 100% { transform: translateX(300%); }
120
+ }
121
+
122
+ @media (prefers-reduced-motion: reduce) {
123
+ .a1-status-bar__fill--indeterminate {
124
+ animation: none;
125
+ inline-size: 100%;
126
+ opacity: 0.4;
127
+ }
128
+ }
129
+
130
+ /* ─── Pause / play button ────────────────────────────────────────────────────── */
131
+
132
+ .a1-status-bar__pause {
133
+ flex-shrink: 0;
134
+ animation: a1-sb-pause-appear var(--semantic-motion-duration-normal) var(--semantic-motion-easing-enter);
135
+ }
136
+
137
+ @keyframes a1-sb-pause-appear {
138
+ from { opacity: 0; transform: scale(0.75); }
139
+ to { opacity: 1; transform: scale(1); }
140
+ }
141
+
142
+ @media (prefers-reduced-motion: reduce) {
143
+ .a1-status-bar__pause {
144
+ animation: none;
145
+ }
146
+ }
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ export { Blockquote } from "./components/blockquote/Blockquote.jsx";
5
5
  export { Breadcrumb } from "./components/breadcrumb/Breadcrumb.jsx";
6
6
  export { Notification } from "./components/notification/Notification.jsx";
7
7
  export { Snackbar } from "./components/snackbar/Snackbar.jsx";
8
+ export { StatusBar } from "./components/status-bar/StatusBar.jsx";
8
9
  export { Bleed } from "./components/bleed/Bleed.jsx";
9
10
  export { IconButton } from "./components/icon-button/IconButton.jsx";
10
11
  export { Button } from "./components/button/Button.jsx";export { ButtonContainer } from "./components/button-container/ButtonContainer.jsx";
package/src/themes.css CHANGED
@@ -52,6 +52,7 @@ html.a1-theme-accessible {
52
52
  --component-side-nav-border-width: 2px;
53
53
  --component-tab-border-width: 2px;
54
54
  --component-top-header-border-width: 2px;
55
+ --component-status-bar-border-width: 2px;
55
56
  --component-button-font-family: var(--theme-a1-accessible-font-family-body);
56
57
  --component-paragraph-font-family: var(--theme-a1-accessible-font-family-body);
57
58
  --component-heading-font-family-heading: var(--theme-a1-accessible-font-family-heading);
package/src/tokens.css CHANGED
@@ -646,6 +646,16 @@
646
646
  --component-side-nav-item-font-line-height: 1.5;
647
647
  --component-side-nav-item-active-font-weight: 500;
648
648
  --component-side-nav-item-chevron-size: 1.125rem;
649
+ --component-status-bar-sm-height: 0.25rem;
650
+ --component-status-bar-md-height: 0.5rem;
651
+ --component-status-bar-lg-height: 0.75rem;
652
+ --component-status-bar-border-radius: 624.9375rem;
653
+ --component-status-bar-border-width: 0;
654
+ --component-status-bar-border-color: #a6b2c4;
655
+ --component-status-bar-track-background: #e1e8f3;
656
+ --component-status-bar-fill-background: #7c3aed;
657
+ --component-status-bar-label-gap: 0.5rem;
658
+ --component-status-bar-indeterminate-duration: 1400ms;
649
659
  --component-switch-track-width: 2.5rem;
650
660
  --component-switch-track-height: 1.375rem;
651
661
  --component-switch-thumb-size: 1rem;