@adia-ai/web-components 0.7.16 → 0.7.18

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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog — @adia-ai/web-components
2
2
 
3
+ ## [0.7.18] — 2026-06-09
4
+
5
+ ### Changed
6
+ - **Clear caret OPEN state across the dropdown family.** Every dropdown-class primitive's caret now flips 180° while its popover/listbox is open (and returns on close), with a fast reduced-motion-respecting transition: `select-ui` (its trigger glyph also changed `caret-up-down` → `caret-down` to join the family convention), `combobox-ui`, `datetime-picker-ui`, `date-range-picker-ui`, and `menu-ui` (a consumer trigger carrying a `caret-down` icon — e.g. `<button-ui icon-trailing="caret-down">` — flips automatically). All rules are **name-guarded** (`icon-ui[name="caret-down"]`) so custom non-caret trigger/suffix glyphs never rotate. Disclosure-row components already had clear states (nav-group/tree/pane/agent-trace rotate 90°, agent-reasoning/agent-artifact swap glyphs, accordion rotates) and are unchanged; `table-toolbar`'s `caret-up-down` is a sort indicator and `calendar-picker`'s carets are month-nav arrows — deliberately excluded. Regression harness: `scripts/dev/caret-open-state-probe.mjs` (15 browser assertions: renders / flips 180° / returns, per component).
7
+
8
+ ### Fixed
9
+ - **`datetime-picker-ui` / `date-range-picker-ui` — the default trigger's caret now actually renders.** Both default triggers declared `trailing-icon="caret-down"`, but `button-ui`'s attribute is `icon-trailing` — the made-up attribute was silently ignored (the silent-failure-on-unknown-attrs class), so the caret never appeared. Fixed to `icon-trailing="caret-down"`; combined with the change above, the picker triggers now show a caret that flips while open.
10
+ - **`combobox-ui` — the menu can no longer cover its own input.** Two root causes fixed in `core/anchor.js`: (1) the native anchor path set an inline `max-height: calc(100vh - 3rem)` that CLOBBERED the component's stylesheet cap, so long option lists grew viewport-tall; (2) neither path reliably flipped — Chromium's `position-try-fallbacks: flip-block` demonstrably does NOT fire for a tall scrollable popover (verified via the new probe), and the JS fallback's viewport clamp slid an oversized menu UP OVER the trigger. Both paths now pick the vertical side **explicitly at open** (prefer the requested side; flip when it can't fit and the other side can; when neither fits, take the roomier side) and **clamp `max-height` to that side's available space** — the popover can never cross the anchor. Components can declare their own cap via `--popover-max-height` (the vertical twin of the existing `--popover-max-width` convention). Affects all `anchorPopover` consumers (combobox, select, menu, context-menu, tooltip, pickers) uniformly for the better. Regression harness: `scripts/dev/combobox-overflow-probe.mjs` (11 browser assertions incl. a short-viewport neither-side-fits case).
11
+ - **`combobox-ui` — opening a flipped menu no longer yanks the page.** Active-option reveal now scrolls WITHIN the listbox (scroll arithmetic) instead of `scrollIntoView({block:'nearest'})`, which also scrolled document ancestors.
12
+ - **`combobox-ui` — match-highlight spacing.** `[role="option"]` is `display:flex`, which dropped the whitespace between the `<mark>` and the rest of the label ("Ada Lovelace" rendered "AdaLovelace"); option labels now render inside a single `[data-option-label]` span. The `mark` rule also consumes the (previously dead) `--combobox-match-bg/fg` tokens with raw-token fallbacks.
13
+
14
+ ### Added
15
+ - **`combobox-ui` `[max-visible-items]`** (default **10**) — caps how many option rows are visible before the menu scrolls; the listbox height hugs N rows (measured live, so it tracks `[size]`/density) instead of growing to the 18rem fallback. Distinct from `[max-options]` (the render cap). `0` disables. Demo: the new "long list — visible-item cap + flip" section (60 declarative options) in `combobox.examples.html`.
16
+ - **`<theme-provider>` one-`<script>` CDN bundle** — `dist/theme-provider.min.js` (536 KB, self-contained: the element + its foundation-adoption machinery). A single `<script src=".../theme-provider.min.js">` registers `<theme-provider>`, which adopts the foundation from anywhere in the DOM (no `<head>` stylesheet link) — the CDN-first path for the 0.7.12 element. Added as a `bundle-js.mjs` entry, so `check:js-bundles-fresh` (blocking) gates its freshness alongside the other 6 bundles; served via the existing `./dist/*` export + the CDN. (First of the `<theme-provider>` follow-ups; build-time `<head>` hoist + an `adia-ui-kit` compose recipe + the embedded-app dogfood remain on the roadmap.)
17
+
18
+ ## [0.7.17] — 2026-06-08
19
+
20
+ ### Fixed
21
+ - **`badge-ui` / `tag-ui` — icon-bearing chips no longer inflate row height.** Capped the inner `icon-ui` block-size to the text line-height (`components/badge/badge.css` `0.75em`, `components/tag/tag.css` `1em`) so a leading icon can't push the chip taller than a text-only one. Plus an `[icon]`-gated spacing tighten — reduced leading padding (`--a-space-1`) + a `~0.2em` icon↔label gap so the icon + label read as one tight unit; text-only chips unchanged. `dist/web-components.min.{css,js}` + `.sheet.js` regenerated to match.
22
+
3
23
  ## [0.7.16] — 2026-06-08
4
24
 
5
25
  ### Changed
@@ -45,13 +45,27 @@
45
45
 
46
46
  /* Leading icon stamped by JS (when [icon] is set). Inherits the badge's
47
47
  foreground color so legend chips read as a single color-coded unit
48
- (e.g. accent variant → accent-colored dot + accent-colored label). */
48
+ (e.g. accent variant → accent-colored dot + accent-colored label).
49
+ ICON-HEIGHT FIX: cap the icon block-size so it cannot exceed the text
50
+ line-height. `line-height:1` on the badge drives the block size via
51
+ the text glyph; an icon taller than 1em would inflate the row. */
49
52
  :scope > icon-ui {
50
53
  --a-icon-size: 0.75em;
54
+ block-size: 0.75em;
51
55
  color: currentColor;
52
56
  flex-shrink: 0;
53
57
  }
54
58
 
59
+ /* ── Icon-badge spacing rebalance (B0) ──
60
+ Symmetric --badge-px looks loose / left-heavy when a leading icon is present:
61
+ the icon's own visual mass eats into the left breathing room. Tighten the
62
+ leading pad to --a-space-1 (4px) and narrow the gap to ~0.2em so the icon+label
63
+ read as ONE tight unit. Gated on [icon] so text-only badges are unaffected. */
64
+ :scope[icon] {
65
+ padding-inline-start: var(--badge-px-icon, var(--a-space-1));
66
+ gap: var(--badge-gap-icon, 0.2em);
67
+ }
68
+
55
69
  /* ── Variants ──
56
70
  Badge fg pairs with the muted (tinted) bg — use --a-{family}-text, which
57
71
  flips with scheme (shade in light, tint in dark) for proper contrast.
@@ -81,6 +81,11 @@
81
81
  "type": "number",
82
82
  "default": 100
83
83
  },
84
+ "max-visible-items": {
85
+ "description": "How many option rows are visible before the listbox scrolls — the menu height hugs N rows instead of growing to the 18rem fallback. Distinct from max-options (the render cap). 0 disables.",
86
+ "type": "number",
87
+ "default": 10
88
+ },
84
89
  "name": {
85
90
  "description": "Form control name for form data submission",
86
91
  "type": "string",
@@ -75,6 +75,10 @@ export class UICombobox extends UIFormElement {
75
75
  filterMode: { type: String, default: 'substring', reflect: true, attribute: 'filter-mode' },
76
76
  loading: { type: Boolean, default: false, reflect: true },
77
77
  maxOptions: { type: Number, default: 100, reflect: true, attribute: 'max-options' },
78
+ // Height cap: how many option rows are VISIBLE before the listbox scrolls
79
+ // (distinct from max-options, which caps how many are RENDERED at all).
80
+ // 0 disables the cap (falls back to the stylesheet's 18rem max).
81
+ maxVisibleItems: { type: Number, default: 10, reflect: true, attribute: 'max-visible-items' },
78
82
  highlightMatch: { type: Boolean, default: true, reflect: true, attribute: 'highlight-match' },
79
83
  };
80
84
 
@@ -392,7 +396,10 @@ export class UICombobox extends UIFormElement {
392
396
  el.dataset.value = opt.value ?? '';
393
397
  if (opt.disabled) el.setAttribute('aria-disabled', 'true');
394
398
  if (opt.value === this.value) el.setAttribute('aria-selected', 'true');
395
- el.innerHTML = this.#renderOptionLabel(opt.label ?? opt.value);
399
+ // Single wrapping span: [role="option"] is display:flex, and a bare
400
+ // <mark> + text-run pair become SEPARATE flex items — flex drops the
401
+ // whitespace between them ("Ada Lovelace" rendered "AdaLovelace").
402
+ el.innerHTML = `<span data-option-label>${this.#renderOptionLabel(opt.label ?? opt.value)}</span>`;
396
403
  el.addEventListener('click', this.#onOptionClick);
397
404
  el.addEventListener('mouseenter', this.#onOptionMouseEnter);
398
405
  return el;
@@ -427,11 +434,40 @@ export class UICombobox extends UIFormElement {
427
434
  this.#emptyEl.style.display = visible || this.loading ? 'none' : '';
428
435
  }
429
436
 
437
+ // Re-apply the visible-item height cap — typing re-filters the row count.
438
+ if (this.open) this.#applyVisibleCap();
439
+
430
440
  // Clear any stale active-descendant pointer.
431
441
  this.#activeIndex = -1;
432
442
  this.#inputEl?.removeAttribute('aria-activedescendant');
433
443
  }
434
444
 
445
+ // ── Visible-item height cap ──
446
+
447
+ /**
448
+ * Cap the listbox to `max-visible-items` rows; rows past the cap scroll.
449
+ * Measured from the first rendered row so the cap tracks [size] / density /
450
+ * register automatically. Distinct from `max-options` (render cap). Only
451
+ * measurable while the popover is open (display:none otherwise) — callers
452
+ * gate on that.
453
+ */
454
+ #applyVisibleCap() {
455
+ if (!this.#listbox) return;
456
+ const cap = Number(this.maxVisibleItems);
457
+ const rows = this.#listbox.querySelectorAll('[role="option"]');
458
+ if (!Number.isFinite(cap) || cap <= 0 || rows.length <= cap) {
459
+ this.#listbox.style.removeProperty('--combobox-list-max-block');
460
+ return;
461
+ }
462
+ const rowH = rows[0].getBoundingClientRect().height;
463
+ if (!rowH) return; // not measurable yet — leave the standing cap untouched
464
+ const cs = getComputedStyle(this.#listbox);
465
+ const chrome =
466
+ (parseFloat(cs.paddingBlockStart) || 0) + (parseFloat(cs.paddingBlockEnd) || 0) +
467
+ (parseFloat(cs.borderBlockStartWidth) || 0) + (parseFloat(cs.borderBlockEndWidth) || 0);
468
+ this.#listbox.style.setProperty('--combobox-list-max-block', `${Math.ceil(rowH * cap + chrome)}px`);
469
+ }
470
+
435
471
  #renderOptionLabel(label) {
436
472
  const text = String(label ?? '');
437
473
  if (!this.highlightMatch || !this.#query) return escapeHTML(text);
@@ -615,7 +651,16 @@ export class UICombobox extends UIFormElement {
615
651
  const el = opts[clamped];
616
652
  if (!el) return;
617
653
  el.setAttribute('data-active', '');
618
- el.scrollIntoView({ block: 'nearest' });
654
+ // Scroll WITHIN the listbox only — scrollIntoView({block:'nearest'}) also
655
+ // scrolls document ancestors, which yanked the whole page when a flipped
656
+ // (above-the-field) menu poked past the viewport top.
657
+ const lb = this.#listbox;
658
+ if (lb) {
659
+ const oTop = el.offsetTop;
660
+ const oBot = oTop + el.offsetHeight;
661
+ if (oTop < lb.scrollTop) lb.scrollTop = oTop;
662
+ else if (oBot > lb.scrollTop + lb.clientHeight) lb.scrollTop = oBot - lb.clientHeight;
663
+ }
619
664
  this.#activeIndex = clamped;
620
665
  this.#inputEl?.setAttribute('aria-activedescendant', el.id);
621
666
  }
@@ -804,6 +849,8 @@ export class UICombobox extends UIFormElement {
804
849
  #openPopover() {
805
850
  if (!this.#listbox) return;
806
851
  this.#listbox.showPopover?.();
852
+ // Cap the height BEFORE anchoring so flip/collision math sees the capped box.
853
+ this.#applyVisibleCap();
807
854
  const trigger = this.querySelector(':scope > [data-field]') || this;
808
855
  this.#listbox.style.minWidth = `${trigger.getBoundingClientRect().width}px`;
809
856
  this.#anchorCleanup?.();
@@ -120,6 +120,14 @@
120
120
  [data-suffix] {
121
121
  cursor: pointer;
122
122
  }
123
+ [data-suffix] icon-ui[name="caret-down"] {
124
+ transition: rotate var(--a-duration-fast) var(--a-easing);
125
+ }
126
+ /* Clear OPEN state: the default caret flips while the listbox is open.
127
+ Name-guarded — a consumer-slotted suffix glyph never rotates. */
128
+ :scope[open] [data-suffix] icon-ui[name="caret-down"] {
129
+ rotate: 180deg;
130
+ }
123
131
 
124
132
  [data-input] {
125
133
  flex: 1;
@@ -180,12 +188,21 @@ combobox-ui [data-field] [data-input] {
180
188
 
181
189
  combobox-ui [data-listbox] {
182
190
  margin: 0;
191
+ /* border-box so the JS visible-item cap (rows + padding + border) maps 1:1
192
+ onto max-height. */
193
+ box-sizing: border-box;
183
194
  padding: var(--a-space-1);
184
195
  border: 1px solid var(--a-ui-border);
185
196
  border-radius: var(--a-radius);
186
197
  background: var(--a-canvas-bright);
187
198
  box-shadow: var(--a-shadow-lg);
188
- max-height: 18rem;
199
+ /* --combobox-list-max-block is set inline by #applyVisibleCap() when the
200
+ rendered options exceed [max-visible-items] (default 10) — the menu hugs
201
+ N rows and scrolls past the cap. 18rem is the uncapped fallback. */
202
+ max-height: var(--combobox-list-max-block, 18rem);
203
+ /* Feed the same cap to core/anchor.js's native-path viewport clamp (which
204
+ sets an INLINE max-height and would otherwise out-cascade this rule). */
205
+ --popover-max-height: var(--combobox-list-max-block, 18rem);
189
206
  overflow-y: auto;
190
207
  font-family: inherit;
191
208
  font-size: var(--a-ui-size);
@@ -239,8 +256,11 @@ combobox-ui [data-listbox] [role="option"][aria-disabled="true"] {
239
256
  }
240
257
 
241
258
  combobox-ui [data-listbox] [role="option"] mark {
242
- background: var(--a-accent-muted);
243
- color: var(--a-accent-strong);
259
+ /* Consume the :where(:scope) match tokens (custom props inherit through the
260
+ DOM even though the popover paints in the top layer); raw-token fallbacks
261
+ keep the highlight alive if the host tokens are unavailable. */
262
+ background: var(--combobox-match-bg, var(--a-accent-muted));
263
+ color: var(--combobox-match-fg, var(--a-accent-strong));
244
264
  padding: 0 0.1em;
245
265
  border-radius: var(--a-radius-sm);
246
266
  }
@@ -195,4 +195,62 @@ describe('combobox-ui', () => {
195
195
  const navigable = el.querySelectorAll('[role="option"]:not([aria-disabled="true"])');
196
196
  expect(navigable.length).toBe(2);
197
197
  });
198
+
199
+ it('max-visible-items defaults to 10 and wires the kebab attribute', async () => {
200
+ const el = mount(`
201
+ <combobox-ui>
202
+ <option value="a">Alpha</option>
203
+ </combobox-ui>
204
+ `);
205
+ await tick();
206
+ expect(el.maxVisibleItems).toBe(10);
207
+ const el2 = mount(`<combobox-ui max-visible-items="5"></combobox-ui>`);
208
+ await tick();
209
+ expect(el2.maxVisibleItems).toBe(5);
210
+ // Geometry is unmeasurable in this env (rect heights are 0), so the cap
211
+ // var must NOT be stamped — the browser-level cap is covered by the
212
+ // Playwright visual harness; here we assert the bail path stays clean.
213
+ expect(el2.querySelector('[data-listbox]').style.getPropertyValue('--combobox-list-max-block')).toBe('');
214
+ });
215
+
216
+ it('typing wraps the matched substring of each filtered option in <mark>', async () => {
217
+ const el = mount(`
218
+ <combobox-ui>
219
+ <option value="a">Alpha</option>
220
+ <option value="b">Altitude</option>
221
+ <option value="c">Gamma</option>
222
+ </combobox-ui>
223
+ `);
224
+ await tick();
225
+ const input = el.querySelector('[data-input]');
226
+ input.textContent = 'al';
227
+ input.dispatchEvent(new Event('input', { bubbles: true }));
228
+ await tick();
229
+ const opts = el.querySelectorAll('[data-listbox] [role="option"]');
230
+ expect(opts.length).toBe(2); // Alpha + Altitude match; Gamma filtered out
231
+ const marks = el.querySelectorAll('[data-listbox] [role="option"] mark');
232
+ expect(marks.length).toBe(2);
233
+ expect(marks[0].textContent.toLowerCase()).toBe('al');
234
+ });
235
+
236
+ it('highlightMatch = false (programmatic) renders filtered options without <mark>', async () => {
237
+ // NOTE: declarative `highlight-match="false"` does NOT disable (Boolean
238
+ // attr present → true; the prop's default:true violates the
239
+ // Boolean-defaults-false contract rule — a pre-existing wart). The
240
+ // supported disable path is the programmatic setter.
241
+ const el = mount(`
242
+ <combobox-ui>
243
+ <option value="a">Alpha</option>
244
+ <option value="c">Gamma</option>
245
+ </combobox-ui>
246
+ `);
247
+ await tick();
248
+ el.highlightMatch = false;
249
+ const input = el.querySelector('[data-input]');
250
+ input.textContent = 'al';
251
+ input.dispatchEvent(new Event('input', { bubbles: true }));
252
+ await tick();
253
+ expect(el.querySelectorAll('[data-listbox] [role="option"]').length).toBe(1);
254
+ expect(el.querySelectorAll('[data-listbox] mark').length).toBe(0);
255
+ });
198
256
  });
@@ -128,6 +128,11 @@ props:
128
128
  type: number
129
129
  default: 100
130
130
  reflect: true
131
+ max-visible-items:
132
+ description: How many option rows are visible before the listbox scrolls — the menu height hugs N rows instead of growing to the 18rem fallback. Distinct from max-options (the render cap). 0 disables.
133
+ type: number
134
+ default: 10
135
+ reflect: true
131
136
  highlight-match:
132
137
  description: Wraps the matched substring in `<mark>` inside option labels
133
138
  type: boolean
@@ -155,7 +155,7 @@ export class UIDateRangePicker extends UIFormElement {
155
155
  // / hidePopover() programmatically. Two calendar instances + a preset
156
156
  // rail + a calendar area sit inside.
157
157
  static parts = {
158
- trigger: '<button-ui slot="trigger" variant="outline" type="button" icon="calendar" trailing-icon="caret-down" aria-label="Open date range picker"></button-ui>',
158
+ trigger: '<button-ui slot="trigger" variant="outline" type="button" icon="calendar" icon-trailing="caret-down" aria-label="Open date range picker"></button-ui>',
159
159
  popover: '<div slot="popover" popover="manual" role="dialog" aria-label="Date range picker"></div>',
160
160
  presets: '<div data-preset-rail role="group" aria-label="Date range presets"></div>',
161
161
  calArea: '<div data-calendar-area></div>',
@@ -89,6 +89,15 @@
89
89
  stamped by date-range-picker.class.js — these rules override button-ui's defaults at
90
90
  equal specificity so the picker trigger reads as part of the
91
91
  input/picker family rather than a generic button. */
92
+ :scope [slot="trigger"] icon-ui[name="caret-down"] {
93
+ transition: rotate var(--a-duration-fast) var(--a-easing);
94
+ }
95
+ /* Clear OPEN state: the default trigger's trailing caret flips while the
96
+ picker popover is open (name-guarded; custom trigger glyphs untouched). */
97
+ :scope[open] [slot="trigger"] icon-ui[name="caret-down"] {
98
+ rotate: 180deg;
99
+ }
100
+
92
101
  :scope [slot="trigger"] {
93
102
  min-width: var(--date-range-picker-trigger-min-width);
94
103
  min-height: var(--date-range-picker-trigger-height);
@@ -146,7 +146,7 @@ export class UIDatetimePicker extends UIFormElement {
146
146
  // showPopover() / hidePopover() programmatically. Mirrors
147
147
  // date-range-picker's shape (the closest sibling).
148
148
  static parts = {
149
- trigger: '<button-ui slot="trigger" variant="outline" type="button" icon="calendar" trailing-icon="caret-down" aria-label="Open date and time picker"></button-ui>',
149
+ trigger: '<button-ui slot="trigger" variant="outline" type="button" icon="calendar" icon-trailing="caret-down" aria-label="Open date and time picker"></button-ui>',
150
150
  popover: '<div slot="popover" popover="manual" role="dialog" aria-label="Date and time picker"></div>',
151
151
  // §FB-Wave1-QA — substrate primitive (extracted from calendar-picker-ui).
152
152
  calPane: '<calendar-grid-ui data-cal-pane aria-label="Date"></calendar-grid-ui>',
@@ -61,6 +61,15 @@
61
61
  picker-family field shape (mirrors date-range-picker): value text
62
62
  left-aligned with the caret pushed to the trailing edge, not centered
63
63
  like a default button. */
64
+ :scope [slot="trigger"] icon-ui[name="caret-down"] {
65
+ transition: rotate var(--a-duration-fast) var(--a-easing);
66
+ }
67
+ /* Clear OPEN state: the default trigger's trailing caret flips while the
68
+ picker popover is open (name-guarded; custom trigger glyphs untouched). */
69
+ :scope[open] [slot="trigger"] icon-ui[name="caret-down"] {
70
+ rotate: 180deg;
71
+ }
72
+
64
73
  :scope [slot="trigger"] {
65
74
  min-width: var(--datetime-picker-trigger-min-width);
66
75
  justify-content: space-between;
@@ -71,6 +71,17 @@ menu-ui [data-menu-popover]:popover-open {
71
71
  }
72
72
  }
73
73
 
74
+ /* Clear OPEN state for caret-bearing triggers: a consumer trigger that carries
75
+ a caret-down icon (e.g. <button-ui icon-trailing="caret-down">) flips it
76
+ while the menu is open. Name-guarded — non-caret trigger glyphs (dots,
77
+ gear, …) never rotate. */
78
+ menu-ui [slot="trigger"] icon-ui[name="caret-down"] {
79
+ transition: rotate var(--a-duration-fast) var(--a-easing);
80
+ }
81
+ menu-ui[open] [slot="trigger"] icon-ui[name="caret-down"] {
82
+ rotate: 180deg;
83
+ }
84
+
74
85
  @media (prefers-reduced-motion: reduce) {
75
86
  menu-ui [data-menu-popover] { transition: none; }
76
87
  }
@@ -27,7 +27,7 @@ export class UISelect extends UIFormElement {
27
27
  // (slot 11). Per FEEDBACK-06 §4 + FEEDBACK-07 §4.
28
28
  // SPEC-040: [multiple] mode stamps <tag-ui> chips (own `x`), checkbox
29
29
  // indicator (`check`), and search-input prefix (`magnifying-glass`).
30
- static requiredIcons = ['caret-up-down', 'check', 'x', 'magnifying-glass'];
30
+ static requiredIcons = ['caret-down', 'check', 'x', 'magnifying-glass'];
31
31
 
32
32
  // §225 (v0.5.9, FEEDBACK-10 §3): once-per-element console.warn dedup when
33
33
  // consumer authors children that aren't native <option>/<optgroup>. The
@@ -445,7 +445,7 @@ export class UISelect extends UIFormElement {
445
445
  <button type="button" data-clear-all aria-label="Clear all" hidden>
446
446
  <icon-ui name="x"></icon-ui>
447
447
  </button>
448
- <icon-ui name="caret-up-down" slot="caret"></icon-ui>
448
+ <icon-ui name="caret-down" slot="caret"></icon-ui>
449
449
  </span>
450
450
  ${hintMarkup}
451
451
  `;
@@ -167,6 +167,14 @@
167
167
  :scope [slot="trigger"] [slot="caret"] {
168
168
  color: var(--select-caret-fg);
169
169
  flex-shrink: 0;
170
+ transition: rotate var(--a-duration-fast) var(--a-easing);
171
+ }
172
+
173
+ /* Clear OPEN state on the caret: the default caret-down flips to point up
174
+ while the listbox is open. Name-guarded so a consumer-slotted non-caret
175
+ glyph in the caret slot never rotates. */
176
+ :scope[open] [slot="trigger"] icon-ui[slot="caret"][name="caret-down"] {
177
+ rotate: 180deg;
170
178
  }
171
179
 
172
180
  /* Size handled by universal [size] attribute system. */
@@ -236,7 +236,7 @@ states:
236
236
  traits: []
237
237
  tokens: {}
238
238
  requiredIcons:
239
- - caret-up-down
239
+ - caret-down
240
240
  - check # checkbox indicator on each option in [multiple] mode
241
241
  - x # chip dismiss + clear-all affordance ([multiple] + [clearable])
242
242
  - magnifying-glass # search-input prefix ([searchable])
@@ -189,10 +189,25 @@ tag-ui[removable]:not([disabled]):hover {
189
189
  /* ── Slotted icons (leading) ──
190
190
  Icons placed inside the tag (e.g. `<icon-ui name="check">`) inherit
191
191
  the host's text color so legend / status chips read as a single
192
- color-coded unit. Mirrors `<badge-ui>`'s convention. */
192
+ color-coded unit. Mirrors `<badge-ui>`'s convention.
193
+ ICON-HEIGHT FIX: cap the icon at 1em so it never exceeds the text
194
+ line-height and inflates the row. The icon is visual decoration on
195
+ a text chip — it must not drive the block size. `flex-none` prevents
196
+ it from growing or shrinking; `flex-shrink:0` is already here. */
193
197
  :scope > icon-ui {
194
198
  color: currentColor;
195
199
  flex-shrink: 0;
200
+ --a-icon-size: 1em;
201
+ block-size: 1em;
202
+ }
203
+
204
+ /* ── Icon-tag spacing rebalance (B0) ──
205
+ Mirrors badge-ui's [icon] rebalance: tighten leading pad + narrow gap when
206
+ a leading icon is present, so the icon+label read as one tight unit.
207
+ Gated on :has(> icon-ui) to leave text-only tags unaffected. */
208
+ :scope:has(> icon-ui) {
209
+ padding-inline-start: var(--tag-px-icon, var(--a-space-1));
210
+ gap: var(--tag-gap-icon, 0.2em);
196
211
  }
197
212
 
198
213
  /* ── Dismiss button ── */
package/core/anchor.js CHANGED
@@ -54,12 +54,42 @@ function anchorNative(anchor, popover, { placement, gap, matchWidth }) {
54
54
 
55
55
  anchor.style.anchorName = name;
56
56
 
57
+ // Resolve the vertical side OURSELVES at apply time (same logic as the JS
58
+ // fallback) instead of trusting `position-try-fallbacks: flip-block` — in
59
+ // practice (verified against Chromium via the combobox overflow probe) the
60
+ // try fallback does NOT fire for a tall scrollable popover, so a long list
61
+ // anchored low simply overflowed the viewport. We pick the side that fits
62
+ // (preferring the requested one), and clamp max-height to that side's
63
+ // available space so the popover can never cross the anchor.
64
+ let resolved = placement;
65
+ if (placement.startsWith('bottom') || placement.startsWith('top')) {
66
+ const a = anchor.getBoundingClientRect();
67
+ const vh = window.innerHeight;
68
+ const spaceBelow = vh - a.bottom - gap * 2;
69
+ const spaceAbove = a.top - gap * 2;
70
+ // The popover's own cap (e.g. combobox's visible-item cap) participates in
71
+ // the fit decision; fall back to content height when no cap is declared.
72
+ const capPx = resolveLengthPx(getComputedStyle(popover).getPropertyValue('--popover-max-height'));
73
+ const want = Number.isFinite(capPx) ? capPx : (popover.scrollHeight || 0);
74
+ const wantBelow = placement.startsWith('bottom');
75
+ const fitsBelow = want <= spaceBelow;
76
+ const fitsAbove = want <= spaceAbove;
77
+ let useBelow = wantBelow;
78
+ if (wantBelow && !fitsBelow) useBelow = fitsAbove ? false : spaceBelow >= spaceAbove;
79
+ else if (!wantBelow && !fitsAbove) useBelow = fitsBelow ? true : spaceBelow > spaceAbove;
80
+ resolved = placement.replace(/^(bottom|top)/, useBelow ? 'bottom' : 'top');
81
+ // 40px floor so a degenerate viewport never collapses the popover entirely.
82
+ const avail = Math.max(40, Math.floor(useBelow ? spaceBelow : spaceAbove));
83
+ popover.style.maxHeight = `${Number.isFinite(capPx) ? Math.min(capPx, avail) : avail}px`;
84
+ }
85
+
57
86
  popover.style.position = 'fixed';
58
87
  popover.style.positionAnchor = name;
59
- popover.style.positionArea = placementToPositionArea(placement);
88
+ popover.style.positionArea = placementToPositionArea(resolved);
60
89
  // Gap only on the main (anchor-adjacent) axis so start/end alignment stays tight.
61
- popover.style.margin = placementToGapMargin(placement, gap);
62
- // Let the browser flip across the opposite side + both perpendicular edges.
90
+ popover.style.margin = placementToGapMargin(resolved, gap);
91
+ // Best-effort extras for post-open scroll/resize (the side is already chosen
92
+ // above; these only help at the margins).
63
93
  popover.style.positionTryFallbacks = 'flip-block, flip-inline, flip-block flip-inline';
64
94
 
65
95
  // Sizing concerns that follow from anchor positioning — kept here (not in
@@ -80,7 +110,11 @@ function anchorNative(anchor, popover, { placement, gap, matchWidth }) {
80
110
  // setting --popover-max-width on the popover element. Viewport safety
81
111
  // (100vw - 1rem) still wins so nothing escapes the screen.
82
112
  popover.style.maxWidth = 'min(calc(100vw - 1rem), var(--popover-max-width, 32rem))';
83
- popover.style.maxHeight = 'calc(100vh - 3rem)';
113
+ // Vertical placements got a side-aware literal clamp above (which also folds
114
+ // in --popover-max-height); horizontal placements keep the viewport-safety cap.
115
+ if (!resolved.startsWith('bottom') && !resolved.startsWith('top')) {
116
+ popover.style.maxHeight = 'calc(100vh - 3rem)';
117
+ }
84
118
  popover.style.overflowY = 'auto';
85
119
 
86
120
  // top/left are controlled by position-area; make sure no stale values fight it.
@@ -93,6 +127,21 @@ function anchorNative(anchor, popover, { placement, gap, matchWidth }) {
93
127
  };
94
128
  }
95
129
 
130
+ // Resolve a --popover-max-height declaration to px. The convention's consumers
131
+ // declare px (JS-measured caps) or rem (stylesheet defaults); anything else
132
+ // (calc, var chains, empty) returns NaN and the caller falls back to content
133
+ // height. Kept deliberately narrow — this feeds a fit DECISION, not layout.
134
+ function resolveLengthPx(raw) {
135
+ const v = String(raw || '').trim();
136
+ if (!v) return NaN;
137
+ const m = /^(-?\d+(?:\.\d+)?)(px|rem)$/.exec(v);
138
+ if (!m) return NaN;
139
+ const n = parseFloat(m[1]);
140
+ if (m[2] === 'px') return n;
141
+ const rootPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
142
+ return n * rootPx;
143
+ }
144
+
96
145
  // Axis-specific margin so gap only separates on the main axis (TRBL).
97
146
  function placementToGapMargin(placement, gap) {
98
147
  if (placement.startsWith('bottom')) return `${gap}px 0 0 0`;
@@ -124,21 +173,49 @@ function placementToPositionArea(placement) {
124
173
  // ── JS fallback ──
125
174
 
126
175
  function anchorJS(anchor, popover, { placement, gap, matchWidth }) {
176
+ // Inline height-clamp state we may apply per-update — captured so each update
177
+ // re-measures from the stylesheet's own cap (and cleanup can restore).
178
+ const prevMaxHeight = popover.style.maxHeight;
179
+ const prevOverflowY = popover.style.overflowY;
180
+
127
181
  function update() {
128
182
  if (!anchor || !popover) return;
129
183
 
184
+ // Reset any clamp WE applied on a prior update before measuring, so the
185
+ // popover's natural (stylesheet-capped) height drives this pass — the
186
+ // anchor may have scrolled somewhere roomier.
187
+ popover.style.maxHeight = prevMaxHeight;
188
+ popover.style.overflowY = prevOverflowY;
189
+
130
190
  const a = anchor.getBoundingClientRect();
131
- const p = popover.getBoundingClientRect();
191
+ let p = popover.getBoundingClientRect();
132
192
  const vw = window.innerWidth;
133
193
  const vh = window.innerHeight;
134
194
 
135
195
  let resolved = placement;
136
196
 
137
- // Vertical flip
138
- if (resolved.startsWith('bottom') && a.bottom + gap + p.height > vh && a.top - gap - p.height > 0) {
139
- resolved = resolved.replace('bottom', 'top');
140
- } else if (resolved.startsWith('top') && a.top - gap - p.height < 0 && a.bottom + gap + p.height < vh) {
141
- resolved = resolved.replace('top', 'bottom');
197
+ // Vertical placements: flip + collision-clamp so the popover NEVER crosses
198
+ // the anchor. The old logic only flipped when the other side fully fit and
199
+ // then viewport-clamped `top` — which slid an oversized popover UP OVER the
200
+ // anchor (the combobox long-list bug: the menu covered its own input).
201
+ if (resolved.startsWith('bottom') || resolved.startsWith('top')) {
202
+ const spaceBelow = vh - a.bottom - gap * 2;
203
+ const spaceAbove = a.top - gap * 2;
204
+ const wantBelow = resolved.startsWith('bottom');
205
+ const fitsBelow = p.height <= spaceBelow;
206
+ const fitsAbove = p.height <= spaceAbove;
207
+ let useBelow = wantBelow;
208
+ if (wantBelow && !fitsBelow) useBelow = fitsAbove ? false : spaceBelow >= spaceAbove;
209
+ else if (!wantBelow && !fitsAbove) useBelow = fitsBelow ? true : spaceBelow > spaceAbove;
210
+ resolved = resolved.replace(/^(bottom|top)/, useBelow ? 'bottom' : 'top');
211
+
212
+ // Clamp to the chosen side's available space; scroll the overflow.
213
+ const avail = Math.max(0, Math.floor(useBelow ? spaceBelow : spaceAbove));
214
+ if (p.height > avail) {
215
+ popover.style.maxHeight = `${avail}px`;
216
+ popover.style.overflowY = 'auto';
217
+ p = popover.getBoundingClientRect();
218
+ }
142
219
  }
143
220
 
144
221
  let top, left;
@@ -168,7 +245,13 @@ function anchorJS(anchor, popover, { placement, gap, matchWidth }) {
168
245
  if (left + p.width > vw - gap) left = a.right - p.width;
169
246
  if (left < gap) left = a.left;
170
247
 
171
- top = Math.max(gap, Math.min(top, vh - p.height - gap));
248
+ // Horizontal placements still viewport-clamp vertically (they center on the
249
+ // anchor, so the clamp can't cross it). Vertical placements are in-bounds
250
+ // by construction after the collision clamp above — clamping them here is
251
+ // exactly what used to drag the popover over the anchor.
252
+ if (resolved.startsWith('left') || resolved.startsWith('right')) {
253
+ top = Math.max(gap, Math.min(top, vh - p.height - gap));
254
+ }
172
255
  left = Math.max(gap, Math.min(left, vw - p.width - gap));
173
256
 
174
257
  popover.style.position = 'fixed';
@@ -190,6 +273,8 @@ function anchorJS(anchor, popover, { placement, gap, matchWidth }) {
190
273
  return () => {
191
274
  for (const sp of scrollParents) sp.removeEventListener('scroll', update);
192
275
  window.removeEventListener('resize', update);
276
+ popover.style.maxHeight = prevMaxHeight;
277
+ popover.style.overflowY = prevOverflowY;
193
278
  };
194
279
  }
195
280