Chapter 23 of 40

Events and a context menu of your own

Override only the events you need – and build a context menu that looks exactly like your app.

After this chapter you can

  • extend EventAdapter
  • tell single from double clicks
  • map a displayed row back to your object
  • let users drag rows into order (since 1.1.95)

EventAdapter: override what you need

The fifth argument of the TableScope is an EventListenerIf. EventAdapter implements every method empty – extend it and override only your events:

src/order-events.ts
import { EventAdapter, type GeMouseEvent } from "@guiexpert/table";
export class OrderEvents extends EventAdapter {
constructor(
private readonly onRowDoubleClick: (rowIndex: number) => void,
private readonly onMenu: (evt: GeMouseEvent) => void) {
super();
}
override onMouseClicked(evt: GeMouseEvent): void { // rowIndex, columnIndex, areaIdent, clickCount
if (evt.areaIdent === "body" && evt.clickCount === 2) this.onRowDoubleClick(evt.rowIndex);
}
override onContextmenu(evt: GeMouseEvent): void {
if (evt.areaIdent === "body" && evt.rowIndex > -1) this.onMenu(evt);
}
}

A GeMouseEvent tells you where it happened:

FieldMeaning
areaIdent, sideIdentheader / body / footer – west / center / east
rowIndex, columnIndexthe cell (−1 outside of cells)
clickCount1 = click, 2 = double-click
originalEventthe browser's MouseEvent (position, modifier keys)

The other events: onModelChanged (edits, chapter 31 (Inline editing)), onSelectionChanged (chapter 22 (Selection & copy)), onCheckboxChanged (chapter 30 (Tree table)), onScroll, onMouseMoved, onMouseDragging, onFocusChanged.

From the displayed row to your object

rowIndex is the row as displayed – after filter and sorting. Never index your original array with it. Ask the body model: AreaModelIf.getRowByIndex().

There is no built-in context menu – so yours looks and works exactly like the rest of your app. About 30 lines, no library:

src/context-menu.ts
export interface MenuItem {
label: string;
action: () => void;
}
export function showContextMenu(event: MouseEvent, items: MenuItem[]): void {
event.preventDefault(); // no browser menu
closeContextMenu();
const menu = document.body.appendChild(document.createElement("div"));
menu.className = "context-menu";
menu.style.left = `${event.clientX}px`;
menu.style.top = `${event.clientY}px`;
for (const item of items) {
const button = menu.appendChild(document.createElement("button"));
button.textContent = item.label;
button.addEventListener("click", () => { closeContextMenu(); item.action(); });
}
}
export function closeContextMenu(): void {
document.querySelector(".context-menu")?.remove();
}
document.addEventListener("mousedown", e => { // close on a click outside …
if (!(e.target as HTMLElement).closest(".context-menu")) closeContextMenu();
});
document.addEventListener("keydown", e => e.key === "Escape" && closeContextMenu()); // … and on Escape

Wire events, menu and API together:

src/main.ts
const orderAt = (rowIndex: number) => tableModel.getBodyModel().getRowByIndex(rowIndex) as Order; // displayed row → object
const showOrder = (o: Order) => info.textContent = `#${o.id} · ${o.customer} · ${o.quantity} × ${o.product}`;
const events = new OrderEvents(row => showOrder(orderAt(row)), evt => {
const order = orderAt(evt.rowIndex);
showContextMenu(evt.originalEvent!, [
{ label: `Show order #${order.id}`, action: () => showOrder(order) },
{ label: "Copy selection", action: () => tableApi.copyToClipboard() },
{ label: `Only ${order.customer}`, action: () => { query = order.customer; tableApi.externalFilterChanged(); } },
{ label: "Show all", action: () => { query = ""; tableApi.externalFilterChanged(); } },
{ label: "Export view as Excel", action: () => tableApi.downloadExcel("orders.xlsx", "GuiExpert") }
]);
});
const tableScope = new TableScope(host, tableModel, new SimpleDomService(), options, events);
tableScope.firstInit();
const tableApi = tableScope.getApi();
Live Double-click and context menu
Screenshot: Double-click and context menu Screenshot: Double-click and context menu
Double-click a row: the toolbar shows the order. Right-click: own menu – filter to one customer, copy, export.

Dragging rows into order

A priority list, a playlist, the steps of a process: users want to put rows in order themselves. Give a column a drag handle – rowDrag – and the table does the rest: ghost, drop marker, auto-scroll at the edges (also through millions of rows), mouse, touch and pen, Escape cancels.

src/main.ts
const customer = columns.find(c => c.property === "customer")!;
customer.rowDrag = true; // the grip (factory shortcut: rowDrag: true)
const tableModel = TableFactory.createTableModel({ rows: orders, columnDefs: columns });

The table moves the rows in your array and then calls onRowDragEnd with a GeRowDragEvent – the place to save the new order:

src/order-events.ts
const listener = new EventAdapter();
listener.onRowDragEnd = (evt: GeRowDragEvent<Order>) => {
if (evt.moved) { // orders has the new order – save it here
ctx.info(`order ${evt.rows[0].id} → position ${evt.toIndex + 1}`);
} else if (evt.type === "cancel") {
ctx.info("cancelled (Escape)");
}
};

Dragging is not for everyone: Ctrl+Alt+↑/↓ moves the focused row, and screen readers hear "Row moved to position 4 of 1000". Two buttons do the same for users who cannot drag:

src/main.ts
ctx.button("move up", () => api.triggerAction("MOVE_ROW_UP")); // like Ctrl+Alt+↑ on the focused row
ctx.button("move down", () => api.triggerAction("MOVE_ROW_DOWN")); // like Ctrl+Alt+↓
Live Drag rows into order
Screenshot: Drag rows into order Screenshot: Drag rows into order
Drag a grip in the customer column. Select rows first (click, Ctrl/⌘-click) to move several at once.

In the framework wrappers

The same events arrive as props or outputs: mouseClicked, contextmenu, selectionChanged, rowDragEnd … (chapter 10 (Svelte, Solid, Preact)).

Next

The menu's last entry exports to Excel – how that works: chapter 24 (Excel export).

API used in this chapter

Showcases