@apliteni/apliteni-ui 0.94.0 → 0.96.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.
@@ -46,11 +46,11 @@
46
46
 
47
47
  **Rule:** Keep a focus indicator at least 3:1 against every ground it reaches.
48
48
 
49
- **Do:** The solid band, with a surface-coloured gap around an accent-filled control.
49
+ **Do:** One solid accent band, drawn 1px off the control it marks.
50
50
 
51
- **Don't:** The halo on its own, with no solid band inside it to measure.
51
+ **Don't:** A glow on its own, with no solid band inside it to measure.
52
52
 
53
- **Except:** The band must clear the bar against the gap and halo pixels too, not only against flat ground.
53
+ **Except:** The band must clear the bar against the surface its offset leaves showing, not only flat ground.
54
54
 
55
55
  ## Disabled control legibility
56
56
 
@@ -6,13 +6,13 @@
6
6
 
7
7
  **Rule:** Give every focusable control the same `--ring`, and show it only on `:focus-visible`.
8
8
 
9
- **Why:** One solid band and halo keep focus consistent while the browser decides when to show it.
9
+ **Why:** One solid band keeps focus consistent while the browser decides when to show it.
10
10
 
11
- **Do:** Apply `--ring` to both the button and input on keyboard focus.
11
+ **Do:** Apply `--ring`, with `--ring-offset`, to both the button and input on keyboard focus.
12
12
 
13
13
  **Don't:** Give the input a separate muted outline while the button uses `--ring`.
14
14
 
15
- **Except:** A scroll region inside a surface takes `--ring-scroll`: the same 1px gap and 2px band, drawn inward, without the halo. `--ring` is drawn for a 32px control, where the halo is a glint; around a 400px region it spreads 14px past the band and lights the surface instead of the box that scrolls. A box that is itself the outermost surface — a panel, a card — keeps `--ring`. Text-entry controls may match `:focus-visible` on mouse focus. Surface backgrounds set the gap colour and recompose `--ring`; adjust width and colour at the root.
15
+ **Except:** A scroll region inside a surface takes `--ring-scroll`: the same band drawn inward, with `--ring-scroll-offset`. An inset shadow paints under a box's own children, so a table scrolling under one erases the band; an outline survives it. A box that is itself the outermost surface — a panel, a card — keeps `--ring`. Text-entry controls may match `:focus-visible` on mouse focus. Both bands are declared once at the root and recomposed nowhere, because an outline leaves its offset unpainted. Adjust width and colour there.
16
16
 
17
17
  ## Busy controls
18
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.94.0",
3
+ "version": "0.96.0",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
@@ -207,7 +207,6 @@
207
207
  display: flex;
208
208
  flex-direction: column;
209
209
  background: var(--bg-elevated);
210
- --ring-gap: var(--bg-elevated);
211
210
  border: 0;
212
211
  box-shadow: var(--elev-drop);
213
212
  border-radius: var(--radius-md);
@@ -275,13 +274,13 @@
275
274
  color: inherit;
276
275
  }
277
276
  .rx-sort:focus-visible {
278
- outline: 2px solid transparent;
279
- box-shadow: var(--ring);
277
+ outline: var(--ring);
278
+ outline-offset: var(--ring-offset);
280
279
  border-radius: 4px;
281
280
  }
282
281
  .ui-table__selection input[type=checkbox]:focus-visible {
283
- outline: 2px solid transparent;
284
- box-shadow: var(--ring);
282
+ outline: var(--ring);
283
+ outline-offset: var(--ring-offset);
285
284
  border-radius: var(--radius-xs);
286
285
  }
287
286
  .rx-caret {
@@ -384,7 +383,8 @@
384
383
  color: var(--strong);
385
384
  }
386
385
  .ui-datepicker__opt:focus-visible {
387
- outline: 2px solid transparent;
386
+ outline: var(--ring);
387
+ outline-offset: var(--ring-offset);
388
388
  }
389
389
  .ui-datepicker__opt.is-inside {
390
390
  background: color-mix(in srgb, var(--accent) 14%, var(--bg-elevated));
@@ -394,7 +394,7 @@
394
394
  border-color: var(--accent-strong);
395
395
  color: var(--accent-contrast);
396
396
  }
397
- .ui-datepicker__opt.is-selected:hover {
397
+ .ui-datepicker__opt.is-selected:hover:not(:focus-visible) {
398
398
  outline-color: transparent;
399
399
  background: var(--accent-strong);
400
400
  color: var(--accent-contrast);
@@ -402,12 +402,15 @@
402
402
  .ui-datepicker__opt.is-today {
403
403
  border-color: var(--accent);
404
404
  }
405
- .ui-datepicker__opt.is-today:hover {
405
+ .ui-datepicker__opt.is-today:hover:not(:focus-visible) {
406
406
  outline-color: transparent;
407
407
  }
408
408
  .ui-datepicker__opt.is-selected.is-today {
409
409
  border-color: var(--accent-contrast);
410
410
  }
411
+ .ui-datepicker__opt.is-today:focus-visible {
412
+ border-color: transparent;
413
+ }
411
414
  .ui-datepicker__opt.is-disabled {
412
415
  color: var(--disabled-ink-bare);
413
416
  cursor: not-allowed;
@@ -416,11 +419,13 @@
416
419
  text-decoration: line-through;
417
420
  }
418
421
  .ui-datepicker__opt.is-disabled:hover {
419
- outline-color: transparent;
420
422
  background: transparent;
421
423
  border-color: transparent;
422
424
  color: var(--disabled-ink-bare);
423
425
  }
426
+ .ui-datepicker__opt.is-disabled:hover:not(:focus-visible) {
427
+ outline-color: transparent;
428
+ }
424
429
  .ui-datepicker__opt.is-disabled.is-selected,
425
430
  .ui-datepicker__opt.is-disabled.is-selected:hover,
426
431
  .ui-datepicker__opt.is-disabled.is-inside,
@@ -645,11 +650,13 @@
645
650
  .ui-file:hover {
646
651
  border-color: var(--field-edge-hover);
647
652
  }
648
- .ui-file:focus-within,
649
653
  .ui-file.is-dragging {
650
654
  border-color: var(--accent);
651
- box-shadow: var(--ring);
652
- outline: 2px solid transparent;
655
+ }
656
+ .ui-file:focus-within,
657
+ .ui-file.is-dragging {
658
+ outline: var(--ring);
659
+ outline-offset: var(--ring-offset);
653
660
  }
654
661
  .ui-file.is-invalid {
655
662
  border-color: var(--pink);
@@ -1891,10 +1891,12 @@ import {
1891
1891
  dropdownMatch,
1892
1892
  dropdownFiltering,
1893
1893
  filterPanelFit,
1894
- transitionMs as transitionMs2
1894
+ transitionMs as transitionMs2,
1895
+ dropdownViewportFit
1895
1896
  } from "@apliteni/apliteni-ui";
1896
1897
  import { Fragment as Fragment5, jsx as jsx20, jsxs as jsxs16 } from "react/jsx-runtime";
1897
1898
  var EXIT_SLACK_MS2 = 50;
1899
+ var SETTLE_MS = 450;
1898
1900
  var inPanel = (node) => {
1899
1901
  const el = node?.nodeType === 1 ? node : node?.parentElement ?? null;
1900
1902
  return !!el?.closest("[data-dropdown-panel]");
@@ -2139,6 +2141,67 @@ function Dropdown({
2139
2141
  list.scrollTop = top + el.offsetHeight - list.clientHeight;
2140
2142
  }
2141
2143
  });
2144
+ useIsoLayoutEffect(() => {
2145
+ const el = panel.current;
2146
+ if (!el || el.closest(".ui-filter-bar")) return;
2147
+ const fit = () => {
2148
+ let got = dropdownViewportFit(el);
2149
+ if (!got) return;
2150
+ el.style.setProperty("--ui-dropdown-ceiling", got.max > 0 ? `${got.max}px` : "");
2151
+ if (got.width > got.max) got = dropdownViewportFit(el) ?? got;
2152
+ el.style.translate = got.shift ? `${got.shift}px` : "";
2153
+ };
2154
+ fit();
2155
+ const view = root.current?.ownerDocument?.defaultView;
2156
+ if (!view) return;
2157
+ const stop = [];
2158
+ if (typeof view.ResizeObserver === "function") {
2159
+ const ro = new view.ResizeObserver(() => fit());
2160
+ if (trigger.current) ro.observe(trigger.current);
2161
+ ro.observe(el);
2162
+ stop.push(() => ro.disconnect());
2163
+ }
2164
+ const on = (type, capture = false, handler = fit) => {
2165
+ view.addEventListener(type, handler, capture);
2166
+ stop.push(() => view.removeEventListener(type, handler, capture));
2167
+ };
2168
+ on("resize");
2169
+ on("scroll", true);
2170
+ let settling = null;
2171
+ const settle = () => {
2172
+ if (settling != null) clearTimeout(settling);
2173
+ settling = setTimeout(() => {
2174
+ settling = null;
2175
+ fit();
2176
+ }, SETTLE_MS);
2177
+ };
2178
+ view.addEventListener("transitionend", fit, true);
2179
+ view.addEventListener("animationend", fit, true);
2180
+ stop.push(() => {
2181
+ view.removeEventListener("transitionend", fit, true);
2182
+ view.removeEventListener("animationend", fit, true);
2183
+ if (settling != null) clearTimeout(settling);
2184
+ });
2185
+ on("transitionstart", true, settle);
2186
+ on("animationstart", true, settle);
2187
+ const html = view.document?.documentElement;
2188
+ if (typeof view.MutationObserver === "function" && html) {
2189
+ const mo = new view.MutationObserver((records) => {
2190
+ if (records.every((m) => inPanel(m.target))) return;
2191
+ fit();
2192
+ });
2193
+ mo.observe(html, {
2194
+ subtree: true,
2195
+ childList: true,
2196
+ attributes: true,
2197
+ characterData: true
2198
+ });
2199
+ stop.push(() => mo.disconnect());
2200
+ }
2201
+ return () => {
2202
+ stop.forEach((off) => off());
2203
+ };
2204
+ }, [open]);
2142
2205
  const choose = (item, e) => {
2143
2206
  if (item.disabled) {
2144
2207
  e.preventDefault();
@@ -268,6 +268,26 @@ function ddGap(panel) {
268
268
  return Number.isFinite(declared) ? declared : DD_GAP;
269
269
  }
270
270
 
271
+ // The least a panel keeps between itself and the edge of its view. The number is
272
+ // --ui-dropdown-edge in src/styles/dropdown.css, and this is the fallback for a
273
+ // document that has not loaded the sheet, as DD_GAP is for the offset.
274
+ const DD_EDGE = 8;
275
+
276
+ function ddEdge(panel) {
277
+ const declared = parseFloat(ddViewOf(panel).getComputedStyle(panel).getPropertyValue('--ui-dropdown-edge'));
278
+ return Number.isFinite(declared) ? declared : DD_EDGE;
279
+ }
280
+
281
+ /** The width a panel has to stay inside: the LAYOUT viewport, which is what both
282
+ * `position: fixed` and a page's scrollable width are measured against. A classic
283
+ * scrollbar is in `innerWidth` and not in the box either of those means, so an
284
+ * end-anchored panel written from `innerWidth` sat a scrollbar's width inside its
285
+ * trigger. The fallback is for a view that lays nothing out, JSDOM among them. */
286
+ const ddViewWidth = (panel) => {
287
+ const view = ddViewOf(panel);
288
+ return view.document?.documentElement?.clientWidth || view.innerWidth || 0;
289
+ };
290
+
271
291
  function ddItemsOf(dd) {
272
292
  const panel = ddPanelOf(dd);
273
293
  if (!panel) return [];
@@ -419,12 +439,14 @@ function ddRowOf(dd) {
419
439
  return dd?.closest?.('.ui-filter-bar__chip')?.closest('.ui-filter-bar') || null;
420
440
  }
421
441
 
422
- /** Is this node inside some dropdown panel? The fit writes to a panel and to
442
+ /** Is this node inside some dropdown panel? Both fits write to a panel and to
423
443
  * nothing else, so a change inside one is never news — and a panel is
424
444
  * `position: absolute`, so nothing in it can move a chip along its row. Any
425
445
  * panel, not only the one being fitted: two open menus in one row would
426
- * otherwise answer each other a mutation at a time.
427
- * why: docs/components.md#a-filter-row-holds-its-panels */
446
+ * otherwise answer each other a mutation at a time, and so did two <Dropdown>s
447
+ * on a page, until the browser gate timed out.
448
+ * why: docs/components.md#a-filter-row-holds-its-panels
449
+ * why: docs/components.md#the-dropdown-panel */
428
450
  function ddOurs(node) {
429
451
  const el = node?.nodeType === 1 ? node : node?.parentElement;
430
452
  return !!el?.closest?.('[data-dropdown-panel]');
@@ -484,10 +506,31 @@ function ddUnwatchRow(dd) {
484
506
  dd.__ddRowMo = null;
485
507
  }
486
508
 
509
+ /* ---- Following a panel's anchor --------------------------------------------
510
+ * A position change need not resize anything and no event reports one, so the CAUSE
511
+ * is watched instead: the DOM change that moved the trigger. Measured on the first
512
+ * answer to #572, a flex row told to end-align its children left a panel at
513
+ * 237.6…477.6 on a 390px screen and an ancestor scrolled sideways left one at
514
+ * −108…132; a ResizeObserver sees neither. The limit, and why no end event is
515
+ * taken for the middle of an animation, is stated with the rule.
516
+ * why: docs/components.md#the-dropdown-panel */
517
+
518
+ /** Fit a panel again where it now is, whichever placement it uses. */
519
+ function ddPlace(dd) {
520
+ if (dd.__ddPanel) positionPortalPanel(dd, dd.__ddPanel);
521
+ else ddFitViewport(dd, ddPanelOf(dd));
522
+ }
523
+
487
524
  /* A few frames past the sheet's own end, so the timer never cuts the fade's last
488
525
  * frame. The number the dialogs' exit uses. */
489
526
  const DD_EXIT_SLACK_MS = 50;
490
527
 
528
+ /* How long the settle fallback waits: past --dur-slow (400ms), the longest motion
529
+ * the kit ships, plus the same slack. A motion longer than this is answered by its
530
+ * own end event; this timer is for the end event that never comes.
531
+ * why: src/tokens/tokens.css */
532
+ const DD_SETTLE_MS = 450;
533
+
491
534
  /**
492
535
  * Hold a closing chip menu's open geometry until its fade has finished.
493
536
  *
@@ -548,6 +591,100 @@ function ddResolveDirection(dd, panel) {
548
591
  panel.classList.toggle('is-up', below < panel.offsetHeight + ddGap(panel) && t.top > below);
549
592
  }
550
593
 
594
+ /** The shift a panel is already carrying, the one ddFitViewport() wrote. Taken
595
+ * off before the next measurement, so asking twice answers the same as asking
596
+ * once — the fit is measured from where the sheet puts the panel, not from where
597
+ * the last fit left it. */
598
+ const ddShiftOf = (panel) => parseFloat(panel.style?.translate) || 0;
599
+
600
+ /** The widest a panel may be: the view it is laid out in, less the keep-out it owes
601
+ * each edge. It is the bound a PORTALLED panel needs, whose room is the whole view
602
+ * because it is `position: fixed`; an in-place one is bounded by its trigger's own
603
+ * block, once `overflow-wrap` has let its long tokens break.
604
+ * why: src/styles/dropdown.css */
605
+ const ddCeiling = (panel) => Math.max(0, ddViewWidth(panel) - 2 * ddEdge(panel));
606
+
607
+ /** Write the ceiling, BEFORE the panel is measured: capping a panel's width
608
+ * changes the width a shift would be calculated from. The sheet reads the
609
+ * property and keeps its own 240px floor where the two disagree, which is a view
610
+ * narrower than 2 × edge + 240. why: src/styles/dropdown.css */
611
+ function ddWriteCeiling(panel) {
612
+ const max = ddCeiling(panel);
613
+ panel.style?.setProperty('--ui-dropdown-ceiling', max > 0 ? `${max}px` : '');
614
+ }
615
+
616
+ /** A panel a filter row bounds to the row itself. It is nothing the viewport fit
617
+ * has to measure, and nothing the wiring has to watch for it.
618
+ * why: docs/components.md#a-filter-row-holds-its-panels */
619
+ const ddInFilterRow = (panel) => typeof panel?.closest === 'function' && !!panel.closest('.ui-filter-bar');
620
+
621
+ /**
622
+ * How wide a panel may be and how far along the inline axis it has to move to stay
623
+ * inside the view it is laid out in: `left` at or after the edge, `right` at or
624
+ * before the far one.
625
+ *
626
+ * `align` decides the edge of the trigger a panel hangs from, and that is all it
627
+ * decides — on a phone it moves the clipping from one side to the other rather
628
+ * than removing it, which is #572. So the preferred edge is measured and kept
629
+ * whenever it fits, and overridden only when it does not: the same shape
630
+ * `direction: 'auto'` gives the block axis.
631
+ *
632
+ * Measured, not derived from the classes: the panel's own box is where the sheet
633
+ * has actually put it, so one answer covers `left: 0`, `.is-end`'s `right: 0` and
634
+ * the topbar's bespoke menus alike, and a rule nobody here has read about cannot
635
+ * send it the wrong way.
636
+ *
637
+ * A panel inside a `.ui-filter-bar` is not this function's subject: a filter row
638
+ * bounds its panels to the row itself, which already holds them inside the view.
639
+ * why: docs/components.md#a-filter-row-holds-its-panels
640
+ *
641
+ * @param {Element} panel a `[data-dropdown-panel]`, as the sheet has placed it
642
+ * @param {{left: number, width: number}} [at] the box the caller is about to
643
+ * write, for a panel it places itself — the portal, whose own box is still the
644
+ * one the last call left it at
645
+ * @returns {{shift: number, left: number, width: number, view: number, edge: number,
646
+ * max: number}|null}
647
+ */
648
+ export function dropdownViewportFit(panel, at) {
649
+ if (!panel || typeof panel.getBoundingClientRect !== 'function') return null;
650
+ if (!at && ddInFilterRow(panel)) return null;
651
+ const view = ddViewWidth(panel);
652
+ const box = panel.getBoundingClientRect();
653
+ const width = at ? at.width : box.width;
654
+ const left = at ? at.left : box.left - ddShiftOf(panel);
655
+ if (!(view > 0) || !(width > 0) || !Number.isFinite(left)) return null;
656
+ const slack = view - width;
657
+ /* The gap is what a panel can afford rather than what it asks for. A panel still
658
+ wider than its view — the floor, inside a view too narrow for it — has no room
659
+ for one and cannot honour both edges at all; it keeps its start, so the reader
660
+ meets the beginning of the rows and not the end. */
661
+ const edge = Math.max(0, Math.min(ddEdge(panel), slack / 2));
662
+ const want = Math.max(edge, Math.min(left, slack - edge));
663
+ return { shift: want - left, left: want, width, view, edge, max: ddCeiling(panel) };
664
+ }
665
+
666
+ /**
667
+ * Keep a panel in place inside its view, by moving it along the inline axis.
668
+ *
669
+ * `translate` and not `left`, `right` or a margin: an absolutely positioned box is
670
+ * shrink-to-fit, so its width is measured against the room left between its own
671
+ * offsets, and a shift written as one of those offsets changes the width the shift
672
+ * was calculated from. A transform moves no layout, so the number stays true. The
673
+ * entry travel keeps `transform` to itself, and the property is off the panel's
674
+ * transition list, so a panel already open lands at once rather than sliding.
675
+ *
676
+ * A portalled panel is placed by positionPortalPanel(), which writes its edges in
677
+ * viewport coordinates and clamps them there.
678
+ * why: docs/components.md#the-dropdown-panel
679
+ */
680
+ function ddFitViewport(dd, panel) {
681
+ if (!panel?.style || dd?.__ddPanel === panel || ddInFilterRow(panel)) return;
682
+ ddWriteCeiling(panel);
683
+ const fit = dropdownViewportFit(panel);
684
+ if (!fit) return;
685
+ panel.style.translate = fit.shift ? `${fit.shift}px` : '';
686
+ }
687
+
551
688
  // Nothing lays a portalled panel out any more, so these four inline values are
552
689
  // its layout. Inline, so no rule in any sheet can pin the opposite edge.
553
690
  function positionPortalPanel(dd, panel) {
@@ -564,12 +701,21 @@ function positionPortalPanel(dd, panel) {
564
701
  s.bottom = 'auto';
565
702
  s.top = `${t.bottom + gap}px`;
566
703
  }
567
- if (panel.classList.contains('is-end')) {
704
+ /* The inline axis is fitted rather than mirrored. Both of these edges put a
705
+ 240px panel off the screen from a trigger near the one it is anchored to, and
706
+ #572 is that: the fit is asked with the box this function is about to write,
707
+ because the panel's own is still where the last call left it. */
708
+ const end = panel.classList.contains('is-end');
709
+ ddWriteCeiling(panel);
710
+ const width = panel.getBoundingClientRect().width;
711
+ const left = end ? t.right - width : t.left;
712
+ const shift = dropdownViewportFit(panel, { left, width })?.shift || 0;
713
+ if (end) {
568
714
  s.left = 'auto';
569
- s.right = `${view.innerWidth - t.right}px`;
715
+ s.right = `${ddViewWidth(panel) - t.right - shift}px`;
570
716
  } else {
571
717
  s.right = 'auto';
572
- s.left = `${t.left}px`;
718
+ s.left = `${t.left + shift}px`;
573
719
  }
574
720
  }
575
721
 
@@ -596,6 +742,11 @@ function closeDropdown(dd) {
596
742
  if (panel?.style && !dd.__ddPanel && ddRowOf(dd)) ddHoldClose(dd, panel);
597
743
  else if (panel?.style && dd.closest?.('.ui-filter-bar')) panel.style.minWidth = '';
598
744
  dd.querySelector('[data-dropdown-trigger]')?.setAttribute('aria-expanded', 'false');
745
+ /* The anchor may have moved while the panel was open, and a shut panel is laid
746
+ out: its box goes on counting towards the page's scrollable width. One reading
747
+ on the way out, so a panel left behind at 237.6…477.6 does not keep the page
748
+ 478px wide with nothing open. why: ddPlace() */
749
+ if (panel) ddFitViewport(dd, panel);
599
750
  }
600
751
 
601
752
  /** Every wired dropdown still in a tree, the ones in frames and shadow roots
@@ -622,7 +773,11 @@ function openDropdown(dd, focusIdx) {
622
773
  ddCancelClose(dd, panel);
623
774
  ddResolveDirection(dd, panel);
624
775
  if (dd.__ddPanel) { positionPortalPanel(dd, panel); panel.classList.add('is-open'); }
625
- else { ddFitFilterPanel(dd, panel); ddWatchRow(dd, panel); }
776
+ /* Both fits, because each one answers for the panels the other declines:
777
+ filterPanelFit() is null outside a filter chip, and dropdownViewportFit()
778
+ is null inside a filter row, whose own row already bounds its panels.
779
+ why: docs/components.md#a-filter-row-holds-its-panels */
780
+ else { ddFitFilterPanel(dd, panel); ddWatchRow(dd, panel); ddFitViewport(dd, panel); }
626
781
  }
627
782
  dd.classList.add('open');
628
783
  /* After `open`, and after the fit: ddResetSearch() pins the width it reads off
@@ -674,12 +829,14 @@ function ddListen(doc) {
674
829
  const open = ddLive().find((dd) => dd.classList.contains('open') && dd.ownerDocument === doc);
675
830
  if (open) { closeDropdown(open); open.querySelector('[data-dropdown-trigger]')?.focus(); }
676
831
  });
677
- // Viewport coordinates go stale the moment anything scrolls. Capture, so a
678
- // scroll inside the rail the panel was lifted out of counts too.
832
+ /* Viewport coordinates go stale the moment anything scrolls, and an in-place
833
+ panel's fit is measured against the view as much as a portalled one's: a
834
+ trigger slid from x=260 to x=10 inside a row that scrolls sideways left its
835
+ panel at −108…132, with the first row's label off the screen. Capture, so a
836
+ scroll inside the rail the panel was lifted out of, or inside that row, counts
837
+ too. why: docs/components.md#the-dropdown-panel */
679
838
  const reposition = () => {
680
- for (const dd of ddLive()) {
681
- if (dd.classList.contains('open') && dd.__ddPanel) positionPortalPanel(dd, dd.__ddPanel);
682
- }
839
+ for (const dd of ddLive()) if (dd.classList.contains('open')) ddPlace(dd);
683
840
  };
684
841
  /* The fallback re-fit, for a view with no ResizeObserver. Not taken as well where
685
842
  * ddWatchRow() is watching: a `resize` event is the worse measurement of the two.
@@ -691,11 +848,65 @@ function ddListen(doc) {
691
848
  }
692
849
  }
693
850
  };
851
+ /* The shut ones, which the sweep above does not reach and which no reader is
852
+ looking at: a shut panel is laid out, so a panel fitted at 1280 and left shut
853
+ at 375 keeps the page scrolling sideways with nothing open. A shut PORTALLED
854
+ panel is not one of them — `position: fixed` puts it in no page's scrollable
855
+ width. why: docs/components.md#the-dropdown-panel */
856
+ const refitInPlace = () => {
857
+ for (const dd of ddLive()) {
858
+ if (!dd.__ddPanel && !dd.classList.contains('open')) ddFitViewport(dd, ddPanelOf(dd));
859
+ }
860
+ };
861
+ // The two together, which is every panel that owes anybody an answer.
862
+ const follow = () => { reposition(); refitInPlace(); };
694
863
  const view = doc.defaultView;
695
864
  if (!view) return;
696
- view.addEventListener('scroll', reposition, true);
865
+ /* Both states on a scroll, not the open one alone: a shut panel is laid out, so
866
+ an ancestor scrolled back to where it started left one at 258…498 on a 390px
867
+ screen with nothing open — #501's page width, in the state the open sweep
868
+ cannot reach. React's half has always answered both; this is that answer.
869
+ why: docs/components.md#the-dropdown-panel */
870
+ view.addEventListener('scroll', follow, true);
697
871
  view.addEventListener('resize', reposition);
698
872
  view.addEventListener('resize', refit);
873
+ view.addEventListener('resize', refitInPlace);
874
+ /* Where motion leaves a trigger, read once it has stopped. The style write that
875
+ starts a transition is a mutation measured in the frame it lands, when the
876
+ trigger has not moved yet: one given `transition: transform 200ms` and then
877
+ translated 220px left its panel 240px outside the view for good, not for 200ms.
878
+ The end events answer that, and one timer per document answers the end event
879
+ that never comes — a cancelled transition's, or one on a box this document
880
+ never hears from. Armed when motion starts and cleared before it is armed
881
+ again, so one is pending at a time, and a fired one walks ddLive(), which has
882
+ already dropped whatever was removed meanwhile. The frames in between are what
883
+ this declines: the reader is owed the final position, not a measurement per
884
+ frame per panel. why: docs/components.md#the-dropdown-panel */
885
+ let settling = null;
886
+ const settle = () => {
887
+ clearTimeout(settling);
888
+ settling = setTimeout(() => { settling = null; follow(); }, DD_SETTLE_MS);
889
+ };
890
+ view.addEventListener('transitionend', follow, true);
891
+ view.addEventListener('animationend', follow, true);
892
+ view.addEventListener('transitionstart', settle, true);
893
+ view.addEventListener('animationstart', settle, true);
894
+ if (typeof view.MutationObserver === 'function' && doc.documentElement) {
895
+ const mo = new view.MutationObserver((records) => {
896
+ if (records.every((m) => ddOurs(m.target))) return;
897
+ follow();
898
+ });
899
+ /* characterData as well as the two structural kinds: growing a sibling TEXT
900
+ NODE moves a trigger along a flex row without changing one observed box, and
901
+ neither a child list nor an attribute changes with it. 39 characters of 10px
902
+ monospace in front of a menu carried both layers' panels from 8…248 to
903
+ 236.8…476.8 and left the page 477px wide. A change inside any panel is still
904
+ skipped, text included: ddOurs() reads a text node's parent.
905
+ why: docs/components.md#the-dropdown-panel */
906
+ mo.observe(doc.documentElement, {
907
+ subtree: true, childList: true, attributes: true, characterData: true,
908
+ });
909
+ }
699
910
  }
700
911
 
701
912
  export function wireDropdown(root = document) {
@@ -828,6 +1039,25 @@ export function wireDropdown(root = document) {
828
1039
  if (dd.classList.contains('open')) positionPortalPanel(dd, dd.__ddPanel);
829
1040
  }).observe(trigger);
830
1041
  }
1042
+ } else if (panel && !ddInFilterRow(panel)) {
1043
+ /* And an in-place panel is fitted here, before anything is clicked, because
1044
+ a shut panel is laid out and a laid-out box counts towards the page's
1045
+ scrollable width. The two boxes the fit is measured from are both watched:
1046
+ the trigger, because the edge an end-aligned panel hangs from is the
1047
+ trigger's, and the panel, because its own width is the other half of the
1048
+ sum. The view's observer and not the module's: a panel in a frame is laid
1049
+ out in that frame. A filter row's panels are skipped outright rather than
1050
+ watched for a fit that declines them: that row is their bound, and
1051
+ ddWatchRow() is the observer they get instead.
1052
+ why: docs/components.md#the-dropdown-panel */
1053
+ ddFitViewport(dd, panel);
1054
+ const view = dd.ownerDocument?.defaultView;
1055
+ if (typeof view?.ResizeObserver === 'function') {
1056
+ // Writing a translate moves no layout, so a fit cannot report itself back.
1057
+ const ro = new view.ResizeObserver(() => ddFitViewport(dd, panel));
1058
+ ro.observe(trigger);
1059
+ ro.observe(panel);
1060
+ }
831
1061
  }
832
1062
  });
833
1063
 
package/src/index.d.ts CHANGED
@@ -46,6 +46,18 @@ export declare function filterPanelFit(
46
46
  dd: Element | null | undefined,
47
47
  floor?: number,
48
48
  ): { room: number; shift: number; floor: number; end: boolean } | null;
49
+ /** How wide a panel may be and how far along the inline axis it has to move to
50
+ * stay inside the view it is laid out in, measured from where the sheet has put
51
+ * it: `shift` is 0 when the edge `align` asked for already fits, and `max` is the
52
+ * view less `edge` at each side — write it before measuring again, because capping
53
+ * a panel changes the width a shift is calculated from. `at` gives the box a
54
+ * caller is about to write instead, which is how the portal's placement asks.
55
+ * `null` when there is nothing to measure, or inside a `.ui-filter-bar`, which
56
+ * bounds its own panels. why: docs/components.md#the-dropdown-panel */
57
+ export declare function dropdownViewportFit(
58
+ panel: Element | null | undefined,
59
+ at?: { left: number; width: number },
60
+ ): { shift: number; left: number; width: number; view: number; edge: number; max: number } | null;
49
61
  export declare function backLink(opts?: Record<string, unknown>): string;
50
62
  export declare function statBand(opts?: Record<string, unknown>): string;
51
63
  export declare function commandPalette(opts?: Record<string, unknown>): string;
@@ -31,8 +31,8 @@
31
31
  (0,2,0) wins without !important — the argument `.ui-nav .ui-nav__item` makes
32
32
  in nav.css. */
33
33
  .ui-back[href] { color: var(--text); }
34
- .ui-back[href]:hover { color: var(--strong); background: var(--surface); /* ring-gap: inherit — control paint; its focus indicator uses the containing surface. */ }
35
- .ui-back:focus-visible { outline: 2px solid transparent; box-shadow: var(--ring); }
34
+ .ui-back[href]:hover { color: var(--strong); background: var(--surface); /* code-bg: inherit — control paint; the containing surface is the ground, for a chip and for a band alike. */ }
35
+ .ui-back:focus-visible { outline: var(--ring); outline-offset: var(--ring-offset); }
36
36
 
37
37
  /* 16px, with the stroke raised to match: the glyph set draws at 1.7 on a 24-unit
38
38
  box, which is 1.13 CSS px at this size and under the kit's 1.5 for a stroked
@@ -15,24 +15,24 @@
15
15
  padding: 3px 9px;
16
16
  border-radius: var(--radius-pill);
17
17
  color: var(--text);
18
- background: var(--surface); /* ring-gap: inherit — decorative or text-only surface has no nested focus target. */
18
+ background: var(--surface); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */
19
19
  white-space: nowrap;
20
20
  }
21
21
  /* Status chips take the --chip-* pairs, so the light app's solid fills live in
22
22
  tokens.css rather than in a theme override down here: on white the glow-tints
23
23
  wash out and the ink has to deepen to clear AA, and that is a token decision. */
24
- .ui-badge--live { color: var(--chip-success-ink); background: var(--chip-success-fill); /* ring-gap: inherit — decorative or text-only surface has no nested focus target. */ }
25
- .ui-badge--soon { color: var(--accent); background: var(--surface); /* ring-gap: inherit — translucent wash retains the opaque containing gap. */ }
26
- .ui-badge--info { color: var(--chip-info-ink); background: var(--chip-info-fill); /* ring-gap: inherit — decorative or text-only surface has no nested focus target. */ }
24
+ .ui-badge--live { color: var(--chip-success-ink); background: var(--chip-success-fill); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */ }
25
+ .ui-badge--soon { color: var(--accent); background: var(--surface); /* code-bg: inherit — a wash paints over a ground and behind a chip, so the opaque ground below it is the step. */ }
26
+ .ui-badge--info { color: var(--chip-info-ink); background: var(--chip-info-fill); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */ }
27
27
  .ui-badge--warn { color: var(--chip-warn-ink); background: var(--chip-warn-fill); }
28
- .ui-badge--danger { color: var(--chip-danger-ink); background: var(--chip-danger-fill); /* ring-gap: inherit — decorative or text-only surface has no nested focus target. */ }
29
- .ui-badge--archive { /* muted-ink: state — archived record */ color: var(--muted); background: var(--surface); /* ring-gap: inherit — decorative or text-only surface has no nested focus target. */ }
28
+ .ui-badge--danger { color: var(--chip-danger-ink); background: var(--chip-danger-fill); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */ }
29
+ .ui-badge--archive { /* muted-ink: state — archived record */ color: var(--muted); background: var(--surface); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */ }
30
30
 
31
31
  /* Semantic status variants — for record/row status in data tables
32
32
  (paid / verified / pending / failed …). Same chip, mapped to meaning. */
33
- .ui-badge--success { color: var(--chip-success-ink); background: var(--chip-success-fill); /* ring-gap: inherit — decorative or text-only surface has no nested focus target. */ }
33
+ .ui-badge--success { color: var(--chip-success-ink); background: var(--chip-success-fill); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */ }
34
34
  .ui-badge--pending { color: var(--chip-warn-ink); background: var(--chip-warn-fill); }
35
- .ui-badge--neutral { color: var(--text); background: var(--surface); /* ring-gap: inherit — decorative or text-only surface has no nested focus target. */ }
35
+ .ui-badge--neutral { color: var(--text); background: var(--surface); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */ }
36
36
 
37
37
  /* Pill: same shape, sentence case, a touch larger — a metadata chip */
38
38
  .ui-pill {
@@ -42,7 +42,7 @@
42
42
  /* rank: chip */
43
43
  font-size: var(--text-xs);
44
44
  color: var(--text);
45
- background: var(--surface); /* ring-gap: inherit — decorative or text-only surface has no nested focus target. */
45
+ background: var(--surface); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */
46
46
  border-radius: var(--radius-pill);
47
47
  padding: 4px 11px;
48
48
  font-weight: var(--weight-semibold);
@@ -50,8 +50,8 @@
50
50
  /* The live pill is the same status paint as the live badge, so it takes the
51
51
  same pair. On its own it was --green on --glow-green, which is 3.97:1 over
52
52
  white — under AA, while the badge beside it cleared at 4.64:1. See issue #131. */
53
- .ui-pill--live { color: var(--chip-success-ink); background: var(--chip-success-fill); /* ring-gap: inherit — decorative or text-only surface has no nested focus target. */ }
54
- .ui-pill--soon { color: var(--accent); background: var(--surface); /* ring-gap: inherit — translucent wash retains the opaque containing gap. */ }
53
+ .ui-pill--live { color: var(--chip-success-ink); background: var(--chip-success-fill); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */ }
54
+ .ui-pill--soon { color: var(--accent); background: var(--surface); /* code-bg: inherit — a wash paints over a ground and behind a chip, so the opaque ground below it is the step. */ }
55
55
 
56
56
  /* Status dot */
57
57
  .ui-dot {