Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: dropdowns open a themed list of their own instead of the browser's

SelectField and the refresh-interval picker are built on a new Listbox, a
select-only combobox: a button that keeps focus and a listbox in the top
layer, so it works the same in every current browser, is not clipped by a
scrolling ancestor and shows above a modal dialog. Keys, type-ahead and
placement are pure functions with tests. required becomes aria-required, as a
button has no native form validation.
This commit is contained in:
iris 2026-10-25 17:15:04 +01:00
commit a2e8a2bda0
9 changed files with 653 additions and 66 deletions

View file

@ -11,6 +11,15 @@ The signed-in user's avatar sits at the right end of the header on every page.
It opens a menu with the user's name and links to authelia's settings and
sign-out pages; the hive dashboard and the per-agent page have the same menu.
Every dropdown (a form's select, the refresh interval, the history window) opens a
list in the swarm UI's own theme, not the browser's, and works the same in every
current browser. Open it with a click, or with the arrow keys, **Enter** or
**Space** while it has focus. Then the arrow keys, **Home**, **End**, **Page Up**
and **Page Down** move through the options, and **Enter**, **Space** or a click take
the highlighted one. **Escape**, **Tab** or a click elsewhere shut the list without
changing anything. Typing the start of an option's name picks it, also with the list
shut. A dropdown inside a dialog opens above the dialog and the dialog doesn't cut it off.
| route | what |
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/` | the start page: the swarm's name over a digital-rain backdrop, plus a paperclip offering to set up an agent while no `ruth` exists (it opens the create form with `ruth` filled in); the swarm name in the header links here |

View file

@ -14,7 +14,7 @@
//
// `hive` field added because agent creation had no way to record which
// hive an agent runs on. `POST /api/agents` now requires it, so the
// roster fetched off `GET /api/hives` backs a required select here
// roster fetched off `GET /api/hives` backs a required dropdown here
// rather than a free-text field — a spawn target has to be one of the
// swarm's actual hives, same reasoning `Ident.parse` gets client-side
// pattern validation for `name`.
@ -159,8 +159,8 @@ export function CreateAgentForm({
setHives(data);
// Single-hive swarms are the common case — default it rather than
// making the operator pick the only option. Multi-hive swarms get
// no default (the placeholder stays selected), so `required` on
// the select below forces an explicit choice per mara's scope.
// no default (the placeholder stays selected), so the identity step
// cannot be left until one is picked (`stepProblem`), per mara's scope.
if (data.length === 1) setHive(data[0].name);
})().catch((e: unknown) => setHivesError({ detail: String(e) }));
}, []);

View file

@ -13,7 +13,6 @@
border-radius: 0.4em;
color: var(--muted);
font-size: 0.9em;
cursor: pointer;
}
.ui-refresh-picker:hover,
.ui-refresh-picker:focus-within {
@ -21,22 +20,21 @@
background: var(--bg);
color: var(--fg);
}
/* The select drives the interaction (native listbox, keyboard, a11y) but
contributes no chrome of its own — its own box is invisible, sized to
just its selected option's text, and its default arrow is stripped
since the chevron span stands in for it. */
.ui-refresh-picker-select {
appearance: none;
/* The Listbox contributes only its value and chevron: its own box is
invisible and as wide as the value, so the picker's border is the one
that shows. */
.ui-refresh-picker .ui-listbox {
width: auto;
}
.ui-refresh-picker-select.ui-form-control {
width: auto;
min-height: 2.5em;
padding: 0;
border: none;
background: none;
color: inherit;
font: inherit;
padding: 0;
margin: 0;
cursor: pointer;
}
.ui-refresh-picker-icon,
.ui-refresh-picker-chevron {
.ui-refresh-picker-icon {
font-size: 0.85em;
line-height: 1;
}

View file

@ -14,18 +14,17 @@
// passive-until-touched setting that lives in a panel's title row
// (`Panel`'s `actions` slot), not a form. Visually quiet (no border/
// fill until hovered/focused) the same way `LinksMenu`'s header button
// is, so it reads as chrome rather than another input to fill in. A
// native `<select>` still drives it (no custom dropdown/listbox to
// reinvent) — `appearance: none` strips its default arrow so the
// chevron span can stand in for it, and the visible "value" text is
// just whichever `<option>` the browser is already rendering as
// selected, not a second copy of the label kept in sync by hand.
// is, so it reads as chrome rather than another input to fill in. The
// dropdown is the shared `Listbox` (its list is themed, not the browser's),
// restyled to have no chrome of its own: the chevron is the Listbox's, and the
// visible value is its selected option.
//
// The hook, not the picker, owns re-fetch safety: a caller passes its
// own fetch callback, and it's the CALLER's job to make sure that
// callback doesn't clobber an input the operator is mid-edit on — this
// hook only decides *when* to call it.
import { useEffect, useRef } from "preact/hooks";
import { Listbox } from "../select-field/Listbox.js";
import "./RefreshInterval.css";
// `null` means "off" throughout this module — no separate boolean, so
@ -51,37 +50,23 @@ export function RefreshIntervalPicker({
}) {
const current = PRESETS.find((p) => p.value === value) ?? PRESETS[0];
return (
// A `<label>` wrapper (not a bare `<span>`) so the icon/chevron
// padding is part of the click/tap target too, not just the native
// select box itself — same touch-target-floor reasoning as the rest
// of the kit, just without a visible label string to hang it off
// of (hence the `aria-label` on the `<select>` for the accessible
// name a sighted-only icon+chevron can't supply).
<label class="ui-refresh-picker" for={id}>
<div class="ui-refresh-picker">
<span class="ui-refresh-picker-icon" aria-hidden="true">
🕐
</span>
<select
<Listbox
id={id}
class="ui-refresh-picker-select"
aria-label="refresh interval"
ariaLabel="refresh interval"
value={current.label}
onChange={(e) => {
const label = (e.target as HTMLSelectElement).value;
options={PRESETS.map((p) => ({ value: p.label, label: p.label }))}
minListWidth={96}
onChange={(label) => {
const preset = PRESETS.find((p) => p.label === label);
if (preset) onChange(preset.value);
}}
>
{PRESETS.map((p) => (
<option key={p.label} value={p.label}>
{p.label}
</option>
))}
</select>
<span class="ui-refresh-picker-chevron" aria-hidden="true">
⌄
</span>
</label>
/>
</div>
);
}

View file

@ -0,0 +1,84 @@
/* <Listbox>: the control is `.ui-form-control`'s box (FormField.css) laid out as
value + chevron, and the list is a themed box in the top layer. */
.ui-listbox {
position: relative;
width: 100%;
}
.ui-select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5em;
text-align: start;
cursor: pointer;
}
.ui-select-trigger:disabled {
cursor: default;
opacity: 0.6;
}
.ui-select-trigger[aria-expanded="true"] {
border-color: var(--purple);
}
.ui-select-value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ui-select-empty {
color: var(--muted);
}
/* A chevron drawn with borders, so it takes the text colour and needs no glyph
a font might lack. */
.ui-select-chevron {
flex: none;
width: 0.45em;
height: 0.45em;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: translateY(-0.12em) rotate(45deg);
opacity: 0.7;
}
.ui-select-trigger[aria-expanded="true"] .ui-select-chevron {
transform: translateY(0.1em) rotate(225deg);
}
/* A popover's own UA styles centre it with `inset: 0; margin: auto`; this puts
it where `placePopup` says. */
.ui-select-list {
position: fixed;
inset: auto;
margin: 0;
padding: 0.25em;
box-sizing: border-box;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--purple-dim) transparent;
list-style: none;
background: var(--bg-elev);
color: var(--fg);
border: 1px solid var(--purple-dim);
border-radius: 0.4em;
box-shadow: 0 0.25em 0.75em rgba(0, 0, 0, 0.3);
font: inherit;
}
.ui-select-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75em;
min-height: 2.5em;
padding: 0.3em 0.6em;
border-radius: 0.3em;
cursor: pointer;
}
.ui-select-option-active {
background: var(--bg);
}
.ui-select-option-selected {
font-weight: 600;
}
.ui-select-option-selected::after {
content: "✓";
color: var(--purple);
}

View file

@ -0,0 +1,269 @@
// <Listbox> — the dropdown a native `<select>` cannot be: its open list is
// drawn by the page, so it wears the theme. `SelectField` and the refresh
// picker are built on it. Same job as a select (pick one of a few options), so
// it follows the WAI-ARIA "select-only combobox" pattern: the control is a
// button with `role="combobox"` that keeps focus, and the list is a
// `role="listbox"` whose current option the control names through
// `aria-activedescendant`.
//
// Keyboard. Closed: Arrow Up/Down, Enter and Space open it; typing picks the
// option starting with those letters. Open: Arrow Up/Down, Home, End, Page Up
// and Page Down move, Enter and Space take the highlighted option, Escape and
// Tab close it without changing anything.
//
// The list is a `popover="manual"` element, so it sits in the browser's top
// layer: it is not clipped by a scrolling ancestor, and inside a modal
// `<dialog>` it shows above the dialog (it is a descendant of the control, so
// the dialog's inertness leaves it alone). `position: fixed` coordinates come
// from the control's box (`placePopup`). A scroll or resize elsewhere, or a
// click outside, closes it, as a native select's list closes.
//
// Not a native control, so there is no form validation: `required` is only
// `aria-required`, and a caller that needs a choice checks it itself.
import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
import {
indexOfValue,
moveIndex,
nextQuery,
placePopup,
typeAhead,
type ListboxOption,
type Placement,
} from "./select-nav.js";
import "./Listbox.css";
export function Listbox({
id,
value,
options,
onChange,
disabled,
required,
placeholder,
ariaLabel,
labelledBy,
class: extraClass,
minListWidth,
}: {
id: string;
value: string;
options: ListboxOption[];
onChange: (value: string) => void;
disabled?: boolean;
required?: boolean;
/** Shown on the control while `value` matches no option. */
placeholder?: string;
/** The accessible name when there is no visible label. */
ariaLabel?: string;
/** The id of the element that is the visible label. */
labelledBy?: string;
/** Extra class on the control, for a compact look. */
class?: string;
/** The list is at least this wide in px, when the control is narrower. */
minListWidth?: number;
}) {
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const [place, setPlace] = useState<Placement | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const typed = useRef({ query: "", at: 0 });
const listId = `${id}-list`;
const optionId = (i: number) => `${id}-opt-${i}`;
const selected = indexOfValue(options, value);
const shown = selected >= 0 ? options[selected].label : (placeholder ?? "");
function openList() {
if (disabled || options.length === 0) return;
setActive(selected >= 0 ? selected : 0);
setOpen(true);
}
function closeList() {
setOpen(false);
setPlace(null);
}
function take(i: number) {
if (i >= 0 && i < options.length && options[i].value !== value) {
onChange(options[i].value);
}
closeList();
triggerRef.current?.focus();
}
// Show the list in the top layer, measure it, and place it.
useLayoutEffect(() => {
const list = listRef.current;
const trigger = triggerRef.current;
if (!list || !trigger) return;
if (!open) {
if (list.matches(":popover-open")) list.hidePopover();
return;
}
if (!list.matches(":popover-open")) list.showPopover();
const r = trigger.getBoundingClientRect();
setPlace(
placePopup(
{ top: r.top, bottom: r.bottom, left: r.left, width: r.width },
{ width: window.innerWidth, height: window.innerHeight },
// The content, plus the border the box-sizing includes.
list.scrollHeight + list.offsetHeight - list.clientHeight,
minListWidth,
),
);
}, [open, minListWidth]);
// Keep the highlighted option in view as the keys move it.
useEffect(() => {
if (open && place) {
listRef.current?.children[active]?.scrollIntoView({ block: "nearest" });
}
}, [open, place, active]);
// A click elsewhere, a scroll of anything but the list, or a resize closes it.
useEffect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
const t = e.target as Node;
if (!triggerRef.current?.contains(t) && !listRef.current?.contains(t))
closeList();
}
function onScroll(e: Event) {
if (!listRef.current?.contains(e.target as Node)) closeList();
}
document.addEventListener("pointerdown", onPointerDown);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", closeList);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", closeList);
};
}, [open]);
function onKeyDown(e: KeyboardEvent) {
if (disabled || e.altKey || e.ctrlKey || e.metaKey) return;
const key = e.key;
const typing = key.length === 1 && key !== " ";
if (typing) {
const now = Date.now();
const query = nextQuery(typed.current.query, key, now - typed.current.at);
typed.current = { query, at: now };
const hit = typeAhead(options, open ? active : selected, query);
if (hit >= 0) {
e.preventDefault();
if (open) setActive(hit);
else if (options[hit].value !== value) onChange(options[hit].value);
}
return;
}
if (!open) {
if (["ArrowDown", "ArrowUp", "Enter", " ", "Home", "End"].includes(key)) {
e.preventDefault();
openList();
if (key === "Home") setActive(0);
if (key === "End") setActive(options.length - 1);
}
return;
}
switch (key) {
case "ArrowDown":
case "ArrowUp":
case "Home":
case "End":
case "PageDown":
case "PageUp":
e.preventDefault();
setActive(moveIndex(active, options.length, key));
break;
case "Enter":
case " ":
e.preventDefault();
take(active);
break;
case "Escape":
e.preventDefault();
// Only the list: a dialog around it must not close as well.
e.stopPropagation();
closeList();
break;
case "Tab":
closeList();
break;
}
}
return (
<div class="ui-listbox">
<button
type="button"
id={id}
ref={triggerRef}
class={
"ui-form-control ui-select-trigger" +
(extraClass ? " " + extraClass : "")
}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
aria-activedescendant={
open && active >= 0 ? optionId(active) : undefined
}
aria-required={required ? true : undefined}
aria-label={ariaLabel}
aria-labelledby={labelledBy}
disabled={disabled}
onClick={() => (open ? closeList() : openList())}
onKeyDown={onKeyDown}
>
<span
class={"ui-select-value" + (selected < 0 ? " ui-select-empty" : "")}
>
{shown}
</span>
<span class="ui-select-chevron" aria-hidden="true" />
</button>
<ul
id={listId}
ref={listRef}
class="ui-select-list"
role="listbox"
aria-labelledby={labelledBy}
aria-label={ariaLabel}
{...{ popover: "manual" }}
style={
place
? {
top: `${place.top}px`,
left: `${place.left}px`,
width: `${place.width}px`,
maxHeight: `${place.maxHeight}px`,
}
: { visibility: "hidden" }
}
>
{options.map((o, i) => (
<li
key={o.value}
id={optionId(i)}
role="option"
aria-selected={i === selected}
class={
"ui-select-option" +
(i === active ? " ui-select-option-active" : "") +
(i === selected ? " ui-select-option-selected" : "")
}
// A press must not take focus off the control.
onPointerDown={(e) => e.preventDefault()}
onPointerMove={() => i !== active && setActive(i)}
onClick={() => take(i)}
>
{o.label}
</li>
))}
</ul>
</div>
);
}

View file

@ -1,10 +1,12 @@
// <SelectField> — a labelled `<select>`, `TextField`'s sibling for the
// <SelectField> — a labelled dropdown (`Listbox`, which draws its own themed
// list instead of the browser's), `TextField`'s sibling for the
// "pick one of these" shape (the hive-choice dropdown the create-agent
// form needs is the motivating caller). Options are plain
// value/label pairs, not `ComponentChildren` — every real caller so far
// has flat string options, and generic children would need a second
// primitive (`SelectField.Option`) for zero real benefit today.
import { FormField } from "../form-field/FormField.js";
import { Listbox } from "./Listbox.js";
export interface SelectOption {
value: string;
@ -26,36 +28,31 @@ export function SelectField({
value: string;
onChange: (value: string) => void;
options: SelectOption[];
// Only `aria-required`: the control is not a native select, so it has no
// form validation. A caller that needs a choice checks it itself.
required?: boolean;
disabled?: boolean;
// Rendered as a disabled, always-first `value=""` option — for a
// required select with no default (e.g. a multi-hive roster: no
// single right answer to pre-select), so the control shows something
// other than silently landing on whatever option happens to be first.
// Omit when the field always has a real default (`value` never `''`).
// Shown on the control while `value` matches no option (it is not a
// listed option) — for a required select with no default (e.g. a
// multi-hive roster: no single right answer to pre-select), so the
// control shows something other than silently landing on whatever
// option happens to be first. Omit when the field always has a real
// default (`value` never `''`).
placeholder?: string;
}) {
const labelId = `${id}-label`;
return (
<FormField label={label} htmlFor={id}>
<select
<FormField label={label} labelId={labelId}>
<Listbox
id={id}
class="ui-form-control"
labelledBy={labelId}
value={value}
options={options}
onChange={onChange}
required={required}
disabled={disabled}
onChange={(e) => onChange((e.target as HTMLSelectElement).value)}
>
{placeholder !== undefined && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
placeholder={placeholder}
/>
</FormField>
);
}

View file

@ -0,0 +1,132 @@
// What <Listbox> decides without a DOM: which option the arrow keys, Home and
// End land on, which one typing picks, and where the list opens. Plain
// functions of their arguments, so `npm test` can run them.
export interface ListboxOption {
value: string;
label: string;
}
/** The index of the option holding `value`, or -1. */
export function indexOfValue(options: ListboxOption[], value: string): number {
return options.findIndex((o) => o.value === value);
}
// How far Page Up and Page Down jump.
const PAGE = 10;
/**
* The active option after `key`, among `count` options. Arrow keys stop at the
* ends, they do not wrap. A key that moves nothing leaves `active` as it was.
*/
export function moveIndex(active: number, count: number, key: string): number {
if (count <= 0) return -1;
const last = count - 1;
switch (key) {
case "ArrowDown":
return Math.min(active + 1, last);
case "ArrowUp":
return Math.max(active - 1, 0);
case "PageDown":
return Math.min(active + PAGE, last);
case "PageUp":
return Math.max(active - PAGE, 0);
case "Home":
return 0;
case "End":
return last;
default:
return active;
}
}
/** How long a pause ends a typed word, in milliseconds. */
export const TYPE_AHEAD_MS = 700;
/**
* The text typed so far once `key` is pressed: it continues the last word when
* the pause since the previous key was short, and starts a new one otherwise.
*/
export function nextQuery(
previous: string,
key: string,
gapMs: number,
): string {
return gapMs > TYPE_AHEAD_MS ? key : previous + key;
}
/**
* The option typing `query` picks, searching on from `from` and wrapping
* round, or -1 when none starts with it. One letter, or that letter typed over
* and over, steps through the options that start with it; a longer word stays
* on the current option while it still matches.
*/
export function typeAhead(
options: ListboxOption[],
from: number,
query: string,
): number {
const q = query.toLowerCase();
if (q === "" || options.length === 0) return -1;
const repeated = q.length > 1 && [...q].every((c) => c === q[0]);
const needle = repeated ? q[0] : q;
const start = needle.length === 1 ? from + 1 : Math.max(from, 0);
for (let step = 0; step < options.length; step++) {
const i = (start + step) % options.length;
if (options[i].label.toLowerCase().startsWith(needle)) return i;
}
return -1;
}
export interface Box {
top: number;
bottom: number;
left: number;
width: number;
}
export interface Placement {
top: number;
left: number;
width: number;
maxHeight: number;
}
// Space kept between the list and the viewport's edge, and between the list
// and the control.
const EDGE_PX = 8;
const GAP_PX = 4;
/**
* Where a list `contentHeight` tall opens for a control at `trigger`: under it
* when it fits, else above it if that has more room, else under it with a
* scroll. At least as wide as the control, and kept inside the viewport.
*/
export function placePopup(
trigger: Box,
viewport: { width: number; height: number },
contentHeight: number,
minWidth = 0,
): Placement {
const below = Math.max(
viewport.height - trigger.bottom - EDGE_PX - GAP_PX,
0,
);
const above = Math.max(trigger.top - EDGE_PX - GAP_PX, 0);
const openUp = contentHeight > below && above > below;
const maxHeight = Math.min(contentHeight, openUp ? above : below);
const width = Math.min(
Math.max(trigger.width, minWidth),
Math.max(viewport.width - 2 * EDGE_PX, 0),
);
const left = Math.min(
Math.max(trigger.left, EDGE_PX),
Math.max(viewport.width - EDGE_PX - width, EDGE_PX),
);
return {
top: openUp ? trigger.top - GAP_PX - maxHeight : trigger.bottom + GAP_PX,
left,
width,
maxHeight,
};
}

View file

@ -0,0 +1,113 @@
// What the styled dropdown's keys and placement do. Run by `npm test`.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
indexOfValue,
moveIndex,
nextQuery,
placePopup,
TYPE_AHEAD_MS,
typeAhead,
} from "../src/ui/select-field/select-nav.ts";
const opts = ["alpha", "beta", "bravo", "charlie", "Delta"].map((label) => ({
value: label.toLowerCase(),
label,
}));
test("the option holding a value is found, or -1", () => {
assert.equal(indexOfValue(opts, "bravo"), 2);
assert.equal(indexOfValue(opts, "nope"), -1);
});
test("arrow keys step and stop at the ends; Home and End jump", () => {
assert.equal(moveIndex(1, 5, "ArrowDown"), 2);
assert.equal(moveIndex(4, 5, "ArrowDown"), 4, "stops at the last");
assert.equal(moveIndex(1, 5, "ArrowUp"), 0);
assert.equal(moveIndex(0, 5, "ArrowUp"), 0, "stops at the first");
assert.equal(moveIndex(2, 5, "Home"), 0);
assert.equal(moveIndex(2, 5, "End"), 4);
assert.equal(moveIndex(0, 30, "PageDown"), 10);
assert.equal(moveIndex(25, 30, "PageDown"), 29);
assert.equal(moveIndex(3, 30, "PageUp"), 0);
});
test("a key that moves nothing, or no options, leave the active one alone", () => {
assert.equal(moveIndex(2, 5, "a"), 2);
assert.equal(moveIndex(0, 0, "ArrowDown"), -1);
assert.equal(
moveIndex(-1, 3, "ArrowDown"),
0,
"from nothing active, the first",
);
});
test("typing a letter steps through the options starting with it, wrapping", () => {
assert.equal(typeAhead(opts, 0, "b"), 1);
assert.equal(typeAhead(opts, 1, "b"), 2);
assert.equal(typeAhead(opts, 2, "b"), 1, "wraps round to the first b");
assert.equal(typeAhead(opts, 0, "d"), 4, "case does not matter");
assert.equal(typeAhead(opts, 0, "z"), -1);
});
test("the same letter typed again steps on; a longer word narrows in place", () => {
assert.equal(typeAhead(opts, 1, "bb"), 2, "bb is b again");
assert.equal(typeAhead(opts, 1, "br"), 2);
assert.equal(typeAhead(opts, 2, "br"), 2, "stays while it still matches");
assert.equal(typeAhead(opts, 0, ""), -1);
assert.equal(typeAhead([], 0, "a"), -1);
});
test("a short pause continues the word, a long one starts a new one", () => {
assert.equal(nextQuery("b", "r", 100), "br");
assert.equal(nextQuery("br", "c", TYPE_AHEAD_MS + 1), "c");
});
const viewport = { width: 1000, height: 800 };
test("the list opens under the control when it fits", () => {
const p = placePopup(
{ top: 100, bottom: 140, left: 50, width: 200 },
viewport,
120,
);
assert.deepEqual(p, { top: 144, left: 50, width: 200, maxHeight: 120 });
});
test("it opens above when under is too short and above has more room", () => {
const p = placePopup(
{ top: 700, bottom: 740, left: 50, width: 200 },
viewport,
300,
);
assert.equal(p.maxHeight, 300);
assert.equal(p.top, 700 - 4 - 300);
});
test("it scrolls under the control when neither side fits it", () => {
const p = placePopup(
{ top: 100, bottom: 140, left: 50, width: 200 },
{ width: 1000, height: 300 },
900,
);
// 300 - 140 - 8 - 4 under; 100 - 12 above: under has more here.
assert.equal(p.top, 144);
assert.equal(p.maxHeight, 148);
});
test("it is at least the minimum width and stays inside the viewport", () => {
const p = placePopup(
{ top: 100, bottom: 140, left: 960, width: 30 },
viewport,
100,
120,
);
assert.equal(p.width, 120);
assert.equal(p.left, 1000 - 8 - 120);
const q = placePopup(
{ top: 0, bottom: 40, left: -20, width: 100 },
viewport,
50,
);
assert.equal(q.left, 8);
});