diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df355437..2d44983a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,8 @@ # Testing Locally 1. Clone the repo -2. From the root, run yarn && yarn start -3. Visit localhost:3000 +2. From the root, run `yarn && yarn start` +3. Visit # Running Tests diff --git a/README.md b/README.md index 4b619833..1e4e6db8 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,7 @@ interface TreeProps { openByDefault?: boolean; selectionFollowsFocus?: boolean; disableMultiSelection?: boolean; + disableSelect?: string | boolean | BoolFunc; disableEdit?: string | boolean | BoolFunc; disableDrag?: string | boolean | BoolFunc; disableDrop?: diff --git a/modules/e2e/cypress/e2e/gmail-spec.cy.js b/modules/e2e/cypress/e2e/gmail-spec.cy.js index 46e48945..a543712e 100644 --- a/modules/e2e/cypress/e2e/gmail-spec.cy.js +++ b/modules/e2e/cypress/e2e/gmail-spec.cy.js @@ -143,6 +143,28 @@ describe("Testing the Gmail Demo", () => { cy.get("@item").contains("Categories").click(); // collapses cy.get("@item").should("have.length", 1); }); + + it("can select inbox but not categories", () => { + cy.get("@item").contains("Inbox").click(); + cy.focused().should("have.attr", "aria-selected", "true"); + cy.get("@item").contains("Categories").click(); + cy.focused().should("have.attr", "aria-selected", "false"); + // Existing selection on Inbox is preserved when clicking an unselectable node + cy.get("@item") + .contains("Inbox") + .parents("[role=treeitem]") + .should("have.attr", "aria-selected", "true"); + }); + + it("select all does not select categories or spam", () => { + cy.get("@item").contains("Inbox").click(); + cy.focused().type("{meta}a"); + cy.get("[aria-selected='true']") + .should("not.contain.text", "Categories") + .should("not.contain.text", "Spam") + .should("contain.text", "Inbox") + .should("have.length", TOTAL_ITEMS - 2); + }); }); function dragAndDrop(src, dst) { diff --git a/modules/react-arborist/src/interfaces/node-api.ts b/modules/react-arborist/src/interfaces/node-api.ts index 34d71383..c4d807bd 100644 --- a/modules/react-arborist/src/interfaces/node-api.ts +++ b/modules/react-arborist/src/interfaces/node-api.ts @@ -59,6 +59,10 @@ export class NodeApi { return this.tree.isEditable(this.data); } + get isSelectable() { + return this.tree.isSelectable(this.data); + } + get isEditing() { return this.tree.editingId === this.id; } diff --git a/modules/react-arborist/src/interfaces/tree-api.ts b/modules/react-arborist/src/interfaces/tree-api.ts index 67e4f9ea..99a33d6a 100644 --- a/modules/react-arborist/src/interfaces/tree-api.ts +++ b/modules/react-arborist/src/interfaces/tree-api.ts @@ -1,5 +1,5 @@ import { EditResult } from "../types/handlers"; -import { Identity, IdObj } from "../types/utils"; +import { BoolFunc, Identity, IdObj } from "../types/utils"; import { TreeProps } from "../types/tree-props"; import { MutableRefObject } from "react"; import { Align, FixedSizeList, ListOnItemsRenderedProps } from "react-window"; @@ -169,7 +169,7 @@ export class TreeApi { return this.visibleNodes.slice(start, end + 1); } - indexOf(id: string | null | IdObj) { + indexOf(id: Identity) { const key = utils.identifyNull(id); if (!key) return null; return this.idToIndex[key]; @@ -219,7 +219,7 @@ export class TreeApi { } } - async delete(node: string | IdObj | null | string[] | IdObj[]) { + async delete(node: Identity | string[] | IdObj[]) { if (!node) return; const idents = Array.isArray(node) ? node : [node]; const ids = idents.map(identify); @@ -256,7 +256,7 @@ export class TreeApi { setTimeout(() => this.onFocus()); // Return focus to element; } - activate(id: string | IdObj | null) { + activate(id: Identity) { const node = this.get(identifyNull(id)); if (!node) return; safeRun(this.props.onActivate, node); @@ -328,14 +328,17 @@ export class TreeApi { const changeFocus = opts.focus !== false; const id = identify(node); if (changeFocus) this.dispatch(focus(id)); - this.dispatch(selection.only(id)); - this.dispatch(selection.anchor(id)); - this.dispatch(selection.mostRecent(id)); + if (this.get(id)?.isSelectable) { + this.setSelection({ + ids: [id], + anchor: id, + mostRecent: id, + }); + } this.scrollTo(id, opts.align); if (this.focusedNode && changeFocus) { safeRun(this.props.onFocus, this.focusedNode); } - safeRun(this.props.onSelect, this.selectedNodes); } deselect(node: Identity) { @@ -350,9 +353,11 @@ export class TreeApi { if (!node) return; const changeFocus = opts.focus !== false; if (changeFocus) this.dispatch(focus(node.id)); - this.dispatch(selection.add(node.id)); - this.dispatch(selection.anchor(node.id)); - this.dispatch(selection.mostRecent(node.id)); + if (node.isSelectable) { + this.dispatch(selection.add(node.id)); + this.dispatch(selection.anchor(node.id)); + this.dispatch(selection.mostRecent(node.id)); + } this.scrollTo(node, opts.align); if (this.focusedNode && changeFocus) { safeRun(this.props.onFocus, this.focusedNode); @@ -363,11 +368,16 @@ export class TreeApi { selectContiguous(identity: Identity) { if (!identity) return; const id = identify(identity); - const { anchor, mostRecent } = this.state.nodes.selection; this.dispatch(focus(id)); - this.dispatch(selection.remove(this.nodesBetween(anchor, mostRecent))); - this.dispatch(selection.add(this.nodesBetween(anchor, identifyNull(id)))); - this.dispatch(selection.mostRecent(id)); + if (this.get(id)?.isSelectable) { + const { anchor, mostRecent } = this.state.nodes.selection; + const selectableNodes = this.filterSelectableNodes( + this.nodesBetween(anchor, identifyNull(id)), + ); + this.dispatch(selection.remove(this.nodesBetween(anchor, mostRecent))); + this.dispatch(selection.add(selectableNodes)); + this.dispatch(selection.mostRecent(id)); + } this.scrollTo(id); if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode); safeRun(this.props.onSelect, this.selectedNodes); @@ -379,20 +389,29 @@ export class TreeApi { } selectAll() { + const allSelectableNodes = this.filterSelectableNodes( + Object.keys(this.idToIndex), + ); this.setSelection({ - ids: Object.keys(this.idToIndex), - anchor: this.firstNode, - mostRecent: this.lastNode, + ids: allSelectableNodes, + anchor: allSelectableNodes[0] ?? null, + mostRecent: allSelectableNodes[allSelectableNodes.length - 1] ?? null, }); this.dispatch(focus(this.lastNode?.id)); if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode); safeRun(this.props.onSelect, this.selectedNodes); } + private filterSelectableNodes(nodes: (IdObj | string)[]) { + return nodes + .map((n) => this.get(identify(n))) + .filter((n): n is NodeApi => !!n && n.isSelectable); + } + setSelection(args: { ids: (IdObj | string)[] | null; - anchor: IdObj | string | null; - mostRecent: IdObj | string | null; + anchor: Identity; + mostRecent: Identity; }) { const ids = new Set(args.ids?.map(identify)); const anchor = identifyNull(args.anchor); @@ -596,16 +615,25 @@ export class TreeApi { } isEditable(data: T) { - const check = this.props.disableEdit || (() => false); - return !utils.access(data, check); + return this.isActionPossible(data, this.props.disableEdit); } isDraggable(data: T) { - const check = this.props.disableDrag || (() => false); - return !utils.access(data, check); + return this.isActionPossible(data, this.props.disableDrag); + } + + isSelectable(data: T) { + return this.isActionPossible(data, this.props.disableSelect); + } + + private isActionPossible( + data: T, + disabler: string | boolean | BoolFunc = () => false, + ) { + return !utils.access(data, disabler); } - isDragging(node: string | IdObj | null) { + isDragging(node: Identity) { const id = identifyNull(node); if (!id) return false; return this.state.nodes.drag.id === id; @@ -619,7 +647,7 @@ export class TreeApi { return this.matchFn(node); } - willReceiveDrop(node: string | IdObj | null) { + willReceiveDrop(node: Identity) { const id = identifyNull(node); if (!id) return false; const { destinationParentId, destinationIndex } = this.state.nodes.drag; diff --git a/modules/react-arborist/src/types/tree-props.ts b/modules/react-arborist/src/types/tree-props.ts index 1f033e4f..22260802 100644 --- a/modules/react-arborist/src/types/tree-props.ts +++ b/modules/react-arborist/src/types/tree-props.ts @@ -41,6 +41,7 @@ export interface TreeProps { openByDefault?: boolean; selectionFollowsFocus?: boolean; disableMultiSelection?: boolean; + disableSelect?: string | boolean | BoolFunc; disableEdit?: string | boolean | BoolFunc; disableDrag?: string | boolean | BoolFunc; disableDrop?: diff --git a/modules/showcase/pages/gmail.tsx b/modules/showcase/pages/gmail.tsx index f400962f..f42334f9 100644 --- a/modules/showcase/pages/gmail.tsx +++ b/modules/showcase/pages/gmail.tsx @@ -47,6 +47,7 @@ export default function GmailSidebar() { renderCursor={Cursor} searchTerm={term} paddingBottom={32} + disableSelect={(data) => ["Categories", "Spam"].includes(data.name)} disableEdit={(data) => data.readOnly} disableDrop={({ parentNode, dragNodes }) => { if ( @@ -78,7 +79,7 @@ export default function GmailSidebar() {

The tree is fully functional. Try the following:

  • Drag the items around
  • -
  • Try to drag Inbox into Categories (not allowed)
  • +
  • Try to drag Inbox into {"'"}Categories{"'"} (not allowed)
  • Move focus with the arrow keys
  • Toggle folders (press spacebar)
  • @@ -89,6 +90,7 @@ export default function GmailSidebar() {
  • Create a new folder (press shift+A)
  • Delete items (press delete)
  • Select multiple items with shift or meta
  • +
  • {"'"}Categories{"'"} and {"'"}Spam{"'"} cannot be selected
  • Filter the tree by typing in this text box:{" "}