UNPKG

cheetah-grid

Version:

Cheetah Grid is a high performance grid engine that works on canvas

418 lines (389 loc) 11.4 kB
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); } } } } }