From 95eb78f55610ae7ac9547b3b30bf6bed74acf365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=8C=80=EC=97=B0?= Date: Fri, 7 Jan 2022 19:25:43 +0900 Subject: [PATCH] fix: editors with drop-down layer to follow scrolling (#1542) * fix: editors with drop-down layer to follow scrolling * test: add tests for editors with drop-down layer to follow scrolling * chore: apply code reviews * chore: fix lint error * chore: apply code review --- .../cypress/integration/editor.spec.ts | 58 +++++++++++++++ packages/toast-ui.grid/src/editor/checkbox.ts | 26 ++++++- .../toast-ui.grid/src/editor/datePicker.ts | 21 +++++- packages/toast-ui.grid/src/editor/dom.ts | 34 +++++++++ packages/toast-ui.grid/src/editor/select.ts | 27 ++++++- packages/toast-ui.grid/src/helper/common.ts | 9 +++ .../toast-ui.grid/src/view/editingLayer.tsx | 72 +++++++++++++++++-- .../toast-ui.grid/types/editor/index.d.ts | 17 +++++ 8 files changed, 248 insertions(+), 16 deletions(-) diff --git a/packages/toast-ui.grid/cypress/integration/editor.spec.ts b/packages/toast-ui.grid/cypress/integration/editor.spec.ts index a369da2b2..eab456663 100644 --- a/packages/toast-ui.grid/cypress/integration/editor.spec.ts +++ b/packages/toast-ui.grid/cypress/integration/editor.spec.ts @@ -585,3 +585,61 @@ describe('original cell value should be kept', () => { cy.get('input').should('have.value', 'undefined'); }); }); + +describe.only('Scroll with editor that has drop-down layer', () => { + function scrollTo(position: Cypress.PositionType) { + cy.getByCls('rside-area', 'body-area').wait(0).scrollTo(position); + } + + beforeEach(() => { + const data = [ + { id: '1', name: 'Kim', age: '10', school: '1', grade: 'First' }, + { id: '2', name: 'Lee', age: '11', school: '1', grade: 'Second' }, + { id: '3', name: 'Park', age: '12', school: '1', grade: 'First' }, + { id: '4', name: 'Choi', age: '13', school: '1', grade: 'Second' }, + { id: '5', name: 'Cho', age: '14', school: '1', grade: 'Third' }, + { id: '6', name: 'Han', age: '14', school: '1', grade: 'Second' }, + { id: '7', name: 'Lim', age: '14', school: '1', grade: 'First' }, + { id: '8', name: 'Ahn', age: '14', school: '1', grade: 'Second' }, + { id: '9', name: 'Ryu', age: '14', school: '1', grade: 'First' }, + ]; + const columns = [ + { name: 'id', minWidth: 100 }, + { name: 'name', minWidth: 100 }, + { + name: 'school', + formatter: 'listItemText', + editor: { + type: 'select', + options: { + listItems: [ + { text: 'Primary', value: '1' }, + { text: 'Middle', value: '2' }, + { text: 'High', value: '3' }, + ], + }, + }, + minWidth: 100, + }, + { name: 'age', minWidth: 100 }, + { name: 'grade', minWidth: 100 }, + ]; + + cy.createGrid({ data, columns, width: 200, bodyHeight: 150 }); + }); + + [ + { direcion: 'down', position: 'bottom' }, + { direcion: 'up', position: 'top' }, + { direcion: 'right', position: 'right' }, + { direcion: 'left', position: 'left' }, + ].forEach(({ direcion, position }) => { + it(`should hides the drop-down layer when scrolling ${direcion}`, () => { + cy.gridInstance().invoke('startEditing', 4, 'school'); + + scrollTo(position as Cypress.PositionType); + + cy.getByCls('editor-select-box-layer').should('have.css', 'z-index', '-100'); + }); + }); +}); diff --git a/packages/toast-ui.grid/src/editor/checkbox.ts b/packages/toast-ui.grid/src/editor/checkbox.ts index 766c63d4e..f91a5005f 100644 --- a/packages/toast-ui.grid/src/editor/checkbox.ts +++ b/packages/toast-ui.grid/src/editor/checkbox.ts @@ -1,10 +1,16 @@ -import { CellEditor, CellEditorProps, PortalEditingKeydown } from '@t/editor'; +import { + CellEditor, + CellEditorProps, + GridRectForDropDownLayerPos, + LayerPos, + PortalEditingKeydown, +} from '@t/editor'; import { CellValue, ListItem } from '@t/store/data'; import { getListItems } from '../helper/editor'; import { cls, hasClass } from '../helper/dom'; import { getKeyStrokeString, isArrowKey } from '../helper/keyboard'; -import { findIndex, isNil } from '../helper/common'; -import { getContainerElement, setLayerPosition, setOpacity } from './dom'; +import { findIndex, isNil, pixelToNumber } from '../helper/common'; +import { getContainerElement, setLayerPosition, setOpacity, moveLayer } from './dom'; const LAYER_CLASSNAME = cls('editor-checkbox-list-layer'); const LIST_ITEM_CLASSNAME = cls('editor-checkbox'); @@ -27,6 +33,8 @@ export class CheckboxEditor implements CellEditor { private elementIds: string[] = []; + private initLayerPos: LayerPos | null = null; + public constructor(props: CellEditorProps) { const { columnInfo, width, formattedValue, portalEditingKeydown } = props; const el = document.createElement('div'); @@ -159,6 +167,12 @@ export class CheckboxEditor implements CellEditor { this.layer.querySelector('input')) as HTMLInputElement; } + public moveDropdownLayer(gridRect: GridRectForDropDownLayerPos) { + if (this.initLayerPos) { + moveLayer(this.layer, this.initLayerPos, gridRect); + } + } + public getElement() { return this.el; } @@ -191,6 +205,11 @@ export class CheckboxEditor implements CellEditor { // @ts-ignore setLayerPosition(this.el, this.layer); + this.initLayerPos = { + top: pixelToNumber(this.layer.style.top), + left: pixelToNumber(this.layer.style.left), + }; + const checkedInput = this.getCheckedInput(); if (checkedInput) { this.highlightItem(`checkbox-${checkedInput.value}`); @@ -204,5 +223,6 @@ export class CheckboxEditor implements CellEditor { this.layer.removeEventListener('mouseover', this.onMouseover); this.layer.removeEventListener('keydown', this.onKeydown); getContainerElement(this.el).removeChild(this.layer); + this.initLayerPos = null; } } diff --git a/packages/toast-ui.grid/src/editor/datePicker.ts b/packages/toast-ui.grid/src/editor/datePicker.ts index 4db027221..4a15610b0 100644 --- a/packages/toast-ui.grid/src/editor/datePicker.ts +++ b/packages/toast-ui.grid/src/editor/datePicker.ts @@ -1,9 +1,9 @@ import TuiDatePicker from 'tui-date-picker'; import { Dictionary } from '@t/options'; -import { CellEditor, CellEditorProps } from '@t/editor'; +import { CellEditor, CellEditorProps, GridRectForDropDownLayerPos, LayerPos } from '@t/editor'; import { cls } from '../helper/dom'; -import { deepMergedCopy, isNumber, isString, isNil } from '../helper/common'; -import { setLayerPosition, getContainerElement, setOpacity } from './dom'; +import { deepMergedCopy, isNumber, isString, isNil, pixelToNumber } from '../helper/common'; +import { setLayerPosition, getContainerElement, setOpacity, moveLayer } from './dom'; export class DatePickerEditor implements CellEditor { public el: HTMLDivElement; @@ -16,6 +16,8 @@ export class DatePickerEditor implements CellEditor { private iconEl?: HTMLElement; + private initLayerPos: LayerPos | null = null; + private createInputElement() { const inputEl = document.createElement('input'); inputEl.className = cls('content-text'); @@ -102,6 +104,12 @@ export class DatePickerEditor implements CellEditor { this.datePickerEl.on('close', () => this.focus()); } + public moveDropdownLayer(gridRect: GridRectForDropDownLayerPos) { + if (this.initLayerPos) { + moveLayer(this.layer, this.initLayerPos, gridRect); + } + } + public getElement() { return this.el; } @@ -119,6 +127,12 @@ export class DatePickerEditor implements CellEditor { // `this.layer.firstElementChild` is real datePicker layer(it is need to get total height) setLayerPosition(this.el, this.layer, this.layer.firstElementChild as HTMLElement, true); + + this.initLayerPos = { + top: pixelToNumber(this.layer.style.top), + left: pixelToNumber(this.layer.style.left), + }; + // To show the layer which has appropriate position setOpacity(this.layer, 1); } @@ -129,5 +143,6 @@ export class DatePickerEditor implements CellEditor { } this.datePickerEl.destroy(); getContainerElement(this.el).removeChild(this.layer); + this.initLayerPos = null; } } diff --git a/packages/toast-ui.grid/src/editor/dom.ts b/packages/toast-ui.grid/src/editor/dom.ts index b68c24a41..7fb90b74c 100644 --- a/packages/toast-ui.grid/src/editor/dom.ts +++ b/packages/toast-ui.grid/src/editor/dom.ts @@ -1,9 +1,22 @@ +import { GridRectForDropDownLayerPos, LayerPos } from '@t/editor'; +import { isBetween } from '../helper/common'; import { findParentByClassName } from '../helper/dom'; const INDENT = 5; const SCROLL_BAR_WIDTH = 17; const SCROLL_BAR_HEIGHT = 17; +function exceedGridViewport( + top: number, + left: number, + { bodyHeight, bodyWidth, headerHeight, leftSideWidth }: GridRectForDropDownLayerPos +) { + return !( + isBetween(top, headerHeight, bodyHeight + headerHeight) && + isBetween(left, leftSideWidth, bodyWidth) + ); +} + export function setOpacity(el: HTMLElement, opacity: number | string) { el.style.opacity = String(opacity); } @@ -12,6 +25,27 @@ export function getContainerElement(el: HTMLElement) { return findParentByClassName(el, 'container')!; } +export function moveLayer( + layerEl: HTMLElement, + initLayerPos: LayerPos, + gridRect: GridRectForDropDownLayerPos +) { + const { top, left } = initLayerPos; + const { initBodyScrollTop, initBodyScrollLeft, bodyScrollTop, bodyScrollLeft } = gridRect; + const newTop = top + initBodyScrollTop - bodyScrollTop; + const newLeft = left + initBodyScrollLeft - bodyScrollLeft; + + if (exceedGridViewport(newTop, newLeft, gridRect)) { + layerEl.style.zIndex = '-100'; + layerEl.style.top = '0px'; + layerEl.style.left = '0px'; + } else { + layerEl.style.zIndex = ''; + layerEl.style.top = `${newTop}px`; + layerEl.style.left = `${newLeft}px`; + } +} + export function setLayerPosition( innerEl: HTMLElement, layerEl: HTMLElement, diff --git a/packages/toast-ui.grid/src/editor/select.ts b/packages/toast-ui.grid/src/editor/select.ts index 297b0826d..46c9d1207 100644 --- a/packages/toast-ui.grid/src/editor/select.ts +++ b/packages/toast-ui.grid/src/editor/select.ts @@ -1,12 +1,18 @@ import SelectBox from '@toast-ui/select-box'; import '@toast-ui/select-box/dist/toastui-select-box.css'; -import { CellEditor, CellEditorProps, PortalEditingKeydown } from '@t/editor'; +import { + CellEditor, + CellEditorProps, + GridRectForDropDownLayerPos, + LayerPos, + PortalEditingKeydown, +} from '@t/editor'; import { CellValue, ListItem } from '@t/store/data'; import { getListItems } from '../helper/editor'; import { cls } from '../helper/dom'; -import { setLayerPosition, getContainerElement, setOpacity } from './dom'; +import { setLayerPosition, getContainerElement, setOpacity, moveLayer } from './dom'; import { getKeyStrokeString } from '../helper/keyboard'; -import { includes, isNil } from '../helper/common'; +import { includes, isNil, pixelToNumber } from '../helper/common'; export class SelectEditor implements CellEditor { public el: HTMLDivElement; @@ -21,6 +27,8 @@ export class SelectEditor implements CellEditor { private portalEditingKeydown: PortalEditingKeydown; + private initLayerPos: LayerPos | null = null; + public constructor(props: CellEditorProps) { const { width, formattedValue, portalEditingKeydown } = props; const el = document.createElement('div'); @@ -93,6 +101,12 @@ export class SelectEditor implements CellEditor { this.selectBoxEl.input.focus(); } + public moveDropdownLayer(gridRect: GridRectForDropDownLayerPos) { + if (this.initLayerPos) { + moveLayer(this.layer, this.initLayerPos, gridRect); + } + } + public getElement() { return this.el; } @@ -107,6 +121,12 @@ export class SelectEditor implements CellEditor { getContainerElement(this.el).appendChild(this.layer); // @ts-ignore setLayerPosition(this.el, this.layer, this.selectBoxEl.dropdown.el); + + this.initLayerPos = { + top: pixelToNumber(this.layer.style.top), + left: pixelToNumber(this.layer.style.left), + }; + this.focusSelectBox(); this.isMounted = true; // To show the layer which has appropriate position @@ -117,5 +137,6 @@ export class SelectEditor implements CellEditor { this.selectBoxEl.destroy(); this.layer.removeEventListener('keydown', this.onKeydown); getContainerElement(this.el).removeChild(this.layer); + this.initLayerPos = null; } } diff --git a/packages/toast-ui.grid/src/helper/common.ts b/packages/toast-ui.grid/src/helper/common.ts index 49897a7cb..33d6c4c95 100644 --- a/packages/toast-ui.grid/src/helper/common.ts +++ b/packages/toast-ui.grid/src/helper/common.ts @@ -433,3 +433,12 @@ export function convertDataToText(data: string[][], delimiter: string) { export function silentSplice(arr: T[], start: number, deleteCount: number, ...items: T[]): T[] { return Array.prototype.splice.call(arr, start, deleteCount, ...items); } + +export function isBetween(value: number, start: number, end: number) { + return start <= value && value <= end; +} + +export function pixelToNumber(pixelString: string) { + const regExp = new RegExp(/[0-9]+px/); + return regExp.test(pixelString) ? parseInt(pixelString.replace('px', ''), 10) : 0; +} diff --git a/packages/toast-ui.grid/src/view/editingLayer.tsx b/packages/toast-ui.grid/src/view/editingLayer.tsx index b23bf0fab..23276fa4b 100644 --- a/packages/toast-ui.grid/src/view/editingLayer.tsx +++ b/packages/toast-ui.grid/src/view/editingLayer.tsx @@ -12,6 +12,11 @@ import { findProp, isNull } from '../helper/common'; import { getInstance } from '../instance'; import Grid from '../grid'; +interface InitBodyScroll { + initBodyScrollTop: number; + initBodyScrollLeft: number; +} + interface StoreProps { active: boolean; grid: Grid; @@ -23,6 +28,12 @@ interface StoreProps { allColumnMap: Dictionary; editingAddress: EditingAddress; cellPosRect?: Rect | null; + bodyScrollTop: number; + bodyScrollLeft: number; + bodyHeight: number; + bodyWidth: number; + headerHeight: number; + leftSideWidth: number; } interface OwnProps { @@ -36,6 +47,8 @@ export class EditingLayerComp extends Component { private contentEl?: HTMLElement; + private initBodyScrollPos: InitBodyScroll = { initBodyScrollTop: 0, initBodyScrollLeft: 0 }; + private moveTabFocus(ev: KeyboardEvent, command: TabCommandType) { const { dispatch } = this.props; @@ -99,6 +112,15 @@ export class EditingLayerComp extends Component { } } + private setInitScrollPos() { + const { bodyScrollTop, bodyScrollLeft } = this.props; + + this.initBodyScrollPos = { + initBodyScrollTop: bodyScrollTop, + initBodyScrollLeft: bodyScrollLeft, + }; + } + private createEditor() { const { allColumnMap, filteredViewData, editingAddress, grid, cellPosRect } = this.props; @@ -129,19 +151,40 @@ export class EditingLayerComp extends Component { // To access the actual mounted DOM elements setTimeout(() => { cellEditor.mounted!(); + this.setInitScrollPos(); }); } } } public componentDidUpdate(prevProps: Props) { - if ( - !prevProps.active && - this.props.active && - this.props.editingAddress?.columnName === this.props.focusedColumnName - ) { + const { + active, + editingAddress, + focusedColumnName, + bodyHeight, + bodyWidth, + bodyScrollTop, + bodyScrollLeft, + headerHeight, + leftSideWidth, + } = this.props; + + if (!prevProps.active && active && editingAddress?.columnName === focusedColumnName) { this.createEditor(); } + + if (this.editor?.moveDropdownLayer) { + this.editor.moveDropdownLayer({ + bodyHeight, + bodyWidth, + bodyScrollTop, + bodyScrollLeft, + headerHeight, + leftSideWidth, + ...this.initBodyScrollPos, + }); + } } public componentWillReceiveProps(nextProps: Props) { @@ -192,7 +235,7 @@ export class EditingLayerComp extends Component { } export const EditingLayer = connect((store, { side }) => { - const { data, column, id, focus, dimension } = store; + const { data, column, id, focus, dimension, viewport, columnCoords } = store; const { editingAddress, side: focusSide, @@ -201,6 +244,15 @@ export const EditingLayer = connect((store, { side }) => { forcedDestroyEditing, cellPosRect, } = focus; + const { scrollTop, scrollLeft } = viewport; + const { + cellBorderWidth, + bodyHeight, + width, + scrollXHeight, + scrollYWidth, + headerHeight, + } = dimension; return { grid: getInstance(id), @@ -209,9 +261,15 @@ export const EditingLayer = connect((store, { side }) => { focusedColumnName, forcedDestroyEditing, cellPosRect, - cellBorderWidth: dimension.cellBorderWidth, + cellBorderWidth, editingAddress, filteredViewData: data.filteredViewData, allColumnMap: column.allColumnMap, + bodyScrollTop: scrollTop, + bodyScrollLeft: scrollLeft, + bodyHeight: bodyHeight - scrollXHeight, + bodyWidth: width - scrollYWidth, + headerHeight, + leftSideWidth: side === 'L' ? 0 : columnCoords.areaWidth.L, }; }, true)(EditingLayerComp); diff --git a/packages/toast-ui.grid/types/editor/index.d.ts b/packages/toast-ui.grid/types/editor/index.d.ts index 847a67a2b..c5ba3f797 100644 --- a/packages/toast-ui.grid/types/editor/index.d.ts +++ b/packages/toast-ui.grid/types/editor/index.d.ts @@ -22,6 +22,7 @@ export interface CellEditorProps { export interface CellEditor { getElement(): HTMLElement | undefined; getValue(): CellValue; + moveDropdownLayer?(gridRect: GridRectForDropDownLayerPos): void; mounted?(): void; beforeDestroy?(): void; el?: HTMLElement; @@ -35,3 +36,19 @@ export interface ListItemOptions { export interface CellEditorClass { new (props: CellEditorProps): CellEditor; } + +export interface GridRectForDropDownLayerPos { + initBodyScrollTop: number; + initBodyScrollLeft: number; + bodyHeight: number; + bodyWidth: number; + bodyScrollTop: number; + bodyScrollLeft: number; + headerHeight: number; + leftSideWidth: number; +} + +export interface LayerPos { + top: number; + left: number; +}