@gtivr4/a1-design-system-react 0.14.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 (57) hide show
  1. package/package.json +3 -2
  2. package/src/color-scheme.css +2 -0
  3. package/src/components/accordion/Accordion.d.ts +8 -0
  4. package/src/components/accordion/Accordion.jsx +9 -1
  5. package/src/components/accordion/accordion.css +46 -6
  6. package/src/components/autocomplete/Autocomplete.d.ts +53 -0
  7. package/src/components/autocomplete/Autocomplete.jsx +380 -0
  8. package/src/components/autocomplete/autocomplete.css +346 -0
  9. package/src/components/banner/Banner.d.ts +9 -2
  10. package/src/components/banner/Banner.jsx +32 -6
  11. package/src/components/banner/banner.css +81 -0
  12. package/src/components/bottom-sheet/BottomSheet.d.ts +22 -0
  13. package/src/components/bottom-sheet/BottomSheet.jsx +154 -0
  14. package/src/components/bottom-sheet/bottom-sheet.css +113 -0
  15. package/src/components/button/button.css +7 -3
  16. package/src/components/code/Code.jsx +6 -1
  17. package/src/components/data-table/DataTable.jsx +11 -1
  18. package/src/components/data-table/data-table.css +19 -0
  19. package/src/components/figure/Figure.d.ts +37 -4
  20. package/src/components/figure/Figure.jsx +78 -9
  21. package/src/components/figure/figure.css +105 -8
  22. package/src/components/grid/Grid.d.ts +1 -1
  23. package/src/components/grid/Grid.jsx +2 -0
  24. package/src/components/grid/grid.css +5 -0
  25. package/src/components/icon-button/IconButton.d.ts +2 -2
  26. package/src/components/icon-button/IconButton.jsx +3 -2
  27. package/src/components/icon-button/icon-button.css +11 -1
  28. package/src/components/menu/Menu.jsx +12 -0
  29. package/src/components/menu/menu.css +17 -6
  30. package/src/components/page-layout/page-layout.css +10 -4
  31. package/src/components/page-nav/PageNav.jsx +29 -8
  32. package/src/components/page-nav/page-nav.css +13 -0
  33. package/src/components/paragraph/Paragraph.d.ts +2 -0
  34. package/src/components/paragraph/Paragraph.jsx +4 -0
  35. package/src/components/paragraph/paragraph.css +6 -6
  36. package/src/components/section/Section.d.ts +6 -0
  37. package/src/components/section/Section.jsx +19 -0
  38. package/src/components/section/section.css +33 -10
  39. package/src/components/segmented-control/SegmentedControl.d.ts +8 -0
  40. package/src/components/segmented-control/SegmentedControl.jsx +16 -3
  41. package/src/components/segmented-control/segmented.css +31 -1
  42. package/src/components/slider/Slider.d.ts +71 -0
  43. package/src/components/slider/Slider.jsx +243 -0
  44. package/src/components/slider/slider.css +238 -0
  45. package/src/components/split-button/SplitButton.d.ts +39 -0
  46. package/src/components/split-button/SplitButton.jsx +94 -0
  47. package/src/components/split-button/split-button.css +40 -0
  48. package/src/components/tabs/tabs.css +3 -0
  49. package/src/components/toolbar/Toolbar.d.ts +131 -0
  50. package/src/components/toolbar/Toolbar.jsx +335 -0
  51. package/src/components/toolbar/toolbar.css +229 -0
  52. package/src/components/top-header/top-header.css +2 -0
  53. package/src/components/tree-menu/TreeMenu.jsx +11 -7
  54. package/src/index.d.ts +71 -0
  55. package/src/index.js +15 -1
  56. package/src/themes.css +293 -0
  57. package/src/tokens.css +26 -3
@@ -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
+ }
@@ -1,8 +1,8 @@
1
1
  .a1-button {
2
2
  box-sizing: border-box;
3
- height: var(--a1-button-height, var(--component-button-min-height));
3
+ /* min-height keeps the standard target size for a single line; the button
4
+ grows taller when a long label wraps to multiple lines. */
4
5
  min-height: var(--a1-button-height, var(--component-button-min-height));
5
- max-height: var(--a1-button-height, var(--component-button-min-height));
6
6
  display: inline-flex;
7
7
  align-items: center;
8
8
  justify-content: center;
@@ -18,7 +18,11 @@
18
18
  --a1-icon-weight: 700;
19
19
  line-height: var(--component-button-font-line-height);
20
20
  text-decoration: none;
21
- white-space: nowrap;
21
+ /* Allow long labels to wrap onto multiple lines (centered), breaking an
22
+ over-long word if needed, instead of overflowing a fixed-height pill. */
23
+ white-space: normal;
24
+ text-align: center;
25
+ overflow-wrap: anywhere;
22
26
  overflow: clip;
23
27
  cursor: pointer;
24
28
  background: var(--a1-button-background);
@@ -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
- {...props}
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;
@@ -13,12 +13,38 @@ export interface FigureProps extends React.HTMLAttributes<HTMLElement> {
13
13
  captionSrOnly?: boolean;
14
14
  /** Caption alignment. Default: "start" */
15
15
  captionPosition?: "start" | "center";
16
- /** Border radius on the image. */
16
+ /** Border radius on the image. Default (no prop) is square, same as "none". */
17
17
  radius?: "none" | "sm" | "md" | "lg";
18
18
  /** Constrain figure width. */
19
- size?: "xs" | "sm" | "md" | "lg";
20
- /** Horizontal alignment of the figure. Default: "start" */
21
- align?: "start" | "center" | "end";
19
+ size?: "3xs" | "2xs" | "xs" | "sm" | "md" | "lg" | "xl" | "xxl";
20
+ /** Horizontal alignment of the figure. Default: "none" (normal flow). */
21
+ align?: "none" | "start" | "center" | "end";
22
+ /**
23
+ * Fix the image to a set aspect ratio, cropping to fill via `object-fit: cover`.
24
+ * Omit for the image's natural ratio.
25
+ */
26
+ aspectRatio?: "16:9" | "4:3" | "3:2" | "1:1" | "2:3" | "3:4" | "9:16" | "21:9";
27
+ /**
28
+ * Crop focal point used when the image is cropped (i.e. when `aspectRatio` or a
29
+ * fixed height applies). Maps to `object-position`. Default: "center"
30
+ */
31
+ crop?:
32
+ | "center"
33
+ | "top"
34
+ | "bottom"
35
+ | "left"
36
+ | "right"
37
+ | "top-left"
38
+ | "top-right"
39
+ | "bottom-left"
40
+ | "bottom-right";
41
+ /**
42
+ * Freeform crop: a sub-rectangle of the image to show, expressed as fractions
43
+ * (0–1) of the natural image — `{ x, y, width, height }` where x/y is the
44
+ * top-left corner. Applied non-destructively (CSS only); the image is never
45
+ * modified. Takes precedence over `aspectRatio` / `crop` when set.
46
+ */
47
+ cropRect?: { x: number; y: number; width: number; height: number };
22
48
  /** Top margin. */
23
49
  marginTop?: "sm" | "md" | "lg";
24
50
  /** Bottom margin. */
@@ -28,6 +54,13 @@ export interface FigureProps extends React.HTMLAttributes<HTMLElement> {
28
54
  * Pass `true` for symmetric bleed or a numeric spacing token for inline-only.
29
55
  */
30
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;
31
64
  /** Extra class names on the `<figure>` element */
32
65
  className?: string;
33
66
  /** Extra class names on the `<img>` element */
@@ -1,11 +1,25 @@
1
1
  import "./figure.css";
2
+ import { useEffect, useState } from "react";
2
3
  import { Bleed } from "../bleed/Bleed.jsx";
4
+ import { Icon } from "../icon/Icon.jsx";
5
+
6
+ function isCropRect(rect) {
7
+ return (
8
+ rect != null &&
9
+ typeof rect === "object" &&
10
+ ["x", "y", "width", "height"].every((k) => typeof rect[k] === "number") &&
11
+ rect.width > 0 &&
12
+ rect.height > 0
13
+ );
14
+ }
3
15
 
4
16
  const rounded = ["none", "sm", "md", "lg"];
5
17
  const captionPositions = ["start", "center"];
6
18
  const spacings = ["sm", "md", "lg"];
7
- const sizes = ["xs", "sm", "md", "lg"];
8
- const alignments = ["start", "center", "end"];
19
+ const sizes = ["3xs", "2xs", "xs", "sm", "md", "lg", "xl", "xxl"];
20
+ const alignments = ["none", "start", "center", "end"];
21
+ const aspectRatios = ["16:9", "4:3", "3:2", "1:1", "2:3", "3:4", "9:16", "21:9"];
22
+ const crops = ["center", "top", "bottom", "left", "right", "top-left", "top-right", "bottom-left", "bottom-right"];
9
23
 
10
24
  export function Figure({
11
25
  src,
@@ -16,21 +30,40 @@ export function Figure({
16
30
  radius,
17
31
  size,
18
32
  align,
33
+ aspectRatio,
34
+ crop,
35
+ cropRect,
19
36
  marginTop,
20
37
  marginBottom,
21
38
  bleed,
39
+ placeholder = true,
40
+ placeholderIcon = "image",
22
41
  className = "",
23
42
  imgClassName = "",
24
43
  style,
25
44
  imgStyle,
26
45
  ...props
27
46
  }) {
47
+ // Freeform crop: a sub-rectangle of the image (fractions 0–1) shown
48
+ // non-destructively. We measure the natural ratio to size the crop box.
49
+ const cropped = isCropRect(cropRect);
50
+ const [naturalRatio, setNaturalRatio] = useState(null);
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
+
28
58
  const classes = [
29
59
  "a1-figure",
30
60
  radius != null && rounded.includes(radius) && `a1-figure--rounded-${radius}`,
31
61
  captionPositions.includes(captionPosition) && captionPosition !== "start" && `a1-figure--caption-${captionPosition}`,
32
62
  sizes.includes(size) && `a1-figure--${size}`,
33
- alignments.includes(align) && align !== "start" && `a1-figure--align-${align}`,
63
+ alignments.includes(align) && align !== "none" && `a1-figure--align-${align}`,
64
+ !cropped && aspectRatios.includes(aspectRatio) && `a1-figure--ratio-${aspectRatio.replace(":", "-")}`,
65
+ !cropped && crops.includes(crop) && crop !== "center" && `a1-figure--crop-${crop}`,
66
+ cropped && "a1-figure--cropped",
34
67
  spacings.includes(marginTop) && `a1-figure--mt-${marginTop}`,
35
68
  spacings.includes(marginBottom) && `a1-figure--mb-${marginBottom}`,
36
69
  className,
@@ -40,14 +73,50 @@ export function Figure({
40
73
  captionSrOnly ? "a1-sr-only" : "a1-figure__caption",
41
74
  ].join(" ");
42
75
 
76
+ const img = (
77
+ <img
78
+ src={src}
79
+ alt={alt}
80
+ className={["a1-figure__img", imgClassName].filter(Boolean).join(" ")}
81
+ style={imgStyle}
82
+ onError={() => setErrored(true)}
83
+ onLoad={cropped ? (e) => {
84
+ const { naturalWidth, naturalHeight } = e.currentTarget;
85
+ if (naturalWidth && naturalHeight) setNaturalRatio(naturalWidth / naturalHeight);
86
+ } : undefined}
87
+ />
88
+ );
89
+
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 ? (
102
+ <div
103
+ className="a1-figure__crop"
104
+ style={{
105
+ "--a1-figure-crop-x": cropRect.x,
106
+ "--a1-figure-crop-y": cropRect.y,
107
+ "--a1-figure-crop-w": cropRect.width,
108
+ "--a1-figure-crop-h": cropRect.height,
109
+ // Box aspect ratio = (cropW · imgW) / (cropH · imgH).
110
+ ...(naturalRatio ? { aspectRatio: `${(cropRect.width / cropRect.height) * naturalRatio}` } : {}),
111
+ }}
112
+ >
113
+ {img}
114
+ </div>
115
+ ) : img;
116
+
43
117
  const figure = (
44
118
  <figure className={classes} style={style} {...props}>
45
- <img
46
- src={src}
47
- alt={alt}
48
- className={["a1-figure__img", imgClassName].filter(Boolean).join(" ")}
49
- style={imgStyle}
50
- />
119
+ {media}
51
120
  {caption && (
52
121
  <figcaption className={captionClasses}>{caption}</figcaption>
53
122
  )}
@@ -15,7 +15,13 @@
15
15
  display: block;
16
16
  width: 100%;
17
17
  height: auto;
18
- border-radius: var(--base-radius-container);
18
+ /* Square by default — this is the same as radius="none". Use the `radius`
19
+ prop to round the corners. */
20
+ border-radius: 0;
21
+ /* Crop point. Harmless on natural-ratio images (the box already matches the
22
+ intrinsic ratio); takes effect once a fixed aspect-ratio or height crops. */
23
+ object-fit: cover;
24
+ object-position: var(--a1-figure-crop, center);
19
25
  }
20
26
 
21
27
  .a1-figure__caption {
@@ -27,25 +33,47 @@
27
33
  font-style: italic;
28
34
  }
29
35
 
30
- /* ── Rounded modifier ──────────────────────────────────────────────────────── */
36
+ /* ── Freeform crop (cropRect) ────────────────────────────────────────────────
37
+ The crop box clips a sub-rectangle of the image. The image is scaled so the
38
+ crop region fills the box width and shifted so the region's top-left aligns. */
39
+ .a1-figure__crop {
40
+ position: relative;
41
+ inline-size: 100%;
42
+ overflow: hidden;
43
+ }
31
44
 
32
- .a1-figure--rounded .a1-figure__img {
33
- border-radius: var(--base-radius-container);
45
+ .a1-figure--cropped .a1-figure__crop .a1-figure__img {
46
+ position: absolute;
47
+ inline-size: calc(100% / var(--a1-figure-crop-w));
48
+ max-inline-size: none;
49
+ block-size: auto;
50
+ inset-inline-start: calc(var(--a1-figure-crop-x) / var(--a1-figure-crop-w) * -100%);
51
+ inset-block-start: calc(var(--a1-figure-crop-y) / var(--a1-figure-crop-h) * -100%);
52
+ border-radius: 0;
34
53
  }
35
54
 
36
- .a1-figure--rounded-none .a1-figure__img {
55
+ /* ── Rounded modifier ──────────────────────────────────────────────────────── */
56
+
57
+ /* radius="none" matches the default (square) — kept so a figure can be reset to
58
+ square when a wrapping context sets a radius. The crop box, when present, is
59
+ the clipping element so it carries the radius. */
60
+ .a1-figure--rounded-none .a1-figure__img,
61
+ .a1-figure--rounded-none .a1-figure__crop {
37
62
  border-radius: 0;
38
63
  }
39
64
 
40
- .a1-figure--rounded-sm .a1-figure__img {
65
+ .a1-figure--rounded-sm .a1-figure__img,
66
+ .a1-figure--rounded-sm .a1-figure__crop {
41
67
  border-radius: var(--base-radius-sm);
42
68
  }
43
69
 
44
- .a1-figure--rounded-md .a1-figure__img {
70
+ .a1-figure--rounded-md .a1-figure__img,
71
+ .a1-figure--rounded-md .a1-figure__crop {
45
72
  border-radius: var(--base-radius-md);
46
73
  }
47
74
 
48
- .a1-figure--rounded-lg .a1-figure__img {
75
+ .a1-figure--rounded-lg .a1-figure__img,
76
+ .a1-figure--rounded-lg .a1-figure__crop {
49
77
  border-radius: var(--base-radius-lg);
50
78
  }
51
79
 
@@ -60,8 +88,11 @@
60
88
  .a1-figure--mb-lg { margin-bottom: var(--base-spacing-24); }
61
89
 
62
90
  /* ── Alignment ───────────────────────────────────────────────────────────── */
91
+ /* Default (align="none"): no alignment — the figure flows at the inline-start in
92
+ normal document flow. start/center/end are explicit alignments. */
63
93
 
64
94
  /* start/end: constrain the figure itself and shift via margin */
95
+ .a1-figure--align-start { margin-inline-end: auto; }
65
96
  .a1-figure--align-end { margin-inline-start: auto; }
66
97
 
67
98
  /* center: figure stays full-width; size constrains img + caption */
@@ -70,14 +101,24 @@
70
101
  /* ── Size ────────────────────────────────────────────────────────────────── */
71
102
 
72
103
  /* Default: constrain the figure element */
104
+ .a1-figure--3xs { max-width: 5rem; }
105
+ .a1-figure--2xs { max-width: 8rem; }
73
106
  .a1-figure--xs { max-width: 12rem; }
74
107
  .a1-figure--sm { max-width: 20rem; }
75
108
  .a1-figure--md { max-width: 30rem; }
76
109
  .a1-figure--lg { max-width: 40rem; }
110
+ .a1-figure--xl { max-width: 50rem; }
111
+ .a1-figure--xxl { max-width: 60rem; }
77
112
 
78
113
  /* Center override: transfer the constraint to img + caption instead */
79
114
  .a1-figure--align-center { max-width: none; }
80
115
 
116
+ .a1-figure--align-center.a1-figure--3xs .a1-figure__img,
117
+ .a1-figure--align-center.a1-figure--3xs .a1-figure__caption { max-width: 5rem; }
118
+
119
+ .a1-figure--align-center.a1-figure--2xs .a1-figure__img,
120
+ .a1-figure--align-center.a1-figure--2xs .a1-figure__caption { max-width: 8rem; }
121
+
81
122
  .a1-figure--align-center.a1-figure--xs .a1-figure__img,
82
123
  .a1-figure--align-center.a1-figure--xs .a1-figure__caption { max-width: 12rem; }
83
124
 
@@ -90,8 +131,64 @@
90
131
  .a1-figure--align-center.a1-figure--lg .a1-figure__img,
91
132
  .a1-figure--align-center.a1-figure--lg .a1-figure__caption { max-width: 40rem; }
92
133
 
134
+ .a1-figure--align-center.a1-figure--xl .a1-figure__img,
135
+ .a1-figure--align-center.a1-figure--xl .a1-figure__caption { max-width: 50rem; }
136
+
137
+ .a1-figure--align-center.a1-figure--xxl .a1-figure__img,
138
+ .a1-figure--align-center.a1-figure--xxl .a1-figure__caption { max-width: 60rem; }
139
+
140
+ /* ── Aspect ratio ────────────────────────────────────────────────────────── */
141
+
142
+ /* A fixed aspect-ratio box; the image fills it via object-fit: cover, cropping
143
+ to the `crop` point. width:100% + height:auto lets aspect-ratio size the box. */
144
+ .a1-figure--ratio-16-9 .a1-figure__img { aspect-ratio: 16 / 9; }
145
+ .a1-figure--ratio-4-3 .a1-figure__img { aspect-ratio: 4 / 3; }
146
+ .a1-figure--ratio-3-2 .a1-figure__img { aspect-ratio: 3 / 2; }
147
+ .a1-figure--ratio-1-1 .a1-figure__img { aspect-ratio: 1 / 1; }
148
+ .a1-figure--ratio-2-3 .a1-figure__img { aspect-ratio: 2 / 3; }
149
+ .a1-figure--ratio-3-4 .a1-figure__img { aspect-ratio: 3 / 4; }
150
+ .a1-figure--ratio-9-16 .a1-figure__img { aspect-ratio: 9 / 16; }
151
+ .a1-figure--ratio-21-9 .a1-figure__img { aspect-ratio: 21 / 9; }
152
+
153
+ /* ── Crop (object-position) ──────────────────────────────────────────────────
154
+ Each modifier sets --a1-figure-crop, read by .a1-figure__img's
155
+ object-position. Default (center) needs no class. */
156
+ .a1-figure--crop-top { --a1-figure-crop: center top; }
157
+ .a1-figure--crop-bottom { --a1-figure-crop: center bottom; }
158
+ .a1-figure--crop-left { --a1-figure-crop: left center; }
159
+ .a1-figure--crop-right { --a1-figure-crop: right center; }
160
+ .a1-figure--crop-top-left { --a1-figure-crop: left top; }
161
+ .a1-figure--crop-top-right { --a1-figure-crop: right top; }
162
+ .a1-figure--crop-bottom-left { --a1-figure-crop: left bottom; }
163
+ .a1-figure--crop-bottom-right { --a1-figure-crop: right bottom; }
164
+
93
165
  /* ── Caption position ──────────────────────────────────────────────────────── */
94
166
 
95
167
  .a1-figure--caption-center .a1-figure__caption {
96
168
  text-align: center;
97
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
 
@@ -13,8 +13,8 @@ export interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonEl
13
13
  label: string;
14
14
  /** Visual style. Default: "tertiary" */
15
15
  variant?: "tertiary" | "secondary" | "destructive" | "success";
16
- /** Button size. "lg" matches Button's large touch target (3.5rem) and icon size, suitable for pairing with large Buttons. Default: "md" */
17
- size?: "md" | "lg";
16
+ /** Button size. "sm" is a 24×24px target (the WCAG 2.2 AA minimum) for dense toolbars; "lg" matches Button's large touch target (3.5rem) and icon size. Default: "md" */
17
+ size?: "sm" | "md" | "lg";
18
18
  /** Link target when rendered with `as="a"`. */
19
19
  href?: string;
20
20
  disabled?: boolean;
@@ -2,7 +2,8 @@ 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
+ const sizes = ["sm", "md", "lg"];
6
+ const sizeClass = { sm: "a1-icon-button--small", lg: "a1-icon-button--large" };
6
7
 
7
8
  export function IconButton({
8
9
  as: Component = "button",
@@ -23,7 +24,7 @@ export function IconButton({
23
24
  const classes = [
24
25
  "a1-icon-button",
25
26
  `a1-icon-button--${resolvedVariant}`,
26
- resolvedSize === "lg" && "a1-icon-button--large",
27
+ resolvedSize && sizeClass[resolvedSize],
27
28
  className,
28
29
  ].filter(Boolean).join(" ");
29
30