@apliteni/apliteni-ui 0.89.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 +1 -1
- package/react/dist/index.js +99 -15
- package/src/components/dropdown.js +252 -2
- package/src/components/filter-bar.js +2 -2
- package/src/index.d.ts +18 -0
- package/src/index.js +1 -1
- package/src/logic/filter-bar.js +20 -0
- package/src/motion.d.ts +2 -0
- package/src/motion.js +27 -0
- package/src/styles/filter-bar.css +55 -10
- package/src/styles/nav.css +58 -51
- package/src/styles/topbar.css +1 -1
package/package.json
CHANGED
package/react/dist/index.js
CHANGED
|
@@ -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 {
|
|
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)
|
|
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
|
|
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)
|
|
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
|
|
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';
|
package/src/logic/filter-bar.js
CHANGED
|
@@ -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
|
-
/*
|
|
16
|
-
|
|
17
|
-
|
|
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. */ }
|
package/src/styles/nav.css
CHANGED
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
/* The rail's two widths, and the one place they are written. --ui-nav-col is the
|
|
13
13
|
open column. --ui-nav-strip is twice a row's own glyph centre — the row's
|
|
14
14
|
padding plus half a glyph — so a strip that wide has the glyph standing in the
|
|
15
|
-
middle of it, exactly where it stood in the open rail.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
middle of it, exactly where it stood in the open rail. The fold travels that
|
|
16
|
+
width on the rail and on the boxes inside it, so a folded rail is only as wide
|
|
17
|
+
as it draws and has nothing to clip. layout.css adds the shell rail's own
|
|
18
|
+
inset to both. stories/apps/shell-states.test.js derives the strip from the
|
|
19
|
+
resolved cascade and refuses a number that has drifted from it.
|
|
20
20
|
Declared for the shell as well as the nav, because the shell's rail is this
|
|
21
21
|
nav with an inset around it, and two copies of 41 is the drift itself. */
|
|
22
22
|
.ui-nav--side,
|
|
@@ -31,14 +31,20 @@
|
|
|
31
31
|
been a second scroll box cutting the rows off at the rail's height. */
|
|
32
32
|
transition: width var(--dur-med) var(--ease);
|
|
33
33
|
}
|
|
34
|
-
/*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
.ui-nav--side > * {
|
|
34
|
+
/* A block takes a width of its own rather than the rail's travelling one, and travels
|
|
35
|
+
it on the RAIL's clock. Declared here, not beside the fold, so the way OUT carries it:
|
|
36
|
+
a block with no clock snapped open while the rail was still a strip wide. */
|
|
37
|
+
.ui-nav--side > * {
|
|
38
|
+
width: var(--ui-nav-col); flex: none;
|
|
39
|
+
transition: width var(--dur-med) var(--ease);
|
|
40
|
+
}
|
|
38
41
|
.ui-nav__section + .ui-nav__section { margin-top: 14px; }
|
|
39
42
|
.ui-nav__list { display: flex; flex-direction: column; gap: 2px; }
|
|
40
43
|
.ui-nav__cap {
|
|
41
44
|
/* rank: label */
|
|
45
|
+
/* Words, never a control, at BOTH widths — as __label, __badge and __caret below.
|
|
46
|
+
why: docs/specification.md#a-folded-rail-takes-the-pointer-only-where-it-draws */
|
|
47
|
+
pointer-events: none;
|
|
42
48
|
font-size: var(--text-sm);
|
|
43
49
|
font-weight: var(--weight-medium);
|
|
44
50
|
color: var(--text);
|
|
@@ -101,6 +107,7 @@
|
|
|
101
107
|
.ui-nav__item.is-current .ui-nav__ic svg { opacity: 1; stroke: currentColor; } /* motion: still — the glyph brightens with its row, whose own highlight already transitions */
|
|
102
108
|
.ui-nav__label {
|
|
103
109
|
flex: 1 1 auto; min-width: 0;
|
|
110
|
+
pointer-events: none; /* words, not a control — see .ui-nav__cap */
|
|
104
111
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
105
112
|
/* The words go before the closing edge reaches them, which is most of why the
|
|
106
113
|
fold is worth animating — a label cut through by that edge reads as the rail
|
|
@@ -129,6 +136,7 @@
|
|
|
129
136
|
/* Per-item badge / counter */
|
|
130
137
|
.ui-nav__badge {
|
|
131
138
|
flex: none;
|
|
139
|
+
pointer-events: none; /* a count, not a control — see .ui-nav__cap */
|
|
132
140
|
min-width: 20px;
|
|
133
141
|
height: 19px;
|
|
134
142
|
padding: 0 6px;
|
|
@@ -163,6 +171,7 @@
|
|
|
163
171
|
.ui-nav__group { display: flex; flex-direction: column; }
|
|
164
172
|
.ui-nav__caret {
|
|
165
173
|
flex: none;
|
|
174
|
+
pointer-events: none; /* a mark on the toggle, not a control — see .ui-nav__cap */
|
|
166
175
|
width: 7px; height: 7px;
|
|
167
176
|
border-right: 1.6px solid var(--muted);
|
|
168
177
|
border-bottom: 1.6px solid var(--muted);
|
|
@@ -223,53 +232,52 @@
|
|
|
223
232
|
border-inline-start-width var(--dur-med) var(--ease);
|
|
224
233
|
}
|
|
225
234
|
|
|
226
|
-
/* Collapsed (icon-only) rail — the same fold the shell's rail makes
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
235
|
+
/* Collapsed (icon-only) rail — the same fold the shell's rail makes. A group's
|
|
236
|
+
children stay rows here, because wireNav() toggles the list's `hidden` attribute
|
|
237
|
+
and nothing else, so folding .ui-nav__sub away left the toggle announcing a list it
|
|
238
|
+
could not open. What goes is the indent and the words, and sidebarNav({ collapsed })
|
|
239
|
+
hands each row its name as a `title` so a pointer can still read it. The shell's
|
|
240
|
+
rail draws a chip of its own instead, on keyboard focus as well — see layout.css. */
|
|
241
|
+
.ui-nav--side.is-collapsed { width: var(--ui-nav-strip); }
|
|
242
|
+
/* Every box in the rail closes with it, whether it paints or not. A block that keeps
|
|
243
|
+
the open column paints nothing and still takes the pointer off 175px of the page
|
|
244
|
+
beside a positioned rail (#588); clipping it instead cut the rows' ring (#575).
|
|
245
|
+
why: docs/specification.md#a-folded-rail-takes-the-pointer-only-where-it-draws */
|
|
246
|
+
.ui-nav--side.is-collapsed > * { width: var(--ui-nav-strip); }
|
|
247
|
+
.ui-nav--side.is-collapsed .ui-nav__item { width: var(--ui-nav-strip); }
|
|
248
|
+
/* The heading is the one box that keeps the open column: its line breaks are the rail's
|
|
249
|
+
height. Inert at both widths, so the column draws and takes nothing; the 175px it adds
|
|
250
|
+
to a page's scrollable width is the one thing this rail does not promise. */
|
|
251
|
+
.ui-nav--side.is-collapsed .ui-nav__cap { width: var(--ui-nav-col); }
|
|
252
|
+
/* The row takes the rail's clock too, so its box IS the rail's box in every frame and
|
|
253
|
+
not only at either end. Waiting out the words on a --dur-fast delay left the open
|
|
254
|
+
column's hit area inside a half-shut rail. (Restates the base it replaces.) */
|
|
255
|
+
.ui-nav--side.is-collapsed .ui-nav__item {
|
|
256
|
+
transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease),
|
|
257
|
+
width var(--dur-med) var(--ease);
|
|
243
258
|
}
|
|
259
|
+
/* What that delay was protecting, kept without it: the words hold their own width
|
|
260
|
+
instead of being squeezed out by the closing row. They overflow it and cost nothing —
|
|
261
|
+
inert at either width, and painting nothing at opacity 0. */
|
|
262
|
+
.ui-nav--side.is-collapsed .ui-nav__label { min-width: max-content; }
|
|
244
263
|
.ui-nav--side.is-collapsed .ui-nav__cap,
|
|
245
264
|
.ui-nav--side.is-collapsed .ui-nav__label,
|
|
246
265
|
.ui-nav--side.is-collapsed .ui-nav__badge,
|
|
247
|
-
.ui-nav--side.is-collapsed .ui-nav__caret { opacity: 0;
|
|
266
|
+
.ui-nav--side.is-collapsed .ui-nav__caret { opacity: 0; }
|
|
248
267
|
.ui-nav--side.is-collapsed .ui-nav__sub {
|
|
249
268
|
margin-inline-start: 0; padding-inline-start: 0; border-inline-start-width: 0;
|
|
250
269
|
}
|
|
251
270
|
.ui-nav--side.is-collapsed .ui-nav__item:not(:has(.ui-nav__ic))::after { opacity: 0.62; }
|
|
252
271
|
|
|
253
|
-
/* The current row on a folded rail:
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
box that wide standing at the row's edge is centred on the glyph, and the mark
|
|
263
|
-
lands where the reader is already looking. --surface-3 is the kit's quiet
|
|
264
|
-
non-text fill — a step off the rail in both themes, carrying a glyph and never
|
|
265
|
-
a word. The references mark current the same way and with no hue at all:
|
|
266
|
-
Cloudflare's docs rail paints a flat plate, and lessly-ui's rail is "a plate and
|
|
267
|
-
the weight step, no hue". Here the glyph's own step from 0.66 to full ink is the
|
|
268
|
-
second half of that pair. See #429.
|
|
269
|
-
|
|
270
|
-
`isolation` is what puts the plate behind the glyph: a positioned ::before paints
|
|
271
|
-
over its row's content, and a negative z-index with no stacking context of its
|
|
272
|
-
own would drop it behind the rail instead. */
|
|
272
|
+
/* The current row on a folded rail: one plate on the glyph column, and nothing else.
|
|
273
|
+
The open rail's 3px marker would stand in the rail's edge at this width, and a
|
|
274
|
+
nested row would put it outside the rail; the hairline would be the same mark said
|
|
275
|
+
twice. The plate is the strip, so it is centred on the glyph, and --surface-3 is the
|
|
276
|
+
kit's quiet non-text fill — a step off the rail in both themes. The glyph's own step
|
|
277
|
+
from 0.66 to full ink is the second half of that pair. `isolation` is what puts the
|
|
278
|
+
plate behind the glyph: a positioned ::before paints over its row's content, and a
|
|
279
|
+
negative z-index with no stacking context of its own would drop it behind the rail.
|
|
280
|
+
why: #429 */
|
|
273
281
|
.ui-nav--side.is-collapsed .ui-nav__item.is-active { isolation: isolate; background: none; }
|
|
274
282
|
/* At rest only — box-shadow carries the hairline AND the focus ring (WCAG 2.4.7). */
|
|
275
283
|
.ui-nav--side.is-collapsed .ui-nav__item.is-active:not(:focus-visible) { box-shadow: none; }
|
|
@@ -281,10 +289,9 @@
|
|
|
281
289
|
background: var(--surface-3); /* ring-gap: inherit — decorative or text-only surface has no nested focus target. */
|
|
282
290
|
z-index: -1;
|
|
283
291
|
}
|
|
284
|
-
/*
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
width. At rest only, so the focus ring is left standing. */
|
|
292
|
+
/* A folded rail answers a pointer with the row's own ink and the name beside it,
|
|
293
|
+
which is the whole readout at this width — the hover wash stands, the edge
|
|
294
|
+
around it does not. At rest only, so the focus ring is left standing. */
|
|
288
295
|
.ui-nav--side.is-collapsed .ui-nav__item:hover:not(:focus-visible) { outline: none; }
|
|
289
296
|
|
|
290
297
|
/* -- Horizontal tabs ----------------------------------------------------- */
|
package/src/styles/topbar.css
CHANGED
|
@@ -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:
|
|
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 */
|