@gtivr4/a1-design-system-react 0.18.0 → 0.20.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.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "React components for the A1 token-driven design system.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -25,6 +25,15 @@ export interface CardProps extends React.HTMLAttributes<HTMLElement> {
25
25
  * Default: "action"
26
26
  */
27
27
  heroColor?: "action" | "neutral" | "info" | "success" | "warn" | "error" | (string & {});
28
+ /** Badge label overlaid on the hero (only renders when `iconDisplay="hero"`). */
29
+ heroBadge?: React.ReactNode;
30
+ /** Status colour of the hero badge. Default: "neutral" */
31
+ heroBadgeStatus?: "neutral" | "info" | "success" | "warn" | "error";
32
+ /** Placement of the hero badge on a 3×3 grid ("{top|middle|bottom}-{start|center|end}"). Default: "top-end" */
33
+ heroBadgePosition?:
34
+ | "top-start" | "top-center" | "top-end"
35
+ | "middle-start" | "middle-center" | "middle-end"
36
+ | "bottom-start" | "bottom-center" | "bottom-end";
28
37
  children?: React.ReactNode;
29
38
  }
30
39
 
@@ -1,5 +1,6 @@
1
1
  import "./card.css";
2
2
  import { Icon } from "../icon/Icon.jsx";
3
+ import { MessageBadge } from "../message/Message.jsx";
3
4
 
4
5
  const HERO_COLORS = {
5
6
  action: "var(--semantic-color-action-background)",
@@ -12,6 +13,14 @@ const HERO_COLORS = {
12
13
 
13
14
  const VALID_ICON_DISPLAY = ["none", "default", "hero"];
14
15
 
16
+ // 3×3 placement of a hero badge: "{block}-{inline}" where block ∈ top|middle|bottom
17
+ // and inline ∈ start|center|end.
18
+ const VALID_HERO_BADGE_POSITIONS = [
19
+ "top-start", "top-center", "top-end",
20
+ "middle-start", "middle-center", "middle-end",
21
+ "bottom-start", "bottom-center", "bottom-end",
22
+ ];
23
+
15
24
  export function Card({
16
25
  as,
17
26
  bare = false,
@@ -20,6 +29,9 @@ export function Card({
20
29
  icon,
21
30
  iconDisplay = "default",
22
31
  heroColor = "action",
32
+ heroBadge,
33
+ heroBadgeStatus = "neutral",
34
+ heroBadgePosition = "top-end",
23
35
  className = "",
24
36
  children,
25
37
  ...props
@@ -47,12 +59,22 @@ export function Card({
47
59
  ? { type: "button" }
48
60
  : {};
49
61
 
62
+ const badgePos = VALID_HERO_BADGE_POSITIONS.includes(heroBadgePosition)
63
+ ? heroBadgePosition
64
+ : "top-end";
65
+ const [badgeBlock, badgeInline] = badgePos.split("-");
66
+
50
67
  return (
51
68
  <Component className={classes} href={href} {...interactiveProps} {...props}>
52
69
  <div className="a1-card__layout">
53
70
  {resolvedDisplay === "hero" && (
54
71
  <div className="a1-card__hero" style={{ "--a1-card-hero-bg": heroBg }}>
55
72
  <Icon name={icon} aria-hidden="true" />
73
+ {heroBadge && (
74
+ <span className={`a1-card__hero-badge a1-card__hero-badge--${badgeBlock} a1-card__hero-badge--${badgeInline}`}>
75
+ <MessageBadge status={heroBadgeStatus} size="sm">{heroBadge}</MessageBadge>
76
+ </span>
77
+ )}
56
78
  </div>
57
79
  )}
58
80
  {resolvedDisplay === "default" && (
@@ -79,6 +79,7 @@ button.a1-card--navigation {
79
79
 
80
80
  .a1-card__hero {
81
81
  /* Bleed out to the card edges on all four sides then add inner padding */
82
+ position: relative;
82
83
  margin-top: calc(-1 * var(--component-card-padding));
83
84
  margin-inline: calc(-1 * var(--component-card-padding));
84
85
  margin-bottom: var(--component-card-padding);
@@ -91,8 +92,26 @@ button.a1-card--navigation {
91
92
  --a1-icon-opsz: 48;
92
93
  }
93
94
 
95
+ /* Hero badge — overlaid on the hero, placed via a 3×3 grid. */
96
+ .a1-card__hero-badge {
97
+ position: absolute;
98
+ z-index: 1;
99
+ }
100
+
101
+ .a1-card__hero-badge--top { inset-block-start: var(--base-spacing-8); }
102
+ .a1-card__hero-badge--bottom { inset-block-end: var(--base-spacing-8); }
103
+ .a1-card__hero-badge--middle { inset-block-start: 50%; }
104
+ .a1-card__hero-badge--start { inset-inline-start: var(--base-spacing-8); }
105
+ .a1-card__hero-badge--end { inset-inline-end: var(--base-spacing-8); }
106
+ .a1-card__hero-badge--center { inset-inline-start: 50%; }
107
+
108
+ /* Centre transforms for the middle/centre axes (combine when both). */
109
+ .a1-card__hero-badge--middle:not(.a1-card__hero-badge--center) { transform: translateY(-50%); }
110
+ .a1-card__hero-badge--center:not(.a1-card__hero-badge--middle) { transform: translateX(-50%); }
111
+ .a1-card__hero-badge--middle.a1-card__hero-badge--center { transform: translate(-50%, -50%); }
112
+
94
113
  /* Higher specificity (0,2,0) beats .a1-icon (0,1,0) so font-size is not overridden by inherit */
95
- .a1-card__hero .a1-icon {
114
+ .a1-card__hero > .a1-icon {
96
115
  font-size: var(--base-spacing-64);
97
116
  color: var(--semantic-color-text-inverse);
98
117
  }
@@ -120,7 +139,7 @@ button.a1-card--navigation {
120
139
  border-end-end-radius: 0;
121
140
  }
122
141
 
123
- .a1-card__hero .a1-icon {
142
+ .a1-card__hero > .a1-icon {
124
143
  font-size: var(--base-spacing-128);
125
144
  }
126
145
 
@@ -13,6 +13,10 @@ export interface CodeProps extends React.HTMLAttributes<HTMLElement> {
13
13
  editable?: boolean;
14
14
  /** Called with the current string value whenever the editable textarea changes. */
15
15
  onChangeValue?: (value: string) => void;
16
+ /** Cap a long read-only block to `collapsedLines` with a fade + Show more/less toggle (the toggle appears only when the content overflows). Block, non-editable only. Default: false */
17
+ collapsible?: boolean;
18
+ /** Approximate number of lines shown when collapsed. Default: 14 */
19
+ collapsedLines?: number;
16
20
  children?: React.ReactNode;
17
21
  }
18
22
 
@@ -56,12 +56,17 @@ export function Code({
56
56
  copyText,
57
57
  editable = false,
58
58
  onChangeValue,
59
+ collapsible = false,
60
+ collapsedLines = 14,
59
61
  className = "",
60
62
  children,
61
63
  ...props
62
64
  }) {
63
65
  const resolvedVariant = variants.includes(variant) ? variant : "inline";
64
66
  const [copied, setCopied] = useState(false);
67
+ const [expanded, setExpanded] = useState(false);
68
+ const [overflows, setOverflows] = useState(false);
69
+ const preRef = useRef(null);
65
70
  const [editableValue, setEditableValue] = useState(() =>
66
71
  textFromChildren(Children.toArray(children))
67
72
  );
@@ -78,11 +83,15 @@ export function Code({
78
83
  const copyLabel = useLabel("code.copyCode", "Copy code");
79
84
  const copiedLabel = useLabel("code.copied", "Copied");
80
85
  const editLabel = useLabel("code.editCode", "Edit code");
86
+ const showMoreLabel = useLabel("code.showMore", "Show more");
87
+ const showLessLabel = useLabel("code.showLess", "Show less");
81
88
  const textToCopy = useMemo(
82
89
  () => copyText || (editable ? editableValue : textFromChildren(Children.toArray(children))),
83
90
  [children, copyText, editable, editableValue],
84
91
  );
85
92
  const shouldRenderBlock = resolvedVariant === "block" || copyCode || editable;
93
+ // Collapsible only applies to a read-only block (not the editable textarea).
94
+ const collapses = collapsible && !editable && shouldRenderBlock;
86
95
 
87
96
  useEffect(() => {
88
97
  return () => {
@@ -90,6 +99,16 @@ export function Code({
90
99
  };
91
100
  }, []);
92
101
 
102
+ // Detect whether the (collapsed) content actually overflows the cap, so the
103
+ // toggle only appears when it's needed. scrollHeight reports the full content
104
+ // height even while clipped, so this is accurate in either state.
105
+ useEffect(() => {
106
+ if (!collapses) { setOverflows(false); return; }
107
+ if (expanded) return; // measured while collapsed; keep so "Show less" stays
108
+ const el = preRef.current;
109
+ if (el) setOverflows(el.scrollHeight - el.clientHeight > 4);
110
+ }, [collapses, expanded, children, collapsedLines]);
111
+
93
112
  function handleTextareaChange(e) {
94
113
  setEditableValue(e.target.value);
95
114
  onChangeValue?.(e.target.value);
@@ -127,12 +146,20 @@ export function Code({
127
146
  );
128
147
  }
129
148
 
149
+ // Cap the height whenever collapsible + not expanded (so the overflow check has
150
+ // a clamped height to measure against); `clipped` adds the fade only when the
151
+ // content actually overflows the cap.
152
+ const collapsed = collapses && !expanded;
153
+ const clipped = collapsed && overflows;
154
+
130
155
  return (
131
156
  <div
132
157
  className={[
133
158
  "a1-code-block",
134
159
  copyCode && "a1-code-block--copyable",
135
160
  editable && "a1-code-block--editable",
161
+ collapsed && "a1-code-block--collapsed",
162
+ clipped && "a1-code-block--clipped",
136
163
  className,
137
164
  ]
138
165
  .filter(Boolean)
@@ -153,23 +180,43 @@ export function Code({
153
180
  {...editableProps}
154
181
  />
155
182
  ) : (
156
- <pre className="a1-code-block__pre">
183
+ <pre
184
+ ref={preRef}
185
+ className="a1-code-block__pre"
186
+ style={collapses ? { "--a1-code-collapsed-max": `${collapsedLines * 1.6}em` } : undefined}
187
+ >
157
188
  <code className={codeClasses} {...props}>
158
189
  {children}
159
190
  </code>
160
191
  </pre>
161
192
  )}
162
- {copyCode && (
163
- <Button
164
- className="a1-code-block__copy"
165
- icon="content_copy"
166
- size="sm"
167
- variant="tertiary"
168
- onClick={handleCopy}
169
- type="button"
170
- >
171
- {copied ? copiedLabel : copyLabel}
172
- </Button>
193
+ {(copyCode || (collapses && overflows)) && (
194
+ <div className="a1-code-block__actions">
195
+ {copyCode && (
196
+ <Button
197
+ className="a1-code-block__copy"
198
+ icon="content_copy"
199
+ size="sm"
200
+ variant="tertiary"
201
+ onClick={handleCopy}
202
+ type="button"
203
+ >
204
+ {copied ? copiedLabel : copyLabel}
205
+ </Button>
206
+ )}
207
+ {collapses && overflows && (
208
+ <Button
209
+ className="a1-code-block__toggle"
210
+ icon={expanded ? "expand_less" : "expand_more"}
211
+ size="sm"
212
+ variant="tertiary"
213
+ onClick={() => setExpanded((v) => !v)}
214
+ type="button"
215
+ >
216
+ {expanded ? showLessLabel : showMoreLabel}
217
+ </Button>
218
+ )}
219
+ </div>
173
220
  )}
174
221
  </div>
175
222
  );
@@ -87,3 +87,33 @@
87
87
  .a1-code-block__copy {
88
88
  margin: 0;
89
89
  }
90
+
91
+ /* Collapsible block: cap the height with a fade and an Expand/Collapse toggle. */
92
+ .a1-code-block--collapsed .a1-code-block__pre {
93
+ position: relative;
94
+ max-block-size: var(--a1-code-collapsed-max, 22rem);
95
+ overflow-y: hidden;
96
+ }
97
+
98
+ .a1-code-block--clipped .a1-code-block__pre::after {
99
+ content: "";
100
+ position: absolute;
101
+ inset-inline: 0;
102
+ inset-block-end: 0;
103
+ block-size: var(--base-spacing-48, 3rem);
104
+ background: linear-gradient(to top, var(--semantic-color-surface-panel), transparent);
105
+ pointer-events: none;
106
+ border-end-start-radius: var(--base-radius-md);
107
+ border-end-end-radius: var(--base-radius-md);
108
+ }
109
+
110
+ .a1-code-block__toggle {
111
+ margin: 0;
112
+ }
113
+
114
+ /* Copy + Show more/less sit inline on one row. */
115
+ .a1-code-block__actions {
116
+ display: flex;
117
+ align-items: center;
118
+ gap: var(--base-spacing-8);
119
+ }
@@ -20,6 +20,8 @@ export interface GridProps extends React.HTMLAttributes<HTMLDivElement> {
20
20
  layout?: "default" | "bento";
21
21
  /** CSS value for `grid-auto-rows` */
22
22
  autoRows?: string;
23
+ /** Cross-axis (vertical) alignment of items within their row. Omit to inherit the grid default ("stretch" = equal-height items filling the row height). */
24
+ alignItems?: "start" | "center" | "end" | "stretch";
23
25
  children?: React.ReactNode;
24
26
  }
25
27
 
@@ -11,6 +11,7 @@ const gapSizes = {
11
11
  };
12
12
  const layouts = ["default", "bento"];
13
13
  const breakpoints = ["xs", "sm", "md", "lg", "xl"];
14
+ const alignments = ["start", "center", "end", "stretch"];
14
15
 
15
16
  function resolveGap(key) {
16
17
  if (key == null) return undefined;
@@ -29,6 +30,7 @@ export function Grid({
29
30
  columnGap,
30
31
  layout = "default",
31
32
  autoRows,
33
+ alignItems,
32
34
  className = "",
33
35
  children,
34
36
  ...props
@@ -40,6 +42,12 @@ export function Grid({
40
42
  classes.push(`a1-grid--${resolvedLayout}`);
41
43
  }
42
44
 
45
+ // Cross-axis (vertical) alignment of items in their row. Omit to inherit the
46
+ // grid default (stretch = equal-height items filling the row).
47
+ if (alignments.includes(alignItems)) {
48
+ classes.push(`a1-grid--align-${alignItems}`);
49
+ }
50
+
43
51
  let inlineCols;
44
52
  if (typeof columns === "number") {
45
53
  inlineCols = columns;
@@ -9,6 +9,12 @@
9
9
  align-items: stretch;
10
10
  }
11
11
 
12
+ /* Cross-axis (vertical) alignment of items within their row. */
13
+ .a1-grid--align-start { align-items: start; }
14
+ .a1-grid--align-center { align-items: center; }
15
+ .a1-grid--align-end { align-items: end; }
16
+ .a1-grid--align-stretch { align-items: stretch; }
17
+
12
18
  .a1-grid--bento > .a1-grid-item {
13
19
  min-height: 0;
14
20
  }
@@ -213,6 +213,13 @@
213
213
  color: var(--semantic-color-text-muted);
214
214
  }
215
215
 
216
+ /* When the parent is the current/selected item (a child page is active), its
217
+ leading icon matches the selected text colour instead of staying muted. */
218
+ .a1-top-header__flyout-trigger.a1-menu-item--active .a1-top-header__flyout-icon,
219
+ .a1-top-header__flyout-trigger[aria-current="page"] .a1-top-header__flyout-icon {
220
+ color: currentColor;
221
+ }
222
+
216
223
  .a1-top-header__flyout-chevron {
217
224
  margin-inline-start: auto;
218
225
  flex-shrink: 0;