Skip to content

Commit

Permalink
feat: navigate table via arrow key and tab (#56)
Browse files Browse the repository at this point in the history
  • Loading branch information
invisal authored Mar 29, 2024
1 parent efc4c1b commit c2016b4
Show file tree
Hide file tree
Showing 6 changed files with 198 additions and 39 deletions.
50 changes: 47 additions & 3 deletions src/components/query-result-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export default function ResultTable({
const renderCell = useCallback(
({ y, x, state, header }: OptimizeTableCellRenderProps) => {
const isFocus = state.hasFocus(y, x);
const editMode = isFocus && state.isInEditMode();

if (header.dataType === TableColumnDataType.TEXT) {
const value = state.getValue(y, x) as DatabaseValue<string>;
Expand All @@ -147,8 +148,9 @@ export default function ResultTable({

return (
<TextCell
state={state}
editor={editor}
readOnly={!tableName}
editMode={editMode}
value={state.getValue(y, x) as DatabaseValue<string>}
focus={isFocus}
isChanged={state.hasCellChange(y, x)}
Expand All @@ -163,7 +165,8 @@ export default function ResultTable({
) {
return (
<NumberCell
readOnly={!tableName}
state={state}
editMode={editMode}
value={state.getValue(y, x) as DatabaseValue<number>}
focus={isFocus}
isChanged={state.hasCellChange(y, x)}
Expand All @@ -176,7 +179,7 @@ export default function ResultTable({

return <GenericCell value={state.getValue(y, x) as string} />;
},
[tableName]
[]
);

const onHeaderContextMenu = useCallback((e: React.MouseEvent) => {
Expand Down Expand Up @@ -358,13 +361,54 @@ export default function ResultTable({

const onKeyDown = useCallback(
(state: OptimizeTableState, e: React.KeyboardEvent) => {
if (state.isInEditMode()) return;

if (KEY_BINDING.copy.match(e as React.KeyboardEvent<HTMLDivElement>)) {
copyCallback(state);
} else if (
KEY_BINDING.paste.match(e as React.KeyboardEvent<HTMLDivElement>)
) {
pasteCallback(state);
} else if (e.key === "ArrowRight") {
const focus = state.getFocus();
if (focus && focus.x + 1 < state.getHeaderCount()) {
state.setFocus(focus.y, focus.x + 1);
state.scrollToFocusCell("right", "top");
}
} else if (e.key === "ArrowLeft") {
const focus = state.getFocus();
if (focus && focus.x - 1 >= 0) {
state.setFocus(focus.y, focus.x - 1);
state.scrollToFocusCell("left", "top");
}
} else if (e.key === "ArrowUp") {
const focus = state.getFocus();
if (focus && focus.y - 1 >= 0) {
state.setFocus(focus.y - 1, focus.x);
state.scrollToFocusCell("left", "top");
}
} else if (e.key === "ArrowDown") {
const focus = state.getFocus();
if (focus && focus.y + 1 < state.getRowsCount()) {
state.setFocus(focus.y + 1, focus.x);
state.scrollToFocusCell("left", "bottom");
}
} else if (e.key === "Tab") {
const focus = state.getFocus();
if (focus) {
const colCount = state.getHeaderCount();
const n = focus.y * colCount + focus.x + 1;
const x = n % colCount;
const y = Math.floor(n / colCount);
if (y >= state.getRowsCount()) return;
state.setFocus(y, x);
state.scrollToFocusCell(x === 0 ? "left" : "right", "bottom");
}
} else if (e.key === "Enter") {
state.enterEditMode();
}

e.preventDefault();
},
[copyCallback, pasteCallback]
);
Expand Down
61 changes: 47 additions & 14 deletions src/components/table-cell/createEditableCell.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import GenericCell from "./GenericCell";
import styles from "./styles.module.css";
import { DatabaseValue } from "@/drivers/base-driver";
import { useBlockEditor } from "@/context/block-editor-provider";
import OptimizeTableState from "../table-optimized/OptimizeTableState";

export interface TableEditableCell<T = unknown> {
value: DatabaseValue<T>;
isChanged?: boolean;
focus?: boolean;
editMode?: boolean;
state: OptimizeTableState;
onChange?: (newValue: DatabaseValue<T>) => void;
readOnly?: boolean;
editor?: "input" | "blocknote";
}

Expand All @@ -25,27 +27,57 @@ function InputCellEditor({
discardChange,
applyChange,
onChange,
state,
}: Readonly<{
align?: "left" | "right";
applyChange: (v: DatabaseValue<string>) => void;
applyChange: (v: DatabaseValue<string>, shouldExit?: boolean) => void;
discardChange: () => void;
value: DatabaseValue<string>;
onChange: (v: string) => void;
state: OptimizeTableState;
}>) {
const inputRef = useRef<HTMLInputElement>(null);
const shouldExit = useRef(true);

useEffect(() => {
if (inputRef.current) {
inputRef.current.select();
inputRef.current.focus();
}
}, [inputRef]);

return (
<input
ref={inputRef}
autoFocus
onBlur={() => {
applyChange(value);
applyChange(value, shouldExit.current);
}}
onChange={(e) => {
onChange(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
applyChange(value);
e.stopPropagation();
} else if (e.key === "Escape") {
discardChange();
} else if (e.key === "Tab") {
// Enter the next cell
const focus = state.getFocus();
if (focus) {
const colCount = state.getHeaderCount();
const n = focus.y * colCount + focus.x + 1;
const x = n % colCount;
const y = Math.floor(n / colCount);
if (y >= state.getRowsCount()) return;

shouldExit.current = false;
state.setFocus(y, x);
state.scrollToFocusCell(x === 0 ? "left" : "right", "bottom");
e.preventDefault();
e.stopPropagation();
}
}
}}
type="text"
Expand Down Expand Up @@ -108,10 +140,10 @@ export default function createEditableCell<T = unknown>({
isChanged,
focus,
onChange,
readOnly,
state,
editMode,
editor,
}: TableEditableCell<T>) {
const [editMode, setEditMode] = useState(false);
const [editValue, setEditValue] = useState<DatabaseValue<string>>(
toString(value)
);
Expand All @@ -121,17 +153,19 @@ export default function createEditableCell<T = unknown>({
}, [value]);

const applyChange = useCallback(
(v: DatabaseValue<string>) => {
(v: DatabaseValue<string>, shouldExitEdit: boolean = true) => {
if (onChange) onChange(toValue(v));
setEditMode(false);
if (shouldExitEdit) {
state.exitEditMode();
}
},
[onChange, setEditMode]
[onChange, state]
);

const discardChange = useCallback(() => {
setEditValue(toString(value));
setEditMode(false);
}, [setEditValue, setEditMode, value]);
state.exitEditMode();
}, [setEditValue, state, value]);

const className = [
styles.cell,
Expand All @@ -158,6 +192,7 @@ export default function createEditableCell<T = unknown>({
return (
<div className={className}>
<InputCellEditor
state={state}
align={align}
applyChange={applyChange}
discardChange={discardChange}
Expand All @@ -175,9 +210,7 @@ export default function createEditableCell<T = unknown>({
focus={focus}
isChanged={isChanged}
onDoubleClick={() => {
if (!readOnly) {
setEditMode(true);
}
state.enterEditMode();
}}
/>
);
Expand Down
88 changes: 88 additions & 0 deletions src/components/table-optimized/OptimizeTableState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export default class OptimizeTableState {
protected selectedRows = new Set<number>();
protected data: OptimizeTableRowValue[] = [];
protected headers: OptimizeTableHeaderProps[] = [];
protected headerWidth: number[] = [];
protected editMode: boolean = false;
protected readOnlyMode: boolean = false;
protected container: HTMLDivElement | null = null;

protected changeCallback: TableChangeEventCallback[] = [];
protected changeDebounceTimerId: NodeJS.Timeout | null = null;
Expand Down Expand Up @@ -78,6 +82,15 @@ export default class OptimizeTableState {
this.data = data.map((row) => ({
raw: row,
}));
this.headerWidth = headers.map((h) => h.initialSize);
}

setReadOnlyMode(readOnly: boolean) {
this.readOnlyMode = readOnly;
}

setContainer(div: HTMLDivElement | null) {
this.container = div;
}

// ------------------------------------------------
Expand Down Expand Up @@ -171,6 +184,10 @@ export default class OptimizeTableState {
return this.data.length;
}

getHeaderCount() {
return this.headers.length;
}

disardAllChange() {
const newRows: OptimizeTableRowValue[] = [];

Expand Down Expand Up @@ -296,11 +313,82 @@ export default class OptimizeTableState {
this.broadcastChange();
}

isInEditMode() {
return this.editMode;
}

enterEditMode() {
if (this.readOnlyMode) return;
this.editMode = true;
this.broadcastChange();
}

exitEditMode() {
this.editMode = false;

if (this.container) {
this.container.focus();
}

this.broadcastChange();
}

clearFocus() {
this.focus = null;
this.broadcastChange();
}

setHeaderWidth(idx: number, newWidth: number) {
return (this.headerWidth[idx] = newWidth);
}

getHeaderWidth() {
return this.headerWidth;
}

scrollToFocusCell(horizontal: "left" | "right", vertical: "top" | "bottom") {
if (this.container && this.focus) {
const cellX = this.focus[1];
const cellY = this.focus[0];
let cellLeft = 0;
let cellRight = 0;
const cellTop = (cellY + 1) * 38;
const cellBottom = cellTop + 38;

for (let i = 0; i < cellX; i++) {
cellLeft += this.headerWidth[i];
}
cellRight = cellLeft + this.headerWidth[cellX];

const width = this.container.clientWidth;
const height = this.container.clientHeight;
const containerLeft = this.container.scrollLeft;
const containerRight = containerLeft + this.container.clientWidth;
const containerTop = this.container.scrollTop;
const containerBottom = containerTop + height;

if (horizontal === "right") {
if (cellRight > containerRight) {
this.container.scrollLeft = Math.max(0, cellRight - width);
}
} else {
if (cellLeft < containerLeft) {
this.container.scrollLeft = cellLeft;
}
}

if (vertical === "bottom") {
if (cellBottom > containerBottom) {
this.container.scrollTop = Math.max(0, cellBottom - height);
}
} else {
if (cellTop - 38 < containerTop) {
this.container.scrollTop = Math.max(0, cellTop - 38);
}
}
}
}

// ------------------------------------------------
// Handle select row logic
// ------------------------------------------------
Expand Down
Loading

0 comments on commit c2016b4

Please sign in to comment.