Files
feuerwerk/src/ui/widgets/Menu.ts
T

177 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { VStack } from '../layout/Stack';
import { searchRank } from '../search';
import { Theme } from '../Theme';
import { ui } from '../UIContext';
import type { Widget } from '../Widget';
import { Button } from './Button';
import { Label } from './Label';
import { Popup } from './Popup';
import { ScrollContainer } from './ScrollContainer';
import { Separator } from './Separator';
import { TextField } from './TextField';
export interface MenuItem {
label: string;
onSelect: () => void;
danger?: boolean;
disabled?: boolean;
/** SVG file in front of the label (see `ButtonOptions.icon`). */
icon?: string;
}
/** A hairline between two groups of entries; it is not selectable and matches no search. */
export interface MenuSeparator {
separator: true;
}
export type MenuEntry = MenuItem | MenuSeparator;
export interface MenuOptions {
/** Adds a search box above the entries that filters as you type; Enter picks the first match. */
searchable?: boolean;
}
interface Row {
widget: Widget;
/** Null for a separator: it is decoration between groups, not an entry. */
button: Button | null;
label: string;
}
/** A vertical list of clickable actions. Picking one closes the menu before it runs. */
export class Menu extends Popup {
private readonly rows: Row[] = [];
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: MenuEntry[], 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) {
if ('separator' in item) {
this.rows.push({ widget: new Separator(), button: null, label: '' });
continue;
}
const button = new Button(item.label, {
variant: item.danger ? 'danger' : 'ghost',
align: 'left',
icon: item.icon,
});
button.setEnabled(!item.disabled);
button.onClick = this.closeThen(item.onSelect);
this.rows.push({ widget: button, button, label: item.label.toLowerCase() });
}
this.scroll.setContent(this.list);
this.content.add(this.scroll);
this.filter();
}
override open(globalX: number, globalY: number): void {
super.open(globalX, globalY);
setCurrent(this);
// open() blurs whatever had focus, so this has to come after it.
if (this.search) ui().focus.focus(this.search);
}
override close(): void {
if (current === this) current = null;
super.close();
}
/** The entries as they stand, in the order they are drawn; `null` is a separator (test hook). */
rowsDrawn(): (string | null)[] {
return this.list.children
.filter((child) => child !== this.empty && child.visible)
.map((child) => (child instanceof Button ? child.text : null));
}
/** Per entry of `rowsDrawn`, whether it can be picked; `null` for a separator (test hook). */
enabledDrawn(): (boolean | null)[] {
return this.list.children
.filter((child) => child !== this.empty && child.visible)
.map((child) => (child instanceof Button ? child.enabled : null));
}
/** Per entry of `rowsDrawn`, whether it shows an icon; `null` for a separator (test hook). */
iconsDrawn(): (boolean | null)[] {
return this.list.children
.filter((child) => child !== this.empty && child.visible)
.map((child) => (child instanceof Button ? child.hasIcon : null));
}
private filter(): void {
const query = this.search?.value.trim().toLowerCase() ?? '';
const shown = query ? this.ranked(query) : groupsOf(this.rows);
// Searching reorders, so the rows are re-hung rather than only hidden. removeChildren detaches
// them; the widgets themselves live in `this.rows` and survive every filter.
this.list.removeChildren();
for (const row of shown) this.list.addChild(row.widget);
this.empty.visible = shown.length === 0;
this.list.addChild(this.empty);
const size = this.list.measure();
const room = this.roomFor(this.scroll);
const scrolls = size.h > room;
this.scroll.minWidth = size.w + (scrolls ? Theme.scrollbarWidth : 0);
this.scroll.minHeight = Math.min(size.h, room);
this.scroll.scrollTo(0);
this.refreshLayout();
// Both are needed: a resize re-lays out only on an actual size change, and two filters can
// leave the list exactly as tall while showing different entries.
this.scroll.relayoutContent();
this.list.relayout();
}
/** Best answers first; a query flattens the groups, so separators drop out with them. */
private ranked(query: string): Row[] {
return this.rows
.map((row, index) => ({ row, index, rank: row.button ? searchRank(query, [row.label]) : -1 }))
.filter((entry) => entry.rank >= 0)
.sort((a, b) => a.rank - b.rank || a.index - b.index)
.map((entry) => entry.row);
}
private pickFirstMatch(): void {
const first = this.list.children.find(
(child): child is Button => child instanceof Button && child.visible && child.enabled,
);
first?.onClick?.();
}
}
let current: Menu | null = null;
function setCurrent(menu: Menu): void {
current = menu;
}
/** The menu standing open, for the test hook. */
export function currentMenu(): Menu | null {
return current;
}
/** Drops separators that would open, close or double up the list – a line needs two sides. */
function groupsOf(rows: Row[]): Row[] {
const out: Row[] = [];
for (const row of rows) {
if (row.button) out.push(row);
else if (out.length > 0 && out[out.length - 1]!.button) out.push(row);
}
if (out.length > 0 && !out[out.length - 1]!.button) out.pop();
return out;
}