diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f5e67f..16fb4eb 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 bf80cb0..2618612 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,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/packages/e2e/cypress/e2e/gmail-spec.cy.ts b/packages/e2e/cypress/e2e/gmail-spec.cy.ts index f51febd..301735a 100644 --- a/packages/e2e/cypress/e2e/gmail-spec.cy.ts +++ b/packages/e2e/cypress/e2e/gmail-spec.cy.ts @@ -139,6 +139,23 @@ 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"); + }); + + 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: any, dst: any) { diff --git a/packages/react-arborist/src/interfaces/node-api.ts b/packages/react-arborist/src/interfaces/node-api.ts index cd4da42..3885ff6 100644 --- a/packages/react-arborist/src/interfaces/node-api.ts +++ b/packages/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/packages/react-arborist/src/interfaces/tree-api.ts b/packages/react-arborist/src/interfaces/tree-api.ts index b442a89..a09e9e8 100644 --- a/packages/react-arborist/src/interfaces/tree-api.ts +++ b/packages/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,9 +328,13 @@ 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); @@ -348,9 +352,11 @@ export class TreeApi { const node = this.get(identifyNull(identity)); if (!node) return; 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); if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode); safeRun(this.props.onSelect, this.selectedNodes); @@ -359,11 +365,14 @@ 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); @@ -375,8 +384,9 @@ export class TreeApi { } selectAll() { + const allSelectableNodes = this.filterSelectableNodes(Object.keys(this.idToIndex)); this.setSelection({ - ids: Object.keys(this.idToIndex), + ids: allSelectableNodes, anchor: this.firstNode, mostRecent: this.lastNode, }); @@ -385,10 +395,16 @@ export class TreeApi { safeRun(this.props.onSelect, this.selectedNodes); } + private filterSelectableNodes(nodes: (IdObj | string)[]) { + return nodes + .map(n => this.get(identify(n))) + .filter(n => !!n && n.isSelectable) as NodeApi[]; + } + 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); @@ -580,16 +596,22 @@ export class TreeApi { } isEditable(data: T) { - const check = this.props.disableEdit || (() => false); - return !utils.access(data, check) ?? true; + return this.isActionPossible(data, this.props.disableEdit); } isDraggable(data: T) { - const check = this.props.disableDrag || (() => false); - return !utils.access(data, check) ?? true; + 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; @@ -603,7 +625,7 @@ export class TreeApi { return this.matchFn(node); } - willReceiveDrop(node: string | IdObj | null) { + willReceiveDrop(node: Identity) { const id = identifyNull(node); if (!id) return false; return id === this.state.nodes.drag.idWillReceiveDrop; diff --git a/packages/react-arborist/src/types/tree-props.ts b/packages/react-arborist/src/types/tree-props.ts index de8fff4..e68b289 100644 --- a/packages/react-arborist/src/types/tree-props.ts +++ b/packages/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/packages/showcase/pages/gmail.tsx b/packages/showcase/pages/gmail.tsx index f400962..f42334f 100644 --- a/packages/showcase/pages/gmail.tsx +++ b/packages/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:{" "}