@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 +20 -0
- package/components/badge/badge.css +15 -1
- package/components/combobox/combobox.a2ui.json +5 -0
- package/components/combobox/combobox.class.js +49 -2
- package/components/combobox/combobox.css +23 -3
- package/components/combobox/combobox.test.js +58 -0
- package/components/combobox/combobox.yaml +5 -0
- package/components/date-range-picker/date-range-picker.class.js +1 -1
- package/components/date-range-picker/date-range-picker.css +9 -0
- package/components/datetime-picker/datetime-picker.class.js +1 -1
- package/components/datetime-picker/datetime-picker.css +9 -0
- package/components/menu/menu.css +11 -0
- package/components/select/select.class.js +2 -2
- package/components/select/select.css +8 -0
- package/components/select/select.yaml +1 -1
- package/components/tag/tag.css +16 -1
- package/core/anchor.js +96 -11
- package/dist/theme-provider.min.js +10 -0
- package/dist/web-components.min.css +1 -1
- package/dist/web-components.min.js +101 -110
- package/dist/web-components.sheet.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
|
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
|
|
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;
|
package/components/menu/menu.css
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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])
|
package/components/tag/tag.css
CHANGED
|
@@ -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(
|
|
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(
|
|
62
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
|