@apliteni/apliteni-ui 0.130.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.
@@ -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:
@@ -16,8 +16,9 @@ export { dropdownMatch, dropdownFiltering, dropdownAvail } from '../logic/dropdo
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
+ import { avatar } from './avatar.js';
19
20
  // The fade's own length, read off the stylesheet rather than copied. why: src/motion.js
20
- import { transitionMs } from '../motion.js';
21
+ import { transitionMs, prefersReducedMotion } from '../motion.js';
21
22
  const cx = (...a) => a.filter(Boolean).join(' ');
22
23
 
23
24
  // A trailing status badge. `badge` is the text shown, in the case it is written
@@ -39,7 +40,7 @@ function ddItem(it, listbox, ext) {
39
40
  const role = listbox ? 'option' : 'menuitem';
40
41
  const asLink = !!it.href && !disabled && !listbox;
41
42
  const tag = asLink ? 'a' : 'div';
42
- const lead = it.icon ? `<span class="ui-dropdown__ic">${icon(it.icon)}</span>` : '';
43
+ const lead = it.avatar ? avatar(it.avatar) : it.icon ? `<span class="ui-dropdown__ic">${icon(it.icon)}</span>` : '';
43
44
  const desc = it.description ? `<span class="ui-dropdown__desc">${esc(it.description)}</span>` : '';
44
45
  const main = `<span class="ui-dropdown__main"><span class="ui-dropdown__label">${esc(it.label)}</span>${desc}</span>`;
45
46
  const badge = ddBadge(it.badge);
@@ -87,14 +88,25 @@ const ddIsRow = (it) => it && it !== '---' && !it.separator;
87
88
 
88
89
  let _ddSeq = 0;
89
90
 
90
- function ddSearchContext(search, id) {
91
+ // "Search 214 people": the size of the whole set, in the placeholder and in the field's
92
+ // name. `count: true` counts the rows, less a "No one" row, which is not a person.
93
+ // why: docs/components.md#a-dropdown-with-a-search-field
94
+ function ddCount(count, rows) {
95
+ if (count == null || count === false) return null;
96
+ const n = count === true ? rows.filter((it) => ddIsRow(it) && !it.avatar?.empty).length : Number(count);
97
+ return Number.isFinite(n) && n >= 0 ? n : null;
98
+ }
99
+
100
+ function ddSearchContext(search, id, rows) {
91
101
  const o = search === true ? {} : search;
102
+ const n = ddCount(o.count, rows);
103
+ const counted = n == null ? null : `Search ${n} ${o.noun || 'people'}`;
92
104
  return {
93
105
  base: id || `ui-dd-${++_ddSeq}`,
94
106
  n: 0,
95
107
  q: o.query || '',
96
- label: o.label,
97
- placeholder: o.placeholder || 'Search',
108
+ label: o.label || counted,
109
+ placeholder: o.placeholder || counted || 'Search',
98
110
  empty: o.empty || 'No match for “{q}”',
99
111
  hint: o.hint || 'Check the spelling, or try fewer letters.',
100
112
  };
@@ -146,7 +158,7 @@ function ddSearchBody({ items, sections }, sx, name, scroll) {
146
158
  * @param {string} [o.value] current value shown in the trigger
147
159
  * @param {string} [o.placeholder] shown when there is no value
148
160
  * @param {string} [o.variant] 'select' (listbox) | 'menu' (inferred from items)
149
- * @param {Array} [o.items] [{ label, value?, description?, icon?, badge?, selected?, disabled?, href?, danger? }]
161
+ * @param {Array} [o.items] [{ label, value?, description?, icon?, avatar?, badge?, selected?, disabled?, href?, danger? }]
150
162
  * @param {Array} [o.sections] [{ label, items }] — grouped alternative to items
151
163
  * @param {string} [o.foot] content for a pinned block at the panel's bottom edge,
152
164
  * drawn as `.ui-dropdown__foot` — raw HTML, so escape your own text
@@ -160,8 +172,10 @@ function ddSearchBody({ items, sections }, sx, name, scroll) {
160
172
  * @param {boolean} [o.disabled] the trigger is off: no stop, no panel, the kit's
161
173
  * unavailable paint. #580
162
174
  * @param {string} [o.ariaLabel] accessible name for the panel and trigger
163
- * @param {boolean|object} [o.search] true, or { placeholder, label, empty, hint, query } —
164
- * a field above the rows that filters them; `empty` may carry {q}
175
+ * @param {boolean|object} [o.search] true, or { placeholder, label, empty, hint, query, count, noun } —
176
+ * a field above the rows that filters them; `empty` may carry {q}; `count` (a number, or
177
+ * true to count the rows) puts the size of the set in the placeholder and the field's
178
+ * name, "Search 214 people", and `noun` replaces "people"
165
179
  * @returns {string} html
166
180
  */
167
181
  export function dropdown({
@@ -174,13 +188,18 @@ export function dropdown({
174
188
  const isSelect = variant === 'select' || (variant == null && flat.some((it) => it && (it.selected || it.value != null)));
175
189
  const listRole = isSelect ? 'listbox' : 'menu';
176
190
  const cur = value != null ? value : (isSelect ? (flat.find((it) => it && it.selected)?.label) : null);
177
- const sx = search ? ddSearchContext(search, id) : null;
191
+ const sx = search ? ddSearchContext(search, id, flat) : null;
178
192
  const name = ariaLabel || (label ? String(label).replace(/:\s*$/, '') : '') || 'Options';
179
193
 
194
+ // A picker of people: the trigger leads with the chosen row's mark, and with no value
195
+ // a dashed ring beside the placeholder. why: docs/components.md#avatar
196
+ const marked = isSelect && flat.some((it) => it && it.avatar);
197
+ const chosen = marked && cur != null ? flat.find((it) => it && it.label === cur) : null;
198
+ const lead = marked && (chosen?.avatar || cur == null) ? avatar(chosen?.avatar ?? { empty: true }) : '';
180
199
  const trig = triggerContent != null
181
200
  ? triggerContent
182
- : `${label ? `<span class="ui-dropdown__pre">${esc(label)}</span>` : ''}` +
183
- `<span class="ui-dropdown__value">${esc(cur != null ? cur : placeholder)}</span>`;
201
+ : `${lead}${label ? `<span class="ui-dropdown__pre">${esc(label)}</span>` : ''}` +
202
+ `<span class="${esc(cx('ui-dropdown__value', marked && cur == null && 'is-placeholder'))}">${esc(cur != null ? cur : placeholder)}</span>`;
184
203
  const triggerAttrs = [
185
204
  `class="${esc(cx('ui-dropdown__trigger', triggerClass))}"`,
186
205
  'type="button"',
@@ -300,6 +319,16 @@ function ddEdge(panel) {
300
319
  return Number.isFinite(declared) ? declared : DD_EDGE;
301
320
  }
302
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
+
303
332
  // The least height a capped panel keeps, below which it would rather spend the
304
333
  // edge inset than shrink further. The number is --ui-dropdown-min in
305
334
  // src/styles/dropdown.css. #489
@@ -599,19 +628,447 @@ export function dropdownHeightFit(dd, panel) {
599
628
  if (!trigger || typeof trigger.getBoundingClientRect !== 'function' || !panel) return null;
600
629
  const t = trigger.getBoundingClientRect();
601
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;
602
638
  return dropdownAvail({
603
- anchorTop: t.top,
604
- anchorBottom: t.bottom,
639
+ anchorTop: t.top - by,
640
+ anchorBottom: t.bottom - by,
605
641
  viewport: view.innerHeight,
606
642
  gap: ddGap(panel),
607
643
  inset: ddEdge(panel),
608
644
  min: ddMin(panel),
609
- up: panel.classList.contains('is-up'),
645
+ up: ddUp(panel),
610
646
  });
611
647
  }
612
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),
1059
+ });
1060
+ set(stop ?? (chrome && Number.isFinite(own) ? ceiling : null));
1061
+ if (stop > room) panel.style.setProperty('--ui-dropdown-avail', `${stop}px`);
1062
+ }
1063
+
613
1064
  function ddSizeHeight(dd, panel) {
614
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;
615
1072
  const avail = dropdownHeightFit(dd, panel);
616
1073
  if (avail == null) return;
617
1074
  // Infinity means the floor could not be met even by spending the whole inset:
@@ -619,19 +1076,19 @@ function ddSizeHeight(dd, panel) {
619
1076
  // viewport less the edge inset, not a trigger-sized sliver — takes over. why:
620
1077
  // dropdownAvail(), src/logic/dropdown.js
621
1078
  //
622
- // `.is-unbounded` is only added where that leaves NO cap at all: a consumer's
623
- // own `--ui-dropdown-cap` (`.is-scroll` or a numeric `scroll`) still clamps the
624
- // panel, and that clamp is still real room to scroll. why: src/styles/dropdown.css
625
- const cap = ddViewOf(panel).getComputedStyle(panel).getPropertyValue('--ui-dropdown-cap').trim();
626
- 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));
627
1082
  // A number that has not changed is not written again, the way the width fit's
628
1083
  // own write is guarded: a mutation inside the row that moved nothing vertically
629
1084
  // would otherwise answer its own write, over and over, through the document
630
1085
  // observer below. why: dropdownViewportFit()'s own "moved" guard, same file
631
1086
  const next = Number.isFinite(avail) ? `${avail}px` : '';
632
- if (panel.style.getPropertyValue('--ui-dropdown-avail') === next) return;
633
- if (next) panel.style.setProperty('--ui-dropdown-avail', next);
634
- else panel.style.removeProperty('--ui-dropdown-avail');
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);
635
1092
  }
636
1093
 
637
1094
  /** Fit a panel again where it now is, whichever placement it uses. */
@@ -641,6 +1098,7 @@ function ddPlace(dd) {
641
1098
  ddResolveDirection(dd, panel);
642
1099
  positionPortalPanel(dd, panel);
643
1100
  }
1101
+ else if (ddLifted(panel)) { if (dd.classList.contains('open')) dropdownLift(dd, panel); }
644
1102
  else ddFitViewport(dd, ddPanelOf(dd));
645
1103
  ddSizeHeight(dd, panel);
646
1104
  }
@@ -822,7 +1280,7 @@ export function dropdownViewportFit(panel, at) {
822
1280
  * why: docs/components.md#the-dropdown-panel
823
1281
  */
824
1282
  function ddFitViewport(dd, panel) {
825
- if (!panel?.style || dd?.__ddPanel === panel || ddInFilterRow(panel)) return;
1283
+ if (!panel?.style || dd?.__ddPanel === panel || ddInFilterRow(panel) || ddLifted(panel)) return;
826
1284
  ddWriteCeiling(panel);
827
1285
  const fit = dropdownViewportFit(panel);
828
1286
  if (!fit) return;
@@ -891,15 +1349,20 @@ function closeDropdown(dd) {
891
1349
  on the way out, so a panel left behind at 237.6…477.6 does not keep the page
892
1350
  478px wide with nothing open. why: ddPlace() */
893
1351
  if (panel) ddFitViewport(dd, panel);
1352
+ if (panel) dropdownRest(dd, panel, false);
894
1353
  }
895
1354
 
896
1355
  /** Every wired dropdown still in a tree, the ones in frames and shadow roots
897
- * 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. */
898
1358
  function ddLive() {
899
1359
  const out = [];
900
1360
  for (const dd of _ddAll) {
901
1361
  if (dd.isConnected) out.push(dd);
902
- else _ddAll.delete(dd);
1362
+ else {
1363
+ dd.__ddRest?.();
1364
+ _ddAll.delete(dd);
1365
+ }
903
1366
  }
904
1367
  return out;
905
1368
  }
@@ -918,6 +1381,7 @@ function openDropdown(dd, focusIdx) {
918
1381
  if (panel) {
919
1382
  // An earlier close may still be holding its geometry; this open owns it now.
920
1383
  ddCancelClose(dd, panel);
1384
+ dropdownRest(dd, panel, true);
921
1385
  ddResolveDirection(dd, panel);
922
1386
  if (dd.__ddPanel) { positionPortalPanel(dd, panel); panel.classList.add('is-open'); }
923
1387
  /* Both fits, because each one answers for the panels the other declines:
@@ -925,8 +1389,14 @@ function openDropdown(dd, focusIdx) {
925
1389
  is null inside a filter row, whose own row already bounds its panels.
926
1390
  why: docs/components.md#a-filter-row-holds-its-panels */
927
1391
  else { ddFitFilterPanel(dd, panel); ddWatchRow(dd, panel); ddFitViewport(dd, panel); }
1392
+ if (!dd.__ddPanel && !ddInFilterRow(panel)) dropdownLift(dd, panel);
1393
+ dd.__ddSized = false;
928
1394
  ddSizeHeight(dd, panel);
1395
+ dd.__ddSized = true;
929
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;
930
1400
  dd.classList.add('open');
931
1401
  /* After `open`, and after the fit: ddResetSearch() pins the width it reads off
932
1402
  * the panel, and the rule that widens a filter menu only applies once the
@@ -941,13 +1411,13 @@ function openDropdown(dd, focusIdx) {
941
1411
  if (search) {
942
1412
  const items = ddItemsOf(dd);
943
1413
  ddSetActive(dd, items.find((el) => el.getAttribute('aria-selected') === 'true') || items[0] || null);
944
- search.focus();
1414
+ search.focus(keep);
945
1415
  return;
946
1416
  }
947
1417
  if (focusIdx != null) {
948
1418
  const items = ddItemsOf(dd);
949
1419
  const sel = items.findIndex((el) => el.getAttribute('aria-selected') === 'true');
950
- (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);
951
1421
  }
952
1422
  }
953
1423
 
@@ -962,6 +1432,13 @@ function selectOption(dd, item) {
962
1432
  const valueEl = dd.querySelector('[data-dropdown-trigger] .ui-dropdown__value');
963
1433
  const label = item.querySelector('.ui-dropdown__label');
964
1434
  if (valueEl && label) valueEl.textContent = label.textContent;
1435
+ // A picker of people carries the row's mark up into the trigger.
1436
+ const mark = item.querySelector(':scope > .ui-avatar');
1437
+ const lead = dd.querySelector('[data-dropdown-trigger] > .ui-avatar');
1438
+ if (mark && lead) lead.replaceWith(mark.cloneNode(true));
1439
+ else if (mark) dd.querySelector('[data-dropdown-trigger]')?.prepend(mark.cloneNode(true));
1440
+ else lead?.remove();
1441
+ valueEl?.classList.remove('is-placeholder');
965
1442
  }
966
1443
 
967
1444
  // Click-outside, Escape and the repositioning sweep, registered once per document
@@ -1016,7 +1493,23 @@ function ddListen(doc) {
1016
1493
  cannot reach. React's half has always answered both; this is that answer.
1017
1494
  why: docs/components.md#the-dropdown-panel */
1018
1495
  view.addEventListener('scroll', follow, true);
1019
- view.addEventListener('resize', reposition);
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);
1020
1513
  view.addEventListener('resize', refit);
1021
1514
  view.addEventListener('resize', refitInPlace);
1022
1515
  /* Where motion leaves a trigger, read once it has stopped. The style write that
@@ -1098,7 +1591,12 @@ export function wireDropdown(root = document) {
1098
1591
  ddFitFilterPanel(dd, panel);
1099
1592
  ddWatchRow(dd, panel);
1100
1593
  }
1101
- if (panel && dd.classList.contains('open')) ddSizeHeight(dd, panel);
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);
1102
1600
 
1103
1601
  trigger.addEventListener('click', (e) => {
1104
1602
  e.stopPropagation();
@@ -1167,6 +1665,7 @@ export function wireDropdown(root = document) {
1167
1665
  e.preventDefault(); ddItemsOf(dd)[0]?.focus();
1168
1666
  } else if (e.key === 'End' && open) {
1169
1667
  e.preventDefault(); const it = ddItemsOf(dd); it[it.length - 1]?.focus();
1668
+ dropdownReveal(dd, ddPanelOf(dd));
1170
1669
  } else if ((e.key === 'Enter' || e.key === ' ') && open && e.target.matches('[data-dd-item]')) {
1171
1670
  e.preventDefault(); e.target.click();
1172
1671
  } else if (e.key === 'Escape' && open) {