@apliteni/apliteni-ui 0.131.0 → 0.132.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 +33 -11
- package/src/components/dropdown.js +493 -20
- package/src/index.d.ts +32 -0
- package/src/logic/dropdown.js +22 -0
- package/src/styles/dropdown.css +74 -22
- package/src/styles/topbar.css +2 -2
package/package.json
CHANGED
package/react/dist/index.js
CHANGED
|
@@ -2115,6 +2115,10 @@ import {
|
|
|
2115
2115
|
transitionMs as transitionMs2,
|
|
2116
2116
|
dropdownViewportFit,
|
|
2117
2117
|
dropdownHeightFit,
|
|
2118
|
+
dropdownLift,
|
|
2119
|
+
dropdownReveal,
|
|
2120
|
+
dropdownRest,
|
|
2121
|
+
dropdownSnap,
|
|
2118
2122
|
filterPanelRow
|
|
2119
2123
|
} from "@apliteni/apliteni-ui";
|
|
2120
2124
|
|
|
@@ -2199,6 +2203,7 @@ function Dropdown({
|
|
|
2199
2203
|
const trigger = useRef11(null);
|
|
2200
2204
|
const field = useRef11(null);
|
|
2201
2205
|
const landOn = useRef11(null);
|
|
2206
|
+
const revealed = useRef11(false);
|
|
2202
2207
|
const at = useRef11("");
|
|
2203
2208
|
const auto = useId8().replace(/:/g, "");
|
|
2204
2209
|
const uid = id ?? auto;
|
|
@@ -2352,24 +2357,39 @@ function Dropdown({
|
|
|
2352
2357
|
};
|
|
2353
2358
|
}, [open]);
|
|
2354
2359
|
useIsoLayoutEffect(() => {
|
|
2355
|
-
if (!open) return;
|
|
2356
2360
|
const el = panel.current;
|
|
2357
|
-
if (!el) return;
|
|
2361
|
+
if (!el || !root.current) return void 0;
|
|
2362
|
+
return dropdownRest(root.current, el, open);
|
|
2363
|
+
}, [open]);
|
|
2364
|
+
useIsoLayoutEffect(() => {
|
|
2365
|
+
const dd = root.current;
|
|
2366
|
+
const el = panel.current;
|
|
2367
|
+
if (!open || !el || !dd) return;
|
|
2358
2368
|
const size = () => {
|
|
2359
|
-
const avail = dropdownHeightFit(
|
|
2369
|
+
const avail = dropdownHeightFit(dd, el);
|
|
2360
2370
|
if (avail == null) return;
|
|
2361
2371
|
if (Number.isFinite(avail)) el.style.setProperty("--ui-dropdown-avail", `${avail}px`);
|
|
2362
2372
|
else el.style.removeProperty("--ui-dropdown-avail");
|
|
2363
|
-
|
|
2364
|
-
|
|
2373
|
+
el.classList.toggle("is-unbounded", !Number.isFinite(avail));
|
|
2374
|
+
dropdownSnap(dd, el);
|
|
2365
2375
|
};
|
|
2376
|
+
dropdownLift(dd, el);
|
|
2366
2377
|
size();
|
|
2378
|
+
revealed.current = dropdownReveal(dd, el) !== 0;
|
|
2379
|
+
const follow = () => {
|
|
2380
|
+
if (el.hasAttribute("data-dd-lifted")) dropdownLift(dd, el);
|
|
2381
|
+
};
|
|
2382
|
+
const resize = () => {
|
|
2383
|
+
dropdownLift(dd, el);
|
|
2384
|
+
size();
|
|
2385
|
+
dropdownReveal(dd, el);
|
|
2386
|
+
};
|
|
2367
2387
|
const view = el.ownerDocument?.defaultView ?? window;
|
|
2368
|
-
view.addEventListener("scroll",
|
|
2369
|
-
view.addEventListener("resize",
|
|
2388
|
+
view.addEventListener("scroll", follow, true);
|
|
2389
|
+
view.addEventListener("resize", resize);
|
|
2370
2390
|
return () => {
|
|
2371
|
-
view.removeEventListener("scroll",
|
|
2372
|
-
view.removeEventListener("resize",
|
|
2391
|
+
view.removeEventListener("scroll", follow, true);
|
|
2392
|
+
view.removeEventListener("resize", resize);
|
|
2373
2393
|
};
|
|
2374
2394
|
}, [open, direction]);
|
|
2375
2395
|
useIsoLayoutEffect(() => {
|
|
@@ -2390,7 +2410,7 @@ function Dropdown({
|
|
|
2390
2410
|
panel.current.style.minWidth = "";
|
|
2391
2411
|
if (panel.current.offsetWidth) panel.current.style.minWidth = `${panel.current.offsetWidth}px`;
|
|
2392
2412
|
}
|
|
2393
|
-
field.current?.focus();
|
|
2413
|
+
field.current?.focus(revealed.current ? { preventScroll: true } : void 0);
|
|
2394
2414
|
return;
|
|
2395
2415
|
}
|
|
2396
2416
|
if (want == null) return;
|
|
@@ -2398,7 +2418,7 @@ function Dropdown({
|
|
|
2398
2418
|
if (!els.length) return;
|
|
2399
2419
|
const sel = els.findIndex((el) => el.getAttribute("aria-selected") === "true");
|
|
2400
2420
|
const to = want === "selected" ? sel >= 0 ? sel : 0 : want;
|
|
2401
|
-
(els[to] || els[0]).focus();
|
|
2421
|
+
(els[to] || els[0]).focus(revealed.current ? { preventScroll: true } : void 0);
|
|
2402
2422
|
}, [open]);
|
|
2403
2423
|
useIsoLayoutEffect(() => {
|
|
2404
2424
|
if (!sx || !open || !active) return;
|
|
@@ -2415,6 +2435,7 @@ function Dropdown({
|
|
|
2415
2435
|
const el = panel.current;
|
|
2416
2436
|
if (!el || el.closest(".ui-filter-bar")) return;
|
|
2417
2437
|
const fit = () => {
|
|
2438
|
+
if (el.hasAttribute("data-dd-lifted")) return;
|
|
2418
2439
|
let got = dropdownViewportFit(el);
|
|
2419
2440
|
if (!got) return;
|
|
2420
2441
|
el.style.setProperty("--ui-dropdown-ceiling", got.max > 0 ? `${got.max}px` : "");
|
|
@@ -2522,6 +2543,7 @@ function Dropdown({
|
|
|
2522
2543
|
e.preventDefault();
|
|
2523
2544
|
const els = itemsIn(panel.current);
|
|
2524
2545
|
els[els.length - 1]?.focus();
|
|
2546
|
+
dropdownReveal(root.current, panel.current);
|
|
2525
2547
|
} else if ((e.key === "Enter" || e.key === " ") && open && e.target.matches?.("[data-dd-item]")) {
|
|
2526
2548
|
e.preventDefault();
|
|
2527
2549
|
e.target.click();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { dropdownMatch, dropdownFiltering, dropdownAvail } from '../logic/dropdown.js';
|
|
1
|
+
import { dropdownMatch, dropdownFiltering, dropdownAvail, dropdownStop } from '../logic/dropdown.js';
|
|
2
2
|
export { dropdownMatch, dropdownFiltering, dropdownAvail } from '../logic/dropdown.js';
|
|
3
3
|
// Dropdown — the kit's one popover-list primitive. A trigger opens a panel of
|
|
4
4
|
// item rows; two flavours share the same panel and the same open/close JS:
|
|
@@ -18,7 +18,7 @@ import { esc, icon } from './index.js';
|
|
|
18
18
|
import { safeUrl } from '../html.js';
|
|
19
19
|
import { avatar } from './avatar.js';
|
|
20
20
|
// The fade's own length, read off the stylesheet rather than copied. why: src/motion.js
|
|
21
|
-
import { transitionMs } from '../motion.js';
|
|
21
|
+
import { transitionMs, prefersReducedMotion } from '../motion.js';
|
|
22
22
|
const cx = (...a) => a.filter(Boolean).join(' ');
|
|
23
23
|
|
|
24
24
|
// A trailing status badge. `badge` is the text shown, in the case it is written
|
|
@@ -319,6 +319,16 @@ function ddEdge(panel) {
|
|
|
319
319
|
return Number.isFinite(declared) ? declared : DD_EDGE;
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
+
// The room a panel that opens down keeps between its end and the end of the Modal
|
|
323
|
+
// or Drawer body that holds it, so its shadow fades before the body clips it. The
|
|
324
|
+
// number is --ui-dropdown-shade in src/styles/dropdown.css.
|
|
325
|
+
const DD_SHADE = 48;
|
|
326
|
+
|
|
327
|
+
function ddShade(panel) {
|
|
328
|
+
const declared = parseFloat(ddViewOf(panel).getComputedStyle(panel).getPropertyValue('--ui-dropdown-shade'));
|
|
329
|
+
return Number.isFinite(declared) ? declared : DD_SHADE;
|
|
330
|
+
}
|
|
331
|
+
|
|
322
332
|
// The least height a capped panel keeps, below which it would rather spend the
|
|
323
333
|
// edge inset than shrink further. The number is --ui-dropdown-min in
|
|
324
334
|
// src/styles/dropdown.css. #489
|
|
@@ -618,19 +628,447 @@ export function dropdownHeightFit(dd, panel) {
|
|
|
618
628
|
if (!trigger || typeof trigger.getBoundingClientRect !== 'function' || !panel) return null;
|
|
619
629
|
const t = trigger.getBoundingClientRect();
|
|
620
630
|
const view = ddViewOf(panel);
|
|
631
|
+
// Inside a scrolling body the panel may take the body less its field: dropdownReveal()
|
|
632
|
+
// scrolls the body to show it, so the room is the body's, not what is left under the
|
|
633
|
+
// trigger now. A lifted panel is measured against the viewport, as a portalled one is.
|
|
634
|
+
const fit = ddLifted(panel) ? null : ddBodyFit(dd, panel);
|
|
635
|
+
if (fit && !fit.lift) return fit.room;
|
|
636
|
+
// A lifted panel is measured from where the body is about to bring its trigger.
|
|
637
|
+
const by = ddLifted(panel) && !ddUp(panel) ? dd.__ddLiftBy || 0 : 0;
|
|
621
638
|
return dropdownAvail({
|
|
622
|
-
anchorTop: t.top,
|
|
623
|
-
anchorBottom: t.bottom,
|
|
639
|
+
anchorTop: t.top - by,
|
|
640
|
+
anchorBottom: t.bottom - by,
|
|
624
641
|
viewport: view.innerHeight,
|
|
625
642
|
gap: ddGap(panel),
|
|
626
643
|
inset: ddEdge(panel),
|
|
627
644
|
min: ddMin(panel),
|
|
628
|
-
up: panel
|
|
645
|
+
up: ddUp(panel),
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/* A panel the wiring lifted out of a body too short to hold it: `position: fixed`, so
|
|
650
|
+
* it opens over the body the way a portalled panel does. The value is the side it
|
|
651
|
+
* opens on. why: docs/components.md#the-dropdown-panel */
|
|
652
|
+
const ddLifted = (panel) => typeof panel?.hasAttribute === 'function' && panel.hasAttribute('data-dd-lifted');
|
|
653
|
+
const ddUp = (panel) => (ddLifted(panel)
|
|
654
|
+
? panel.getAttribute('data-dd-lifted') === 'up'
|
|
655
|
+
: !!panel?.classList?.contains('is-up'));
|
|
656
|
+
|
|
657
|
+
/** Every ancestor that scrolls the block axis, from a wrapper to the Modal or Drawer
|
|
658
|
+
* body round it, with the part of them a reader can see: their padding boxes clipped
|
|
659
|
+
* to each other and to the viewport. `el` is the one whose end is that part's end,
|
|
660
|
+
* which is the one that has to scroll. `height` is the least any of them shows, which
|
|
661
|
+
* is the room a panel has whatever they are scrolled to. `null` for a portalled panel
|
|
662
|
+
* (it is not inside them), a filter row (its row already bounds its panels) and a
|
|
663
|
+
* trigger with none. */
|
|
664
|
+
function ddScrollBox(dd, panel) {
|
|
665
|
+
if (dd.__ddPanel || ddInFilterRow(panel)) return null;
|
|
666
|
+
const view = ddViewOf(panel);
|
|
667
|
+
const els = [];
|
|
668
|
+
let top = 0;
|
|
669
|
+
let bottom = view.innerHeight;
|
|
670
|
+
let height = view.innerHeight;
|
|
671
|
+
let el = null;
|
|
672
|
+
for (let at = dd.parentElement; at && at !== at.ownerDocument.body; at = at.parentElement) {
|
|
673
|
+
const overflow = view.getComputedStyle(at).overflowY;
|
|
674
|
+
if (overflow !== 'auto' && overflow !== 'scroll') continue;
|
|
675
|
+
const start = at.getBoundingClientRect().top + at.clientTop;
|
|
676
|
+
const end = start + at.clientHeight;
|
|
677
|
+
els.push(at);
|
|
678
|
+
top = Math.max(top, start);
|
|
679
|
+
if (end <= bottom) { bottom = end; el = at; }
|
|
680
|
+
height = Math.min(height, at.clientHeight);
|
|
681
|
+
}
|
|
682
|
+
if (!els.length || !(bottom > top)) return null;
|
|
683
|
+
return { el: el || els[0], els, top, bottom, height };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/* The top of what has to stay in view over the panel: the field's label when the
|
|
687
|
+
* trigger has one above it, so the reader still sees what the list is for. */
|
|
688
|
+
function ddAnchorTop(dd, t) {
|
|
689
|
+
const label = dd.closest?.('.ui-field')?.querySelector(':scope > .ui-field__label');
|
|
690
|
+
const top = label?.getBoundingClientRect?.().top;
|
|
691
|
+
return Number.isFinite(top) && top < t.top ? top : t.top;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/* The height a panel's rows ask for, uncapped: its own box plus whatever it and its
|
|
695
|
+
* list hold past their ends. */
|
|
696
|
+
function ddContentHeight(panel) {
|
|
697
|
+
let h = panel.offsetHeight + panel.scrollHeight - panel.clientHeight;
|
|
698
|
+
const list = panel.querySelector?.('.ui-dropdown__list');
|
|
699
|
+
if (list) h += list.scrollHeight - list.clientHeight;
|
|
700
|
+
return h;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* How a panel fits the body it opens in. The body has to hold the field's label and
|
|
705
|
+
* trigger, the gap, the panel and the room under it. That room is 48px where the body
|
|
706
|
+
* can spare it, so the shadow fades before the body clips it, and it is spent down to
|
|
707
|
+
* the 8px edge where the body cannot. Where even then the body cannot hold the floor
|
|
708
|
+
* (or the whole list, when that is shorter), the panel is lifted out of the body.
|
|
709
|
+
* Measured from the heights alone, so the answer is the same however far the body is
|
|
710
|
+
* scrolled. `null` with no scrolling body.
|
|
711
|
+
*/
|
|
712
|
+
function ddBodyFit(dd, panel) {
|
|
713
|
+
const trigger = dd.querySelector('[data-dropdown-trigger]');
|
|
714
|
+
const box = ddScrollBox(dd, panel);
|
|
715
|
+
if (!box || !trigger) return null;
|
|
716
|
+
const t = trigger.getBoundingClientRect();
|
|
717
|
+
const edge = ddEdge(panel);
|
|
718
|
+
const gap = ddGap(panel);
|
|
719
|
+
const need = Math.min(ddMin(panel), ddContentHeight(panel));
|
|
720
|
+
if (ddUp(panel)) {
|
|
721
|
+
const room = box.height - 2 * edge - t.height - gap;
|
|
722
|
+
return room >= need ? { box, end: edge, room, anchor: t.top } : { box, lift: true };
|
|
723
|
+
}
|
|
724
|
+
const anchor = ddAnchorTop(dd, t);
|
|
725
|
+
const spare = box.height - edge - (t.bottom - anchor) - gap - need;
|
|
726
|
+
const end = Math.max(edge, Math.min(ddShade(panel), spare));
|
|
727
|
+
const room = box.height - edge - (t.bottom - anchor) - gap - end;
|
|
728
|
+
return room >= need ? { box, end, room, anchor } : { box, lift: true };
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Lift an open panel out of a body too short to hold it, or put a lifted one back.
|
|
733
|
+
* A lifted panel is `position: fixed` against the viewport and opens over the body,
|
|
734
|
+
* below its trigger or, where the viewport has more room above, over it, the way a
|
|
735
|
+
* portalled panel opens. It follows its trigger while the body scrolls. Call it with
|
|
736
|
+
* the panel open and laid out, before sizing it. Returns whether the panel is lifted.
|
|
737
|
+
* why: docs/components.md#the-dropdown-panel
|
|
738
|
+
*/
|
|
739
|
+
export function dropdownLift(dd, panel) {
|
|
740
|
+
if (!dd || !panel?.style || dd.__ddPanel) return false;
|
|
741
|
+
const lift = !!ddBodyFit(dd, panel)?.lift;
|
|
742
|
+
if (!lift) {
|
|
743
|
+
if (ddLifted(panel)) {
|
|
744
|
+
panel.removeAttribute('data-dd-lifted');
|
|
745
|
+
for (const side of ['top', 'bottom', 'left', 'right']) panel.style.removeProperty(side);
|
|
746
|
+
panel.style.removeProperty('inline-size');
|
|
747
|
+
}
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
dd.__ddLiftBy = ddLiftPlan(dd, panel);
|
|
751
|
+
ddPlaceLifted(dd, panel);
|
|
752
|
+
return true;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/* How far the body scrolls to give a lifted panel that opens down the viewport's room
|
|
756
|
+
* under its trigger: no further than the field's label reaching the body's top, and no
|
|
757
|
+
* further than the panel needs. 0 for a panel that opens up. The label stays in view,
|
|
758
|
+
* where a panel lifted over it would cover it. */
|
|
759
|
+
function ddLiftPlan(dd, panel) {
|
|
760
|
+
const trigger = dd.querySelector('[data-dropdown-trigger]');
|
|
761
|
+
const box = ddScrollBox(dd, panel);
|
|
762
|
+
if (!trigger || !box || panel.classList.contains('is-up')) return 0;
|
|
763
|
+
const t = trigger.getBoundingClientRect();
|
|
764
|
+
const edge = ddEdge(panel);
|
|
765
|
+
const rise = Math.max(0, ddAnchorTop(dd, t) - edge - box.top);
|
|
766
|
+
const want = Math.min(ddContentHeight(panel), ddCeilingOf(panel));
|
|
767
|
+
const over = t.bottom + ddGap(panel) + want + edge - ddViewOf(panel).innerHeight;
|
|
768
|
+
return Math.max(0, Math.min(rise, Math.ceil(over)));
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/* The tallest a panel may be before the room has a say: the cap a consumer asked for,
|
|
772
|
+
* which under a search field is the list's and the field over it, or the standard cap. */
|
|
773
|
+
function ddCeilingOf(panel) {
|
|
774
|
+
const view = ddViewOf(panel);
|
|
775
|
+
const list = panel.querySelector?.('.ui-dropdown__list');
|
|
776
|
+
const own = parseFloat((list || panel).style?.getPropertyValue('--ui-dropdown-cap'));
|
|
777
|
+
if (Number.isFinite(own)) return own + (list ? panel.offsetHeight - list.offsetHeight : 0);
|
|
778
|
+
const max = parseFloat(view.getComputedStyle(panel).getPropertyValue('--ui-dropdown-max'));
|
|
779
|
+
return Number.isFinite(max) ? max : Infinity;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function ddPlaceLifted(dd, panel) {
|
|
783
|
+
const trigger = dd.querySelector('[data-dropdown-trigger]');
|
|
784
|
+
if (!trigger) return;
|
|
785
|
+
const view = ddViewOf(panel);
|
|
786
|
+
const t = trigger.getBoundingClientRect();
|
|
787
|
+
const gap = ddGap(panel);
|
|
788
|
+
// Below is counted after the body has scrolled the field up (ddLiftPlan()).
|
|
789
|
+
const below = view.innerHeight - t.bottom - gap + (dd.__ddLiftBy || 0);
|
|
790
|
+
const above = t.top - gap;
|
|
791
|
+
// The configured side, unless it cannot hold the floor and the other side has more.
|
|
792
|
+
const configured = panel.classList.contains('is-up');
|
|
793
|
+
const mine = configured ? above : below;
|
|
794
|
+
const other = configured ? below : above;
|
|
795
|
+
const up = mine < Math.min(ddMin(panel), ddContentHeight(panel)) && other > mine ? !configured : configured;
|
|
796
|
+
const s = panel.style;
|
|
797
|
+
s.translate = '';
|
|
798
|
+
if (panel.getAttribute('data-dd-lifted') !== (up ? 'up' : 'down')) {
|
|
799
|
+
const held = s.transition;
|
|
800
|
+
s.transition = 'none';
|
|
801
|
+
panel.setAttribute('data-dd-lifted', up ? 'up' : 'down');
|
|
802
|
+
view.getComputedStyle(panel).transform;
|
|
803
|
+
s.transition = held;
|
|
804
|
+
}
|
|
805
|
+
ddWriteCeiling(panel);
|
|
806
|
+
if (dd.parentElement?.classList.contains('ui-field')) s.inlineSize = `${t.width}px`;
|
|
807
|
+
const width = panel.getBoundingClientRect().width;
|
|
808
|
+
const end = panel.classList.contains('is-end');
|
|
809
|
+
const shift = dropdownViewportFit(panel, { left: end ? t.right - width : t.left, width })?.shift || 0;
|
|
810
|
+
const left = (end ? t.right - width : t.left) + shift;
|
|
811
|
+
s.right = 'auto';
|
|
812
|
+
s.left = `${left}px`;
|
|
813
|
+
if (up) { s.top = 'auto'; s.bottom = `${view.innerHeight - t.top + gap}px`; }
|
|
814
|
+
else { s.bottom = 'auto'; s.top = `${t.bottom + gap}px`; }
|
|
815
|
+
/* An ancestor with a transform holds a fixed box too, and then the numbers above are
|
|
816
|
+
* read from its corner. Measured and taken back, so the panel lands where they say. */
|
|
817
|
+
const r = panel.getBoundingClientRect();
|
|
818
|
+
if (!(r.width > 0)) return;
|
|
819
|
+
const dx = r.left - left;
|
|
820
|
+
const dy = up ? r.bottom - (t.top - gap) : r.top - (t.bottom + gap);
|
|
821
|
+
if (Math.abs(dx) > 0.5) s.left = `${left - dx}px`;
|
|
822
|
+
if (Math.abs(dy) > 0.5) {
|
|
823
|
+
if (up) s.bottom = `${view.innerHeight - t.top + gap + dy}px`;
|
|
824
|
+
else s.top = `${t.bottom + gap - dy}px`;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Scroll the body an open panel sits in the least distance that shows the whole
|
|
830
|
+
* panel, the room under it and the field's label over it (dropdownHeightFit() makes
|
|
831
|
+
* the panel no taller than that). A body that already shows them does not move. A
|
|
832
|
+
* wrapper inside the body that does not scroll is passed over: the ancestor whose end
|
|
833
|
+
* is the end of what the reader sees is the one scrolled. Instant under
|
|
834
|
+
* `prefers-reduced-motion`. Returns the distance scrolled.
|
|
835
|
+
* why: docs/components.md#the-dropdown-panel
|
|
836
|
+
*/
|
|
837
|
+
export function dropdownReveal(dd, panel) {
|
|
838
|
+
const by = ddRevealOnce(dd, panel);
|
|
839
|
+
/* A shut panel is held 6px off its place and the body cannot scroll past where
|
|
840
|
+
* it ends, so the first scroll falls up to 6px short. One more look once the
|
|
841
|
+
* panel has settled. */
|
|
842
|
+
if (by && panel.style) {
|
|
843
|
+
setTimeout(() => { if (dd.classList.contains('open')) ddRevealOnce(dd, panel); },
|
|
844
|
+
transitionMs(panel) + DD_EXIT_SLACK_MS);
|
|
845
|
+
}
|
|
846
|
+
return by;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/* A body cannot scroll past the end of what it holds, and a panel is the last thing
|
|
850
|
+
* in it: the panel's own bottom is the end. Without more below it, the room the
|
|
851
|
+
* reveal keeps cannot be reached at the end of the body. The sheet draws a 1px spot
|
|
852
|
+
* at the distance written here, below the trigger. `end` is that room: the body's,
|
|
853
|
+
* or on a page the 8px edge, which the page's own fit keeps, so opening a panel on a
|
|
854
|
+
* page that does not scroll makes it scroll by nothing. why: src/styles/dropdown.css */
|
|
855
|
+
function ddReserveEnd(dd, panel, end) {
|
|
856
|
+
if (!dd.style || ddUp(panel) || ddLifted(panel)) return;
|
|
857
|
+
const next = `${ddGap(panel) + panel.offsetHeight + end - 1}px`;
|
|
858
|
+
if (dd.style.getPropertyValue('--ui-dropdown-reach') !== next) dd.style.setProperty('--ui-dropdown-reach', next);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/* A shut panel is still laid out, and a box that is laid out counts towards the scroll
|
|
862
|
+
* of what holds it: the end of a Modal or Drawer body, or of a page, scrolled to blank
|
|
863
|
+
* space as tall as the panel. So a shut panel is collapsed, once its fade is over, and
|
|
864
|
+
* its room under it goes with it. The body cannot scroll past where it ends, so
|
|
865
|
+
* collapsing under a reader who is looking at that room would slide the field down by
|
|
866
|
+
* the whole of it: the box stays until the reader has scrolled out of the room, and the
|
|
867
|
+
* field never moves. The panel is given its box back before anything measures it.
|
|
868
|
+
* why: docs/components.md#the-dropdown-panel */
|
|
869
|
+
function ddScrollerOf(dd, panel) {
|
|
870
|
+
return ddScrollBox(dd, panel)?.el ?? dd.ownerDocument.scrollingElement;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function ddCollapse(dd, panel) {
|
|
874
|
+
const scroller = ddScrollerOf(dd, panel);
|
|
875
|
+
const before = scroller?.scrollTop ?? 0;
|
|
876
|
+
const reach = dd.style?.getPropertyValue('--ui-dropdown-reach') ?? '';
|
|
877
|
+
panel.setAttribute('data-dd-shut', '');
|
|
878
|
+
dd.style?.removeProperty('--ui-dropdown-reach');
|
|
879
|
+
if (!scroller || scroller.scrollTop === before) return true;
|
|
880
|
+
panel.removeAttribute('data-dd-shut');
|
|
881
|
+
if (reach) dd.style.setProperty('--ui-dropdown-reach', reach);
|
|
882
|
+
if (typeof scroller.scrollTo === 'function') scroller.scrollTo({ top: before, behavior: 'instant' });
|
|
883
|
+
else scroller.scrollTop = before;
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Give a panel its box while it is open, and take the box away once it has shut and
|
|
889
|
+
* its fade is over. Returns a function that cancels a collapse still waiting.
|
|
890
|
+
* Does nothing for a portalled panel, which is `position: fixed` and adds no scroll,
|
|
891
|
+
* and for a panel in a filter row, which its row already bounds.
|
|
892
|
+
* why: docs/components.md#the-dropdown-panel
|
|
893
|
+
*
|
|
894
|
+
* @param {Element} dd the `[data-dropdown]` container
|
|
895
|
+
* @param {Element} panel its `[data-dropdown-panel]`
|
|
896
|
+
* @param {boolean} open whether the panel is opening (`true`) or has shut (`false`)
|
|
897
|
+
* @param {number} [wait] ms to wait before collapsing; the panel's own fade by default
|
|
898
|
+
*/
|
|
899
|
+
export function dropdownRest(dd, panel, open, wait) {
|
|
900
|
+
if (!dd || !panel?.style || dd.__ddPanel || ddInFilterRow(panel)) return () => {};
|
|
901
|
+
dd.__ddRest?.();
|
|
902
|
+
if (open) {
|
|
903
|
+
panel.removeAttribute('data-dd-shut');
|
|
904
|
+
return () => {};
|
|
905
|
+
}
|
|
906
|
+
let timer = null;
|
|
907
|
+
let off = null;
|
|
908
|
+
const cancel = () => {
|
|
909
|
+
clearTimeout(timer);
|
|
910
|
+
off?.();
|
|
911
|
+
dd.__ddRest = null;
|
|
912
|
+
};
|
|
913
|
+
const attempt = () => {
|
|
914
|
+
const done = !dd.isConnected || dd.classList.contains('open') || ddCollapse(dd, panel);
|
|
915
|
+
if (done) cancel();
|
|
916
|
+
return done;
|
|
917
|
+
};
|
|
918
|
+
const start = () => {
|
|
919
|
+
timer = null;
|
|
920
|
+
if (attempt()) return;
|
|
921
|
+
const scroller = ddScrollerOf(dd, panel);
|
|
922
|
+
const target = scroller === dd.ownerDocument.scrollingElement ? ddViewOf(panel) : scroller;
|
|
923
|
+
target.addEventListener('scroll', attempt, { passive: true });
|
|
924
|
+
off = () => target.removeEventListener('scroll', attempt);
|
|
925
|
+
};
|
|
926
|
+
dd.__ddRest = cancel;
|
|
927
|
+
const ms = wait ?? transitionMs(panel) + DD_EXIT_SLACK_MS;
|
|
928
|
+
if (ms > 0) timer = setTimeout(start, ms);
|
|
929
|
+
else start();
|
|
930
|
+
return cancel;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function ddRevealOnce(dd, panel) {
|
|
934
|
+
const trigger = dd?.querySelector?.('[data-dropdown-trigger]');
|
|
935
|
+
if (!trigger || typeof trigger.getBoundingClientRect !== 'function' || !panel) return 0;
|
|
936
|
+
if (ddLifted(panel)) return ddRevealLifted(dd, panel, trigger);
|
|
937
|
+
const fit = ddBodyFit(dd, panel);
|
|
938
|
+
if (!fit) {
|
|
939
|
+
// A page is held the same way: the shut panel lifts 6px, which would end the page 6px
|
|
940
|
+
// sooner under a reader at its end.
|
|
941
|
+
if (!dd.__ddPanel && !ddInFilterRow(panel)) ddReserveEnd(dd, panel, ddEdge(panel));
|
|
942
|
+
// Focusing End can scroll only far enough to show the row. Finish the page
|
|
943
|
+
// scroll so the open panel keeps its edge and the row's outline has room.
|
|
944
|
+
if (!dd.__ddPanel && !ddLifted(panel) && !ddInFilterRow(panel)
|
|
945
|
+
&& panel.contains(dd.ownerDocument.activeElement?.closest?.('[data-dd-item]'))) {
|
|
946
|
+
const view = ddViewOf(panel);
|
|
947
|
+
const by = Math.ceil(panel.getBoundingClientRect().bottom + ddEdge(panel) - view.innerHeight);
|
|
948
|
+
if (by > 0) { view.scrollBy({ top: by, behavior: 'instant' }); return by; }
|
|
949
|
+
}
|
|
950
|
+
return 0;
|
|
951
|
+
}
|
|
952
|
+
if (fit.lift) return 0;
|
|
953
|
+
const { box, end } = fit;
|
|
954
|
+
ddReserveEnd(dd, panel, end);
|
|
955
|
+
const t = trigger.getBoundingClientRect();
|
|
956
|
+
const edge = ddEdge(panel);
|
|
957
|
+
const h = panel.offsetHeight;
|
|
958
|
+
const up = ddUp(panel);
|
|
959
|
+
const top = up ? t.top - ddGap(panel) - h - edge : ddAnchorTop(dd, t) - edge;
|
|
960
|
+
const bottom = (up ? t.bottom : t.bottom + ddGap(panel) + h) + end;
|
|
961
|
+
let by = 0;
|
|
962
|
+
if (bottom > box.bottom) by = Math.min(bottom - box.bottom, top - box.top);
|
|
963
|
+
else if (top < box.top) by = top - box.top;
|
|
964
|
+
by = Math.round(by);
|
|
965
|
+
if (!by) return 0;
|
|
966
|
+
const behavior = prefersReducedMotion() ? 'auto' : 'smooth';
|
|
967
|
+
/* The ancestor whose end the reader sees takes the scroll. What it has no room to
|
|
968
|
+
* take goes to the others, nearest first, each up to the room it has. */
|
|
969
|
+
const move = (el, d) => {
|
|
970
|
+
if (typeof el.scrollBy === 'function') el.scrollBy({ top: d, behavior });
|
|
971
|
+
else el.scrollTop += d;
|
|
972
|
+
};
|
|
973
|
+
const room = (el, d) => (d > 0 ? el.scrollHeight - el.clientHeight - el.scrollTop : -el.scrollTop);
|
|
974
|
+
const first = room(box.el, by);
|
|
975
|
+
move(box.el, by);
|
|
976
|
+
let left = by > 0 ? by - Math.max(0, Math.min(by, first)) : by - Math.min(0, Math.max(by, first));
|
|
977
|
+
for (const el of box.els) {
|
|
978
|
+
if (el === box.el || !left) continue;
|
|
979
|
+
const can = left > 0 ? Math.max(0, Math.min(left, room(el, left))) : Math.min(0, Math.max(left, room(el, left)));
|
|
980
|
+
if (can) { move(el, can); left -= can; }
|
|
981
|
+
}
|
|
982
|
+
return by;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/* The scroll ddLiftPlan() asked for. The body may hold too little to scroll that far, so
|
|
986
|
+
* the reserved spot is written to where the body's end then has to be. */
|
|
987
|
+
function ddRevealLifted(dd, panel, trigger) {
|
|
988
|
+
const by = dd.__ddLiftBy || 0;
|
|
989
|
+
const box = by && !ddUp(panel) ? ddScrollBox(dd, panel) : null;
|
|
990
|
+
if (!box) return 0;
|
|
991
|
+
const t = trigger.getBoundingClientRect();
|
|
992
|
+
dd.style?.setProperty('--ui-dropdown-reach', `${Math.max(0, box.bottom + by - t.bottom - 1)}px`);
|
|
993
|
+
const behavior = prefersReducedMotion() ? 'auto' : 'smooth';
|
|
994
|
+
if (typeof box.el.scrollBy === 'function') box.el.scrollBy({ top: by, behavior });
|
|
995
|
+
else box.el.scrollTop += by;
|
|
996
|
+
return by;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Stop an open panel's height where it cuts a row through the middle. A height that
|
|
1001
|
+
* falls between two rows, or in a row's padding, shows a complete last label and the
|
|
1002
|
+
* list looks whole; a half row says it goes on. The height is the one the panel gets:
|
|
1003
|
+
* the smaller of its cap and the room the wiring measured, so a panel the room limits
|
|
1004
|
+
* is cut the same way as one the cap limits. The rows are measured as they are drawn,
|
|
1005
|
+
* so every row height the kit has, with a description or without and under a search
|
|
1006
|
+
* field or not, stops at its own half row. A cap a consumer asked for (`scroll: <n>`)
|
|
1007
|
+
* is kept to its number where it is the smaller, and a panel that fits has nothing to
|
|
1008
|
+
* cut. Call it with the panel laid out and its room written.
|
|
1009
|
+
* why: docs/components.md#the-dropdown-panel
|
|
1010
|
+
*/
|
|
1011
|
+
export function dropdownSnap(dd, panel) {
|
|
1012
|
+
if (!panel?.style) return;
|
|
1013
|
+
const view = ddViewOf(panel);
|
|
1014
|
+
const set = (v) => {
|
|
1015
|
+
const next = v == null ? '' : `${v}px`;
|
|
1016
|
+
if (panel.style.getPropertyValue('--ui-dropdown-fit') === next) return;
|
|
1017
|
+
if (next) panel.style.setProperty('--ui-dropdown-fit', next);
|
|
1018
|
+
else panel.style.removeProperty('--ui-dropdown-fit');
|
|
1019
|
+
};
|
|
1020
|
+
const rows = Array.from(panel.querySelectorAll('[data-dd-item]')).filter((el) => el.getClientRects().length);
|
|
1021
|
+
let clip = panel;
|
|
1022
|
+
for (let el = rows[0]?.parentElement; el && el !== panel; el = el.parentElement) {
|
|
1023
|
+
const overflow = view.getComputedStyle(el).overflowY;
|
|
1024
|
+
if (overflow === 'auto' || overflow === 'scroll') { clip = el; break; }
|
|
1025
|
+
}
|
|
1026
|
+
// The consumer's cap is on the panel, or on the list under a search field, where the
|
|
1027
|
+
// panel is that cap and the field over it.
|
|
1028
|
+
const own = parseFloat((clip === panel ? panel : clip).style.getPropertyValue('--ui-dropdown-cap'));
|
|
1029
|
+
const chrome = clip === panel ? 0 : panel.offsetHeight - clip.offsetHeight;
|
|
1030
|
+
const ceiling = ddCeilingOf(panel);
|
|
1031
|
+
const room = parseFloat(panel.style.getPropertyValue('--ui-dropdown-avail'));
|
|
1032
|
+
/* In a body, the room under the panel may be spent down to the 8px edge to reach the
|
|
1033
|
+
* next half row up, rather than stop a whole row short of the room. */
|
|
1034
|
+
const fit = room < ceiling && !ddLifted(panel) ? ddBodyFit(dd, panel) : null;
|
|
1035
|
+
const spare = fit && !fit.lift ? fit.end - ddEdge(panel) : 0;
|
|
1036
|
+
const height = Number.isFinite(room) ? Math.min(ceiling, room) : ceiling;
|
|
1037
|
+
if (!rows.length || !Number.isFinite(height)) { set(null); return; }
|
|
1038
|
+
// Kept to the consumer's number where it is the one that binds.
|
|
1039
|
+
if (Number.isFinite(own) && !(room < ceiling)) { set(chrome ? ceiling : null); return; }
|
|
1040
|
+
const box = panel.getBoundingClientRect();
|
|
1041
|
+
const clipBox = clip.getBoundingClientRect();
|
|
1042
|
+
if (!(box.height > 0)) return;
|
|
1043
|
+
let scrolled = 0;
|
|
1044
|
+
for (let el = rows[0].parentElement; el; el = el.parentElement) {
|
|
1045
|
+
scrolled += el.scrollTop;
|
|
1046
|
+
if (el === panel) break;
|
|
1047
|
+
}
|
|
1048
|
+
const edge = clip === panel ? panel.offsetHeight - panel.clientHeight - panel.clientTop : box.bottom - clipBox.bottom;
|
|
1049
|
+
const lead = clip === panel ? panel.clientTop : clipBox.top - box.top;
|
|
1050
|
+
const stop = dropdownStop({
|
|
1051
|
+
rows: rows.map((el) => {
|
|
1052
|
+
const r = el.getBoundingClientRect();
|
|
1053
|
+
return { top: r.top - box.top + scrolled, height: r.height };
|
|
1054
|
+
}),
|
|
1055
|
+
below: edge,
|
|
1056
|
+
content: lead + clip.scrollHeight + edge,
|
|
1057
|
+
ceiling: height,
|
|
1058
|
+
reach: Math.min(ceiling, height + spare),
|
|
629
1059
|
});
|
|
1060
|
+
set(stop ?? (chrome && Number.isFinite(own) ? ceiling : null));
|
|
1061
|
+
if (stop > room) panel.style.setProperty('--ui-dropdown-avail', `${stop}px`);
|
|
630
1062
|
}
|
|
631
1063
|
|
|
632
1064
|
function ddSizeHeight(dd, panel) {
|
|
633
1065
|
if (!panel?.style) return;
|
|
1066
|
+
/* Decided when the panel opens and kept while the page or the body scrolls under it,
|
|
1067
|
+
* so the panel does not grow or shrink under the reader. A resize, or the next open,
|
|
1068
|
+
* decides again. A portalled panel follows its trigger instead, so it is measured
|
|
1069
|
+
* again where the trigger now is. A lifted one follows its trigger at the height it
|
|
1070
|
+
* opened at. why: docs/components.md#the-dropdown-panel */
|
|
1071
|
+
if (dd.__ddSized && !dd.__ddPanel) return;
|
|
634
1072
|
const avail = dropdownHeightFit(dd, panel);
|
|
635
1073
|
if (avail == null) return;
|
|
636
1074
|
// Infinity means the floor could not be met even by spending the whole inset:
|
|
@@ -638,19 +1076,19 @@ function ddSizeHeight(dd, panel) {
|
|
|
638
1076
|
// viewport less the edge inset, not a trigger-sized sliver — takes over. why:
|
|
639
1077
|
// dropdownAvail(), src/logic/dropdown.js
|
|
640
1078
|
//
|
|
641
|
-
// `.is-unbounded`
|
|
642
|
-
//
|
|
643
|
-
|
|
644
|
-
const cap = ddViewOf(panel).getComputedStyle(panel).getPropertyValue('--ui-dropdown-cap').trim();
|
|
645
|
-
panel.classList.toggle('is-unbounded', !Number.isFinite(avail) && !cap);
|
|
1079
|
+
// `.is-unbounded` marks a panel the room cannot hold. It keeps its cap, and the
|
|
1080
|
+
// page, not the panel, is the way to the rows past the fold. why: src/styles/dropdown.css
|
|
1081
|
+
panel.classList.toggle('is-unbounded', !Number.isFinite(avail));
|
|
646
1082
|
// A number that has not changed is not written again, the way the width fit's
|
|
647
1083
|
// own write is guarded: a mutation inside the row that moved nothing vertically
|
|
648
1084
|
// would otherwise answer its own write, over and over, through the document
|
|
649
1085
|
// observer below. why: dropdownViewportFit()'s own "moved" guard, same file
|
|
650
1086
|
const next = Number.isFinite(avail) ? `${avail}px` : '';
|
|
651
|
-
if (panel.style.getPropertyValue('--ui-dropdown-avail')
|
|
652
|
-
|
|
653
|
-
|
|
1087
|
+
if (panel.style.getPropertyValue('--ui-dropdown-avail') !== next) {
|
|
1088
|
+
if (next) panel.style.setProperty('--ui-dropdown-avail', next);
|
|
1089
|
+
else panel.style.removeProperty('--ui-dropdown-avail');
|
|
1090
|
+
}
|
|
1091
|
+
dropdownSnap(dd, panel);
|
|
654
1092
|
}
|
|
655
1093
|
|
|
656
1094
|
/** Fit a panel again where it now is, whichever placement it uses. */
|
|
@@ -660,6 +1098,7 @@ function ddPlace(dd) {
|
|
|
660
1098
|
ddResolveDirection(dd, panel);
|
|
661
1099
|
positionPortalPanel(dd, panel);
|
|
662
1100
|
}
|
|
1101
|
+
else if (ddLifted(panel)) { if (dd.classList.contains('open')) dropdownLift(dd, panel); }
|
|
663
1102
|
else ddFitViewport(dd, ddPanelOf(dd));
|
|
664
1103
|
ddSizeHeight(dd, panel);
|
|
665
1104
|
}
|
|
@@ -841,7 +1280,7 @@ export function dropdownViewportFit(panel, at) {
|
|
|
841
1280
|
* why: docs/components.md#the-dropdown-panel
|
|
842
1281
|
*/
|
|
843
1282
|
function ddFitViewport(dd, panel) {
|
|
844
|
-
if (!panel?.style || dd?.__ddPanel === panel || ddInFilterRow(panel)) return;
|
|
1283
|
+
if (!panel?.style || dd?.__ddPanel === panel || ddInFilterRow(panel) || ddLifted(panel)) return;
|
|
845
1284
|
ddWriteCeiling(panel);
|
|
846
1285
|
const fit = dropdownViewportFit(panel);
|
|
847
1286
|
if (!fit) return;
|
|
@@ -910,15 +1349,20 @@ function closeDropdown(dd) {
|
|
|
910
1349
|
on the way out, so a panel left behind at 237.6…477.6 does not keep the page
|
|
911
1350
|
478px wide with nothing open. why: ddPlace() */
|
|
912
1351
|
if (panel) ddFitViewport(dd, panel);
|
|
1352
|
+
if (panel) dropdownRest(dd, panel, false);
|
|
913
1353
|
}
|
|
914
1354
|
|
|
915
1355
|
/** Every wired dropdown still in a tree, the ones in frames and shadow roots
|
|
916
|
-
* included. Disconnected containers are dropped as they are passed
|
|
1356
|
+
* included. Disconnected containers are dropped as they are passed, with a collapse
|
|
1357
|
+
* still waiting for a scroll, which would otherwise hold them until the next one. */
|
|
917
1358
|
function ddLive() {
|
|
918
1359
|
const out = [];
|
|
919
1360
|
for (const dd of _ddAll) {
|
|
920
1361
|
if (dd.isConnected) out.push(dd);
|
|
921
|
-
else
|
|
1362
|
+
else {
|
|
1363
|
+
dd.__ddRest?.();
|
|
1364
|
+
_ddAll.delete(dd);
|
|
1365
|
+
}
|
|
922
1366
|
}
|
|
923
1367
|
return out;
|
|
924
1368
|
}
|
|
@@ -937,6 +1381,7 @@ function openDropdown(dd, focusIdx) {
|
|
|
937
1381
|
if (panel) {
|
|
938
1382
|
// An earlier close may still be holding its geometry; this open owns it now.
|
|
939
1383
|
ddCancelClose(dd, panel);
|
|
1384
|
+
dropdownRest(dd, panel, true);
|
|
940
1385
|
ddResolveDirection(dd, panel);
|
|
941
1386
|
if (dd.__ddPanel) { positionPortalPanel(dd, panel); panel.classList.add('is-open'); }
|
|
942
1387
|
/* Both fits, because each one answers for the panels the other declines:
|
|
@@ -944,8 +1389,14 @@ function openDropdown(dd, focusIdx) {
|
|
|
944
1389
|
is null inside a filter row, whose own row already bounds its panels.
|
|
945
1390
|
why: docs/components.md#a-filter-row-holds-its-panels */
|
|
946
1391
|
else { ddFitFilterPanel(dd, panel); ddWatchRow(dd, panel); ddFitViewport(dd, panel); }
|
|
1392
|
+
if (!dd.__ddPanel && !ddInFilterRow(panel)) dropdownLift(dd, panel);
|
|
1393
|
+
dd.__ddSized = false;
|
|
947
1394
|
ddSizeHeight(dd, panel);
|
|
1395
|
+
dd.__ddSized = true;
|
|
948
1396
|
}
|
|
1397
|
+
// The body was scrolled to show the panel: a row taking focus must not scroll it
|
|
1398
|
+
// again to the row alone.
|
|
1399
|
+
const keep = panel && dropdownReveal(dd, panel) ? { preventScroll: true } : undefined;
|
|
949
1400
|
dd.classList.add('open');
|
|
950
1401
|
/* After `open`, and after the fit: ddResetSearch() pins the width it reads off
|
|
951
1402
|
* the panel, and the rule that widens a filter menu only applies once the
|
|
@@ -960,13 +1411,13 @@ function openDropdown(dd, focusIdx) {
|
|
|
960
1411
|
if (search) {
|
|
961
1412
|
const items = ddItemsOf(dd);
|
|
962
1413
|
ddSetActive(dd, items.find((el) => el.getAttribute('aria-selected') === 'true') || items[0] || null);
|
|
963
|
-
search.focus();
|
|
1414
|
+
search.focus(keep);
|
|
964
1415
|
return;
|
|
965
1416
|
}
|
|
966
1417
|
if (focusIdx != null) {
|
|
967
1418
|
const items = ddItemsOf(dd);
|
|
968
1419
|
const sel = items.findIndex((el) => el.getAttribute('aria-selected') === 'true');
|
|
969
|
-
(items[focusIdx === 'selected' && sel >= 0 ? sel : (focusIdx === 'selected' ? 0 : focusIdx)] || items[0])?.focus();
|
|
1420
|
+
(items[focusIdx === 'selected' && sel >= 0 ? sel : (focusIdx === 'selected' ? 0 : focusIdx)] || items[0])?.focus(keep);
|
|
970
1421
|
}
|
|
971
1422
|
}
|
|
972
1423
|
|
|
@@ -1042,7 +1493,23 @@ function ddListen(doc) {
|
|
|
1042
1493
|
cannot reach. React's half has always answered both; this is that answer.
|
|
1043
1494
|
why: docs/components.md#the-dropdown-panel */
|
|
1044
1495
|
view.addEventListener('scroll', follow, true);
|
|
1045
|
-
|
|
1496
|
+
/* A resize decides the placement again, a panel that was not lifted included: a
|
|
1497
|
+
Drawer that held the panel at 844 tall cannot at 320. Lifted first, because the
|
|
1498
|
+
height is measured from the placement, and the body is then scrolled to show what
|
|
1499
|
+
the new placement needs, as it is on an open. why: openDropdown() */
|
|
1500
|
+
const replace = () => {
|
|
1501
|
+
for (const dd of ddLive()) {
|
|
1502
|
+
dd.__ddSized = false;
|
|
1503
|
+
if (!dd.classList.contains('open')) continue;
|
|
1504
|
+
const panel = ddPanelOf(dd);
|
|
1505
|
+
if (panel && !dd.__ddPanel && !ddInFilterRow(panel)) dropdownLift(dd, panel);
|
|
1506
|
+
ddPlace(dd);
|
|
1507
|
+
// Held again at the new height while the page scrolls, as after an open.
|
|
1508
|
+
dd.__ddSized = true;
|
|
1509
|
+
if (panel) dropdownReveal(dd, panel);
|
|
1510
|
+
}
|
|
1511
|
+
};
|
|
1512
|
+
view.addEventListener('resize', replace);
|
|
1046
1513
|
view.addEventListener('resize', refit);
|
|
1047
1514
|
view.addEventListener('resize', refitInPlace);
|
|
1048
1515
|
/* Where motion leaves a trigger, read once it has stopped. The style write that
|
|
@@ -1124,7 +1591,12 @@ export function wireDropdown(root = document) {
|
|
|
1124
1591
|
ddFitFilterPanel(dd, panel);
|
|
1125
1592
|
ddWatchRow(dd, panel);
|
|
1126
1593
|
}
|
|
1127
|
-
if (panel && dd.classList.contains('open'))
|
|
1594
|
+
if (panel && dd.classList.contains('open')) {
|
|
1595
|
+
if (!dd.__ddPanel && !ddInFilterRow(panel)) dropdownLift(dd, panel);
|
|
1596
|
+
ddSizeHeight(dd, panel);
|
|
1597
|
+
dd.__ddSized = true;
|
|
1598
|
+
}
|
|
1599
|
+
else if (panel) dropdownRest(dd, panel, false, 0);
|
|
1128
1600
|
|
|
1129
1601
|
trigger.addEventListener('click', (e) => {
|
|
1130
1602
|
e.stopPropagation();
|
|
@@ -1193,6 +1665,7 @@ export function wireDropdown(root = document) {
|
|
|
1193
1665
|
e.preventDefault(); ddItemsOf(dd)[0]?.focus();
|
|
1194
1666
|
} else if (e.key === 'End' && open) {
|
|
1195
1667
|
e.preventDefault(); const it = ddItemsOf(dd); it[it.length - 1]?.focus();
|
|
1668
|
+
dropdownReveal(dd, ddPanelOf(dd));
|
|
1196
1669
|
} else if ((e.key === 'Enter' || e.key === ' ') && open && e.target.matches('[data-dd-item]')) {
|
|
1197
1670
|
e.preventDefault(); e.target.click();
|
|
1198
1671
|
} else if (e.key === 'Escape' && open) {
|
package/src/index.d.ts
CHANGED
|
@@ -95,6 +95,38 @@ export declare function dropdownHeightFit(
|
|
|
95
95
|
dd: Element | null | undefined,
|
|
96
96
|
panel: Element | null | undefined,
|
|
97
97
|
): number | null;
|
|
98
|
+
/** Stop an open panel's height where it cuts a row through the middle, so the list
|
|
99
|
+
* reads as going on: the smaller of its cap and its measured room. Call it with the
|
|
100
|
+
* panel laid out and its room written.
|
|
101
|
+
* why: docs/components.md#the-dropdown-panel */
|
|
102
|
+
export declare function dropdownSnap(
|
|
103
|
+
dd: Element | null | undefined,
|
|
104
|
+
panel: Element | null | undefined,
|
|
105
|
+
): void;
|
|
106
|
+
/** Lift an open panel out of a Modal or Drawer body too short to hold its field and
|
|
107
|
+
* the panel's floor, so it opens over the body the way a portalled panel does, or put a
|
|
108
|
+
* lifted one back. Returns whether the panel is lifted.
|
|
109
|
+
* why: docs/components.md#the-dropdown-panel */
|
|
110
|
+
export declare function dropdownLift(
|
|
111
|
+
dd: Element | null | undefined,
|
|
112
|
+
panel: Element | null | undefined,
|
|
113
|
+
): boolean;
|
|
114
|
+
/** Scroll the body an open panel sits in the least distance that shows the whole
|
|
115
|
+
* panel and its field's label, with the room under the panel kept. Returns the distance scrolled, 0 when
|
|
116
|
+
* nothing moved. why: docs/components.md#the-dropdown-panel */
|
|
117
|
+
export declare function dropdownReveal(
|
|
118
|
+
dd: Element | null | undefined,
|
|
119
|
+
panel: Element | null | undefined,
|
|
120
|
+
): number;
|
|
121
|
+
/** Give an open panel its box, and take the box away from a shut one once its fade is
|
|
122
|
+
* over, so a shut panel adds no scroll room. Returns a function that cancels a
|
|
123
|
+
* collapse still waiting. why: docs/components.md#the-dropdown-panel */
|
|
124
|
+
export declare function dropdownRest(
|
|
125
|
+
dd: Element | null | undefined,
|
|
126
|
+
panel: Element | null | undefined,
|
|
127
|
+
open: boolean,
|
|
128
|
+
wait?: number,
|
|
129
|
+
): () => void;
|
|
98
130
|
export declare function backLink(opts?: Record<string, unknown>): string;
|
|
99
131
|
export declare function statBand(opts?: Record<string, unknown>): string;
|
|
100
132
|
export declare function commandPalette(opts?: Record<string, unknown>): string;
|
package/src/logic/dropdown.js
CHANGED
|
@@ -37,3 +37,25 @@ export const dropdownAvail = ({ anchorTop, anchorBottom, viewport, gap, inset, m
|
|
|
37
37
|
const room = Math.max(up ? anchorTop - gap : viewport - anchorBottom - gap, 0);
|
|
38
38
|
return room < min ? Infinity : Math.max(room - inset, min);
|
|
39
39
|
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where a capped panel stops so that its last row is cut through the middle: the
|
|
43
|
+
* list then reads as going on, and a half row is the least that says so. `rows` are
|
|
44
|
+
* the rows' tops and heights from the panel's top edge, scrolled to its start;
|
|
45
|
+
* `below` is the panel's box under the part that scrolls; `content` is the panel's
|
|
46
|
+
* full height; `ceiling` is the tallest it may be. `reach`, past the ceiling, is room
|
|
47
|
+
* that may be spent to stop at the next half row instead of a whole row short: the
|
|
48
|
+
* least of it that does so is taken. Returns the height, or `null` where the panel fits
|
|
49
|
+
* whole under the ceiling or no row's middle fits under it.
|
|
50
|
+
*/
|
|
51
|
+
export const dropdownStop = ({ rows, below, content, ceiling, reach = ceiling }) => {
|
|
52
|
+
if (!(content > ceiling)) return null;
|
|
53
|
+
let stop = null;
|
|
54
|
+
let over = null;
|
|
55
|
+
for (const { top, height } of rows) {
|
|
56
|
+
const cut = Math.floor(top + height / 2 + below);
|
|
57
|
+
if (cut <= ceiling && (stop === null || cut > stop)) stop = cut;
|
|
58
|
+
else if (cut > ceiling && cut <= reach && (over === null || cut < over)) over = cut;
|
|
59
|
+
}
|
|
60
|
+
return over ?? stop;
|
|
61
|
+
};
|
package/src/styles/dropdown.css
CHANGED
|
@@ -6,6 +6,22 @@
|
|
|
6
6
|
* ========================================================================== */
|
|
7
7
|
|
|
8
8
|
.ui-dropdown { position: relative; display: inline-flex; }
|
|
9
|
+
/* Inside a scrolling body the wiring writes --ui-dropdown-reach when it opens the
|
|
10
|
+
panel: the distance from the trigger to one edge inset past the panel. The panel is
|
|
11
|
+
the last thing the body holds, so the body ends where the panel ends; this 1px spot
|
|
12
|
+
lets it scroll that far. It stays through the fade and until the reader has scrolled
|
|
13
|
+
out of it, so closing does not move the field, and it paints nothing.
|
|
14
|
+
why: docs/components.md#the-dropdown-panel */
|
|
15
|
+
.ui-dropdown[style*="--ui-dropdown-reach"]::after {
|
|
16
|
+
content: "";
|
|
17
|
+
position: absolute;
|
|
18
|
+
inset-inline-start: 0;
|
|
19
|
+
top: 100%;
|
|
20
|
+
width: 1px;
|
|
21
|
+
height: 1px;
|
|
22
|
+
margin-top: var(--ui-dropdown-reach);
|
|
23
|
+
pointer-events: none;
|
|
24
|
+
}
|
|
9
25
|
|
|
10
26
|
/* Trigger — pill button: [pre] [value] [chevron] */
|
|
11
27
|
.ui-dropdown__trigger {
|
|
@@ -88,12 +104,26 @@
|
|
|
88
104
|
the screen's edge sets it to 0.
|
|
89
105
|
why: docs/components.md#the-dropdown-panel */
|
|
90
106
|
--ui-dropdown-edge: 8px;
|
|
107
|
+
/* What a panel that opens down inside a Modal or Drawer body keeps between its end
|
|
108
|
+
and the body's end. The drop shadow fades out over 43px in light and 15px in dark,
|
|
109
|
+
measured from pixels; the body clips whatever is left of it. 48px is the 8px
|
|
110
|
+
step above the light figure. why: docs/components.md#the-dropdown-panel */
|
|
111
|
+
--ui-dropdown-shade: 48px;
|
|
91
112
|
/* The least height a capped panel keeps before it would rather spend the edge
|
|
92
113
|
inset above than shrink further — three rows or so. Below it the panel
|
|
93
114
|
still ends at or before the edge; it never spends room past the edge to
|
|
94
115
|
reach this floor, because that room does not exist. #489
|
|
95
116
|
why: docs/components.md#the-dropdown-panel */
|
|
96
117
|
--ui-dropdown-min: 120px;
|
|
118
|
+
/* The tallest a panel grows before it scrolls inside itself. It is the figure the
|
|
119
|
+
reference systems settle on, 256 to 300px, and the one `scroll` and the search
|
|
120
|
+
list already used. The wiring stops the cap short of it, in the middle of a row
|
|
121
|
+
(--ui-dropdown-fit), so the last row is cut and the list is seen to go on. That
|
|
122
|
+
shows 7 whole rows and half of the eighth in a plain list, 4 and a half with a
|
|
123
|
+
description line, 5 and a half under a search field and 3 and a half with both.
|
|
124
|
+
A consumer asks for another cap with `scroll: <n>`.
|
|
125
|
+
why: docs/components.md#the-dropdown-panel */
|
|
126
|
+
--ui-dropdown-max: 300px;
|
|
97
127
|
/* Named for the same reason the offset is: a block pinned to an edge has to
|
|
98
128
|
bleed back through it, and #306 is a page copying the number by hand.
|
|
99
129
|
why: docs/components.md#the-dropdown-panel */
|
|
@@ -142,15 +172,18 @@
|
|
|
142
172
|
/* The cap, and the whole of #489: a panel took its content's height until that
|
|
143
173
|
height ran off the screen, and then it took what was left. Two numbers decide
|
|
144
174
|
it and the smaller wins — --ui-dropdown-cap is what the consumer asked for
|
|
145
|
-
(the `scroll` option writes it
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
175
|
+
(the `scroll` option writes it, and --ui-dropdown-max stands in when it is
|
|
176
|
+
unset), --ui-dropdown-avail is the room between the trigger and the viewport
|
|
177
|
+
edge, or the body's, which the wiring measures when the panel opens. Between
|
|
178
|
+
them --ui-dropdown-fit is the height the wiring stops the smaller at, in the
|
|
179
|
+
middle of a row. An unset avail
|
|
180
|
+
means nothing has measured yet, which is a server-rendered page before
|
|
181
|
+
hydration — so the fallback promises the same thing at a coarser grain, the
|
|
182
|
+
viewport less the edge inset at each end.
|
|
151
183
|
why: docs/components.md#the-dropdown-panel */
|
|
152
184
|
max-height: min(
|
|
153
|
-
var(--ui-dropdown-cap,
|
|
185
|
+
var(--ui-dropdown-cap, var(--ui-dropdown-max)),
|
|
186
|
+
var(--ui-dropdown-fit, 100dvh),
|
|
154
187
|
var(--ui-dropdown-avail, calc(100dvh - 2 * var(--ui-dropdown-edge)))
|
|
155
188
|
);
|
|
156
189
|
overflow-y: auto;
|
|
@@ -173,21 +206,40 @@
|
|
|
173
206
|
z-index: var(--z-dropdown);
|
|
174
207
|
}
|
|
175
208
|
.ui-dropdown__panel.is-end { left: auto; right: 0; }
|
|
176
|
-
/* `.is-scroll` asks for
|
|
177
|
-
the room the viewport left either way. #489 */
|
|
178
|
-
.ui-dropdown__panel.is-scroll { --ui-dropdown-cap:
|
|
209
|
+
/* `.is-scroll` asks for the standard cap by name; the panel above caps every panel
|
|
210
|
+
at the room the viewport left either way. #489 */
|
|
211
|
+
.ui-dropdown__panel.is-scroll { --ui-dropdown-cap: var(--ui-dropdown-max); }
|
|
179
212
|
|
|
180
|
-
/* The wiring adds this where the
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
213
|
+
/* The wiring adds this where the room is below the floor: even spending the whole
|
|
214
|
+
edge inset, there is no usable height. The panel keeps its cap — it is never
|
|
215
|
+
taller than 300px — and scrolls inside it. Its room runs past the fold, so the
|
|
216
|
+
page is the way to the rows below it, and `overscroll-behavior: contain` above
|
|
217
|
+
would stop the wheel at the panel's own end instead of passing it on. `auto`
|
|
218
|
+
lets it through. why: dropdownAvail(), src/logic/dropdown.js */
|
|
219
|
+
.ui-dropdown__panel.is-unbounded { overscroll-behavior: auto; }
|
|
220
|
+
|
|
221
|
+
/* The wiring lifts a panel out of a Modal or Drawer body too short to hold the field's
|
|
222
|
+
label and trigger, the floor and the 8px edge under it. Fixed, it opens over the
|
|
223
|
+
body the way a portalled panel does, and the wiring writes its edges from the
|
|
224
|
+
trigger's. The value is the side it opens on. why: docs/components.md#the-dropdown-panel */
|
|
225
|
+
.ui-dropdown__panel[data-dd-lifted] { position: fixed; }
|
|
226
|
+
.ui-dropdown__panel[data-dd-lifted="up"] { transform: translateY(6px); }
|
|
227
|
+
|
|
228
|
+
/* A search list that carries the consumer's own cap keeps it, and the panel is that cap
|
|
229
|
+
and the search field over it. The wiring writes the sum as --ui-dropdown-fit; this
|
|
230
|
+
lifts the standard cap out of its way. why: docs/components.md#the-dropdown-panel */
|
|
231
|
+
.ui-dropdown__panel--search:has(> .ui-dropdown__list[style*="--ui-dropdown-cap"]) { --ui-dropdown-max: 100dvh; }
|
|
232
|
+
|
|
233
|
+
/* A shut panel is still laid out, and a box that is laid out counts towards the
|
|
234
|
+
scroll of the body or page it sits in. The wiring collapses it once its fade is
|
|
235
|
+
over and gives the box back before the next open measures it. The width stays, so
|
|
236
|
+
the horizontal fit reads the same number. why: docs/components.md#the-dropdown-panel */
|
|
237
|
+
.ui-dropdown__panel[data-dd-shut] {
|
|
238
|
+
max-height: 0;
|
|
239
|
+
padding-block: 0;
|
|
240
|
+
border-block-width: 0;
|
|
241
|
+
overflow: hidden;
|
|
242
|
+
}
|
|
191
243
|
|
|
192
244
|
/* Chrome makes a scroll container a keyboard stop on its own — no tabindex, no
|
|
193
245
|
author rule — so `.is-scroll` took the browser's outline, black in light mode.
|
|
@@ -396,7 +448,7 @@
|
|
|
396
448
|
|
|
397
449
|
/* The rows' own cap, read from the same property the panel reads: the `scroll`
|
|
398
450
|
option lands on the list when there is a field above it. #489 */
|
|
399
|
-
.ui-dropdown__list { max-height: var(--ui-dropdown-cap, 300px); overflow-y: auto; overscroll-behavior: contain; }
|
|
451
|
+
.ui-dropdown__list { max-height: var(--ui-dropdown-cap, var(--ui-dropdown-fit, var(--ui-dropdown-max, 300px))); overflow-y: auto; overscroll-behavior: contain; }
|
|
400
452
|
/* Rows are `role="option" tabindex="-1"`, so the LIST takes the keyboard stop and
|
|
401
453
|
answered with the native outline. The band is drawn INWARD, on the list: an outset
|
|
402
454
|
one 6px inside a --radius-lg corner leaves the panel, and the panel is not the box
|
package/src/styles/topbar.css
CHANGED
|
@@ -206,10 +206,10 @@
|
|
|
206
206
|
transition: background var(--dur-fast) var(--ease);
|
|
207
207
|
}
|
|
208
208
|
/* Menu rows use the reading surface and an edge on hover — see
|
|
209
|
-
src/styles/dropdown.css:
|
|
209
|
+
src/styles/dropdown.css:323 `.ui-dropdown__item:hover`.
|
|
210
210
|
why: docs/foundations.md#elevation */
|
|
211
211
|
.vopt:hover { outline: 1px solid var(--control-edge); outline-offset: -1px; background: var(--surface); /* code-bg: inherit — control paint; the containing surface is the ground, for a chip and for a band alike. */ }
|
|
212
|
-
/* The row is a keyboard stop: src/components/dropdown.js:
|
|
212
|
+
/* The row is a keyboard stop: src/components/dropdown.js:1663
|
|
213
213
|
`items[next < 0 ? 0 : next]?.focus()` moves focus onto a `[data-dd-item]` with
|
|
214
214
|
the arrow keys, and this is one. It takes the ring on its own 10px radius, the
|
|
215
215
|
same way `.amenu a` above does. #482 */
|