diff --git a/README.md b/README.md index aacdf58..3e2d7ab 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,9 @@ interface TreeProps { searchTerm?: string; searchMatch?: (node: NodeApi, searchTerm: string) => boolean; + /* Keybinding */ + keybinding?: Keybinding; + /* Extra */ className?: string | undefined; rowClassName?: string | undefined; diff --git a/packages/react-arborist/src/components/default-container.tsx b/packages/react-arborist/src/components/default-container.tsx index 70b8a30..7701909 100644 --- a/packages/react-arborist/src/components/default-container.tsx +++ b/packages/react-arborist/src/components/default-container.tsx @@ -1,12 +1,13 @@ import { FixedSizeList } from "react-window"; import { useDataUpdates, useTreeApi } from "../context"; -import { focusNextElement, focusPrevElement } from "../utils"; import { ListOuterElement } from "./list-outer-element"; import { ListInnerElement } from "./list-inner-element"; import { RowContainer } from "./row-container"; - -let focusSearchTerm = ""; -let timeoutId: any = null; +import { SearchForNode } from "../interfaces/commands"; +import { + parseKeybinding, + filterFalseyToString, +} from "../interfaces/keybinding"; /** * All these keyboard shortcuts seem like they should be configurable. @@ -41,177 +42,34 @@ export function DefaultContainer() { if (tree.isEditing) { return; } - if (e.key === "Backspace") { - if (!tree.props.onDelete) return; - const ids = Array.from(tree.selectedIds); - if (ids.length > 1) { - let nextFocus = tree.mostRecentNode; - while (nextFocus && nextFocus.isSelected) { - nextFocus = nextFocus.nextSibling; - } - if (!nextFocus) nextFocus = tree.lastNode; - tree.focus(nextFocus, { scroll: false }); - tree.delete(Array.from(ids)); - } else { - const node = tree.focusedNode; - if (node) { - const sib = node.nextSibling; - const parent = node.parent; - tree.focus(sib || parent, { scroll: false }); - tree.delete(node); - } - } - return; - } - if (e.key === "Tab" && !e.shiftKey) { - e.preventDefault(); - focusNextElement(e.currentTarget); - return; - } - if (e.key === "Tab" && e.shiftKey) { - e.preventDefault(); - focusPrevElement(e.currentTarget); - return; - } - if (e.key === "ArrowDown") { - e.preventDefault(); - const next = tree.nextNode; - if (e.metaKey) { - tree.select(tree.focusedNode); - tree.activate(tree.focusedNode); - return; - } else if (!e.shiftKey || tree.props.disableMultiSelection) { - tree.focus(next); - return; - } else { - if (!next) return; - const current = tree.focusedNode; - if (!current) { - tree.focus(tree.firstNode); - } else if (current.isSelected) { - tree.selectContiguous(next); - } else { - tree.selectMulti(next); - } - return; - } - } - if (e.key === "ArrowUp") { - e.preventDefault(); - const prev = tree.prevNode; - if (!e.shiftKey || tree.props.disableMultiSelection) { - tree.focus(prev); - return; - } else { - if (!prev) return; - const current = tree.focusedNode; - if (!current) { - tree.focus(tree.lastNode); // ? - } else if (current.isSelected) { - tree.selectContiguous(prev); - } else { - tree.selectMulti(prev); - } - return; - } - } - if (e.key === "ArrowRight") { - const node = tree.focusedNode; - if (!node) return; - if (node.isInternal && node.isOpen) { - tree.focus(tree.nextNode); - } else if (node.isInternal) tree.open(node.id); - return; - } - if (e.key === "ArrowLeft") { - const node = tree.focusedNode; - if (!node || node.isRoot) return; - if (node.isInternal && node.isOpen) tree.close(node.id); - else if (!node.parent?.isRoot) { - tree.focus(node.parent); - } - return; - } - if (e.key === "a" && e.metaKey) { - e.preventDefault(); - tree.selectAll(); - return; - } - if (e.key === "a" && !e.metaKey) { - if (!tree.props.onCreate) return; - tree.createLeaf(); - return; - } - if (e.key === "A" && !e.metaKey) { - if (!tree.props.onCreate) return; - tree.createInternal(); - return; - } - if (e.key === "Home") { - // add shift keys - e.preventDefault(); - tree.focus(tree.firstNode); - return; - } - if (e.key === "End") { - // add shift keys + const keybinding = tree.keybinding; + + const keysToControls = parseKeybinding(keybinding); + + const currentKeys = [ + e.key.toLowerCase(), + e.shiftKey ? "shift" : false, + e.metaKey ? "meta" : false, + ].filter(filterFalseyToString); + + const matches = keysToControls.filter((keysToControl) => { + const keys = keysToControl[0]; + return ( + keys.length === currentKeys.length && + keys.every((key) => currentKeys.includes(key)) + ); + }); + + if (matches.length > 0) { e.preventDefault(); - tree.focus(tree.lastNode); - return; - } - if (e.key === "Enter") { - if (!tree.props.onRename) return; - setTimeout(() => { - if (tree.focusedNode) tree.edit(tree.focusedNode); + matches.forEach((match) => { + const control = match[1]; + control(tree, e); }); - return; + } else { + SearchForNode(tree, e); } - if (e.key === " ") { - e.preventDefault(); - const node = tree.focusedNode; - if (!node) return; - if (node.isLeaf) { - node.select(); - node.activate(); - } else { - node.toggle(); - } - return; - } - if (e.key === "*") { - const node = tree.focusedNode; - if (!node) return; - tree.openSiblings(node); - return; - } - if (e.key === "PageUp") { - e.preventDefault(); - tree.pageUp(); - return; - } - if (e.key === "PageDown") { - e.preventDefault(); - tree.pageDown(); - } - - // If they type a sequence of characters - // collect them. Reset them after a timeout. - // Use it to search the tree for a node, then focus it. - // Clean this up a bit later - clearTimeout(timeoutId); - focusSearchTerm += e.key; - timeoutId = setTimeout(() => { - focusSearchTerm = ""; - }, 600); - const node = tree.visibleNodes.find((n) => { - // @ts-ignore - const name = n.data.name; - if (typeof name === "string") { - return name.toLowerCase().startsWith(focusSearchTerm); - } else return false; - }); - if (node) tree.focus(node.id); }} > ( + tree: TreeApi, + e?: KeyboardEvent +) => void; + +const Delete: Command = (tree) => { + if (!tree.props.onDelete) return; + const ids = Array.from(tree.selectedIds); + if (ids.length > 1) { + let nextFocus = tree.mostRecentNode; + while (nextFocus && nextFocus.isSelected) { + nextFocus = nextFocus.nextSibling; + } + if (!nextFocus) nextFocus = tree.lastNode; + tree.focus(nextFocus, { scroll: false }); + tree.delete(Array.from(ids)); + } else { + const node = tree.focusedNode; + if (node) { + const sib = node.nextSibling; + const parent = node.parent; + tree.focus(sib || parent, { scroll: false }); + tree.delete(node); + } + } +}; + +const FocusOutsideNext: Command = (_, e) => { + if (!e) throw Error(`Required keyboard event`); + focusNextElement(e.currentTarget); +}; + +const FocusOutsidePrev: Command = (_, e) => { + if (!e) throw Error(`Required keyboard event`); + focusPrevElement(e.currentTarget); +}; + +const SelectAndActivate: Command = (tree) => { + tree.select(tree.focusedNode); + tree.activate(tree.focusedNode); +}; + +const FocusNext: Command = (tree) => { + const next = tree.nextNode; + tree.focus(next); +}; + +const FocusFirst: Command = (tree) => { + tree.focus(tree.firstNode); +}; + +const FocusPrev: Command = (tree) => { + tree.focus(tree.prevNode); +}; + +const Prev: Command = (tree) => { + const prev = tree.prevNode; + if (!prev) return; + if (tree.props.disableMultiSelection) { + tree.focus(prev); + return; + } + const current = tree.focusedNode; + if (!current) { + tree.focus(tree.lastNode); // ? + } else if (current.isSelected) { + tree.selectContiguous(prev); + } else { + tree.selectMulti(prev); + } +}; + +const FocusLast: Command = (tree) => { + tree.focus(tree.lastNode); +}; + +const Next: Command = (tree) => { + const next = tree.nextNode; + if (tree.props.disableMultiSelection) { + tree.focus(next); + return; + } + + const current = tree.focusedNode; + if (!current) { + tree.focus(tree.firstNode); + } else if (current.isSelected) { + tree.selectContiguous(next); + } else { + tree.selectMulti(next); + } +}; + +const Right: Command = (tree) => { + const node = tree.focusedNode; + if (!node) return; + if (node.isInternal && node.isOpen) { + tree.focus(tree.nextNode); + } else if (node.isInternal) { + tree.open(node.id); + } +}; + +const Left: Command = (tree) => { + const node = tree.focusedNode; + if (!node || node.isRoot) return; + if (node.isInternal && node.isOpen) { + tree.close(node.id); + } else if (!node.parent?.isRoot) { + tree.focus(node.parent); + } +}; + +const SelectAll: Command = (tree) => { + tree.selectAll(); +}; + +const CreateLeaf: Command = (tree) => { + if (!tree.props.onCreate) return; + tree.createLeaf(); +}; + +const CreateInternal: Command = (tree) => { + if (!tree.props.onCreate) return; + tree.createInternal(); +}; + +const Rename: Command = (tree) => { + if (!tree.props.onRename) return; + setTimeout(() => { + if (tree.focusedNode) tree.edit(tree.focusedNode); + }); +}; + +const SelectOrToggle: Command = (tree) => { + const node = tree.focusedNode; + if (!node) return; + if (node.isLeaf) { + node.select(); + node.activate(); + } else { + node.toggle(); + } +}; + +const OpenSiblings: Command = (tree) => { + const node = tree.focusedNode; + if (!node) return; + tree.openSiblings(node); +}; + +const PageUp: Command = (tree) => { + tree.pageUp(); +}; + +const PageDown: Command = (tree) => { + tree.pageDown(); +}; + +export const commands = { + Delete, + SelectAndActivate, + FocusOutsideNext, + FocusOutsidePrev, + FocusNext, + FocusPrev, + FocusFirst, + FocusLast, + Next, + Prev, + Right, + Left, + SelectAll, + CreateLeaf, + CreateInternal, + Rename, + SelectOrToggle, + OpenSiblings, + PageUp, + PageDown, +} as const; + +export const SearchForNode = (() => { + // variables for the closure + let focusSearchTerm = ""; + let timeoutId: NodeJS.Timeout | null = null; + + return ( + tree: TreeApi, + e: KeyboardEvent + ) => { + // If they type a sequence of characters + // collect them. Reset them after a timeout. + // Use it to search the tree for a node, then focus it. + // Clean this up a bit later + if (timeoutId) { + clearTimeout(timeoutId); + } + focusSearchTerm += e.key; + timeoutId = setTimeout(() => { + focusSearchTerm = ""; + }, 600); + const node = tree.visibleNodes.find((n) => { + // @ts-ignore + const name = n.data.name; + if (typeof name === "string") { + return name.toLowerCase().startsWith(focusSearchTerm); + } else return false; + }); + if (node) tree.focus(node.id); + }; +})(); diff --git a/packages/react-arborist/src/interfaces/keybinding.ts b/packages/react-arborist/src/interfaces/keybinding.ts new file mode 100644 index 0000000..c4231db --- /dev/null +++ b/packages/react-arborist/src/interfaces/keybinding.ts @@ -0,0 +1,35 @@ +import { commands, Command } from "./commands"; + +export type Keybinding = Record; + +export const DEFAULT_KEYBINDING: Keybinding = { + Backspace: "Delete", + Tab: "FocusOutsideNext", + "Tab+shift": "FocusOutsidePrev", + "ArrowDown+meta": "SelectAndActivate", + ArrowDown: "Next", + ArrowUp: "Prev", + ArrowRight: "Right", + ArrowLeft: "Left", + "a+meta": "SelectAll", + a: "CreateLeaf", + A: "CreateInternal", + Home: "FocusFirst", + End: "FocusLast", + Enter: "Rename", + " ": "SelectOrToggle", + "*": "OpenSiblings", + PageUp: "PageUp", + PageDown: "PageDown", +}; + +export const filterFalseyToString = (key: unknown): key is string => !!key; + +type KeysToControl = [string[], Command]; + +export const parseKeybinding = (keybinding: Keybinding): KeysToControl[] => + Object.keys(keybinding).reduce((acc, key) => { + const keys = key.toLowerCase().split(/[ +]/g).filter(filterFalseyToString); + acc.push([keys, commands[keybinding[key]]]); + return acc; + }, [] as KeysToControl[]); diff --git a/packages/react-arborist/src/interfaces/tree-api.ts b/packages/react-arborist/src/interfaces/tree-api.ts index 10c3cda..808346c 100644 --- a/packages/react-arborist/src/interfaces/tree-api.ts +++ b/packages/react-arborist/src/interfaces/tree-api.ts @@ -21,6 +21,7 @@ import { Cursor } from "../dnd/compute-drop"; import { Store } from "redux"; import { createList } from "../data/create-list"; import { createIndex } from "../data/create-index"; +import { DEFAULT_KEYBINDING, Keybinding } from "./keybinding"; const { safeRun, identify, identifyNull } = utils; export class TreeApi { @@ -555,6 +556,10 @@ export class TreeApi { return id === this.state.nodes.drag.idWillReceiveDrop; } + get keybinding(): Keybinding { + return this.props.keybinding || DEFAULT_KEYBINDING; + } + /* Tree Event Handlers */ onFocus() { diff --git a/packages/react-arborist/src/types/tree-props.ts b/packages/react-arborist/src/types/tree-props.ts index 1e3e2ce..04d690a 100644 --- a/packages/react-arborist/src/types/tree-props.ts +++ b/packages/react-arborist/src/types/tree-props.ts @@ -5,6 +5,7 @@ import { ElementType, MouseEventHandler } from "react"; import { ListOnScrollProps } from "react-window"; import { NodeApi } from "../interfaces/node-api"; import { OpenMap, OpenSlice } from "../state/open-slice"; +import { Keybinding } from "../interfaces/keybinding"; export interface TreeProps { /* Data Options */ @@ -63,6 +64,9 @@ export interface TreeProps { className?: string | undefined; rowClassName?: string | undefined; + /* Keybinding */ + keybinding?: Keybinding; + dndRootElement?: globalThis.Node | null; onClick?: MouseEventHandler; onContextMenu?: MouseEventHandler; diff --git a/packages/showcase/pages/gmail.tsx b/packages/showcase/pages/gmail.tsx index e42062c..73f1b46 100644 --- a/packages/showcase/pages/gmail.tsx +++ b/packages/showcase/pages/gmail.tsx @@ -2,7 +2,7 @@ import clsx from "clsx"; import { CursorProps, NodeApi, NodeRendererProps, Tree } from "react-arborist"; import { gmailData, GmailItem } from "../data/gmail"; import * as icons from "react-icons/md"; -import styles from "../styles/gmail.module.css"; +import styles from "../styles/Gmail.module.css"; import { FillFlexParent } from "../components/fill-flex-parent"; import { SiGmail } from "react-icons/si"; import { BsTree } from "react-icons/bs"; diff --git a/packages/showcase/pages/index.tsx b/packages/showcase/pages/index.tsx index 8885a1e..cfaae9e 100644 --- a/packages/showcase/pages/index.tsx +++ b/packages/showcase/pages/index.tsx @@ -2,7 +2,7 @@ import type { NextPage } from "next"; import Image from "next/image"; import Head from "next/head"; import Link from "next/link"; -import styles from "../styles/home.module.css"; +import styles from "../styles/Home.module.css"; const Home: NextPage = () => { return (