177 lines
6.1 KiB
TypeScript
177 lines
6.1 KiB
TypeScript
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;
|
||
}
|