128 lines
4.0 KiB
TypeScript
128 lines
4.0 KiB
TypeScript
import { Button, HStack, Menu } from '../ui';
|
|
import { tabRowLayout } from './tabRow';
|
|
|
|
/** Fixed, so that the count of hidden tabs cannot feed back into how much room the row has. */
|
|
const OVERFLOW_WIDTH = 28;
|
|
|
|
export interface TabPlace<T extends string> {
|
|
tab: T;
|
|
x: number;
|
|
right: number;
|
|
/** Top edge and height, so a check can tell the tab from a label of the same text elsewhere. */
|
|
y: number;
|
|
h: number;
|
|
}
|
|
|
|
/**
|
|
* The row of tab buttons. What does not fit goes behind a `»` button that opens the rest as a
|
|
* menu, so the row survives any window width and any number of tabs.
|
|
*/
|
|
export class TabStrip<T extends string> extends HStack {
|
|
private readonly order: T[] = [];
|
|
private readonly labels = new Map<T, string>();
|
|
private readonly buttons = new Map<T, Button>();
|
|
private readonly overflow: Button;
|
|
private hiddenTabs: T[] = [];
|
|
private active: T;
|
|
|
|
constructor(
|
|
tabs: readonly { tab: T; label: string }[],
|
|
private readonly onPick: (tab: T) => void,
|
|
) {
|
|
super({ gap: 2, padding: 4, align: 'center' });
|
|
this.active = tabs[0]!.tab;
|
|
this.overflow = new Button('»', {
|
|
variant: 'ghost',
|
|
width: OVERFLOW_WIDTH,
|
|
onClick: () => this.openMenu(),
|
|
});
|
|
this.add(this.overflow);
|
|
for (const { tab, label } of tabs) this.place(tab, label);
|
|
this.buttons.get(this.active)!.setToggled(true);
|
|
}
|
|
|
|
/** Appends a tab at the end of the row, in front of the `»` button. */
|
|
addTab(tab: T, label: string): void {
|
|
this.place(tab, label);
|
|
this.relayout();
|
|
}
|
|
|
|
/** Takes a tab out of the row again; a plugin switched off (#114). Unknown tabs are ignored. */
|
|
removeTab(tab: T): void {
|
|
const button = this.buttons.get(tab);
|
|
if (!button) return;
|
|
this.order.splice(this.order.indexOf(tab), 1);
|
|
this.labels.delete(tab);
|
|
this.buttons.delete(tab);
|
|
this.hiddenTabs = this.hiddenTabs.filter((hidden) => hidden !== tab);
|
|
button.destroy({ children: true });
|
|
this.relayout();
|
|
}
|
|
|
|
private place(tab: T, label: string): void {
|
|
// Half the theme padding: nine tabs at the full 8 px overflow the centre column of a 1280 px
|
|
// window by 27 px, and a button past its edge sits under the inspector (measured, CI run 5).
|
|
const button = new Button(label, {
|
|
variant: 'ghost',
|
|
padding: 4,
|
|
onClick: () => this.onPick(tab),
|
|
});
|
|
this.order.push(tab);
|
|
this.labels.set(tab, label);
|
|
this.buttons.set(tab, button);
|
|
this.add(button);
|
|
this.setChildIndex(this.overflow, this.children.length - 1);
|
|
}
|
|
|
|
setActive(tab: T): void {
|
|
this.active = tab;
|
|
for (const [key, button] of this.buttons) button.setToggled(key === tab);
|
|
// Not `resize`: the strip keeps its size while a different tab has to come into view.
|
|
this.relayout();
|
|
}
|
|
|
|
/** Global x-extent of the tab buttons currently in the row, in order. */
|
|
places(): TabPlace<T>[] {
|
|
const out: TabPlace<T>[] = [];
|
|
for (const tab of this.order) {
|
|
const button = this.buttons.get(tab)!;
|
|
if (!button.visible) continue;
|
|
const at = button.toGlobal({ x: 0, y: 0 });
|
|
out.push({ tab, x: at.x, right: at.x + button.w, y: at.y, h: button.h });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
hidden(): T[] {
|
|
return [...this.hiddenTabs];
|
|
}
|
|
|
|
override layout(): void {
|
|
this.applyOverflow();
|
|
super.layout();
|
|
}
|
|
|
|
private applyOverflow(): void {
|
|
const tabs = this.order.map((tab) => ({ key: tab, width: this.buttons.get(tab)!.measure().w }));
|
|
const { hidden } = tabRowLayout({
|
|
tabs,
|
|
available: this.w - this.padding * 2,
|
|
gap: this.gap,
|
|
overflowWidth: OVERFLOW_WIDTH,
|
|
active: this.active,
|
|
});
|
|
this.hiddenTabs = hidden;
|
|
const away = new Set(hidden);
|
|
for (const [tab, button] of this.buttons) button.visible = !away.has(tab);
|
|
this.overflow.visible = hidden.length > 0;
|
|
}
|
|
|
|
private openMenu(): void {
|
|
const items = this.hiddenTabs.map((tab) => ({
|
|
label: this.labels.get(tab)!,
|
|
onSelect: () => this.onPick(tab),
|
|
}));
|
|
new Menu(items).openBelow(this.overflow);
|
|
}
|
|
}
|