@apliteni/apliteni-ui 0.90.0 → 0.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.90.0",
3
+ "version": "0.91.0",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
@@ -433,6 +433,7 @@ import {
433
433
  useRef as useRef3,
434
434
  useState as useState2
435
435
  } from "react";
436
+ import { transitionMs } from "@apliteni/apliteni-ui";
436
437
  var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), details > summary:first-of-type';
437
438
  function tabbable(el) {
438
439
  const tabindex = el.getAttribute("tabindex");
@@ -453,17 +454,6 @@ var dismissOnScrim = (onClose) => (e) => {
453
454
  onClose();
454
455
  };
455
456
  var useIsoLayoutEffect = typeof document === "undefined" ? useEffect2 : useLayoutEffect3;
456
- function transitionMs(el) {
457
- const cs = getComputedStyle(el);
458
- const times = (list) => list.split(",").map((t) => {
459
- const n = Number.parseFloat(t);
460
- if (!Number.isFinite(n)) return 0;
461
- return t.trim().endsWith("ms") ? n : n * 1e3;
462
- });
463
- const durations = times(cs.transitionDuration);
464
- const delays = times(cs.transitionDelay);
465
- return Math.max(0, ...durations.map((d, i) => d + delays[i % delays.length]));
466
- }
467
457
  var DialogScope = createContext(null);
468
458
  var stack = [];
469
459
  var leaving = /* @__PURE__ */ new Set();
@@ -1928,8 +1918,19 @@ import {
1928
1918
  useRef as useRef11,
1929
1919
  useState as useState7
1930
1920
  } from "react";
1931
- import { icon as icon3, dropdownMatch, dropdownFiltering } from "@apliteni/apliteni-ui";
1921
+ import {
1922
+ icon as icon3,
1923
+ dropdownMatch,
1924
+ dropdownFiltering,
1925
+ filterPanelFit,
1926
+ transitionMs as transitionMs2
1927
+ } from "@apliteni/apliteni-ui";
1932
1928
  import { Fragment as Fragment5, jsx as jsx20, jsxs as jsxs16 } from "react/jsx-runtime";
1929
+ var EXIT_SLACK_MS2 = 50;
1930
+ var inPanel = (node) => {
1931
+ const el = node?.nodeType === 1 ? node : node?.parentElement ?? null;
1932
+ return !!el?.closest("[data-dropdown-panel]");
1933
+ };
1933
1934
  var cx6 = (...a) => a.filter(Boolean).join(" ");
1934
1935
  var isRow = (it) => Boolean(it) && it !== "---" && !it.separator;
1935
1936
  var Glyph2 = ({ name, className, hidden }) => /* @__PURE__ */ jsx20(
@@ -2002,6 +2003,84 @@ function Dropdown({
2002
2003
  useIsoLayoutEffect(() => {
2003
2004
  close.current = setOpen;
2004
2005
  });
2006
+ useIsoLayoutEffect(() => {
2007
+ const el = panel.current;
2008
+ if (!el || !open) return;
2009
+ const fit = () => {
2010
+ const got = filterPanelFit(root.current);
2011
+ if (!got) return;
2012
+ let moved = false;
2013
+ for (const prop of ["room", "shift", "floor"]) {
2014
+ const name2 = `--ui-filter-panel-${prop}`;
2015
+ const next = `${got[prop]}px`;
2016
+ if (el.style.getPropertyValue(name2) === next) continue;
2017
+ el.style.setProperty(name2, next);
2018
+ moved = true;
2019
+ }
2020
+ if (!moved) return;
2021
+ if (!el.style.minWidth) return;
2022
+ el.style.minWidth = "";
2023
+ if (el.offsetWidth) el.style.minWidth = `${el.offsetWidth}px`;
2024
+ };
2025
+ fit();
2026
+ const view = root.current?.ownerDocument?.defaultView;
2027
+ const row2 = root.current?.closest(".ui-filter-bar__chip")?.closest(".ui-filter-bar");
2028
+ if (!row2 || !view) return;
2029
+ const stop = [];
2030
+ let ro = null;
2031
+ if (typeof view.ResizeObserver === "function") {
2032
+ ro = new view.ResizeObserver(() => fit());
2033
+ ro.observe(row2);
2034
+ for (const kid of Array.from(row2.children)) ro.observe(kid);
2035
+ stop.push(() => ro?.disconnect());
2036
+ } else {
2037
+ view.addEventListener("resize", fit);
2038
+ stop.push(() => view.removeEventListener("resize", fit));
2039
+ }
2040
+ if (typeof view.MutationObserver === "function") {
2041
+ const mo = new view.MutationObserver((records) => {
2042
+ if (records.every((m) => inPanel(m.target))) return;
2043
+ if (ro && records.some((m) => m.type === "childList")) {
2044
+ for (const kid of Array.from(row2.children)) ro.observe(kid);
2045
+ }
2046
+ fit();
2047
+ });
2048
+ mo.observe(row2, { subtree: true, childList: true, attributes: true, characterData: true });
2049
+ stop.push(() => mo.disconnect());
2050
+ }
2051
+ return () => {
2052
+ stop.forEach((off) => off());
2053
+ };
2054
+ }, [open]);
2055
+ const opened = useRef11(false);
2056
+ useIsoLayoutEffect(() => {
2057
+ const el = panel.current;
2058
+ if (open) {
2059
+ opened.current = true;
2060
+ return;
2061
+ }
2062
+ if (!opened.current || !el || !root.current?.closest(".ui-filter-bar__chip")) return;
2063
+ el.classList.add("is-closing");
2064
+ let done = false;
2065
+ const finish = () => {
2066
+ if (done) return;
2067
+ done = true;
2068
+ el.classList.remove("is-closing");
2069
+ el.style.minWidth = "";
2070
+ for (const prop of ["room", "shift", "floor"]) el.style.removeProperty(`--ui-filter-panel-${prop}`);
2071
+ };
2072
+ const onEnd = (e) => {
2073
+ if (e.target === el && e.propertyName === "opacity") finish();
2074
+ };
2075
+ el.addEventListener("transitionend", onEnd);
2076
+ const timer = setTimeout(finish, transitionMs2(el) + EXIT_SLACK_MS2);
2077
+ return () => {
2078
+ done = true;
2079
+ el.removeEventListener("transitionend", onEnd);
2080
+ clearTimeout(timer);
2081
+ el.classList.remove("is-closing");
2082
+ };
2083
+ }, [open]);
2005
2084
  const sx = search ? { ...SEARCH_DEFAULTS, ...strip(search === true ? {} : search) } : null;
2006
2085
  const entries = sections?.length ? sections.flatMap((s) => s.items || []) : items || [];
2007
2086
  const rows = entries.filter(isRow);
@@ -2056,7 +2135,12 @@ function Dropdown({
2056
2135
  useIsoLayoutEffect(() => {
2057
2136
  const want = landOn.current;
2058
2137
  landOn.current = null;
2059
- if (!open) return;
2138
+ if (!open) {
2139
+ if (panel.current?.style && root.current?.closest(".ui-filter-bar") && !root.current.closest(".ui-filter-bar__chip")) {
2140
+ panel.current.style.minWidth = "";
2141
+ }
2142
+ return;
2143
+ }
2060
2144
  if (sx) {
2061
2145
  setQuery("");
2062
2146
  const enabled = rows.filter((it) => !it.disabled);
@@ -2412,7 +2496,7 @@ function Segmented({ options, value, onChange, label, appearance = "pill", disab
2412
2496
 
2413
2497
  // src/FilterBar.tsx
2414
2498
  import { useRef as useRef12, useState as useState8 } from "react";
2415
- import { filterChipText, filterChipName, filterChipUnset, focusNextStop } from "@apliteni/apliteni-ui";
2499
+ import { filterChipText, filterChipName, filterChipUnset, filterChipItems, focusNextStop } from "@apliteni/apliteni-ui";
2416
2500
  import { jsx as jsx24, jsxs as jsxs19 } from "react/jsx-runtime";
2417
2501
  function FilterBar({ filters, label = "Filters", clearLabel = "Clear all filters", disabled = false, busy = false, onRemove, onClear, onChange }) {
2418
2502
  const bar = useRef12(null);
@@ -2459,7 +2543,7 @@ function FilterBar({ filters, label = "Filters", clearLabel = "Clear all filters
2459
2543
  /* @__PURE__ */ jsx24(
2460
2544
  Dropdown,
2461
2545
  {
2462
- items: filter.items,
2546
+ items: filterChipItems(filter),
2463
2547
  variant: "select",
2464
2548
  ariaLabel: filterChipName(filter),
2465
2549
  triggerContent: /* @__PURE__ */ jsx24("span", { className: filterChipUnset(filter) ? "ui-dropdown__value is-placeholder" : "ui-dropdown__value", children: filterChipText(filter) }),
@@ -16,6 +16,8 @@ export { dropdownMatch, dropdownFiltering } from '../logic/dropdown.js';
16
16
  // wireDropdown(container); // or let wireTopbar() do it
17
17
  import { esc, icon } from './index.js';
18
18
  import { safeUrl } from '../html.js';
19
+ // The fade's own length, read off the stylesheet rather than copied. why: src/motion.js
20
+ import { transitionMs } from '../motion.js';
19
21
  const cx = (...a) => a.filter(Boolean).join(' ');
20
22
 
21
23
  // A trailing status badge. `badge` is the text shown, in the case it is written
@@ -325,6 +327,215 @@ function ddResetSearch(dd, panel, search) {
325
327
  if (panel.offsetWidth) panel.style.minWidth = `${panel.offsetWidth}px`;
326
328
  }
327
329
 
330
+ /* The kit's menu floor, the one `.ui-dropdown__panel` writes. The stylesheets
331
+ * carry the same number; stories/filter-bar-fit.test.js reads all of them and
332
+ * fails when one drifts. why: src/styles/dropdown.css */
333
+ export const DD_MENU_FLOOR = 240;
334
+
335
+ /**
336
+ * Where a filter chip's menu may sit. #484 bounds the panel to its trigger, so a
337
+ * chip printing its value alone (#536) left a 48px menu breaking words
338
+ * mid-letter — #549. An OPEN panel takes the floor instead and slides along the
339
+ * row when the room on the side it opens from is short; shut, it keeps the
340
+ * trigger's width, which is why #467 needs no measuring at all.
341
+ *
342
+ * A panel is anchored at one edge of its dropdown — its inline end when it
343
+ * carries `is-end` — so room is measured from that edge and the slide goes the
344
+ * other way. Measuring an end-anchored panel forwards put one off the page.
345
+ * why: docs/specification.md#a-filter-row-holds-its-panels
346
+ *
347
+ * @param {Element} dd a `.ui-dropdown` that may be inside a filter row
348
+ * @param {number} [floor] the width to reach for
349
+ * @returns {{room: number, shift: number, floor: number, end: boolean}|null}
350
+ */
351
+ export function filterPanelFit(dd, floor = DD_MENU_FLOOR) {
352
+ /* A chip's menu, not any menu in a filter row: the slide below is measured from
353
+ * the trigger's offset along the row, so a panel anchored to the row itself is
354
+ * not this function's subject and must size itself. why: src/styles/filter-bar.css */
355
+ const chip = typeof dd?.closest === 'function' ? dd.closest('.ui-filter-bar__chip') : null;
356
+ const bar = chip ? chip.closest('.ui-filter-bar') : null;
357
+ if (!bar || typeof bar.getBoundingClientRect !== 'function') return null;
358
+ const row = bar.getBoundingClientRect();
359
+ const box = dd.getBoundingClientRect();
360
+ const panel = typeof dd.querySelector === 'function' ? dd.querySelector('.ui-dropdown__panel') : null;
361
+ const end = !!panel?.classList?.contains('is-end');
362
+ // A row narrower than the floor decides the width; nothing may leave the row.
363
+ const want = Math.min(floor, row.width);
364
+ const ahead = end
365
+ ? Math.max(0, box.right - row.left) // an end-anchored panel grows backwards
366
+ : Math.max(0, row.right - box.left);
367
+ const slack = end
368
+ ? Math.max(0, row.right - box.right) // …and slides towards the row's end
369
+ : Math.max(0, box.left - row.left);
370
+ const shift = Math.min(slack, Math.max(0, want - ahead));
371
+ return { room: ahead + shift, shift, floor: want, end };
372
+ }
373
+
374
+ /** Write the fit onto the panel, and say whether any of the three numbers moved.
375
+ * All three, because the stylesheet reads all three; it falls back to the
376
+ * trigger's width when they are unset, so a panel opened before this runs is
377
+ * bounded rather than unbounded.
378
+ *
379
+ * A number that has not changed is not written again, which is what stops the
380
+ * observers below from answering their own writes: a fit landing on the same
381
+ * three numbers mutates nothing, so it reports nothing. */
382
+ function ddWriteFit(panel, fit) {
383
+ let moved = false;
384
+ for (const prop of ['room', 'shift', 'floor']) {
385
+ const name = `--ui-filter-panel-${prop}`;
386
+ const next = `${fit[prop]}px`;
387
+ if (panel.style.getPropertyValue(name) === next) continue;
388
+ panel.style.setProperty(name, next);
389
+ moved = true;
390
+ }
391
+ return moved;
392
+ }
393
+
394
+ /** Fit a panel just opened, or leave it alone outside a filter row. */
395
+ function ddFitFilterPanel(dd, panel) {
396
+ const fit = filterPanelFit(dd);
397
+ if (!fit || !panel?.style) return false;
398
+ return ddWriteFit(panel, fit);
399
+ }
400
+
401
+ /** Measure an open menu again where its row now puts it. The fit it was opened
402
+ * with can describe a row that no longer exists — a menu opened at 1280 and left
403
+ * open at 390 kept a 1102px room and stood 11px off the page — or a place in that
404
+ * row the chip has since left. A search panel's inline `min-width` was read at
405
+ * the old width too, and inline beats the sheet, so it is read again off the
406
+ * newly bounded box; only when the fit moved, because re-reading it is itself a
407
+ * write.
408
+ * why: docs/specification.md#a-filter-row-holds-its-panels */
409
+ function ddRefitFilterPanel(dd, panel) {
410
+ const fit = panel?.style ? filterPanelFit(dd) : null;
411
+ if (!fit || !ddWriteFit(panel, fit)) return;
412
+ if (!panel.style.minWidth) return;
413
+ panel.style.minWidth = '';
414
+ if (panel.offsetWidth) panel.style.minWidth = `${panel.offsetWidth}px`;
415
+ }
416
+
417
+ /** The row this chip's menu is fitted to, or null outside one. */
418
+ function ddRowOf(dd) {
419
+ return dd?.closest?.('.ui-filter-bar__chip')?.closest('.ui-filter-bar') || null;
420
+ }
421
+
422
+ /** Is this node inside some dropdown panel? The fit writes to a panel and to
423
+ * nothing else, so a change inside one is never news — and a panel is
424
+ * `position: absolute`, so nothing in it can move a chip along its row. Any
425
+ * panel, not only the one being fitted: two open menus in one row would
426
+ * otherwise answer each other a mutation at a time.
427
+ * why: docs/specification.md#a-filter-row-holds-its-panels */
428
+ function ddOurs(node) {
429
+ const el = node?.nodeType === 1 ? node : node?.parentElement;
430
+ return !!el?.closest?.('[data-dropdown-panel]');
431
+ }
432
+
433
+ /**
434
+ * Watch what the fit is measured from, and fit the menu again when it changes.
435
+ *
436
+ * Two inputs, two watchers. The row's width is one, observed rather than taken
437
+ * from `resize`, which fires before an animating row has settled. Where the chip
438
+ * sits along the row is the other, and it moves while the row keeps both of its
439
+ * dimensions: re-labelling the chip in front of an open menu at 390px slid this
440
+ * one 27.97px and took the menu 27.97px out of its row. So the boxes laid out in
441
+ * the row are observed too — a chip is moved by the size of the ones in front of
442
+ * it — and the mutation that moved it is watched for the frame before any of it
443
+ * is laid out.
444
+ *
445
+ * Why those two cover the arithmetic, and the one case they do not:
446
+ * why: docs/specification.md#a-filter-row-holds-its-panels
447
+ */
448
+ function ddWatchRow(dd, panel) {
449
+ ddUnwatchRow(dd);
450
+ const row = ddRowOf(dd);
451
+ const view = dd.ownerDocument?.defaultView;
452
+ if (!row) return;
453
+ const refit = () => { if (dd.classList.contains('open')) ddRefitFilterPanel(dd, panel); };
454
+ if (typeof view?.ResizeObserver === 'function') {
455
+ // observe() reports each current box at once, which the fit was just written
456
+ // from, so the first round writes nothing.
457
+ const ro = new view.ResizeObserver(refit);
458
+ ro.observe(row);
459
+ for (const kid of row.children) ro.observe(kid);
460
+ dd.__ddRowFit = ro;
461
+ }
462
+ if (typeof view?.MutationObserver !== 'function') return;
463
+ const mo = new view.MutationObserver((records) => {
464
+ if (records.every((m) => ddOurs(m.target))) return;
465
+ // A chip added or removed brings a box the observer above is not watching yet.
466
+ if (dd.__ddRowFit && records.some((m) => m.type === 'childList')) {
467
+ for (const kid of row.children) dd.__ddRowFit.observe(kid);
468
+ }
469
+ refit();
470
+ });
471
+ /* characterData as well as the two structural kinds: a chip's printed value
472
+ replaced is a text node changed, with no child list and no attribute moving
473
+ with it — which is what a React re-render of that value arrives as too.
474
+ Attributes, because a class is how a chip changes shape; subtree, because the
475
+ text that sets a chip's width is inside it. */
476
+ mo.observe(row, { subtree: true, childList: true, attributes: true, characterData: true });
477
+ dd.__ddRowMo = mo;
478
+ }
479
+
480
+ function ddUnwatchRow(dd) {
481
+ dd.__ddRowFit?.disconnect();
482
+ dd.__ddRowFit = null;
483
+ dd.__ddRowMo?.disconnect();
484
+ dd.__ddRowMo = null;
485
+ }
486
+
487
+ /* A few frames past the sheet's own end, so the timer never cuts the fade's last
488
+ * frame. The number the dialogs' exit uses. */
489
+ const DD_EXIT_SLACK_MS = 50;
490
+
491
+ /**
492
+ * Hold a closing chip menu's open geometry until its fade has finished.
493
+ *
494
+ * `.open` stops matching in the frame the menu closes; the fade does not — the
495
+ * panel transitions opacity over --dur-med. Dropping the width there left a 240px
496
+ * menu painted as a 48px column of single letters: #549 on the way out. The timer
497
+ * is the way out of a transitionend that never comes.
498
+ * why: src/styles/filter-bar.css
499
+ */
500
+ function ddHoldClose(dd, panel) {
501
+ ddCancelClose(dd, panel);
502
+ panel.classList.add('is-closing');
503
+ let done = false;
504
+ const finish = () => {
505
+ if (done) return;
506
+ done = true;
507
+ dd.__ddClosing = null;
508
+ panel.classList.remove('is-closing');
509
+ panel.style.minWidth = '';
510
+ for (const prop of ['room', 'shift', 'floor']) {
511
+ panel.style.removeProperty(`--ui-filter-panel-${prop}`);
512
+ }
513
+ };
514
+ // Only the panel's own fade: a row's background transition bubbles here too.
515
+ const onEnd = (e) => { if (e.target === panel && e.propertyName === 'opacity') stop(); };
516
+ const timer = setTimeout(() => stop(), transitionMs(panel) + DD_EXIT_SLACK_MS);
517
+ function stop() {
518
+ panel.removeEventListener('transitionend', onEnd);
519
+ clearTimeout(timer);
520
+ finish();
521
+ }
522
+ panel.addEventListener('transitionend', onEnd);
523
+ // Re-opened mid-fade: the wait is abandoned, so it cannot clear a fit the new
524
+ // open has already written.
525
+ dd.__ddClosing = () => {
526
+ done = true;
527
+ dd.__ddClosing = null;
528
+ panel.removeEventListener('transitionend', onEnd);
529
+ clearTimeout(timer);
530
+ panel.classList.remove('is-closing');
531
+ };
532
+ }
533
+
534
+ function ddCancelClose(dd, panel) {
535
+ if (dd.__ddClosing) dd.__ddClosing();
536
+ else panel?.classList?.remove('is-closing');
537
+ }
538
+
328
539
  // `auto` is the only direction the wiring decides; `up` and the default are the
329
540
  // panel's own class, set once at render. Flip only when below is too tight AND
330
541
  // above is roomier, so a panel that fits nowhere still opens the way it says.
@@ -373,7 +584,17 @@ function sweepOrphanPanels(host) {
373
584
  function closeDropdown(dd) {
374
585
  if (!dd.classList.contains('open')) return;
375
586
  dd.classList.remove('open');
376
- ddPanelOf(dd)?.classList.remove('is-open');
587
+ const panel = ddPanelOf(dd);
588
+ panel?.classList.remove('is-open');
589
+ ddUnwatchRow(dd);
590
+ /* A search panel carries the width it was opened at as an inline `min-width`,
591
+ * and inline beats any sheet. Inside a filter row that leaves a SHUT panel wider
592
+ * than its trigger, which is #467. Cleared here, and ddResetSearch() writes it
593
+ * again on the next open — except for a chip's menu, which gives the pin and the
594
+ * fit back at the END of its fade, both being its open geometry. A portalled
595
+ * panel is never fitted, so it has none to hold. why: ddHoldClose() */
596
+ if (panel?.style && !dd.__ddPanel && ddRowOf(dd)) ddHoldClose(dd, panel);
597
+ else if (panel?.style && dd.closest?.('.ui-filter-bar')) panel.style.minWidth = '';
377
598
  dd.querySelector('[data-dropdown-trigger]')?.setAttribute('aria-expanded', 'false');
378
599
  }
379
600
 
@@ -396,12 +617,21 @@ function openDropdown(dd, focusIdx) {
396
617
  closeAllDropdowns(dd);
397
618
  const panel = ddPanelOf(dd);
398
619
  const search = ddSearchOf(dd);
399
- if (panel && search) ddResetSearch(dd, panel, search);
400
620
  if (panel) {
621
+ // An earlier close may still be holding its geometry; this open owns it now.
622
+ ddCancelClose(dd, panel);
401
623
  ddResolveDirection(dd, panel);
402
624
  if (dd.__ddPanel) { positionPortalPanel(dd, panel); panel.classList.add('is-open'); }
625
+ else { ddFitFilterPanel(dd, panel); ddWatchRow(dd, panel); }
403
626
  }
404
627
  dd.classList.add('open');
628
+ /* After `open`, and after the fit: ddResetSearch() pins the width it reads off
629
+ * the panel, and the rule that widens a filter menu only applies once the
630
+ * panel is open. Reading first pinned 50px inline, which beats any stylesheet
631
+ * — a searchable chip in a filter row kept the 74px menu #549 reported, while
632
+ * React, whose effects already ran in this order, gave 240px for the same
633
+ * markup. why: src/styles/filter-bar.css */
634
+ if (panel && search) ddResetSearch(dd, panel, search);
405
635
  dd.querySelector('[data-dropdown-trigger]')?.setAttribute('aria-expanded', 'true');
406
636
  // With search, focus goes to the field however the panel was opened, and
407
637
  // the selected row (or the first) is the one Enter would pick.
@@ -451,10 +681,21 @@ function ddListen(doc) {
451
681
  if (dd.classList.contains('open') && dd.__ddPanel) positionPortalPanel(dd, dd.__ddPanel);
452
682
  }
453
683
  };
684
+ /* The fallback re-fit, for a view with no ResizeObserver. Not taken as well where
685
+ * ddWatchRow() is watching: a `resize` event is the worse measurement of the two.
686
+ * why: docs/specification.md#a-filter-row-holds-its-panels */
687
+ const refit = () => {
688
+ for (const dd of ddLive()) {
689
+ if (dd.classList.contains('open') && !dd.__ddPanel && !dd.__ddRowFit) {
690
+ ddRefitFilterPanel(dd, ddPanelOf(dd));
691
+ }
692
+ }
693
+ };
454
694
  const view = doc.defaultView;
455
695
  if (!view) return;
456
696
  view.addEventListener('scroll', reposition, true);
457
697
  view.addEventListener('resize', reposition);
698
+ view.addEventListener('resize', refit);
458
699
  }
459
700
 
460
701
  export function wireDropdown(root = document) {
@@ -489,6 +730,15 @@ export function wireDropdown(root = document) {
489
730
  }
490
731
  }
491
732
 
733
+ // A panel rendered already-open never passes through openDropdown(), so its
734
+ // fit has to be taken here too — otherwise a story or a server-rendered bar
735
+ // keeps the fallback width and #549 survives in exactly the place a
736
+ // default-open menu makes most visible.
737
+ if (panel && !dd.__ddPanel && dd.classList.contains('open')) {
738
+ ddFitFilterPanel(dd, panel);
739
+ ddWatchRow(dd, panel);
740
+ }
741
+
492
742
  trigger.addEventListener('click', (e) => {
493
743
  e.stopPropagation();
494
744
  if (dd.classList.contains('open')) closeDropdown(dd);
@@ -1,13 +1,13 @@
1
1
  import { button, esc } from './index.js';
2
2
  import { dropdown, wireDropdown } from './dropdown.js';
3
- import { filterChipText, filterChipName, filterChipUnset, focusNextStop } from '../logic/filter-bar.js';
3
+ import { filterChipText, filterChipName, filterChipUnset, filterChipItems, focusNextStop } from '../logic/filter-bar.js';
4
4
 
5
5
  export function filterBar({ filters = [], label = 'Filters', clearLabel = 'Clear all filters', disabled = false, busy = false } = {}) {
6
6
  return `<fieldset class="ui-filter-bar" data-filter-bar${disabled || busy ? ' disabled' : ''}${busy ? ' aria-busy="true"' : ''}>`
7
7
  + `<legend class="ui-filter-bar__legend">${esc(label)}</legend>`
8
8
  + filters.map(filter => `<fieldset class="ui-filter-bar__chip" data-filter-id="${esc(filter.id)}"${filter.disabled ? ' disabled' : ''}>`
9
9
  + `<legend class="ui-filter-bar__legend">${esc(filter.label)}</legend>`
10
- + dropdown({ items: filter.items || [], variant: 'select',
10
+ + dropdown({ items: filterChipItems(filter), variant: 'select',
11
11
  // The chip prints one line; the field's name reaches a reader through the
12
12
  // trigger's name and the chip's own legend. why: docs/specification.md#dense-financial-tables
13
13
  triggerContent: `<span class="${filterChipUnset(filter) ? 'ui-dropdown__value is-placeholder' : 'ui-dropdown__value'}">${esc(filterChipText(filter))}</span>`,
package/src/index.d.ts CHANGED
@@ -12,6 +12,12 @@ export declare function filterChipName(filter?: { label?: string; value?: string
12
12
  export declare function filterChipUnset(filter?: { value?: string }): boolean;
13
13
  export declare function nextFocusStop(host: Element | null | undefined): HTMLElement | null;
14
14
  export declare function focusNextStop(host: Element | null | undefined): HTMLElement | null;
15
+ /** The chip's items with the chip's own `value` marked `selected`, so the line the chip
16
+ * prints and the row its menu marks cannot disagree. A row is matched by `value`, or by
17
+ * `label` where it has none. Every row's flag is written, so a chip that is unset or
18
+ * whose value is in no row marks nothing rather than leaving a `selected` the caller
19
+ * set standing. The caller's array and objects are not written to. */
20
+ export declare function filterChipItems<T>(filter?: { value?: string; items?: T[] }): T[];
15
21
  export declare function segmentedNextIndex(key: string, index: number, length: number): number | null;
16
22
  export declare function icon(name: string, className?: string): string;
17
23
  export declare function snippet(opts?: Record<string, unknown>): string;
@@ -28,6 +34,18 @@ export declare function wireDropdown(root?: Document | Element): void;
28
34
  /** The kit's own match, asked rather than re-implemented by <Dropdown>. */
29
35
  export declare function dropdownMatch(label: unknown, query: unknown): boolean;
30
36
  export declare function dropdownFiltering(query: unknown): boolean;
37
+ /** The kit's menu floor, the width `.ui-dropdown__panel` writes. */
38
+ export declare const DD_MENU_FLOOR: number;
39
+ /** Where a filter chip's open menu can sit: the room from the edge it is
40
+ * anchored at to the far side of its row once slid, how far it had to slide,
41
+ * the width it reached for, and whether it is anchored at its inline end.
42
+ * `null` unless the dropdown is inside a `.ui-filter-bar__chip`: the slide is a
43
+ * chip's offset along its row, so a panel anchored to the row itself is not this
44
+ * function's subject and sizes itself. why: docs/library.md */
45
+ export declare function filterPanelFit(
46
+ dd: Element | null | undefined,
47
+ floor?: number,
48
+ ): { room: number; shift: number; floor: number; end: boolean } | null;
31
49
  export declare function backLink(opts?: Record<string, unknown>): string;
32
50
  export declare function statBand(opts?: Record<string, unknown>): string;
33
51
  export declare function commandPalette(opts?: Record<string, unknown>): string;
package/src/index.js CHANGED
@@ -30,7 +30,7 @@ export * from './components/filter-bar.js';
30
30
  export { dropdownMatch, dropdownFiltering } from './logic/dropdown.js';
31
31
  export { SCORE, rankGroups, rankCommands, scoreCommand, paletteHotkey } from './logic/command-palette.js';
32
32
  export { segmentedNextIndex } from './logic/segmented.js';
33
- export { filterChipText, filterChipName, filterChipUnset, nextFocusStop, focusNextStop } from './logic/filter-bar.js';
33
+ export { filterChipText, filterChipName, filterChipUnset, filterChipItems, nextFocusStop, focusNextStop } from './logic/filter-bar.js';
34
34
  export { PAGE_SIZES, DEFAULT_PAGE_SIZE } from './logic/pagination.js';
35
35
  export { calloutIcons } from './logic/callout.js';
36
36
  export { formatNumericValue, formatDeltaValue } from './logic/table-values.js';
@@ -103,3 +103,23 @@ export function focusNextStop(host) {
103
103
  }
104
104
  return null;
105
105
  }
106
+
107
+ /** The chip's menu with the chip's own value marked, so the line the chip prints
108
+ * and the row the menu marks cannot disagree. A row is identified the way the
109
+ * dropdown identifies it when it reports a pick: by `value`, or by `label` where
110
+ * it carries none.
111
+ *
112
+ * Every row's flag is written, not only the chosen one's, so a chip with nothing
113
+ * chosen or a value in no row marks no row. Handing those items back as the
114
+ * consumer wrote them left whatever they had marked standing — a menu checking
115
+ * `All`, `aria-selected="true"` on it, under a chip printing `Asia`. The
116
+ * consumer's own array and objects are not written to. #550
117
+ * why: docs/specification.md#a-filter-row-holds-its-panels */
118
+ export function filterChipItems(filter = {}) {
119
+ const items = filter.items || [];
120
+ const chosen = text(filter.value);
121
+ const row = (it) => !!it && it !== '---' && !it.separator;
122
+ const key = (it) => text(it.value != null ? it.value : it.label);
123
+ const inForce = !filterChipUnset(filter) && items.some((it) => row(it) && key(it) === chosen);
124
+ return items.map((it) => (row(it) ? { ...it, selected: inForce && key(it) === chosen } : it));
125
+ }
package/src/motion.d.ts CHANGED
@@ -4,3 +4,5 @@ export declare function initReveal(root?: Document | Element): IntersectionObser
4
4
  export declare const ENTRANCE_FALLBACK_MS: number;
5
5
  export declare function playEntrance(element: Element | null, className?: string): void;
6
6
  export declare function replay(element: HTMLElement | null): void;
7
+ /** How long the stylesheet says this element's transition lasts, in ms; 0 off-DOM. */
8
+ export declare function transitionMs(element: Element | null | undefined): number;
package/src/motion.js CHANGED
@@ -100,6 +100,33 @@ export function playEntrance(el, className = 'is-entering') {
100
100
  entering.set(el, () => done());
101
101
  }
102
102
 
103
+ /**
104
+ * How long the stylesheet says this element's transition lasts — the longest
105
+ * duration plus its own delay, in ms, and 0 off-DOM or for no element at all.
106
+ *
107
+ * What a backstop timer beside a `transitionend` listener is sized from, so the
108
+ * duration stays the sheet's rather than a copy of --dur-med. The event does not
109
+ * come for a transition that did not run, and jsdom never fires it at all.
110
+ */
111
+ export function transitionMs(el) {
112
+ // getComputedStyle throws a TypeError for a non-element, and the view below
113
+ // falls back to the global window, so a missing ref took its caller down.
114
+ if (el?.nodeType !== 1) return 0;
115
+ const view = el.ownerDocument?.defaultView
116
+ || (typeof window === 'undefined' ? null : window);
117
+ const cs = view?.getComputedStyle?.(el);
118
+ if (!cs) return 0;
119
+ // A delay list shorter than the duration list repeats, as the spec says.
120
+ const times = (list) => String(list || '').split(',').map((t) => {
121
+ const n = Number.parseFloat(t);
122
+ if (!Number.isFinite(n)) return 0;
123
+ return t.trim().endsWith('ms') ? n : n * 1000;
124
+ });
125
+ const durations = times(cs.transitionDuration);
126
+ const delays = times(cs.transitionDelay);
127
+ return Math.max(0, ...durations.map((d, i) => d + delays[i % delays.length]));
128
+ }
129
+
103
130
  /** Restart the CSS animation on an element (for a "replay" control). */
104
131
  export function replay(el) {
105
132
  if (!el || !el.style) return;
@@ -12,16 +12,9 @@
12
12
  take. `:enabled` leaves a disabled chip its own ink. why: guidelines/labels-and-titles.md */
13
13
  .ui-filter-bar button:enabled .ui-dropdown__value.is-placeholder { /* muted-ink: placeholder — a filter chip with nothing chosen */ color: var(--muted); }
14
14
 
15
- /* A chip's panel is as wide as the chip's trigger, never the 240px floor
16
- .ui-dropdown__panel gives a standalone dropdown. A panel is positioned at its
17
- trigger's inline start, so on a narrow row a later chip put its panel past the
18
- screen — shut as well as open, a `visibility: hidden` box still being laid out.
19
- The percentage resolves against .ui-dropdown, the panel's containing block,
20
- which is the trigger's width because the trigger is its one in-flow child.
21
- `overflow-wrap` keeps that a bound rather than a clip: a panel is
22
- `overflow: visible`, so a value with no break opportunity would run out of it
23
- and off the page. It inherits, reaching an option's label and description.
24
- why: docs/specification.md#a-filter-row-holds-its-panels */
15
+ /* Shut, a panel is the trigger's width, never the 240px floor a standalone
16
+ dropdown gets: it is laid out while hidden, so a wider one widened the page
17
+ (#467). why: docs/specification.md#a-filter-row-holds-its-panels */
25
18
  .ui-filter-bar .ui-dropdown__panel { min-width: 100%; max-width: 100%; overflow-wrap: anywhere; }
26
19
  /* The clear action takes the kit's bordered skin minus its fill: words belong on
27
20
  the page, not on a grey block, in any state — Artur, round r28. Rest stays at
@@ -39,3 +32,55 @@
39
32
  measures on every ground. The edge stays the kit's --disabled-border. */
40
33
  .ui-filter-bar [data-filter-clear] button:disabled,
41
34
  .ui-filter-bar [data-filter-clear] button[aria-disabled="true"] { /* muted-ink: state — the clear action is off and its words sit on the page */ color: var(--disabled-ink-bare); }
35
+
36
+ /* Open, a chip's menu takes the kit's menu floor, measured and slid by
37
+ filterPanelFit(); `is-closing` holds that geometry through the fade. #549
38
+ why: docs/specification.md#a-filter-row-holds-its-panels */
39
+ .ui-filter-bar__chip .ui-dropdown.open .ui-dropdown__panel,
40
+ .ui-filter-bar__chip .ui-dropdown__panel.is-closing {
41
+ min-width: min(var(--ui-filter-panel-floor, 240px), var(--ui-filter-panel-room, 100%));
42
+ max-width: var(--ui-filter-panel-room, 100%);
43
+ margin-inline-start: calc(-1 * var(--ui-filter-panel-shift, 0px));
44
+ }
45
+ /* An end-aligned panel is pinned by its right edge, so the slide pushes it. */
46
+ .ui-filter-bar__chip .ui-dropdown.open .ui-dropdown__panel.is-end,
47
+ .ui-filter-bar__chip .ui-dropdown__panel.is-end.is-closing {
48
+ margin-inline-start: 0;
49
+ margin-inline-end: calc(-1 * var(--ui-filter-panel-shift, 0px));
50
+ }
51
+
52
+ /* A 16px tick and an 11px gap do not fit the 48px trigger a chip printing `All`
53
+ gives its SHUT panel, and overflow-wrap cannot break a mark; the clip is what
54
+ shrinks the glyph and not just its box.
55
+
56
+ Shut is the whole of it. Written for every panel in a chip, this reached the
57
+ open menu too and left 9.8px of the check in a 240px menu — a chevron, not a
58
+ tick. Open the menu is at the floor with room for the mark, and `is-closing`
59
+ holds the open geometry through the fade, so it is open for this as well. #550
60
+ why: docs/specification.md#a-filter-row-holds-its-panels */
61
+ .ui-filter-bar__chip .ui-dropdown:not(.open) .ui-dropdown__panel:not(.is-closing) .ui-dropdown__tick {
62
+ flex-shrink: 1;
63
+ min-width: 0;
64
+ overflow: hidden;
65
+ }
66
+
67
+ /* The chosen value takes --glow-purple, the wash .ui-nav--tabs.is-pill gives the
68
+ tab you are on, and keeps the kit's 16px check. The wash is a 1.2:1 tint, so the
69
+ check is what identifies the state under WCAG 1.4.1; Apple, Material, Carbon,
70
+ Primer and Radix all tick a chosen menu row. #549
71
+ why: docs/specification.md#a-filter-row-holds-its-panels */
72
+ .ui-filter-bar .ui-dropdown__item.is-selected { background: var(--glow-purple); /* ring-gap: inherit — translucent wash retains the opaque containing gap. */ }
73
+ /* Kit-wide a chosen label steps down to --text, which left the row in force the
74
+ faintest in the panel. Under the wash it holds --strong. */
75
+ .ui-filter-bar .ui-dropdown__item.is-selected .ui-dropdown__label { color: var(--strong); }
76
+ /* The keyboard cursor and focus each set an opaque --surface, which would take the
77
+ wash off; restated here so the guarantee is one declaration a reader and
78
+ filter-selected-mark.test.js can both find, and each still reads by its own paint.
79
+ why: guidelines/accessibility-floor.md#measurable-pairs */
80
+ .ui-filter-bar .ui-dropdown__item.is-selected:focus-visible,
81
+ .ui-filter-bar .ui-dropdown__item.is-selected.is-active { background: var(--glow-purple); /* ring-gap: inherit — translucent wash retains the opaque containing gap. */ }
82
+ /* Hover's other paint is a 1px edge, 1.06:1 on the wash, which left the row in force
83
+ the one row a pointer got no answer from. Selection is a background highlight, so
84
+ hover deepens it: 9% more accent, the step .ui-table--zebra gives a hovered row.
85
+ why: docs/specification.md#a-filter-row-holds-its-panels */
86
+ .ui-filter-bar .ui-dropdown__item.is-selected:hover { background: color-mix(in srgb, var(--glow-purple), var(--accent) 9%); /* ring-gap: inherit — translucent wash retains the opaque containing gap. */ }
@@ -191,7 +191,7 @@
191
191
  src/styles/dropdown.css:176 `.ui-dropdown__item:hover`.
192
192
  why: docs/specification.md#elevation */
193
193
  .vopt:hover { outline: 1px solid var(--control-edge); outline-offset: -1px; background: var(--surface); /* ring-gap: inherit — control paint; its focus indicator uses the containing surface. */ }
194
- /* The row is a keyboard stop: src/components/dropdown.js:554
194
+ /* The row is a keyboard stop: src/components/dropdown.js:804
195
195
  `items[next < 0 ? 0 : next]?.focus()` moves focus onto a `[data-dd-item]` with
196
196
  the arrow keys, and this is one. It takes the ring on its own 10px radius, the
197
197
  same way `.amenu a` above does. #482 */