Left-align menu entries, add a Dropdown field, scrollable and searchable menus

Menu entries are left-aligned and live in a ScrollContainer capped at 320 px, so a
long list (the parent picker holds every object of the scene) scrolls instead of
running off the screen. `Menu(items, { searchable: true })` puts a filter box above
the scroll area; Enter takes the first match. Filtering forces a relayout because
two queries with the same hit count leave the height unchanged, which would keep
the stale row positions.

Fields that open a menu are a new `Dropdown` widget: the value sits flush left and
the chevron is drawn flush right instead of being written into the label. A menu
opened below a field is never narrower than that field.

New debug hooks `overlayTexts()` / `overlayRect()` make the popup contents
assertable; e2e step 11 covers the cap, the scrolling and the search box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 11:31:14 +02:00
1 parent d54a620ebd
commit 15ffcd5739
16 files changed
+297 -39

No files matched your search

+3 -2
View File
@@ -5,14 +5,15 @@ export { initUI, ui, type UIContext } from './UIContext';
export { drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw';
export { clamp, centreY, zoomFactor } from './math';
export { Label } from './widgets/Label';
export { Button } from './widgets/Button';
export { Button, type ButtonOptions } from './widgets/Button';
export { Dropdown } from './widgets/Dropdown';
export { Checkbox } from './widgets/Checkbox';
export { Panel } from './widgets/Panel';
export { Splitter } from './widgets/Splitter';
export { ScrollContainer } from './widgets/ScrollContainer';
export { ListView, type ListItem, type PickModifiers } from './widgets/ListView';
export { Popup, type PopupOptions } from './widgets/Popup';
export { Menu, type MenuItem } from './widgets/Menu';
export { Menu, type MenuItem, type MenuOptions } from './widgets/Menu';
export { Dialog, type DialogButton } from './widgets/Dialog';
export { TextField } from './widgets/TextField';
export { NumberField } from './widgets/NumberField';
+15 -3
View File
@@ -9,6 +9,11 @@ export interface ButtonOptions {
variant?: 'default' | 'primary' | 'ghost' | 'danger';
/** Fixed width; otherwise measured from the label. */
width?: number;
/**
* Label position in a button wider than its text. 'center' suits standalone buttons,
* 'left' the ones that read as a list or a field (menu entries, dropdowns).
*/
align?: 'center' | 'left';
onClick?: () => void;
}
@@ -20,11 +25,13 @@ export class Button extends Widget {
/** Caller-owned toggle state: renders like a primary button but is not flipped by clicks. */
toggled = false;
variant: NonNullable<ButtonOptions['variant']>;
protected readonly align: NonNullable<ButtonOptions['align']>;
onClick?: () => void;
constructor(text: string, options: ButtonOptions = {}) {
super();
this.variant = options.variant ?? 'default';
this.align = options.align ?? 'center';
this.onClick = options.onClick;
this.focusable = true;
this.cursor = 'pointer';
@@ -70,6 +77,11 @@ export class Button extends Widget {
this.invalidate();
}
/** Whether the label sits on a filled accent surface and therefore needs the light style. */
protected get onAccent(): boolean {
return this.toggled || this.variant === 'primary' || this.variant === 'danger';
}
override measure(): Size {
const metrics = CanvasTextMetrics.measureText(this.labelText.text, this.labelText.style);
return {
@@ -119,9 +131,9 @@ export class Button extends Widget {
} else if (stroke !== null) {
this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: stroke });
}
const onAccent = this.toggled || this.variant === 'primary' || this.variant === 'danger';
this.labelText.style = textStyle(onAccent ? 'accent' : 'body');
this.labelText.style = textStyle(this.onAccent ? 'accent' : 'body');
const label = this.labelText;
label.position.set(Math.round((this.w - label.width) / 2), centreY(this.h, label.height));
const x = this.align === 'left' ? Theme.padding : (this.w - label.width) / 2;
label.position.set(Math.round(x), centreY(this.h, label.height));
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Graphics } from 'pixi.js';
import { Theme } from '../Theme';
import type { Size } from '../Widget';
import { Button, type ButtonOptions } from './Button';
/** Space reserved at the right edge for the chevron. */
const CHEVRON_SLOT = 16;
/**
* A button that opens a menu: value flush left, chevron flush right. The chevron is drawn
* rather than written into the label, so the label stays the plain value – which keeps it
* readable, lets it be compared against the menu entries, and stays crisp at any size.
*/
export class Dropdown extends Button {
private readonly chevron = new Graphics();
constructor(label: string, options: ButtonOptions = {}) {
super(label, { ...options, align: 'left' });
this.addChild(this.chevron);
}
override measure(): Size {
const size = super.measure();
return { w: size.w + CHEVRON_SLOT, h: size.h };
}
override redraw(): void {
super.redraw();
const x = this.w - CHEVRON_SLOT / 2 - Theme.padding / 2;
const y = this.h / 2;
this.chevron
.clear()
.moveTo(x - 4, y - 2)
.lineTo(x + 4, y - 2)
.lineTo(x, y + 3)
.fill(this.onAccent ? Theme.colors.accentText : Theme.colors.textMuted);
}
}
+84 -5
View File
@@ -1,5 +1,11 @@
import { VStack } from '../layout/Stack';
import { Theme } from '../Theme';
import { ui } from '../UIContext';
import { Button } from './Button';
import { Label } from './Label';
import { Popup } from './Popup';
import { ScrollContainer } from './ScrollContainer';
import { TextField } from './TextField';
export interface MenuItem {
label: string;
@@ -8,16 +14,89 @@ export interface MenuItem {
disabled?: boolean;
}
/** A vertical list of clickable actions. Picking one closes the menu before it runs. */
export interface MenuOptions {
/**
* Adds a search box above the entries that filters them as you type; Enter picks the first
* match. For menus that can grow with the project (the parent picker lists every object).
*/
searchable?: boolean;
}
/** How tall the entry list gets before it starts scrolling. */
const MAX_LIST_HEIGHT = 320;
/**
* A vertical list of clickable actions. Picking one closes the menu before it runs.
* Entries read as a list: left-aligned, scrolled once they no longer fit on screen.
*/
export class Menu extends Popup {
constructor(items: MenuItem[]) {
super({ padding: 4, gap: 2 });
private readonly entries: { button: Button; haystack: string }[] = [];
private readonly list = new VStack({ gap: 2 });
private readonly scroll = new ScrollContainer();
private readonly empty = new Label('No match', { style: 'muted', padding: Theme.padding });
private readonly search: TextField | null;
constructor(items: MenuItem[], options: MenuOptions = {}) {
super({ padding: 4, gap: 4 });
this.minWidth = 160;
this.search = options.searchable
? new TextField('', {
placeholder: 'Search…',
onInput: () => this.filter(),
// Enter only. NOT onChange: that also fires when the field loses focus because an
// entry was clicked, which would then run the first match instead of the clicked one.
onSubmit: () => this.pickFirstMatch(),
})
: null;
if (this.search) this.content.add(this.search);
for (const item of items) {
const button = new Button(item.label, { variant: item.danger ? 'danger' : 'ghost' });
const button = new Button(item.label, {
variant: item.danger ? 'danger' : 'ghost',
align: 'left',
});
button.setEnabled(!item.disabled);
button.onClick = this.closeThen(item.onSelect);
this.content.add(button);
this.list.add(button);
this.entries.push({ button, haystack: item.label.toLowerCase() });
}
this.list.add(this.empty);
this.scroll.setContent(this.list);
this.content.add(this.scroll);
this.filter();
}
override open(globalX: number, globalY: number): void {
super.open(globalX, globalY);
// open() blurs whatever had focus, so this has to come after it.
if (this.search) ui().focus.focus(this.search);
}
/** Shows the entries matching the search box and resizes the menu around them. */
private filter(): void {
const query = this.search?.value.trim().toLowerCase() ?? '';
let matches = 0;
for (const entry of this.entries) {
entry.button.visible = entry.haystack.includes(query);
if (entry.button.visible) matches++;
}
this.empty.visible = matches === 0;
const size = this.list.measure();
const scrolls = size.h > MAX_LIST_HEIGHT;
this.scroll.minWidth = size.w + (scrolls ? Theme.scrollbarWidth + 4 : 0);
this.scroll.minHeight = Math.min(size.h, MAX_LIST_HEIGHT);
this.scroll.scrollTo(0);
this.refreshLayout();
// Both calls are needed: a resize alone re-lays out only when the size actually changed,
// and two different filters can leave the list exactly as tall as before while showing
// an entirely different set of entries.
this.scroll.relayoutContent();
this.list.relayout();
}
private pickFirstMatch(): void {
const first = this.entries.find((entry) => entry.button.visible && entry.button.enabled);
first?.button.onClick?.();
}
}
+29 -6
View File
@@ -24,6 +24,9 @@ export class Popup extends Widget {
readonly content: VStack;
onClose?: () => void;
private readonly modal: boolean;
/** Where `open()` was asked to put the top-left corner, before clamping to the screen. */
private openX = 0;
private openY = 0;
constructor(options: PopupOptions = {}) {
super();
@@ -56,18 +59,38 @@ export class Popup extends Widget {
.fill({ color: 0x000000, alpha: this.modal ? 0.4 : 0.001 });
this.backdrop.hitArea = new Rectangle(0, 0, screen.width, screen.height);
overlay.addChild(this.backdrop, this);
const size = this.measure();
this.resize(size.w, size.h);
const x = clamp(globalX, 0, screen.width - size.w);
const y = clamp(globalY, 0, screen.height - size.h);
this.position.set(Math.round(x), Math.round(y));
this.openX = globalX;
this.openY = globalY;
this.place();
focus.blur();
window.addEventListener('keydown', this.onEscape, { capture: true });
}
/** Opens left-aligned with `anchor`, `gap` px below its bottom edge (dropdown menus, pickers). */
/**
* Re-measures and repositions an open popup after its content changed size (a menu whose
* entries were filtered). No-op while closed, so it is safe to call during construction.
*/
refreshLayout(): void {
if (this.isOpen) this.place();
}
/** Sizes the popup to its content and puts it at the requested spot, shifted to stay on screen. */
private place(): void {
const screen = ui().app.screen;
const size = this.measure();
this.resize(size.w, size.h);
const x = clamp(this.openX, 0, Math.max(0, screen.width - size.w));
const y = clamp(this.openY, 0, Math.max(0, screen.height - size.h));
this.position.set(Math.round(x), Math.round(y));
}
/**
* Opens left-aligned with `anchor`, `gap` px below its bottom edge (dropdown menus, pickers).
* The popup is never narrower than the anchor, so a menu reads as the field it drops from.
*/
openBelow(anchor: Widget, gap = 2): void {
const g = anchor.getGlobalPosition();
this.minWidth = Math.max(this.minWidth, anchor.w);
this.open(g.x, g.y + anchor.h + gap);
}