cheetah-grid
Version:
Cheetah Grid is a high performance grid engine that works on canvas
418 lines (389 loc) • 11.4 kB
text/typescript
import type {
ColumnMenuItemOption,
ListGridAPI,
RectProps,
} from "../../../ts-types";
import {
appendHtml,
createElement,
disableFocus,
empty,
findNextSiblingFocusable,
findPrevSiblingFocusable,
isFocusable,
} from "../../../internal/dom";
import { EventHandler } from "../../../internal/EventHandler";
import type { Rect } from "../../../internal/Rect";
import { event } from "../../../internal/utils";
const KEY_TAB = 9;
const KEY_ENTER = 13;
const KEY_UP = 38;
const KEY_DOWN = 40;
const KEY_ESC = 27;
const CLASSNAME = "cheetah-grid__inline-menu";
const ITEM_CLASSNAME = `${CLASSNAME}__menu-item`;
const HIDDEN_CLASSNAME = `${CLASSNAME}--hidden`;
const SHOWN_CLASSNAME = `${CLASSNAME}--shown`;
const EMPTY_ITEM_CLASSNAME = `${ITEM_CLASSNAME}--empty`;
function findItemParents(target: HTMLElement | null): HTMLElement | null {
let el: HTMLElement | null = target;
while (el && !el.classList.contains(ITEM_CLASSNAME)) {
el = el.parentElement;
if (!el || el.classList.contains(CLASSNAME)) {
return null;
}
}
return el;
}
function createMenuElement(): HTMLUListElement {
require("@/columns/action/internal/InlineMenuElement.css");
return createElement("ul", { classList: CLASSNAME });
}
function attachElement(
element: HTMLElement,
rect: RectProps,
menu: HTMLUListElement
): void {
menu.style.top = `${rect.top.toFixed()}px`;
menu.style.left = `${rect.left.toFixed()}px`;
menu.style.width = `${rect.width.toFixed()}px`;
menu.style.lineHeight = `${rect.height.toFixed()}px`;
element.appendChild(menu);
}
function optionToLi(
{ classList, label, value, html }: ColumnMenuItemOption,
index: number
): HTMLLIElement {
const item = createElement("li", { classList: ITEM_CLASSNAME });
item.tabIndex = 0;
item.dataset.valueindex = `${index}`;
if (classList) {
item.classList.add(...(Array.isArray(classList) ? classList : [classList]));
}
if (label) {
const span = createElement("span", { text: label });
item.appendChild(span);
} else if (html) {
appendHtml(item, html);
}
if (value === "" || value == null) {
item.classList.add(EMPTY_ITEM_CLASSNAME);
}
return item;
}
function openMenu<T>(
grid: ListGridAPI<T>,
editor: EditorProps<T>,
col: number,
row: number,
value: string,
options: ColumnMenuItemOption[],
menu: HTMLUListElement
): void {
const { classList } = editor;
menu.classList.remove(SHOWN_CLASSNAME);
menu.classList.add(HIDDEN_CLASSNAME);
empty(menu);
menu.style.font = grid.font || "16px sans-serif";
let emptyItemEl: null | HTMLElement = null;
let valueItemEl: null | HTMLElement = null;
options.forEach((option, i) => {
const item = optionToLi(option, i);
menu.appendChild(item);
if (option.value === value) {
valueItemEl = item;
item.dataset.select = "select";
}
if (item.classList.contains(EMPTY_ITEM_CLASSNAME)) {
emptyItemEl = item;
}
});
const focusEl =
valueItemEl || emptyItemEl || (menu.children[0] as HTMLElement);
if (classList) {
menu.classList.add(...classList);
}
const children = Array.prototype.slice.call(menu.children, 0);
const focusIndex = children.indexOf(focusEl);
const { element, rect } = grid.getAttachCellsArea(
grid.getCellRange(col, row)
);
// Cover the right line
rect.width++;
// append for calculation
attachElement(element, rect, menu);
// Make the selection item at the middle
let offset = 0;
for (let i = 0; i < focusIndex; i++) {
const { offsetHeight } = children[i];
offset += offsetHeight;
}
(rect as Rect).offsetTop(-offset);
menu.style.transformOrigin = `center ${
offset + Math.ceil(children[focusIndex].offsetHeight / 2)
}px 0px`;
attachElement(element, rect, menu);
// Control not to overflow the screen range
const bkTransform = menu.style.transform;
let menuClientRect;
try {
// To calculate the original position, set `transform` to `none`.
menu.style.transform = "none";
menuClientRect = menu.getBoundingClientRect();
} finally {
menu.style.transform = bkTransform;
}
const orgMenuTop = menuClientRect.top;
let menuTop = orgMenuTop;
const winBottom = window.innerHeight;
const winMargin = 20;
if (menuClientRect.bottom > winBottom - winMargin) {
const diff = menuClientRect.bottom - winBottom + winMargin;
menuTop -= diff;
}
if (menuTop < 0 /*winTop*/ + winMargin) {
menuTop = winMargin;
}
if (menuTop !== orgMenuTop) {
(rect as Rect).offsetTop(-(orgMenuTop - menuTop));
// Sets the center of the menu since it is not possible to determine the exact center of the selected element.
menu.style.transformOrigin = "center";
// re update
attachElement(element, rect, menu);
}
if (focusEl) {
focusEl.focus();
}
menu.classList.remove(HIDDEN_CLASSNAME);
menu.classList.add(SHOWN_CLASSNAME);
}
function closeMenu<T>(
_grid: ListGridAPI<T>,
_col: number,
_row: number,
menu: HTMLUListElement
): void {
menu.classList.remove(SHOWN_CLASSNAME);
menu.classList.add(HIDDEN_CLASSNAME);
disableFocus(menu);
}
type EditorProps<T> = {
classList?: string[];
options: (record: T | undefined) => ColumnMenuItemOption[];
};
type ActiveData<T> = {
grid: ListGridAPI<T>;
col: number;
row: number;
editor: EditorProps<T>;
options: ColumnMenuItemOption[];
};
export class InlineMenuElement<T> {
private _handler: EventHandler;
private _menu: HTMLUListElement;
private _beforePropEditor?: EditorProps<T> | null;
private _activeData?: ActiveData<T> | null;
private _registerBodyClickListener: () => void;
private _deregisterBodyClickListener: () => void;
constructor() {
const handler = (this._handler = new EventHandler());
this._menu = createMenuElement();
this._bindMenuEvents();
let bodyClickListenerId: number | undefined;
const deregisterBodyClickListener = (this._deregisterBodyClickListener =
() => handler.off(bodyClickListenerId));
this._registerBodyClickListener = () => {
deregisterBodyClickListener();
bodyClickListenerId = handler.on(
document.body,
"click",
this._onBodyClick.bind(this),
{ capture: true }
);
};
}
dispose(): void {
const menu = this._menu;
this.detach();
this._handler.dispose();
// @ts-expect-error -- ignore
delete this._menu;
this._beforePropEditor = null;
menu.parentElement?.removeChild(menu);
}
attach(
grid: ListGridAPI<T>,
editor: EditorProps<T>,
col: number,
row: number,
value: string,
record: T | undefined
): void {
const menu = this._menu;
if (this._beforePropEditor) {
const { classList } = this._beforePropEditor;
if (classList) {
menu.classList.remove(...classList);
}
}
const options = editor.options(record);
openMenu(grid, editor, col, row, value, options, menu);
this._activeData = { grid, col, row, editor, options };
this._beforePropEditor = editor;
this._registerBodyClickListener();
}
detach(gridFocus?: boolean): void {
if (this._isActive()) {
const { grid, col, row } = this._activeData!;
const menu = this._menu;
closeMenu(grid, col, row, menu);
const range = grid.getCellRange(col, row);
grid.invalidateCellRange(range);
if (gridFocus) {
grid.focus();
}
}
this._activeData = null;
this._deregisterBodyClickListener();
}
_doChangeValue(valueindex: number | string): void {
if (!this._isActive()) {
return;
}
const { grid, col, row, options } = this._activeData!;
const option = options[Number(valueindex)];
if (option) {
const { value } = option;
grid.doChangeValue(col, row, () => value);
}
}
_isActive(): boolean {
const menu = this._menu;
if (!menu || !menu.parentElement) {
return false;
}
if (!this._activeData) {
return false;
}
return true;
}
_bindMenuEvents(): void {
const handler = this._handler;
const menu = this._menu;
const stopPropagationOnly = (e: Event): void => e.stopPropagation(); // gridにイベントが伝播しないように
handler.on(menu, "mousedown", stopPropagationOnly);
handler.on(menu, "touchstart", stopPropagationOnly);
handler.on(menu, "dblclick", stopPropagationOnly);
handler.on(menu, "click", (e) => {
e.stopPropagation();
const item = findItemParents(e.target as HTMLElement);
if (!item) {
return;
}
const { valueindex } = item.dataset;
this._doChangeValue(valueindex || "");
this.detach(true);
});
handler.on(menu, "keydown", (e) => {
const item = findItemParents(e.target as HTMLElement);
if (!item) {
return;
}
const keyCode = event.getKeyCode(e);
if (keyCode === KEY_ENTER) {
this._onKeydownEnter(menu, item, e);
} else if (keyCode === KEY_ESC) {
this.detach(true);
event.cancel(e);
} else if (keyCode === KEY_UP) {
const n = findPrevSiblingFocusable(item);
if (n) {
n.focus();
event.cancel(e);
}
} else if (keyCode === KEY_DOWN) {
const n = findNextSiblingFocusable(item);
if (n) {
n.focus();
event.cancel(e);
}
} else if (keyCode === KEY_TAB) {
this._onKeydownTab(menu, item, e);
}
});
}
_onBodyClick(e: MouseEvent): void {
const el = e.target as Element | null;
if (!el) {
return;
}
if (this._menu.contains(el)) {
return;
}
if (this._isActive()) {
const { grid } = this._activeData!;
if (grid.getElement().contains(el)) {
return;
}
}
this.detach();
}
_onKeydownEnter(
_menu: HTMLUListElement,
item: HTMLElement,
e: KeyboardEvent
): void {
const grid = this._isActive() ? this._activeData!.grid : null;
const { valueindex } = item.dataset;
this._doChangeValue(valueindex || "");
this.detach(true);
event.cancel(e);
if (grid) {
if (grid.keyboardOptions?.moveCellOnEnter) {
grid.onKeyDownMove(e);
}
}
}
_onKeydownTab(
menu: HTMLUListElement,
item: HTMLElement,
e: KeyboardEvent
): void {
if (this._isActive()) {
const { grid } = this._activeData!;
if (grid.keyboardOptions?.moveCellOnTab) {
const { valueindex } = item.dataset;
this._doChangeValue(valueindex || "");
this.detach(true);
grid.onKeyDownMove(e);
return;
}
}
if (!e.shiftKey) {
if (!findNextSiblingFocusable(item)) {
let n: HTMLElement | null = menu.querySelector(
`.${ITEM_CLASSNAME}`
) as HTMLElement;
if (!isFocusable(n)) {
n = findNextSiblingFocusable(n);
}
if (n) {
n.focus();
event.cancel(e);
}
}
} else {
if (!findPrevSiblingFocusable(item)) {
const items = menu.querySelectorAll(`.${ITEM_CLASSNAME}`);
let n: HTMLElement | null = items[items.length - 1] as HTMLElement;
if (!isFocusable(n)) {
n = findPrevSiblingFocusable(n);
}
if (n) {
n.focus();
event.cancel(e);
}
}
}
}
}