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:
1 parent
d54a620ebd
commit
15ffcd5739
16 files changed
+297
-39
No files matched your search
+84
-5
@@ -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?.();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user