@apliteni/apliteni-ui 0.97.0 → 0.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.97.0",
3
+ "version": "0.98.0",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
@@ -1892,7 +1892,8 @@ import {
1892
1892
  dropdownFiltering,
1893
1893
  filterPanelFit,
1894
1894
  transitionMs as transitionMs2,
1895
- dropdownViewportFit
1895
+ dropdownViewportFit,
1896
+ dropdownHeightFit
1896
1897
  } from "@apliteni/apliteni-ui";
1897
1898
  import { Fragment as Fragment5, jsx as jsx20, jsxs as jsxs16 } from "react/jsx-runtime";
1898
1899
  var EXIT_SLACK_MS2 = 50;
@@ -2102,6 +2103,27 @@ function Dropdown({
2102
2103
  doc.removeEventListener("keydown", onKey2);
2103
2104
  };
2104
2105
  }, [open]);
2106
+ useIsoLayoutEffect(() => {
2107
+ if (!open) return;
2108
+ const el = panel.current;
2109
+ if (!el) return;
2110
+ const size = () => {
2111
+ const avail = dropdownHeightFit(root.current, el);
2112
+ if (avail == null) return;
2113
+ if (Number.isFinite(avail)) el.style.setProperty("--ui-dropdown-avail", `${avail}px`);
2114
+ else el.style.removeProperty("--ui-dropdown-avail");
2115
+ const cap2 = el.ownerDocument.defaultView?.getComputedStyle(el).getPropertyValue("--ui-dropdown-cap").trim();
2116
+ el.classList.toggle("is-unbounded", !Number.isFinite(avail) && !cap2);
2117
+ };
2118
+ size();
2119
+ const view = el.ownerDocument?.defaultView ?? window;
2120
+ view.addEventListener("scroll", size, true);
2121
+ view.addEventListener("resize", size);
2122
+ return () => {
2123
+ view.removeEventListener("scroll", size, true);
2124
+ view.removeEventListener("resize", size);
2125
+ };
2126
+ }, [open, direction]);
2105
2127
  useIsoLayoutEffect(() => {
2106
2128
  const want = landOn.current;
2107
2129
  landOn.current = null;
@@ -2328,7 +2350,7 @@ function Dropdown({
2328
2350
  section.label ?? `s${si}`
2329
2351
  );
2330
2352
  }) : (items || []).map((entry, i) => renderRow(entry, `i${i}`));
2331
- const cap = scroll && scroll !== true ? { maxHeight: typeof scroll === "number" ? `${scroll}px` : scroll } : void 0;
2353
+ const cap = scroll && scroll !== true ? { "--ui-dropdown-cap": typeof scroll === "number" ? `${scroll}px` : scroll } : void 0;
2332
2354
  const none = sx && filtering && !visible.length ? /* @__PURE__ */ jsxs16(Fragment5, { children: [
2333
2355
  /* @__PURE__ */ jsx20("span", { className: "ui-dropdown__none-title", children: sx.empty.replace("{q}", () => query.trim()) }),
2334
2356
  /* @__PURE__ */ jsx20("span", { className: "ui-dropdown__none-hint", children: sx.hint })
@@ -1,5 +1,5 @@
1
- import { dropdownMatch, dropdownFiltering } from '../logic/dropdown.js';
2
- export { dropdownMatch, dropdownFiltering } from '../logic/dropdown.js';
1
+ import { dropdownMatch, dropdownFiltering, dropdownAvail } from '../logic/dropdown.js';
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:
5
5
  //
@@ -120,7 +120,10 @@ function ddSearchBody({ items, sections }, sx, name, scroll) {
120
120
  const rows = ddBody({ items, sections }, true, sx);
121
121
  const flat = (sections ? sections.flatMap((s) => s.items || []) : (items || [])).filter(ddIsRow);
122
122
  const shown = flat.some((it) => dropdownMatch(it.label, sx.q));
123
- const cap = scroll && scroll !== true ? ` style="max-height:${typeof scroll === 'number' ? scroll + 'px' : esc(scroll)}"` : '';
123
+ // The cap property and not `max-height`: an inline height outranks the sheet's
124
+ // min() and would put the panel back past the viewport edge. #489
125
+ const cap = scroll && scroll !== true
126
+ ? ` style="--ui-dropdown-cap:${typeof scroll === 'number' ? scroll + 'px' : esc(scroll)}"` : '';
124
127
  const input = [
125
128
  'class="ui-dropdown__search-input"', 'type="text"', 'role="combobox"',
126
129
  'aria-autocomplete="list"', 'aria-expanded="true"', `aria-controls="${esc(listId)}"`,
@@ -202,7 +205,10 @@ export function dropdown({
202
205
  // With search the panel holds a field and a list, which a listbox may not.
203
206
  `role="${sx ? 'dialog' : listRole}"`,
204
207
  sx ? `aria-label="${esc(name)}"` : (ariaLabel ? `aria-label="${esc(ariaLabel)}"` : ''),
205
- scroll && scroll !== true && !sx ? `style="max-height:${typeof scroll === 'number' ? scroll + 'px' : esc(scroll)}"` : '',
208
+ // The cap property and not `max-height`: an inline height outranks the
209
+ // sheet's min() and would put the panel back past the viewport edge. #489
210
+ scroll && scroll !== true && !sx
211
+ ? `style="--ui-dropdown-cap:${typeof scroll === 'number' ? scroll + 'px' : esc(scroll)}"` : '',
206
212
  ].filter(Boolean).join(' ');
207
213
 
208
214
  // The block the sheet bleeds to the panel's bottom edge. It sits OUTSIDE the
@@ -278,6 +284,16 @@ function ddEdge(panel) {
278
284
  return Number.isFinite(declared) ? declared : DD_EDGE;
279
285
  }
280
286
 
287
+ // The least height a capped panel keeps, below which it would rather spend the
288
+ // edge inset than shrink further. The number is --ui-dropdown-min in
289
+ // src/styles/dropdown.css. #489
290
+ const DD_MIN = 120;
291
+
292
+ function ddMin(panel) {
293
+ const declared = parseFloat(ddViewOf(panel).getComputedStyle(panel).getPropertyValue('--ui-dropdown-min'));
294
+ return Number.isFinite(declared) ? declared : DD_MIN;
295
+ }
296
+
281
297
  /** The width a panel has to stay inside: the LAYOUT viewport, which is what both
282
298
  * `position: fixed` and a page's scrollable width are measured against. A classic
283
299
  * scrollbar is in `innerWidth` and not in the box either of those means, so an
@@ -515,10 +531,65 @@ function ddUnwatchRow(dd) {
515
531
  * taken for the middle of an animation, is stated with the rule.
516
532
  * why: docs/components.md#the-dropdown-panel */
517
533
 
534
+ /**
535
+ * How tall a panel may be: the room between its trigger and the viewport edge,
536
+ * from dropdownAvail() in src/logic/dropdown.js — one calculation, so the
537
+ * vanilla wiring and the React `<Dropdown>` cannot disagree about where a panel
538
+ * ends. Measured from the trigger, not the panel, the way dropdownViewportFit()
539
+ * is measured from the panel and not the trigger: each is the box the number it
540
+ * answers actually depends on. `null` when there is nothing to measure.
541
+ * why: docs/components.md#the-dropdown-panel
542
+ *
543
+ * @param {Element} dd the `[data-dropdown]` container the trigger lives in
544
+ * @param {Element} panel a `[data-dropdown-panel]`, for its gap/edge/min and its
545
+ * `is-up` direction
546
+ */
547
+ export function dropdownHeightFit(dd, panel) {
548
+ const trigger = dd?.querySelector?.('[data-dropdown-trigger]');
549
+ if (!trigger || typeof trigger.getBoundingClientRect !== 'function' || !panel) return null;
550
+ const t = trigger.getBoundingClientRect();
551
+ const view = ddViewOf(panel);
552
+ return dropdownAvail({
553
+ anchorTop: t.top,
554
+ anchorBottom: t.bottom,
555
+ viewport: view.innerHeight,
556
+ gap: ddGap(panel),
557
+ inset: ddEdge(panel),
558
+ min: ddMin(panel),
559
+ up: panel.classList.contains('is-up'),
560
+ });
561
+ }
562
+
563
+ function ddSizeHeight(dd, panel) {
564
+ if (!panel?.style) return;
565
+ const avail = dropdownHeightFit(dd, panel);
566
+ if (avail == null) return;
567
+ // Infinity means the floor could not be met even by spending the whole inset:
568
+ // the property is removed rather than written, so the sheet's own fallback — the
569
+ // viewport less the edge inset, not a trigger-sized sliver — takes over. why:
570
+ // dropdownAvail(), src/logic/dropdown.js
571
+ //
572
+ // `.is-unbounded` is only added where that leaves NO cap at all: a consumer's
573
+ // own `--ui-dropdown-cap` (`.is-scroll` or a numeric `scroll`) still clamps the
574
+ // panel, and that clamp is still real room to scroll. why: src/styles/dropdown.css
575
+ const cap = ddViewOf(panel).getComputedStyle(panel).getPropertyValue('--ui-dropdown-cap').trim();
576
+ panel.classList.toggle('is-unbounded', !Number.isFinite(avail) && !cap);
577
+ // A number that has not changed is not written again, the way the width fit's
578
+ // own write is guarded: a mutation inside the row that moved nothing vertically
579
+ // would otherwise answer its own write, over and over, through the document
580
+ // observer below. why: dropdownViewportFit()'s own "moved" guard, same file
581
+ const next = Number.isFinite(avail) ? `${avail}px` : '';
582
+ if (panel.style.getPropertyValue('--ui-dropdown-avail') === next) return;
583
+ if (next) panel.style.setProperty('--ui-dropdown-avail', next);
584
+ else panel.style.removeProperty('--ui-dropdown-avail');
585
+ }
586
+
518
587
  /** Fit a panel again where it now is, whichever placement it uses. */
519
588
  function ddPlace(dd) {
589
+ const panel = dd.__ddPanel || ddPanelOf(dd);
520
590
  if (dd.__ddPanel) positionPortalPanel(dd, dd.__ddPanel);
521
591
  else ddFitViewport(dd, ddPanelOf(dd));
592
+ ddSizeHeight(dd, panel);
522
593
  }
523
594
 
524
595
  /* A few frames past the sheet's own end, so the timer never cuts the fade's last
@@ -778,6 +849,7 @@ function openDropdown(dd, focusIdx) {
778
849
  is null inside a filter row, whose own row already bounds its panels.
779
850
  why: docs/components.md#a-filter-row-holds-its-panels */
780
851
  else { ddFitFilterPanel(dd, panel); ddWatchRow(dd, panel); ddFitViewport(dd, panel); }
852
+ ddSizeHeight(dd, panel);
781
853
  }
782
854
  dd.classList.add('open');
783
855
  /* After `open`, and after the fit: ddResetSearch() pins the width it reads off
@@ -949,6 +1021,7 @@ export function wireDropdown(root = document) {
949
1021
  ddFitFilterPanel(dd, panel);
950
1022
  ddWatchRow(dd, panel);
951
1023
  }
1024
+ if (panel && dd.classList.contains('open')) ddSizeHeight(dd, panel);
952
1025
 
953
1026
  trigger.addEventListener('click', (e) => {
954
1027
  e.stopPropagation();
package/src/index.d.ts CHANGED
@@ -34,6 +34,13 @@ export declare function wireDropdown(root?: Document | Element): void;
34
34
  /** The kit's own match, asked rather than re-implemented by <Dropdown>. */
35
35
  export declare function dropdownMatch(label: unknown, query: unknown): boolean;
36
36
  export declare function dropdownFiltering(query: unknown): boolean;
37
+ /** How tall a floating panel may be: the room between its anchor and the
38
+ * viewport edge, less `gap` and `inset`. `min` is a floor that spends `inset`,
39
+ * down to nothing, rather than room past the edge, which does not exist. */
40
+ export declare function dropdownAvail(opts: {
41
+ anchorTop: number; anchorBottom: number; viewport: number;
42
+ gap: number; inset: number; min: number; up?: boolean;
43
+ }): number;
37
44
  /** The kit's menu floor, the width `.ui-dropdown__panel` writes. */
38
45
  export declare const DD_MENU_FLOOR: number;
39
46
  /** Where a filter chip's open menu can sit: the room from the edge it is
@@ -58,6 +65,14 @@ export declare function dropdownViewportFit(
58
65
  panel: Element | null | undefined,
59
66
  at?: { left: number; width: number },
60
67
  ): { shift: number; left: number; width: number; view: number; edge: number; max: number } | null;
68
+ /** How tall a panel may be: the room between its trigger (inside `dd`) and the
69
+ * viewport edge, from the same dropdownAvail() dropdownViewportFit's `max`
70
+ * shares its calculation with. `null` when there is nothing to measure.
71
+ * why: docs/components.md#the-dropdown-panel */
72
+ export declare function dropdownHeightFit(
73
+ dd: Element | null | undefined,
74
+ panel: Element | null | undefined,
75
+ ): number | null;
61
76
  export declare function backLink(opts?: Record<string, unknown>): string;
62
77
  export declare function statBand(opts?: Record<string, unknown>): string;
63
78
  export declare function commandPalette(opts?: Record<string, unknown>): string;
package/src/index.js CHANGED
@@ -27,7 +27,7 @@ export * from './components/table-values.js';
27
27
  export * from './components/segmented.js';
28
28
  export * from './components/filter-bar.js';
29
29
 
30
- export { dropdownMatch, dropdownFiltering } from './logic/dropdown.js';
30
+ export { dropdownMatch, dropdownFiltering, dropdownAvail } from './logic/dropdown.js';
31
31
  export { SCORE, rankGroups, rankCommands, scoreCommand, paletteHotkey } from './logic/command-palette.js';
32
32
  export { segmentedNextIndex } from './logic/segmented.js';
33
33
  export { filterChipText, filterChipName, filterChipUnset, filterChipItems, nextFocusStop, focusNextStop } from './logic/filter-bar.js';
@@ -14,3 +14,26 @@ export const dropdownFiltering = (query) => fold(query).trim() !== '';
14
14
 
15
15
  /** Does `label` match `query`? A substring, anywhere, ignoring case and accents. */
16
16
  export const dropdownMatch = (label, query) => fold(label).includes(fold(query).trim());
17
+
18
+ /**
19
+ * How tall a floating panel may be: the room between its anchor and the viewport
20
+ * edge, less the gap it keeps from the anchor and the edge inset it keeps from
21
+ * that edge. `up` measures the room above the anchor, otherwise below it.
22
+ *
23
+ * Public for the same reason the two above are: the vanilla wiring and the React
24
+ * <Dropdown> both have to arrive at the same number, and a panel that ends inside
25
+ * the viewport in one and past it in the other is the bug this answers (#489).
26
+ *
27
+ * `min` is a floor: a trigger near the edge has little room, and a panel reduced
28
+ * to a sliver is worse than one left to its content height. The floor never
29
+ * spends room PAST the edge to reach itself, because that room does not exist.
30
+ * Once even the whole inset cannot reach it, there is no cap a reader could
31
+ * use, so this returns `Infinity`: "nothing measured," the word a caller
32
+ * already reads from an unmeasured panel. The panel then keeps its content
33
+ * height, the document grows, and a reader reaches every row by scrolling the
34
+ * page — a 27px sliver hiding 446px of rows was danger 1 in #641's review.
35
+ */
36
+ export const dropdownAvail = ({ anchorTop, anchorBottom, viewport, gap, inset, min, up = false }) => {
37
+ const room = Math.max(up ? anchorTop - gap : viewport - anchorBottom - gap, 0);
38
+ return room < min ? Infinity : Math.max(room - inset, min);
39
+ };
@@ -62,6 +62,12 @@
62
62
  the screen's edge sets it to 0.
63
63
  why: docs/components.md#the-dropdown-panel */
64
64
  --ui-dropdown-edge: 8px;
65
+ /* The least height a capped panel keeps before it would rather spend the edge
66
+ inset above than shrink further — three rows or so. Below it the panel
67
+ still ends at or before the edge; it never spends room past the edge to
68
+ reach this floor, because that room does not exist. #489
69
+ why: docs/components.md#the-dropdown-panel */
70
+ --ui-dropdown-min: 120px;
65
71
  /* Named for the same reason the offset is: a block pinned to an edge has to
66
72
  bleed back through it, and #306 is a page copying the number by hand.
67
73
  why: docs/components.md#the-dropdown-panel */
@@ -107,6 +113,24 @@
107
113
  box-shadow: inset 0 0 0 1px var(--elev-edge, var(--float-edge-inner)), var(--elev-drop);
108
114
  border-radius: var(--radius-lg);
109
115
  padding: var(--ui-dropdown-pad);
116
+ /* The cap, and the whole of #489: a panel took its content's height until that
117
+ height ran off the screen, and then it took what was left. Two numbers decide
118
+ it and the smaller wins — --ui-dropdown-cap is what the consumer asked for
119
+ (the `scroll` option writes it), --ui-dropdown-avail is the room between the
120
+ trigger and the viewport edge, which the wiring measures on every open and
121
+ every scroll. Neither is declared here: an unset cap means the consumer wants
122
+ the whole list, and an unset avail means nothing has measured yet, which is a
123
+ server-rendered page before hydration — so the fallback promises the same
124
+ thing at a coarser grain, the viewport less the edge inset at each end.
125
+ why: docs/components.md#the-dropdown-panel */
126
+ max-height: min(
127
+ var(--ui-dropdown-cap, 100dvh),
128
+ var(--ui-dropdown-avail, calc(100dvh - 2 * var(--ui-dropdown-edge)))
129
+ );
130
+ overflow-y: auto;
131
+ /* The wheel stops at the panel's own end instead of carrying on down the page
132
+ underneath it — the second half of #489. */
133
+ overscroll-behavior: contain;
110
134
  opacity: 0;
111
135
  visibility: hidden;
112
136
  /* Drawn for the whole of the fade out, so the rows do not vanish mid-fade — and a
@@ -123,7 +147,21 @@
123
147
  z-index: var(--z-dropdown);
124
148
  }
125
149
  .ui-dropdown__panel.is-end { left: auto; right: 0; }
126
- .ui-dropdown__panel.is-scroll { max-height: 300px; overflow-y: auto; }
150
+ /* `.is-scroll` asks for 300px specifically; the panel above caps every panel at
151
+ the room the viewport left either way. #489 */
152
+ .ui-dropdown__panel.is-scroll { --ui-dropdown-cap: 300px; }
153
+
154
+ /* The wiring adds this where the floor (above) left the panel uncapped: below
155
+ 120px even spending the whole edge inset, there is no usable cap, so the
156
+ panel keeps its content height and the page grows to hold it instead —
157
+ #641's danger 1. That panel has nothing of its own to scroll, so the
158
+ `overflow-y: auto` above still claims a wheel over it for nothing, and
159
+ `overscroll-behavior: contain` then stops the gesture from reaching the
160
+ page that is now the only way to the rows below the fold. `visible` lets it
161
+ through. A panel that also carries `.is-scroll` or a numeric cap keeps
162
+ `auto`: that cap is still real room to scroll inside. why: dropdownAvail(),
163
+ src/logic/dropdown.js */
164
+ .ui-dropdown__panel.is-unbounded { overflow-y: visible; }
127
165
 
128
166
  /* Chrome makes a scroll container a keyboard stop on its own — no tabindex, no
129
167
  author rule — so `.is-scroll` took the browser's outline, black in light mode.
@@ -286,7 +324,13 @@
286
324
  while the list scrolls. Every rule below keys on a class only the search
287
325
  variant emits, so the plain dropdown renders as it did.
288
326
  why: docs/components.md#a-dropdown-with-a-search-field */
289
- .ui-dropdown__panel--search { min-width: 280px; }
327
+ /* A column, so the panel's own avail-based max-height (above) lands on the rows
328
+ and not on the field: the field and the empty-state block keep their content
329
+ height, and the list — the child that can overflow — gives up the room they
330
+ take. #489 */
331
+ .ui-dropdown__panel--search { min-width: 280px; display: flex; flex-direction: column; }
332
+ .ui-dropdown__panel--search > * { flex: none; }
333
+ .ui-dropdown__panel--search > .ui-dropdown__list { flex: 1 1 auto; min-height: 0; }
290
334
 
291
335
  .ui-dropdown__search { position: relative; display: flex; align-items: center; margin-bottom: 6px; }
292
336
  .ui-dropdown__search-ic {
@@ -324,7 +368,9 @@
324
368
  .ui-dropdown__search-input:hover { border-color: var(--field-edge-hover); }
325
369
  .ui-dropdown__search-input:focus-visible { outline: var(--ring); outline-offset: var(--ring-offset); }
326
370
 
327
- .ui-dropdown__list { max-height: 300px; overflow-y: auto; }
371
+ /* The rows' own cap, read from the same property the panel reads: the `scroll`
372
+ option lands on the list when there is a field above it. #489 */
373
+ .ui-dropdown__list { max-height: var(--ui-dropdown-cap, 300px); overflow-y: auto; overscroll-behavior: contain; }
328
374
  /* Rows are `role="option" tabindex="-1"`, so the LIST takes the keyboard stop and
329
375
  answered with the native outline. The band is drawn INWARD, on the list: an outset
330
376
  one 6px inside a --radius-lg corner leaves the panel, and the panel is not the box
@@ -141,6 +141,13 @@
141
141
  box-shadow: inset 0 0 0 1px var(--elev-edge, var(--float-edge-inner)), var(--elev-drop);
142
142
  border-radius: 16px;
143
143
  padding: 6px;
144
+ /* The same cap `.ui-dropdown__panel` writes itself, in bespoke clothes: this
145
+ menu is `wireDropdown()` too, so wireDropdown() measures it the same way and
146
+ writes the same two properties. #489
147
+ why: docs/components.md#the-dropdown-panel */
148
+ max-height: min(var(--ui-dropdown-cap, 100dvh), var(--ui-dropdown-avail, calc(100dvh - 16px)));
149
+ overflow-y: auto;
150
+ overscroll-behavior: contain;
144
151
  opacity: 0;
145
152
  visibility: hidden;
146
153
  /* Drawn for the whole of the fade out, so the rows do not vanish mid-fade — and a box
@@ -157,6 +164,13 @@
157
164
  visibility var(--dur-med) linear;
158
165
  z-index: var(--z-dropdown);
159
166
  }
167
+ /* Chrome makes a scroll container a keyboard stop on its own — no tabindex, no
168
+ author rule — and #489's cap is what gave this menu the `overflow-y` that
169
+ makes it one, the same way `.ui-dropdown__panel` got it. `.vopt` is
170
+ `tabindex="-1"` and reached only by the arrow keys, so Tab lands on the menu
171
+ itself rather than a row inside it, and the ring belongs here for the same
172
+ reason `.ui-dropdown__panel` keeps it. why: docs/foundations.md#elevation */
173
+ .vsw__menu:focus-visible { outline: var(--ring); outline-offset: var(--ring-offset); }
160
174
  /* Open, the menu is clickable and visible AT ONCE. Both menus in this file are
161
175
  `wireDropdown()` in bespoke clothes — same hooks, same keyboard, same fade — so they
162
176
  owe a panel's two rules as much as `.ui-dropdown__panel` does: `visibility` off the
@@ -192,10 +206,10 @@
192
206
  transition: background var(--dur-fast) var(--ease);
193
207
  }
194
208
  /* Menu rows use the reading surface and an edge on hover — see
195
- src/styles/dropdown.css:207 `.ui-dropdown__item:hover`.
209
+ src/styles/dropdown.css:245 `.ui-dropdown__item:hover`.
196
210
  why: docs/foundations.md#elevation */
197
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. */ }
198
- /* The row is a keyboard stop: src/components/dropdown.js:1015
212
+ /* The row is a keyboard stop: src/components/dropdown.js:1088
199
213
  `items[next < 0 ? 0 : next]?.focus()` moves focus onto a `[data-dd-item]` with
200
214
  the arrow keys, and this is one. It takes the ring on its own 10px radius, the
201
215
  same way `.amenu a` above does. #482 */
@@ -277,10 +291,24 @@
277
291
  border: 1px solid var(--float-edge);
278
292
  box-shadow: inset 0 0 0 1px var(--elev-edge, var(--float-edge-inner)), var(--elev-drop);
279
293
  border-radius: 14px;
280
- /* No clip. The rows reach this panel's edges, and the ring a focused row draws reaches
281
- 3px past itself; `overflow: hidden` here cut that down to the one pixel of border it
282
- had, and the reader met two accent bars instead of a ring. The ring crosses the edge
283
- now. #519 why: docs/components.md#a-menu-panel-does-not-cut-off-its-rows-ring */
294
+ /* #519 chose no clip over padding: `overflow: hidden` with no room cut the ring down to
295
+ the one pixel of border this panel had, so the rule took the clip away instead and let
296
+ the ring cross the edge. #489's cap takes that choice away — `overflow-y: auto` clips
297
+ again, because a menu that scrolls has to stop its content at its own edge — so this
298
+ reserves the room instead, the same 6px `.vsw__menu` and `.ui-dropdown__panel` already
299
+ keep rather than a new figure. why: docs/components.md#a-menu-panel-does-not-cut-off-its-rows-ring */
300
+ padding: 6px;
301
+ /* The same cap `.ui-dropdown__panel` writes itself — see .vsw__menu above. #489 */
302
+ max-height: min(var(--ui-dropdown-cap, 100dvh), var(--ui-dropdown-avail, calc(100dvh - 16px)));
303
+ overflow-y: auto;
304
+ overscroll-behavior: contain;
305
+ /* Chromium scrolls a focused row flush with the scrollport edge, which is 3px
306
+ short of the row's own ring: 1px of gap plus a 2px band. The padding above
307
+ reserves that room while the menu stands still; this keeps it reserved once
308
+ scrolling the menu — reaching the first row this PR made it do — puts a
309
+ focused row at either end. #641's danger 2.
310
+ why: docs/components.md#a-menu-panel-does-not-cut-off-its-rows-ring */
311
+ scroll-padding-block: 3px;
284
312
  opacity: 0;
285
313
  visibility: hidden;
286
314
  pointer-events: none; /* see .vsw__menu above */
@@ -292,6 +320,10 @@
292
320
  visibility var(--dur-med) linear;
293
321
  z-index: var(--z-dropdown);
294
322
  }
323
+ /* The same gap as `.vsw__menu` above, from the same cause: #489's cap. `.amenu
324
+ a` is `tabindex="-1"` too, so Tab still lands on the menu and not a row, and
325
+ the ring belongs here. why: docs/foundations.md#elevation */
326
+ .amenu:focus-visible { outline: var(--ring); outline-offset: var(--ring-offset); }
295
327
  /* Clickable and visible at once — see .vsw.open .vsw__menu above. The row this one
296
328
  costs is `.aout`: a stray click in the fade out ends the reader's session. */
297
329
  .acct.open .amenu {
@@ -301,12 +333,28 @@
301
333
  transform: translateY(0);
302
334
  transition-property: opacity, transform;
303
335
  }
304
- .amenu .ahead { padding: 13px 15px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 11px; }
336
+ /* `margin` bleeds the head back through the 6px panel padding above, the way
337
+ `.ui-dropdown__panel > .ui-dropdown__head` already does for its own panel:
338
+ the border-bottom hairline reaches the panel's edge instead of stopping a
339
+ notch short of it. #641's warning.
340
+ why: docs/components.md#the-dropdown-panel */
341
+ .amenu .ahead {
342
+ padding: 13px 15px;
343
+ margin: -6px -6px 0;
344
+ border-bottom: 1px solid var(--border);
345
+ border-radius: 14px 14px 0 0;
346
+ display: flex; align-items: center; gap: 11px;
347
+ }
305
348
  .amenu .ahead .avatar { cursor: default; }
306
349
  .amenu .ahead .aw { min-width: 0; display: flex; flex-direction: column; gap: 1px; line-height: 1.35; }
307
350
  .amenu .ahead .anm { color: var(--strong); font-size: var(--text-sm); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
308
351
  .amenu .ahead .aem { color: var(--text); font-size: calc(var(--text-xs) + 0.5px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
309
- .amenu a { display: flex; align-items: center; gap: 11px; padding: 10px 15px; color: var(--text); text-decoration: none; font-family: var(--font-sans); font-size: var(--text-sm); cursor: pointer; }
352
+ /* `scroll-margin-block` on the row, beside `scroll-padding-block` on the panel
353
+ above: Chromium's own focus-follow scroll honoured the padding scrolling
354
+ toward the end of the list and not the start, so the row carries its own
355
+ reserve too rather than leaving the start of the list short. #641's danger 2.
356
+ why: docs/components.md#a-menu-panel-does-not-cut-off-its-rows-ring */
357
+ .amenu a { display: flex; align-items: center; gap: 11px; padding: 10px 15px; color: var(--text); text-decoration: none; font-family: var(--font-sans); font-size: var(--text-sm); cursor: pointer; scroll-margin-block: 3px; }
310
358
  .amenu a: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. */ }
311
359
  .amenu a:focus-visible { outline: var(--ring); outline-offset: var(--ring-offset); border-radius: var(--radius-xs); }
312
360
  .amenu a.cur { color: var(--accent); }