@adia-ai/web-components 0.0.26 → 0.0.28

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 (60) hide show
  1. package/components/agent-artifact/agent-artifact.a2ui.json +1 -1
  2. package/components/agent-artifact/agent-artifact.css +11 -0
  3. package/components/agent-artifact/agent-artifact.js +23 -2
  4. package/components/agent-artifact/agent-artifact.yaml +1 -1
  5. package/components/agent-questions/agent-questions.css +20 -1
  6. package/components/agent-reasoning/agent-reasoning.css +11 -0
  7. package/components/agent-reasoning/agent-reasoning.js +16 -0
  8. package/components/agent-trace/agent-trace.css +36 -12
  9. package/components/alert/alert.a2ui.json +10 -4
  10. package/components/alert/alert.css +13 -0
  11. package/components/alert/alert.js +1 -1
  12. package/components/alert/alert.yaml +21 -4
  13. package/components/badge/badge.a2ui.json +0 -2
  14. package/components/badge/badge.css +20 -0
  15. package/components/badge/badge.js +10 -2
  16. package/components/badge/badge.yaml +0 -2
  17. package/components/breadcrumb/breadcrumb.a2ui.json +16 -1
  18. package/components/breadcrumb/breadcrumb.css +27 -0
  19. package/components/breadcrumb/breadcrumb.js +95 -17
  20. package/components/breadcrumb/breadcrumb.yaml +15 -1
  21. package/components/calendar-picker/calendar-picker.css +17 -0
  22. package/components/chart/chart.css +20 -13
  23. package/components/chart/chart.js +49 -17
  24. package/components/chart-legend/chart-legend.css +30 -54
  25. package/components/chart-legend/chart-legend.js +48 -30
  26. package/components/code/code.css +41 -0
  27. package/components/code/code.js +44 -3
  28. package/components/command/command.js +52 -1
  29. package/components/empty-state/empty-state.js +32 -21
  30. package/components/feed/feed-item.yaml +50 -0
  31. package/components/feed/feed.a2ui.json +59 -0
  32. package/components/feed/feed.css +141 -0
  33. package/components/feed/feed.js +276 -0
  34. package/components/feed/feed.yaml +33 -0
  35. package/components/index.js +2 -0
  36. package/components/list/list.js +20 -16
  37. package/components/menu/menu.css +18 -0
  38. package/components/menu/menu.js +24 -10
  39. package/components/pane/pane.css +5 -0
  40. package/components/pipeline-status/pipeline-status.css +15 -1
  41. package/components/popover/popover.css +17 -0
  42. package/components/select/select.css +18 -0
  43. package/components/swatch/swatch.a2ui.json +116 -0
  44. package/components/swatch/swatch.css +141 -0
  45. package/components/swatch/swatch.js +121 -0
  46. package/components/swatch/swatch.yaml +101 -0
  47. package/components/swiper/swiper.css +9 -0
  48. package/components/table/table.css +5 -0
  49. package/components/table/table.js +45 -1
  50. package/components/table-toolbar/table-toolbar.css +13 -0
  51. package/components/tag/tag.css +10 -0
  52. package/components/timeline/timeline.css +15 -4
  53. package/components/toast/toast.css +93 -48
  54. package/components/toast/toast.js +101 -22
  55. package/components/toolbar/toolbar.css +13 -0
  56. package/components/tooltip/tooltip.css +11 -3
  57. package/core/provider.js +1 -0
  58. package/package.json +1 -1
  59. package/styles/colors/semantics.css +1 -1
  60. package/styles/components.css +1 -0
@@ -32,7 +32,7 @@
32
32
  "default": ""
33
33
  },
34
34
  "kind": {
35
- "description": "Uppercase badge label.",
35
+ "description": "Badge label; value is normalized to uppercase before rendering.",
36
36
  "type": "string",
37
37
  "default": ""
38
38
  },
@@ -13,6 +13,7 @@
13
13
  --agent-artifact-fg-muted: var(--a-fg-muted);
14
14
  --agent-artifact-border: var(--a-border-subtle);
15
15
  --agent-artifact-header-bg: var(--a-bg);
16
+ --agent-artifact-header-bg-hover: var(--a-bg-hover);
16
17
 
17
18
  /* ── Typography ── */
18
19
  --agent-artifact-title-size: var(--a-ui-md);
@@ -46,6 +47,16 @@
46
47
  cursor: pointer;
47
48
  user-select: none;
48
49
  min-width: 0;
50
+ transition: background var(--agent-artifact-duration);
51
+ }
52
+
53
+ [data-artifact-header]:hover {
54
+ background: var(--agent-artifact-header-bg-hover);
55
+ }
56
+
57
+ [data-artifact-header]:focus-visible {
58
+ outline: none;
59
+ box-shadow: var(--a-focus-ring) inset;
49
60
  }
50
61
 
51
62
  [data-artifact-icon] {
@@ -17,7 +17,8 @@
17
17
  *
18
18
  * Attributes:
19
19
  * title — header title (string)
20
- * kind — small uppercase badge, e.g. "A2UI", "JSON", "TICKET"
20
+ * kind — small badge, e.g. "A2UI", "JSON", "TICKET" (value is
21
+ * normalized to uppercase before rendering)
21
22
  * icon — icon-ui name for the header
22
23
  * collapsed — start collapsed (body hidden)
23
24
  * tone — neutral (default) | accent | warning | danger
@@ -59,6 +60,7 @@ class AdiaAgentArtifact extends AdiaElement {
59
60
 
60
61
  disconnected() {
61
62
  this.#headerEl?.removeEventListener('click', this.#onHeaderClick);
63
+ this.#headerEl?.removeEventListener('keydown', this.#onHeaderKey);
62
64
  this.#headerEl = this.#iconEl = this.#titleEl = null;
63
65
  this.#kindEl = this.#chevronEl = this.#bodyEl = null;
64
66
  }
@@ -73,6 +75,17 @@ class AdiaAgentArtifact extends AdiaElement {
73
75
  this.render();
74
76
  };
75
77
 
78
+ // Keyboard activation — Space/Enter toggle (matches role="button" semantics).
79
+ #onHeaderKey = (e) => {
80
+ if (e.key === ' ' || e.key === 'Enter') {
81
+ // Don't fire when the focus is on a slotted action button — let those
82
+ // handle their own activation.
83
+ if (e.target.closest('[slot="primary"], [slot="secondary"]')) return;
84
+ e.preventDefault();
85
+ this.#onHeaderClick(e);
86
+ }
87
+ };
88
+
76
89
  render() {
77
90
  if (!this.#headerEl) return;
78
91
 
@@ -95,6 +108,9 @@ class AdiaAgentArtifact extends AdiaElement {
95
108
  if (this.#bodyEl) {
96
109
  this.#bodyEl.hidden = this.collapsed;
97
110
  }
111
+ if (this.#headerEl) {
112
+ this.#headerEl.setAttribute('aria-expanded', String(!this.collapsed));
113
+ }
98
114
  }
99
115
 
100
116
  #build() {
@@ -120,10 +136,15 @@ class AdiaAgentArtifact extends AdiaElement {
120
136
 
121
137
  this.innerHTML = '';
122
138
 
123
- // Header
139
+ // Header — keyboard-focusable button-style row that toggles collapsed.
140
+ // role/tabindex make Space/Enter activate it the same as click.
124
141
  this.#headerEl = document.createElement('div');
125
142
  this.#headerEl.setAttribute('data-artifact-header', '');
143
+ this.#headerEl.setAttribute('role', 'button');
144
+ this.#headerEl.setAttribute('tabindex', '0');
145
+ this.#headerEl.setAttribute('aria-expanded', String(!this.collapsed));
126
146
  this.#headerEl.addEventListener('click', this.#onHeaderClick);
147
+ this.#headerEl.addEventListener('keydown', this.#onHeaderKey);
127
148
 
128
149
  this.#iconEl = document.createElement('icon-ui');
129
150
  this.#iconEl.setAttribute('data-artifact-icon', '');
@@ -9,7 +9,7 @@ version: 1
9
9
  description: Inline container for structured agent artifacts (A2UI, JSON, tickets).
10
10
  props:
11
11
  kind:
12
- description: Uppercase badge label.
12
+ description: Badge label; value is normalized to uppercase before rendering.
13
13
  type: string
14
14
  default: ""
15
15
  collapsed:
@@ -63,7 +63,13 @@
63
63
 
64
64
  [data-questions-option] {
65
65
  display: flex;
66
- align-items: center;
66
+ /* Top-align so the leading icon stays with the label's first line
67
+ when a description wraps below; we restore visual centering on
68
+ the icon and check by nudging them to the label's first-line
69
+ center via margin-top. Centering the whole row dragged the icon
70
+ down to the body's vertical midpoint, which sat between label
71
+ and description rather than on the label. */
72
+ align-items: start;
67
73
  gap: var(--agent-questions-option-gap);
68
74
  padding: var(--agent-questions-option-padding);
69
75
  border: 1px solid var(--agent-questions-option-border);
@@ -101,6 +107,16 @@
101
107
 
102
108
  [data-questions-option-icon] {
103
109
  flex-shrink: 0;
110
+ /* Align the leading icon's center with the label's first-line
111
+ center. `1.5em` matches the label's effective line-height
112
+ (label is `font-size:` only — line-height inherits as 1.5).
113
+ `var(--a-icon-size)` is icon-ui's rendered height. Centering
114
+ the icon inside one line-box of the label's height puts its
115
+ midpoint on the label's first-line midpoint. We use em on the
116
+ icon's own font-size (which icon-ui keeps at the option's
117
+ font-size) so the offset scales with whatever the consumer
118
+ sets `--agent-questions-label-size` to. */
119
+ margin-top: calc((1.5em - var(--a-icon-size, 1em)) / 2);
104
120
  }
105
121
 
106
122
  [data-questions-option-body] {
@@ -134,6 +150,9 @@
134
150
  background: transparent;
135
151
  opacity: 0;
136
152
  transition: opacity var(--agent-questions-duration) var(--agent-questions-easing);
153
+ /* Match the leading-icon offset so a row of [icon · label · check]
154
+ reads as one horizontal line aligned to the label's first line. */
155
+ margin-top: calc((1.5em - var(--agent-questions-check-size)) / 2);
137
156
  }
138
157
 
139
158
  [data-questions-option][data-selected] [data-questions-option-check] {
@@ -55,6 +55,17 @@
55
55
  user-select: none;
56
56
  padding-bottom: var(--a-space-1);
57
57
  color: var(--agent-reasoning-fg-muted);
58
+ border-radius: var(--a-radius-sm);
59
+ transition: color var(--agent-reasoning-duration) var(--agent-reasoning-easing);
60
+ }
61
+
62
+ [data-reasoning-summary]:hover {
63
+ color: var(--agent-reasoning-fg);
64
+ }
65
+
66
+ [data-reasoning-summary]:focus-visible {
67
+ outline: none;
68
+ box-shadow: var(--a-focus-ring);
58
69
  }
59
70
 
60
71
  [data-reasoning-summary] > [data-reasoning-check],
@@ -109,6 +109,7 @@ class AdiaAgentReasoning extends AdiaElement {
109
109
  this.#finishTimer = null;
110
110
  }
111
111
  this.#summaryEl?.removeEventListener('click', this.#onSummaryClick);
112
+ this.#summaryEl?.removeEventListener('keydown', this.#onSummaryKey);
112
113
  this.#bodyEl?.removeEventListener('timeline-toggle', this.#onStepToggle);
113
114
  this.#summaryEl = null;
114
115
  this.#bodyEl = null;
@@ -265,12 +266,26 @@ class AdiaAgentReasoning extends AdiaElement {
265
266
  this.#render();
266
267
  };
267
268
 
269
+ // Keyboard activation — Space/Enter toggle (matches role="button" semantics).
270
+ #onSummaryKey = (e) => {
271
+ if (e.key === ' ' || e.key === 'Enter') {
272
+ e.preventDefault();
273
+ this.#onSummaryClick();
274
+ }
275
+ };
276
+
268
277
  #buildShell() {
269
278
  this.innerHTML = '';
270
279
 
280
+ // Summary is a click-to-toggle row; mark up + key-activate so it
281
+ // behaves like a button under keyboard nav too.
271
282
  this.#summaryEl = document.createElement('div');
272
283
  this.#summaryEl.setAttribute('data-reasoning-summary', '');
284
+ this.#summaryEl.setAttribute('role', 'button');
285
+ this.#summaryEl.setAttribute('tabindex', '0');
286
+ this.#summaryEl.setAttribute('aria-expanded', String(!this.collapsed));
273
287
  this.#summaryEl.addEventListener('click', this.#onSummaryClick);
288
+ this.#summaryEl.addEventListener('keydown', this.#onSummaryKey);
274
289
  this.appendChild(this.#summaryEl);
275
290
 
276
291
  this.#bodyEl = document.createElement('div');
@@ -341,6 +356,7 @@ class AdiaAgentReasoning extends AdiaElement {
341
356
  `;
342
357
 
343
358
  this.#bodyEl.hidden = this.collapsed;
359
+ this.#summaryEl.setAttribute('aria-expanded', String(!this.collapsed));
344
360
  if (this.collapsed) return;
345
361
 
346
362
  // Body rendering: we group contiguous steps into timeline-ui blocks and
@@ -43,9 +43,23 @@
43
43
  gap: var(--agent-trace-inline-gap);
44
44
  cursor: pointer;
45
45
  white-space: nowrap;
46
+ border-radius: var(--a-radius-sm);
47
+ padding: var(--a-space-0-5) var(--a-space-1);
48
+ margin: calc(var(--a-space-0-5) * -1) calc(var(--a-space-1) * -1);
49
+ transition: background var(--agent-trace-chevron-dur);
50
+ }
51
+
52
+ [data-trace-root] > summary:hover {
53
+ background: var(--a-bg-subtle);
54
+ }
55
+
56
+ [data-trace-root] > summary:focus-visible {
57
+ outline: none;
58
+ box-shadow: var(--a-focus-ring);
46
59
  }
47
60
 
48
61
  [data-trace-root] > summary::-webkit-details-marker { display: none; }
62
+ [data-trace-root] > summary::marker { content: ''; }
49
63
 
50
64
  [data-trace-status] {
51
65
  flex: 1;
@@ -92,35 +106,40 @@
92
106
  margin-block-start: var(--agent-trace-block-gap);
93
107
  }
94
108
 
95
- /* Rows container — a stack; each row owns its own grid. This makes
96
- expandable details elements play well, since <details> can't be a
97
- grid item spanning multiple tracks cleanly. */
109
+ /* Rows container — one grid, every row participates via subgrid.
110
+ This is the only way to get score + detail to land on the same X
111
+ across all rows; per-row grids let `max-content` widen the score
112
+ track row-by-row, and "94/100" pushes its row's detail column ~16px
113
+ right of "7%"'s detail column. Subgrid pulls the track widths up
114
+ to the parent so every row sees the same column stops. */
98
115
  [data-trace-rows] {
99
116
  --trace-row-cols: var(--agent-trace-row-label-col) max-content 1fr;
100
- display: flex;
101
- flex-direction: column;
117
+ display: grid;
118
+ grid-template-columns: var(--trace-row-cols);
119
+ column-gap: var(--agent-trace-block-gap);
102
120
  }
103
121
 
104
122
  [data-trace-rows][data-has-details] {
105
123
  --trace-row-cols: var(--agent-trace-row-label-col) max-content 1fr auto;
106
124
  }
107
125
 
108
- /* Each row's summary lays out the columns. For non-expandable rows
109
- (plain divs), the row itself is the grid. For expandable rows
110
- (<details>), the summary is the grid; the body is a block below. */
126
+ /* Each row + the headers row + each <details>'s <summary> all use
127
+ subgrid so they inherit the parent's column stops. <details> itself
128
+ is also a grid child so its open-state body can span all columns. */
111
129
  [data-trace-headers],
112
130
  div[data-trace-row],
131
+ details[data-trace-row],
113
132
  details[data-trace-row] > summary {
114
133
  display: grid;
115
- grid-template-columns: var(--trace-row-cols);
134
+ grid-template-columns: subgrid;
135
+ grid-column: 1 / -1;
116
136
  column-gap: var(--agent-trace-block-gap);
117
137
  align-items: baseline;
118
138
  min-width: 0;
119
139
  }
120
140
 
121
- details[data-trace-row] {
122
- display: block;
123
- min-width: 0;
141
+ details[data-trace-row] > [data-trace-row-body] {
142
+ grid-column: 1 / -1;
124
143
  }
125
144
 
126
145
  /* Column headers — small caps with subtle underline */
@@ -163,6 +182,11 @@
163
182
  background: var(--a-bg-subtle);
164
183
  }
165
184
 
185
+ details[data-trace-row] > summary:focus-visible {
186
+ outline: none;
187
+ box-shadow: var(--a-focus-ring);
188
+ }
189
+
166
190
  [data-trace-label] {
167
191
  color: var(--agent-trace-fg-subtle);
168
192
  }
@@ -93,11 +93,17 @@
93
93
  "button"
94
94
  ],
95
95
  "slots": {
96
- "default": {
97
- "description": "Rich content for the alert body"
96
+ "action": {
97
+ "description": "Optional trailing action button (e.g. \"Refresh\", \"Status page\"). Right-aligned in the flex layout. See system-banners pattern for examples."
98
98
  },
99
- "icon": {
100
- "description": "Custom icon element"
99
+ "close": {
100
+ "description": "Close button. Stamped automatically when `closable` is set; the stamped element is `<button-ui slot=\"close\" icon=\"x\" variant=\"ghost\" size=\"sm\">`. Override by passing a custom `slot=\"close\"` button."
101
+ },
102
+ "content": {
103
+ "description": "Alert body. For single-line text use the `text=` attribute (alert.js stamps a `<span slot=\"content\">` automatically). For rich content with title + description, wrap in `<col-ui slot=\"content\">` — the alert root is `display: flex` row, so multiple bare children would lay out side-by-side instead of stacking."
104
+ },
105
+ "leading": {
106
+ "description": "Leading icon. Stamped automatically from the `icon=` attribute; consumers can override by passing a custom `<icon-ui slot=\"leading\">`."
101
107
  }
102
108
  },
103
109
  "states": [
@@ -65,6 +65,19 @@
65
65
  --alert-icon-fg: var(--a-danger-strong);
66
66
  }
67
67
 
68
+ /* `muted` and `neutral` are semantic aliases of the base — same tokens
69
+ as the default variant, declared explicitly so the yaml enum and the
70
+ CSS contract agree. Use cases: passive callouts (beta banners,
71
+ tertiary notes) where the alert should read as quiet chrome rather
72
+ than carrying tonal weight. */
73
+ :scope[variant="muted"],
74
+ :scope[variant="neutral"] {
75
+ --alert-bg: var(--a-bg-muted);
76
+ --alert-fg: var(--a-fg);
77
+ --alert-border: var(--a-border-subtle);
78
+ --alert-icon-fg: var(--a-fg-muted);
79
+ }
80
+
68
81
  /* ── Slots ── */
69
82
  :scope [slot="leading"] {
70
83
  flex-shrink: 0;
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Inline alert banner with optional icon and close button.
6
6
  *
7
- * Variants: default, info, success, warning, danger
7
+ * Variants: default, info, success, warning, danger, muted, neutral
8
8
  * Slots: leading (icon), content (text), close (dismiss button)
9
9
  *
10
10
  * Events:
@@ -36,10 +36,27 @@ events:
36
36
  close:
37
37
  description: Fired when the close button is clicked
38
38
  slots:
39
- default:
40
- description: Rich content for the alert body
41
- icon:
42
- description: Custom icon element
39
+ content:
40
+ description: >-
41
+ Alert body. For single-line text use the `text=` attribute (alert.js
42
+ stamps a `<span slot="content">` automatically). For rich content
43
+ with title + description, wrap in `<col-ui slot="content">` — the
44
+ alert root is `display: flex` row, so multiple bare children would
45
+ lay out side-by-side instead of stacking.
46
+ leading:
47
+ description: >-
48
+ Leading icon. Stamped automatically from the `icon=` attribute;
49
+ consumers can override by passing a custom `<icon-ui slot="leading">`.
50
+ close:
51
+ description: >-
52
+ Close button. Stamped automatically when `closable` is set; the
53
+ stamped element is `<button-ui slot="close" icon="x" variant="ghost"
54
+ size="sm">`. Override by passing a custom `slot="close"` button.
55
+ action:
56
+ description: >-
57
+ Optional trailing action button (e.g. "Refresh", "Status page").
58
+ Right-aligned in the flex layout. See system-banners pattern for
59
+ examples.
43
60
  states:
44
61
  - name: idle
45
62
  description: Default, ready for interaction.
@@ -61,8 +61,6 @@
61
61
  "warning",
62
62
  "danger",
63
63
  "primary",
64
- "soft",
65
- "outline",
66
64
  "muted",
67
65
  "neutral"
68
66
  ],
@@ -74,5 +74,25 @@
74
74
  --badge-fg: var(--a-danger-text);
75
75
  }
76
76
 
77
+ /* `primary` is the accent-filled variant — solid bg + on-accent text,
78
+ used for "PRO"-style emphasis chips that should read as a brand
79
+ stamp rather than tinted-on-canvas. Uses the L3 primary-* surface
80
+ matrix so it tracks the same accent-strong source as button-ui's
81
+ primary variant. */
82
+ :scope[variant="primary"] {
83
+ --badge-bg: var(--a-primary-bg);
84
+ --badge-fg: var(--a-primary-fg);
85
+ }
86
+
87
+ /* `muted` and `neutral` are semantic aliases of the base — same tokens
88
+ as the default variant, declared explicitly so the yaml enum and the
89
+ CSS contract agree. Use cases: passive metadata chips (counts, IDs,
90
+ "Not set" states) where the badge should read as quiet chrome. */
91
+ :scope[variant="muted"],
92
+ :scope[variant="neutral"] {
93
+ --badge-bg: var(--a-bg-muted);
94
+ --badge-fg: var(--a-fg);
95
+ }
96
+
77
97
  /* Size handled by universal [size] attribute system. */
78
98
  }
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Inline badge / tag. Pill-shaped, text rendered via CSS attr().
8
8
  *
9
- * Variants: default, accent, info, success, warning, danger
9
+ * Variants: default, accent, info, success, warning, danger, primary, muted, neutral
10
10
  * Sizes: sm, md (default)
11
11
  * Icon: optional leading icon (any registered icon name; "dot" for legend
12
12
  * markers). Inherits the variant's foreground color so legend chips
@@ -36,7 +36,15 @@ class AdiaBadge extends AdiaElement {
36
36
  static template = () => null;
37
37
 
38
38
  connected() {
39
- this.setAttribute('role', 'status');
39
+ /* Default role is `status` (matches badge's "passive callout" semantic
40
+ — a screen reader announces the badge text as a state without
41
+ interrupting). Consumers that wire interactivity (e.g.
42
+ `<chart-legend-ui>` toggling series) are free to set
43
+ `role="button"` (or any other role) before connection — we don't
44
+ overwrite an explicit consumer choice. */
45
+ if (!this.hasAttribute('role')) {
46
+ this.setAttribute('role', 'status');
47
+ }
40
48
  }
41
49
 
42
50
  render() {
@@ -52,8 +52,6 @@ props:
52
52
  - warning
53
53
  - danger
54
54
  - primary
55
- - soft
56
- - outline
57
55
  - muted
58
56
  - neutral
59
57
  events: {}
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://adiaui.dev/a2ui/v0_9/components/Breadcrumb.json",
4
4
  "title": "Breadcrumb",
5
- "description": "Breadcrumb trail with auto-inserted separators.",
5
+ "description": "Breadcrumb trail with auto-inserted separators. Supports a leading icon (first child) and an overflow popover that collapses middle crumbs into a `…` menu.",
6
6
  "type": "object",
7
7
  "allOf": [
8
8
  {
@@ -13,6 +13,21 @@
13
13
  }
14
14
  ],
15
15
  "properties": {
16
+ "collapse": {
17
+ "description": "Collapse middle crumbs into a `…` overflow popover when there are 4+ items.",
18
+ "type": "boolean",
19
+ "default": false
20
+ },
21
+ "collapseKeepLeading": {
22
+ "description": "Number of leading items to keep visible when [collapse] is active. The first item (often a home/icon link) sits before the overflow popover.",
23
+ "type": "number",
24
+ "default": 1
25
+ },
26
+ "collapseKeepTrailing": {
27
+ "description": "Number of trailing items to keep visible when [collapse] is active. The last item is always the current page.",
28
+ "type": "number",
29
+ "default": 2
30
+ },
16
31
  "component": {
17
32
  "const": "Breadcrumb"
18
33
  },
@@ -75,4 +75,31 @@
75
75
  pointer-events: none;
76
76
  text-decoration: none;
77
77
  }
78
+
79
+ /* Items moved into the overflow popover are kept in the light DOM
80
+ so the consumer's order of truth stays intact; we only hide them
81
+ visually when [collapse] is active. */
82
+ [data-collapsed] {
83
+ display: none;
84
+ }
85
+
86
+ /* Overflow trigger — `<menu-ui data-overflow>` stamped between
87
+ the leading and trailing visible groups. Layout-wise it behaves
88
+ like any other [data-item], but the trigger button inside it
89
+ handles its own padding/focus, so we strip the item max-width to
90
+ let the `…` glyph size to its content. The popover surface and
91
+ the menu-item-ui rows inherit menu-ui's canonical styling — no
92
+ visual overrides needed here. */
93
+ [data-overflow] {
94
+ max-width: none;
95
+ overflow: visible;
96
+ }
97
+
98
+ /* Icon-leading shape — a `<icon-ui>` (or a link wrapping one) as
99
+ the first child should optical-center on the text baseline of
100
+ the rest of the row without inheriting the text-truncate cap. */
101
+ [data-item] > icon-ui:only-child,
102
+ a[data-item]:has(> icon-ui:only-child) {
103
+ max-width: none;
104
+ }
78
105
  }
@@ -1,8 +1,32 @@
1
+ /**
2
+ * <breadcrumb-ui separator="/">
3
+ * <a href="/"><icon-ui name="house"></icon-ui></a>
4
+ * <a href="#">Workspace</a>
5
+ * <span>Current page</span>
6
+ * </breadcrumb-ui>
7
+ *
8
+ * Trail with auto-inserted separators. Items can be plain elements or
9
+ * `<a href>` links; the last item is marked `aria-current="page"`.
10
+ *
11
+ * Variants:
12
+ * • Default — text-only crumbs
13
+ * • Icon-leading — first child is `<icon-ui>` (or a link wrapping one)
14
+ * • Collapsed — `[collapse]` hides middle crumbs into a `…` overflow
15
+ * popover; keeps `[collapse-keep-leading]` (default 1) and
16
+ * `[collapse-keep-trailing]` (default 2) visible at the edges.
17
+ *
18
+ * The overflow uses `<popover-ui>` so it rides the top-layer (no
19
+ * z-index battles inside scrolling containers).
20
+ */
21
+
1
22
  import { AdiaElement } from '../../core/element.js';
2
23
 
3
24
  class AdiaBreadcrumb extends AdiaElement {
4
25
  static properties = {
5
- separator: { type: String, default: '/', reflect: true },
26
+ separator: { type: String, default: '/', reflect: true },
27
+ collapse: { type: Boolean, default: false, reflect: true },
28
+ collapseKeepLeading: { type: Number, default: 1, attribute: 'collapse-keep-leading', reflect: true },
29
+ collapseKeepTrailing: { type: Number, default: 2, attribute: 'collapse-keep-trailing', reflect: true },
6
30
  };
7
31
 
8
32
  static template = () => null;
@@ -13,30 +37,84 @@ class AdiaBreadcrumb extends AdiaElement {
13
37
  }
14
38
 
15
39
  render() {
16
- // Remove old separators
17
- this.querySelectorAll('[data-sep]').forEach(el => el.remove());
40
+ // Strip prior chrome so render is idempotent across attribute changes.
41
+ this.querySelectorAll('[data-sep], [data-overflow]').forEach(el => el.remove());
18
42
 
19
- const items = Array.from(this.children).filter(c => !c.hasAttribute('data-sep'));
43
+ const items = Array.from(this.children).filter(c =>
44
+ !c.hasAttribute('data-sep') && !c.hasAttribute('data-overflow')
45
+ );
20
46
  const last = items.length - 1;
21
47
 
48
+ // Reset per-item chrome
22
49
  items.forEach((child, i) => {
23
- child.removeAttribute('data-separator');
24
50
  child.setAttribute('data-item', '');
51
+ child.removeAttribute('data-collapsed');
52
+ if (i === last) child.setAttribute('aria-current', 'page');
53
+ else child.removeAttribute('aria-current');
54
+ });
55
+
56
+ const keepLeading = Math.max(0, this.collapseKeepLeading | 0);
57
+ const keepTrailing = Math.max(0, this.collapseKeepTrailing | 0);
58
+ const minLen = keepLeading + keepTrailing + 1;
59
+ const shouldCollapse = this.collapse && items.length >= Math.max(minLen, 4);
25
60
 
26
- if (i === last) {
27
- child.setAttribute('aria-current', 'page');
28
- } else {
29
- child.removeAttribute('aria-current');
30
- // Insert separator span after this item (before next sibling)
31
- if (!child.nextElementSibling?.hasAttribute('data-sep')) {
32
- const sep = document.createElement('span');
33
- sep.setAttribute('data-sep', '');
34
- sep.setAttribute('aria-hidden', 'true');
35
- sep.textContent = this.separator;
36
- child.after(sep);
37
- }
61
+ if (shouldCollapse) {
62
+ const collapsed = items.slice(keepLeading, items.length - keepTrailing);
63
+ collapsed.forEach(el => el.setAttribute('data-collapsed', ''));
64
+
65
+ const overflow = this.#buildOverflow(collapsed);
66
+ if (keepLeading > 0) items[keepLeading - 1].after(overflow);
67
+ else this.prepend(overflow);
68
+ }
69
+
70
+ // Stamp separators between every consecutive visible sibling.
71
+ const visible = Array.from(this.children).filter(c =>
72
+ !c.hasAttribute('data-sep') && !c.hasAttribute('data-collapsed')
73
+ );
74
+ for (let i = 0; i < visible.length - 1; i++) {
75
+ const sep = document.createElement('span');
76
+ sep.setAttribute('data-sep', '');
77
+ sep.setAttribute('aria-hidden', 'true');
78
+ sep.textContent = this.separator;
79
+ visible[i].after(sep);
80
+ }
81
+ }
82
+
83
+ #buildOverflow(collapsedItems) {
84
+ // Use <menu-ui> as the canonical popover-list primitive — same surface
85
+ // tokens as select-ui's listbox + menu-ui's action menu, with keyboard
86
+ // nav, top-layer rendering, and anchor positioning for free.
87
+ const menu = document.createElement('menu-ui');
88
+ menu.setAttribute('data-overflow', '');
89
+ menu.setAttribute('data-item', '');
90
+ menu.setAttribute('placement', 'bottom-start');
91
+
92
+ const trigger = document.createElement('button-ui');
93
+ trigger.setAttribute('slot', 'trigger');
94
+ trigger.setAttribute('text', '…');
95
+ trigger.setAttribute('variant', 'ghost');
96
+ trigger.setAttribute('size', 'sm');
97
+ trigger.setAttribute('aria-label', 'Show collapsed crumbs');
98
+ menu.appendChild(trigger);
99
+
100
+ for (const item of collapsedItems) {
101
+ const mi = document.createElement('menu-item-ui');
102
+ mi.setAttribute('text', item.textContent.trim());
103
+ // Encode the link target as the menu-item value; navigated on action.
104
+ if (item.tagName === 'A' && item.hasAttribute('href')) {
105
+ mi.setAttribute('value', item.getAttribute('href'));
38
106
  }
107
+ menu.appendChild(mi);
108
+ }
109
+
110
+ // menu-ui dispatches `action` with `detail: { value, text }` on item
111
+ // activation. Navigate to the encoded href; ignore placeholder `#`.
112
+ menu.addEventListener('action', (e) => {
113
+ const href = e.detail?.value;
114
+ if (href && href !== '#') window.location.href = href;
39
115
  });
116
+
117
+ return menu;
40
118
  }
41
119
  }
42
120