@apliteni/apliteni-ui 0.90.0 → 0.92.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.92.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();
@@ -945,7 +935,7 @@ function rowId(uid, groups, item) {
945
935
  }
946
936
 
947
937
  // src/DataTable.tsx
948
- import { useEffect as useEffect5, useId as useId7, useMemo as useMemo2, useRef as useRef9, useState as useState5 } from "react";
938
+ import { useMemo as useMemo2, useState as useState5 } from "react";
949
939
  import { DEFAULT_PAGE_SIZE as DEFAULT_PAGE_SIZE2 } from "@apliteni/apliteni-ui";
950
940
 
951
941
  // src/Pagination.tsx
@@ -1209,31 +1199,6 @@ function DataTable({
1209
1199
  hasMore = false
1210
1200
  }) {
1211
1201
  const scrollable = stickyHeader || pinnedIdentity;
1212
- const scrollId = useId7();
1213
- const scrollRegion = useRef9(null);
1214
- const [columnScroll, setColumnScroll] = useState5({ overflow: false, start: true, end: false });
1215
- const measureColumns = () => {
1216
- const region = scrollRegion.current;
1217
- if (region) setColumnScroll({
1218
- overflow: region.scrollWidth > region.clientWidth + 1,
1219
- start: region.scrollLeft <= 1,
1220
- end: region.scrollLeft + region.clientWidth >= region.scrollWidth - 1
1221
- });
1222
- };
1223
- useEffect5(() => {
1224
- if (!scrollable) return;
1225
- const region = scrollRegion.current;
1226
- if (!region) return;
1227
- const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measureColumns);
1228
- observer?.observe(region);
1229
- if (region.firstElementChild) observer?.observe(region.firstElementChild);
1230
- measureColumns();
1231
- return () => observer?.disconnect();
1232
- }, [scrollable, columns, rows]);
1233
- const scrollColumns = (direction) => {
1234
- const region = scrollRegion.current;
1235
- if (region) region.scrollBy({ left: direction * region.clientWidth / 2, behavior: "instant" });
1236
- };
1237
1202
  const [localSort, setLocalSort] = useState5(
1238
1203
  { key: columns.find((c) => c.sortable)?.key, dir: -1 }
1239
1204
  );
@@ -1286,20 +1251,13 @@ function DataTable({
1286
1251
  );
1287
1252
  const pageAllOn = selectable && slice.length > 0 && slice.every((r) => selected.has(r.name));
1288
1253
  return /* @__PURE__ */ jsxs13(Fragment3, { children: [
1289
- scrollable && columnScroll.overflow && /* @__PURE__ */ jsxs13("div", { className: "ui-card__row", role: "group", "aria-label": `${scrollLabel} columns`, children: [
1290
- /* @__PURE__ */ jsx17(Button, { size: "sm", icon: "arrowLeft", "aria-controls": scrollId, disabled: columnScroll.start, onClick: () => scrollColumns(-1), children: "Previous columns" }),
1291
- /* @__PURE__ */ jsx17(Button, { size: "sm", iconRight: "arrowRight", "aria-controls": scrollId, disabled: columnScroll.end, onClick: () => scrollColumns(1), children: "More columns" })
1292
- ] }),
1293
1254
  /* @__PURE__ */ jsx17(
1294
1255
  "div",
1295
1256
  {
1296
- id: scrollId,
1297
- ref: scrollRegion,
1298
- onScroll: measureColumns,
1299
- className: stickyHeader || pinnedIdentity ? "ui-table-scroll" : void 0,
1300
- role: stickyHeader || pinnedIdentity ? "region" : void 0,
1301
- "aria-label": stickyHeader || pinnedIdentity ? scrollLabel : void 0,
1302
- tabIndex: stickyHeader || pinnedIdentity ? 0 : void 0,
1257
+ className: scrollable ? "ui-table-scroll" : void 0,
1258
+ role: scrollable ? "region" : void 0,
1259
+ "aria-label": scrollable ? scrollLabel : void 0,
1260
+ tabIndex: scrollable ? 0 : void 0,
1303
1261
  children: /* @__PURE__ */ jsxs13(
1304
1262
  "table",
1305
1263
  {
@@ -1452,9 +1410,9 @@ function Denied({
1452
1410
  // src/DatePicker.tsx
1453
1411
  import {
1454
1412
  useCallback as useCallback2,
1455
- useEffect as useEffect6,
1413
+ useEffect as useEffect5,
1456
1414
  useMemo as useMemo3,
1457
- useRef as useRef10,
1415
+ useRef as useRef9,
1458
1416
  useState as useState6
1459
1417
  } from "react";
1460
1418
  import { jsx as jsx19, jsxs as jsxs15 } from "react/jsx-runtime";
@@ -1467,7 +1425,7 @@ var DAY_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
1467
1425
  var PHONE_MAX = 560;
1468
1426
  function usePhone() {
1469
1427
  const [phone, setPhone] = useState6(false);
1470
- useEffect6(() => {
1428
+ useEffect5(() => {
1471
1429
  const query = typeof window === "undefined" ? null : window.matchMedia?.(`(max-width: ${PHONE_MAX}px)`);
1472
1430
  if (!query) return;
1473
1431
  const read = () => setPhone(query.matches);
@@ -1562,9 +1520,9 @@ function DatePicker({
1562
1520
  const picksRange = mode === "range" || mode === "day-range";
1563
1521
  const phone = usePhone();
1564
1522
  const asSheet = sheet ?? phone;
1565
- const root = useRef10(null);
1566
- const trigger = useRef10(null);
1567
- const grid = useRef10(null);
1523
+ const root = useRef9(null);
1524
+ const trigger = useRef9(null);
1525
+ const grid = useRef9(null);
1568
1526
  const [selfOpen, setSelfOpen] = useState6(defaultOpen);
1569
1527
  const open = openProp ?? selfOpen;
1570
1528
  const [selfValue, setSelfValue] = useState6(defaultValue);
@@ -1601,19 +1559,19 @@ function DatePicker({
1601
1559
  }, [today, grain]);
1602
1560
  const anchor = toIndex(picked, grain) ?? toIndex(picksRange ? span.start ?? span.end : null, grain) ?? todayIndex;
1603
1561
  const [cursor, setCursor] = useState6(() => clamp(anchor, lo, hi));
1604
- const land = useRef10(false);
1562
+ const land = useRef9(false);
1605
1563
  const setOpen = useCallback2((next) => {
1606
1564
  if (openProp === void 0) setSelfOpen(next);
1607
1565
  onOpenChange?.(next);
1608
1566
  }, [openProp, onOpenChange]);
1609
- const close = useRef10(setOpen);
1567
+ const close = useRef9(setOpen);
1610
1568
  useIsoLayoutEffect(() => {
1611
1569
  close.current = setOpen;
1612
1570
  });
1613
- useEffect6(() => {
1571
+ useEffect5(() => {
1614
1572
  if (open) setCursor(clamp(anchor, lo, hi));
1615
1573
  }, [open]);
1616
- useEffect6(() => {
1574
+ useEffect5(() => {
1617
1575
  if (!open || asSheet) return;
1618
1576
  const doc = root.current?.ownerDocument ?? document;
1619
1577
  const onClick = (e) => {
@@ -1923,13 +1881,24 @@ function DatePicker({
1923
1881
  import {
1924
1882
  Fragment as Fragment4,
1925
1883
  useCallback as useCallback3,
1926
- useEffect as useEffect7,
1927
- useId as useId8,
1928
- useRef as useRef11,
1884
+ useEffect as useEffect6,
1885
+ useId as useId7,
1886
+ useRef as useRef10,
1929
1887
  useState as useState7
1930
1888
  } from "react";
1931
- import { icon as icon3, dropdownMatch, dropdownFiltering } from "@apliteni/apliteni-ui";
1889
+ import {
1890
+ icon as icon3,
1891
+ dropdownMatch,
1892
+ dropdownFiltering,
1893
+ filterPanelFit,
1894
+ transitionMs as transitionMs2
1895
+ } from "@apliteni/apliteni-ui";
1932
1896
  import { Fragment as Fragment5, jsx as jsx20, jsxs as jsxs16 } from "react/jsx-runtime";
1897
+ var EXIT_SLACK_MS2 = 50;
1898
+ var inPanel = (node) => {
1899
+ const el = node?.nodeType === 1 ? node : node?.parentElement ?? null;
1900
+ return !!el?.closest("[data-dropdown-panel]");
1901
+ };
1933
1902
  var cx6 = (...a) => a.filter(Boolean).join(" ");
1934
1903
  var isRow = (it) => Boolean(it) && it !== "---" && !it.separator;
1935
1904
  var Glyph2 = ({ name, className, hidden }) => /* @__PURE__ */ jsx20(
@@ -1986,22 +1955,100 @@ function Dropdown({
1986
1955
  const [pick, setPick] = useState7(null);
1987
1956
  const [query, setQuery] = useState7(() => search && search !== true ? search.query || "" : "");
1988
1957
  const [activeKey, setActiveKey] = useState7(null);
1989
- const root = useRef11(null);
1990
- const panel = useRef11(null);
1991
- const trigger = useRef11(null);
1992
- const field = useRef11(null);
1993
- const landOn = useRef11(null);
1994
- const at = useRef11("");
1995
- const auto = useId8().replace(/:/g, "");
1958
+ const root = useRef10(null);
1959
+ const panel = useRef10(null);
1960
+ const trigger = useRef10(null);
1961
+ const field = useRef10(null);
1962
+ const landOn = useRef10(null);
1963
+ const at = useRef10("");
1964
+ const auto = useId7().replace(/:/g, "");
1996
1965
  const uid = id ?? auto;
1997
1966
  const setOpen = useCallback3((next) => {
1998
1967
  if (openProp === void 0) setSelfOpen(next);
1999
1968
  onOpenChange?.(next);
2000
1969
  }, [openProp, onOpenChange]);
2001
- const close = useRef11(setOpen);
1970
+ const close = useRef10(setOpen);
2002
1971
  useIsoLayoutEffect(() => {
2003
1972
  close.current = setOpen;
2004
1973
  });
1974
+ useIsoLayoutEffect(() => {
1975
+ const el = panel.current;
1976
+ if (!el || !open) return;
1977
+ const fit = () => {
1978
+ const got = filterPanelFit(root.current);
1979
+ if (!got) return;
1980
+ let moved = false;
1981
+ for (const prop of ["room", "shift", "floor"]) {
1982
+ const name2 = `--ui-filter-panel-${prop}`;
1983
+ const next = `${got[prop]}px`;
1984
+ if (el.style.getPropertyValue(name2) === next) continue;
1985
+ el.style.setProperty(name2, next);
1986
+ moved = true;
1987
+ }
1988
+ if (!moved) return;
1989
+ if (!el.style.minWidth) return;
1990
+ el.style.minWidth = "";
1991
+ if (el.offsetWidth) el.style.minWidth = `${el.offsetWidth}px`;
1992
+ };
1993
+ fit();
1994
+ const view = root.current?.ownerDocument?.defaultView;
1995
+ const row2 = root.current?.closest(".ui-filter-bar__chip")?.closest(".ui-filter-bar");
1996
+ if (!row2 || !view) return;
1997
+ const stop = [];
1998
+ let ro = null;
1999
+ if (typeof view.ResizeObserver === "function") {
2000
+ ro = new view.ResizeObserver(() => fit());
2001
+ ro.observe(row2);
2002
+ for (const kid of Array.from(row2.children)) ro.observe(kid);
2003
+ stop.push(() => ro?.disconnect());
2004
+ } else {
2005
+ view.addEventListener("resize", fit);
2006
+ stop.push(() => view.removeEventListener("resize", fit));
2007
+ }
2008
+ if (typeof view.MutationObserver === "function") {
2009
+ const mo = new view.MutationObserver((records) => {
2010
+ if (records.every((m) => inPanel(m.target))) return;
2011
+ if (ro && records.some((m) => m.type === "childList")) {
2012
+ for (const kid of Array.from(row2.children)) ro.observe(kid);
2013
+ }
2014
+ fit();
2015
+ });
2016
+ mo.observe(row2, { subtree: true, childList: true, attributes: true, characterData: true });
2017
+ stop.push(() => mo.disconnect());
2018
+ }
2019
+ return () => {
2020
+ stop.forEach((off) => off());
2021
+ };
2022
+ }, [open]);
2023
+ const opened = useRef10(false);
2024
+ useIsoLayoutEffect(() => {
2025
+ const el = panel.current;
2026
+ if (open) {
2027
+ opened.current = true;
2028
+ return;
2029
+ }
2030
+ if (!opened.current || !el || !root.current?.closest(".ui-filter-bar__chip")) return;
2031
+ el.classList.add("is-closing");
2032
+ let done = false;
2033
+ const finish = () => {
2034
+ if (done) return;
2035
+ done = true;
2036
+ el.classList.remove("is-closing");
2037
+ el.style.minWidth = "";
2038
+ for (const prop of ["room", "shift", "floor"]) el.style.removeProperty(`--ui-filter-panel-${prop}`);
2039
+ };
2040
+ const onEnd = (e) => {
2041
+ if (e.target === el && e.propertyName === "opacity") finish();
2042
+ };
2043
+ el.addEventListener("transitionend", onEnd);
2044
+ const timer = setTimeout(finish, transitionMs2(el) + EXIT_SLACK_MS2);
2045
+ return () => {
2046
+ done = true;
2047
+ el.removeEventListener("transitionend", onEnd);
2048
+ clearTimeout(timer);
2049
+ el.classList.remove("is-closing");
2050
+ };
2051
+ }, [open]);
2005
2052
  const sx = search ? { ...SEARCH_DEFAULTS, ...strip(search === true ? {} : search) } : null;
2006
2053
  const entries = sections?.length ? sections.flatMap((s) => s.items || []) : items || [];
2007
2054
  const rows = entries.filter(isRow);
@@ -2012,7 +2059,7 @@ function Dropdown({
2012
2059
  const given = rows.find((it) => it.selected);
2013
2060
  const givenKey = given ? keyOf(given) : null;
2014
2061
  const pickedKey = pick && pick.against === givenKey ? pick.key : null;
2015
- useEffect7(() => {
2062
+ useEffect6(() => {
2016
2063
  if (pick && pick.against !== givenKey) setPick(null);
2017
2064
  }, [givenKey, pick]);
2018
2065
  const picked = pickedKey != null ? rows.find((it) => keyOf(it) === pickedKey) : void 0;
@@ -2026,7 +2073,7 @@ function Dropdown({
2026
2073
  const rowIds = /* @__PURE__ */ new Map();
2027
2074
  rows.forEach((it, i) => rowIds.set(it, `${uid}-opt-${i}`));
2028
2075
  const listId = `${uid}-list`;
2029
- useEffect7(() => {
2076
+ useEffect6(() => {
2030
2077
  if (!open) return;
2031
2078
  const mine = () => close.current(false);
2032
2079
  for (const other of [...openNow]) other();
@@ -2035,7 +2082,7 @@ function Dropdown({
2035
2082
  openNow.delete(mine);
2036
2083
  };
2037
2084
  }, [open]);
2038
- useEffect7(() => {
2085
+ useEffect6(() => {
2039
2086
  if (!open) return;
2040
2087
  const doc = root.current?.ownerDocument ?? document;
2041
2088
  const onClick = (e) => {
@@ -2056,7 +2103,12 @@ function Dropdown({
2056
2103
  useIsoLayoutEffect(() => {
2057
2104
  const want = landOn.current;
2058
2105
  landOn.current = null;
2059
- if (!open) return;
2106
+ if (!open) {
2107
+ if (panel.current?.style && root.current?.closest(".ui-filter-bar") && !root.current.closest(".ui-filter-bar__chip")) {
2108
+ panel.current.style.minWidth = "";
2109
+ }
2110
+ return;
2111
+ }
2060
2112
  if (sx) {
2061
2113
  setQuery("");
2062
2114
  const enabled = rows.filter((it) => !it.disabled);
@@ -2411,12 +2463,12 @@ function Segmented({ options, value, onChange, label, appearance = "pill", disab
2411
2463
  }
2412
2464
 
2413
2465
  // src/FilterBar.tsx
2414
- import { useRef as useRef12, useState as useState8 } from "react";
2415
- import { filterChipText, filterChipName, filterChipUnset, focusNextStop } from "@apliteni/apliteni-ui";
2466
+ import { useRef as useRef11, useState as useState8 } from "react";
2467
+ import { filterChipText, filterChipName, filterChipUnset, filterChipItems, focusNextStop } from "@apliteni/apliteni-ui";
2416
2468
  import { jsx as jsx24, jsxs as jsxs19 } from "react/jsx-runtime";
2417
2469
  function FilterBar({ filters, label = "Filters", clearLabel = "Clear all filters", disabled = false, busy = false, onRemove, onClear, onChange }) {
2418
- const bar = useRef12(null);
2419
- const focused = useRef12(null);
2470
+ const bar = useRef11(null);
2471
+ const focused = useRef11(null);
2420
2472
  const [opened, setOpened] = useState8({});
2421
2473
  const blocked = disabled || busy;
2422
2474
  useIsoLayoutEffect(() => {
@@ -2459,7 +2511,7 @@ function FilterBar({ filters, label = "Filters", clearLabel = "Clear all filters
2459
2511
  /* @__PURE__ */ jsx24(
2460
2512
  Dropdown,
2461
2513
  {
2462
- items: filter.items,
2514
+ items: filterChipItems(filter),
2463
2515
  variant: "select",
2464
2516
  ariaLabel: filterChipName(filter),
2465
2517
  triggerContent: /* @__PURE__ */ jsx24("span", { className: filterChipUnset(filter) ? "ui-dropdown__value is-placeholder" : "ui-dropdown__value", children: filterChipText(filter) }),
@@ -2522,13 +2574,13 @@ function DrawerSection({ title, headingLevel = 3, className, children, ...rest }
2522
2574
  }
2523
2575
 
2524
2576
  // src/Snippet.tsx
2525
- import { useEffect as useEffect8, useRef as useRef13, useState as useState9 } from "react";
2577
+ import { useEffect as useEffect7, useRef as useRef12, useState as useState9 } from "react";
2526
2578
  import { Fragment as Fragment6, jsx as jsx26, jsxs as jsxs21 } from "react/jsx-runtime";
2527
2579
  function Snippet({ label = "shell", code = "", children, copy = true, reveal = false, copyLabel = "Copy code" }) {
2528
2580
  const [status, setStatus] = useState9("idle");
2529
- const request = useRef13(0);
2530
- const timer = useRef13(void 0);
2531
- useEffect8(() => {
2581
+ const request = useRef12(0);
2582
+ const timer = useRef12(void 0);
2583
+ useEffect7(() => {
2532
2584
  setStatus("idle");
2533
2585
  return () => {
2534
2586
  request.current++;
@@ -2571,15 +2623,15 @@ function Snippet({ label = "shell", code = "", children, copy = true, reveal = f
2571
2623
  }
2572
2624
 
2573
2625
  // src/Tabs.tsx
2574
- import { useEffect as useEffect9, useId as useId9, useRef as useRef14 } from "react";
2626
+ import { useEffect as useEffect8, useId as useId8, useRef as useRef13 } from "react";
2575
2627
  import { playEntrance } from "@apliteni/apliteni-ui/motion";
2576
2628
  import { Fragment as Fragment7, jsx as jsx27, jsxs as jsxs22 } from "react/jsx-runtime";
2577
2629
  function Tabs({ items, value, onChange, label }) {
2578
- const id = useId9();
2579
- const previous = useRef14(value);
2580
- const activePanel = useRef14(null);
2581
- const buttons = useRef14([]);
2582
- useEffect9(() => {
2630
+ const id = useId8();
2631
+ const previous = useRef13(value);
2632
+ const activePanel = useRef13(null);
2633
+ const buttons = useRef13([]);
2634
+ useEffect8(() => {
2583
2635
  if (previous.current !== value) playEntrance(activePanel.current);
2584
2636
  previous.current = value;
2585
2637
  }, [value]);
@@ -2648,14 +2700,14 @@ function Tabs({ items, value, onChange, label }) {
2648
2700
  }
2649
2701
 
2650
2702
  // src/Timeline.tsx
2651
- import { useEffect as useEffect10, useRef as useRef15 } from "react";
2703
+ import { useEffect as useEffect9, useRef as useRef14 } from "react";
2652
2704
  import { playEntrance as playEntrance2, prefersReducedMotion } from "@apliteni/apliteni-ui/motion";
2653
2705
  import { jsx as jsx28, jsxs as jsxs23 } from "react/jsx-runtime";
2654
2706
  var kindIcons = { person: "user", rule: "bolt", reversal: "refresh" };
2655
2707
  function Timeline({ events, className, ...rest }) {
2656
- const previousIds = useRef15(new Set(events.map((event) => event.id)));
2657
- const rows = useRef15(/* @__PURE__ */ new Map());
2658
- useEffect10(() => {
2708
+ const previousIds = useRef14(new Set(events.map((event) => event.id)));
2709
+ const rows = useRef14(/* @__PURE__ */ new Map());
2710
+ useEffect9(() => {
2659
2711
  if (previousIds.current.size > 0 && !prefersReducedMotion()) {
2660
2712
  for (const event of events) {
2661
2713
  if (previousIds.current.has(event.id)) continue;
@@ -2698,7 +2750,7 @@ function Timeline({ events, className, ...rest }) {
2698
2750
  }
2699
2751
 
2700
2752
  // src/Field.tsx
2701
- import { forwardRef as forwardRef6, useEffect as useEffect11, useId as useId10, useRef as useRef16, useState as useState10 } from "react";
2753
+ import { forwardRef as forwardRef6, useEffect as useEffect10, useId as useId9, useRef as useRef15, useState as useState10 } from "react";
2702
2754
  import { esc, icon as icon4 } from "@apliteni/apliteni-ui";
2703
2755
  import { jsx as jsx29, jsxs as jsxs24 } from "react/jsx-runtime";
2704
2756
  function messages(id, hint, error) {
@@ -2727,12 +2779,12 @@ function wiring(id, hint, error) {
2727
2779
  return { id, "aria-describedby": messages(id, hint, error), "aria-invalid": error ? true : void 0 };
2728
2780
  }
2729
2781
  function Field({ id: suppliedId, label, hint, error, required, children }) {
2730
- const generatedId = useId10();
2782
+ const generatedId = useId9();
2731
2783
  const id = suppliedId ?? generatedId;
2732
2784
  return /* @__PURE__ */ jsx29(Frame, { ...{ id, label, hint, error, required }, children: children({ ...wiring(id, hint, error), required }) });
2733
2785
  }
2734
2786
  var TextField = forwardRef6(function TextField2({ label, hint, error, required, type = "text", unit, icon: glyph, className = "", ...props }, ref) {
2735
- const id = useId10();
2787
+ const id = useId9();
2736
2788
  const hasUnit = type === "number" && !!unit;
2737
2789
  const describedBy = [messages(id, hint, error), hasUnit ? `${id}-unit` : ""].filter(Boolean).join(" ") || void 0;
2738
2790
  const control = /* @__PURE__ */ jsx29(
@@ -2759,7 +2811,7 @@ var TextField = forwardRef6(function TextField2({ label, hint, error, required,
2759
2811
  ] }) : control });
2760
2812
  });
2761
2813
  function TextArea({ label, hint, error, required, className = "", ...props }) {
2762
- const id = useId10();
2814
+ const id = useId9();
2763
2815
  return /* @__PURE__ */ jsx29(Frame, { ...{ id, label, hint, error, required }, children: /* @__PURE__ */ jsx29(
2764
2816
  "textarea",
2765
2817
  {
@@ -2772,7 +2824,7 @@ function TextArea({ label, hint, error, required, className = "", ...props }) {
2772
2824
  ) });
2773
2825
  }
2774
2826
  function SelectField({ label, hint, error, required, className = "", children, ...props }) {
2775
- const id = useId10();
2827
+ const id = useId9();
2776
2828
  return /* @__PURE__ */ jsx29(Frame, { ...{ id, label, hint, error, required }, children: /* @__PURE__ */ jsx29(
2777
2829
  "select",
2778
2830
  {
@@ -2785,11 +2837,11 @@ function SelectField({ label, hint, error, required, className = "", children, .
2785
2837
  ) });
2786
2838
  }
2787
2839
  function FileField({ label, hint, error, required, disabled, onFileChange, className = "", ...props }) {
2788
- const id = useId10();
2789
- const input = useRef16(null);
2840
+ const id = useId9();
2841
+ const input = useRef15(null);
2790
2842
  const [file, setFile] = useState10(null);
2791
2843
  const [dragging, setDragging] = useState10(false);
2792
- useEffect11(() => {
2844
+ useEffect10(() => {
2793
2845
  const form = input.current?.form;
2794
2846
  const reset = () => {
2795
2847
  setFile(null);
@@ -2848,7 +2900,7 @@ function FileField({ label, hint, error, required, disabled, onFileChange, class
2848
2900
  }
2849
2901
 
2850
2902
  // src/FileDrop.tsx
2851
- import { useRef as useRef17, useState as useState11 } from "react";
2903
+ import { useRef as useRef16, useState as useState11 } from "react";
2852
2904
  import { Fragment as Fragment8, jsx as jsx30, jsxs as jsxs25 } from "react/jsx-runtime";
2853
2905
  var MARKS = {
2854
2906
  uploading: "clock",
@@ -2882,7 +2934,7 @@ function FileDrop({
2882
2934
  children,
2883
2935
  className
2884
2936
  }) {
2885
- const input = useRef17(null);
2937
+ const input = useRef16(null);
2886
2938
  const [over, setOver] = useState11(false);
2887
2939
  const status = file?.status ?? "uploading";
2888
2940
  const word = status === "error" ? file?.error ?? WORDS.error : file?.state ?? WORDS[status];
@@ -3017,7 +3069,7 @@ var SearchField = forwardRef7(function SearchField2({ ariaLabel, className, ...p
3017
3069
  });
3018
3070
 
3019
3071
  // src/Toast.tsx
3020
- import { createContext as createContext2, useCallback as useCallback4, useContext as useContext2, useEffect as useEffect12, useLayoutEffect as useLayoutEffect4, useRef as useRef18, useState as useState12 } from "react";
3072
+ import { createContext as createContext2, useCallback as useCallback4, useContext as useContext2, useEffect as useEffect11, useLayoutEffect as useLayoutEffect4, useRef as useRef17, useState as useState12 } from "react";
3021
3073
  import { createPortal as createPortal4 } from "react-dom";
3022
3074
  import { jsx as jsx32, jsxs as jsxs27 } from "react/jsx-runtime";
3023
3075
  var Context = createContext2(null);
@@ -3028,10 +3080,10 @@ function useToast() {
3028
3080
  return push;
3029
3081
  }
3030
3082
  function Notice({ notice, remove }) {
3031
- const root = useRef18(null);
3083
+ const root = useRef17(null);
3032
3084
  const [leaving2, setLeaving] = useState12(false);
3033
- const dismissed = useRef18(false);
3034
- const remaining = useRef18(5e3);
3085
+ const dismissed = useRef17(false);
3086
+ const remaining = useRef17(5e3);
3035
3087
  const [hovered, setHovered] = useState12(false);
3036
3088
  const [focused, setFocused] = useState12(false);
3037
3089
  const paused = hovered || focused;
@@ -3042,7 +3094,7 @@ function Notice({ notice, remove }) {
3042
3094
  if (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches) remove();
3043
3095
  else setLeaving(true);
3044
3096
  }, [remove]);
3045
- useEffect12(() => {
3097
+ useEffect11(() => {
3046
3098
  if (leaving2) {
3047
3099
  const element = root.current;
3048
3100
  const finish = (event) => {
@@ -3056,7 +3108,7 @@ function Notice({ notice, remove }) {
3056
3108
  };
3057
3109
  }
3058
3110
  }, [leaving2, remove]);
3059
- useEffect12(() => {
3111
+ useEffect11(() => {
3060
3112
  if (leaving2 || notice.action || paused) return;
3061
3113
  const started = Date.now();
3062
3114
  const timer = setTimeout(dismiss, remaining.current);
@@ -3134,8 +3186,8 @@ function usePublishedReach(stack2, count) {
3134
3186
  }
3135
3187
  function Toast({ children }) {
3136
3188
  const [entries, setEntries] = useState12([]);
3137
- const nextId = useRef18(0);
3138
- const stack2 = useRef18(null);
3189
+ const nextId = useRef17(0);
3190
+ const stack2 = useRef17(null);
3139
3191
  const push = useCallback4((notice) => {
3140
3192
  const id = nextId.current++;
3141
3193
  const remove = () => setEntries((current) => current.filter((entry) => entry.id !== id));
@@ -3152,22 +3204,22 @@ function Toast({ children }) {
3152
3204
  }
3153
3205
 
3154
3206
  // src/AppShell.tsx
3155
- import { useEffect as useEffect14, useId as useId12, useState as useState14 } from "react";
3207
+ import { useEffect as useEffect13, useId as useId11, useState as useState14 } from "react";
3156
3208
  import { applyTheme, paletteHotkey, prism, railCollapsed, themeIcon, themeName } from "@apliteni/apliteni-ui";
3157
3209
 
3158
3210
  // src/SidebarNav.tsx
3159
- import { forwardRef as forwardRef8, useEffect as useEffect13, useId as useId11, useState as useState13 } from "react";
3211
+ import { forwardRef as forwardRef8, useEffect as useEffect12, useId as useId10, useState as useState13 } from "react";
3160
3212
  import { ENTRANCE_FALLBACK_MS } from "@apliteni/apliteni-ui/motion";
3161
3213
  import { Fragment as Fragment9, jsx as jsx33, jsxs as jsxs28 } from "react/jsx-runtime";
3162
3214
  var badgeText = (badge) => typeof badge === "object" ? badge.text : badge;
3163
3215
  function Row({ item, active, activeIs, collapsed, renderLink, sub }) {
3164
- const uid = useId11();
3216
+ const uid = useId10();
3165
3217
  const group = !sub && !!item.items?.length;
3166
3218
  const childActive = group && item.items.some((child) => child.id === active);
3167
3219
  const [expanded, setExpanded] = useState13();
3168
3220
  const open = expanded ?? item.defaultOpen ?? childActive;
3169
3221
  const [entering, setEntering] = useState13(false);
3170
- useEffect13(() => {
3222
+ useEffect12(() => {
3171
3223
  if (!entering) return;
3172
3224
  const timer = setTimeout(() => setEntering(false), ENTRANCE_FALLBACK_MS);
3173
3225
  return () => clearTimeout(timer);
@@ -3218,7 +3270,7 @@ function Row({ item, active, activeIs, collapsed, renderLink, sub }) {
3218
3270
  return /* @__PURE__ */ jsx33("li", { children: renderLink ? renderLink(item, props) : /* @__PURE__ */ jsx33("a", { ...props }) });
3219
3271
  }
3220
3272
  function Section({ section, ...props }) {
3221
- const uid = useId11();
3273
+ const uid = useId10();
3222
3274
  return /* @__PURE__ */ jsxs28("div", { className: "ui-nav__section", children: [
3223
3275
  section.label && /* @__PURE__ */ jsx33("div", { className: "ui-nav__cap", id: uid, "aria-hidden": props.collapsed || void 0, children: section.label }),
3224
3276
  /* @__PURE__ */ jsx33("ul", { className: "ui-nav__list", "aria-labelledby": section.label ? uid : void 0, children: section.items.map((item) => /* @__PURE__ */ jsx33(Row, { item, ...props }, item.id)) })
@@ -3274,21 +3326,21 @@ function AppShell({
3274
3326
  palette,
3275
3327
  renderLink
3276
3328
  }) {
3277
- const uid = useId12();
3329
+ const uid = useId11();
3278
3330
  const [collapsed, setCollapsed] = useState14(defaultCollapsed);
3279
3331
  const [search, setSearch] = useState14(false);
3280
3332
  const [more, setMore] = useState14(false);
3281
3333
  const [theme, setTheme] = useState14("dark");
3282
3334
  const current = path(pathname);
3283
3335
  const active = sections.filter((section) => current === path(section.href) || path(section.href) !== "/" && current.startsWith(`${path(section.href)}/`)).sort((a, b) => path(b.href).length - path(a.href).length)[0];
3284
- useEffect14(() => {
3336
+ useEffect13(() => {
3285
3337
  setCollapsed(railCollapsed() ?? defaultCollapsed);
3286
3338
  }, [defaultCollapsed]);
3287
- useEffect14(() => {
3339
+ useEffect13(() => {
3288
3340
  setMore(false);
3289
3341
  setSearch(false);
3290
3342
  }, [pathname]);
3291
- useEffect14(() => {
3343
+ useEffect13(() => {
3292
3344
  const root = document.documentElement;
3293
3345
  const sync2 = () => setTheme(root.getAttribute("data-theme") === "light" ? "light" : "dark");
3294
3346
  sync2();
@@ -3296,7 +3348,7 @@ function AppShell({
3296
3348
  observer.observe(root, { attributes: true, attributeFilter: ["data-theme"] });
3297
3349
  return () => observer.disconnect();
3298
3350
  }, []);
3299
- useEffect14(() => {
3351
+ useEffect13(() => {
3300
3352
  const hotkey = (event) => {
3301
3353
  if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "k") return;
3302
3354
  const target = event.target;
@@ -3523,7 +3575,7 @@ function ThemeToggle({ labelled = false }) {
3523
3575
  }
3524
3576
 
3525
3577
  // src/Success.tsx
3526
- import { forwardRef as forwardRef9, useEffect as useEffect15, useRef as useRef19, useState as useState15 } from "react";
3578
+ import { forwardRef as forwardRef9, useEffect as useEffect14, useRef as useRef18, useState as useState15 } from "react";
3527
3579
  import { jsx as jsx37, jsxs as jsxs32 } from "react/jsx-runtime";
3528
3580
  var SuccessCheck = forwardRef9(function SuccessCheck2({ variant = "line", className, ...rest }, ref) {
3529
3581
  const circled = variant === "circled";
@@ -3571,11 +3623,11 @@ var pieces = [
3571
3623
  ];
3572
3624
  function Countdown({ seconds, label = "Redirecting", onDone }) {
3573
3625
  const [remaining, setRemaining] = useState15(seconds);
3574
- const callback = useRef19(onDone);
3575
- useEffect15(() => {
3626
+ const callback = useRef18(onDone);
3627
+ useEffect14(() => {
3576
3628
  callback.current = onDone;
3577
3629
  }, [onDone]);
3578
- useEffect15(() => {
3630
+ useEffect14(() => {
3579
3631
  let count = seconds;
3580
3632
  const timer = setInterval(() => {
3581
3633
  count -= 1;
@@ -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 */