react-arborist 3.9.0 → 3.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -245,15 +245,17 @@ function App() {
245
245
 
246
246
  ### Dynamic sizing
247
247
 
248
- You can add a ref to it with this package [ZeeCoder/use-resize-observer](https://github.com/ZeeCoder/use-resize-observer)
248
+ You can add a ref to it with this package [Pmndrs/react-use-measure](https://github.com/pmndrs/react-use-measure)
249
249
 
250
- That hook will return the height and width of the parent whenever it changes. You then pass these numbers to the Tree.
250
+ That hook will measure the boundaries (for instance width, height, top, left) of a view you reference. Then you pass the width and the height to the Tree.
251
251
 
252
252
  ```js
253
- const { ref, width, height } = useResizeObserver();
253
+ import useMeasure from "react-use-measure";
254
+
255
+ const [ref, bounds] = useMeasure();
254
256
 
255
257
  <div className="parent" ref={ref}>
256
- <Tree height={height} width={width} />
258
+ <Tree height={bounds.height} width={bounds.width} />
257
259
  </div>
258
260
  ```
259
261
 
@@ -18,7 +18,7 @@ let timeoutId = null;
18
18
  function DefaultContainer() {
19
19
  (0, context_1.useDataUpdates)();
20
20
  const tree = (0, context_1.useTreeApi)();
21
- return ((0, jsx_runtime_1.jsx)("div", { role: "tree", style: {
21
+ return ((0, jsx_runtime_1.jsx)("div", { role: "tree", "aria-label": tree.props["aria-label"], "aria-labelledby": tree.props["aria-labelledby"], "aria-multiselectable": !tree.props.disableMultiSelection || undefined, style: {
22
22
  height: tree.height,
23
23
  width: tree.width,
24
24
  minHeight: 0,
@@ -144,16 +144,16 @@ function DefaultContainer() {
144
144
  }
145
145
  return;
146
146
  }
147
- if (e.key === "a" && e.metaKey && !tree.props.disableMultiSelection) {
147
+ if (e.key === "a" && (e.metaKey || e.ctrlKey) && !tree.props.disableMultiSelection) {
148
148
  e.preventDefault();
149
149
  tree.selectAll();
150
150
  return;
151
151
  }
152
- if (e.key === "a" && !e.metaKey && tree.props.onCreate) {
152
+ if (e.key === "a" && !e.metaKey && !e.ctrlKey && tree.props.onCreate) {
153
153
  tree.createLeaf();
154
154
  return;
155
155
  }
156
- if (e.key === "A" && !e.metaKey) {
156
+ if (e.key === "A" && !e.metaKey && !e.ctrlKey) {
157
157
  if (!tree.props.onCreate)
158
158
  return;
159
159
  tree.createInternal();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ const jsx_runtime_1 = require("react/jsx-runtime");
13
+ const react_1 = require("@testing-library/react");
14
+ const tree_1 = require("./tree");
15
+ const data = [
16
+ {
17
+ id: "1",
18
+ name: "root",
19
+ children: [
20
+ { id: "2", name: "a" },
21
+ { id: "3", name: "b", children: [{ id: "4", name: "c" }] },
22
+ ],
23
+ },
24
+ ];
25
+ /* Selecting a row kicks off tree.scrollTo(), whose promise resolves on a
26
+ microtask after fireEvent's synchronous act() scope has exited — the
27
+ resulting List scrollToItem() update would otherwise warn about not being
28
+ wrapped in act(). Awaiting an async act flushes that trailing update. */
29
+ function click(el, init) {
30
+ return __awaiter(this, void 0, void 0, function* () {
31
+ yield (0, react_1.act)(() => __awaiter(this, void 0, void 0, function* () {
32
+ react_1.fireEvent.click(el, init);
33
+ }));
34
+ });
35
+ }
36
+ /* #303: multi-select should respond to Ctrl+Click (Windows) as well as
37
+ Cmd/Meta+Click (macOS). */
38
+ test("Ctrl+Click adds a row to the selection (#303)", () => __awaiter(void 0, void 0, void 0, function* () {
39
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(tree_1.Tree, { data: data, openByDefault: true }));
40
+ const [, a, b] = react_1.screen.getAllByRole("treeitem");
41
+ yield click(a);
42
+ expect(a.getAttribute("aria-selected")).toBe("true");
43
+ yield click(b, { ctrlKey: true });
44
+ expect(a.getAttribute("aria-selected")).toBe("true");
45
+ expect(b.getAttribute("aria-selected")).toBe("true");
46
+ }));
47
+ test("Ctrl+Click toggles an already-selected row off (#303)", () => __awaiter(void 0, void 0, void 0, function* () {
48
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(tree_1.Tree, { data: data, openByDefault: true }));
49
+ const [, a, b] = react_1.screen.getAllByRole("treeitem");
50
+ yield click(a);
51
+ yield click(b, { ctrlKey: true });
52
+ yield click(b, { ctrlKey: true });
53
+ expect(a.getAttribute("aria-selected")).toBe("true");
54
+ expect(b.getAttribute("aria-selected")).toBe("false");
55
+ }));
56
+ test("Ctrl+Click falls through to a plain select when multi-select is disabled (#303)", () => __awaiter(void 0, void 0, void 0, function* () {
57
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(tree_1.Tree, { data: data, openByDefault: true, disableMultiSelection: true }));
58
+ const [, a, b] = react_1.screen.getAllByRole("treeitem");
59
+ yield click(a);
60
+ yield click(b, { ctrlKey: true });
61
+ expect(a.getAttribute("aria-selected")).toBe("false");
62
+ expect(b.getAttribute("aria-selected")).toBe("true");
63
+ }));
64
+ /* #10: a row's background/selection highlight must span the full scrollable
65
+ width, not stop at the viewport edge, when content overflows horizontally. */
66
+ test("rows get min-width: max-content so the highlight spans overflow (#10)", () => {
67
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(tree_1.Tree, { data: data, openByDefault: true }));
68
+ for (const row of react_1.screen.getAllByRole("treeitem")) {
69
+ expect(row.style.minWidth).toBe("max-content");
70
+ }
71
+ });
72
+ /* #325: forward an accessible name and multiselectable state onto the
73
+ role="tree" element. */
74
+ test("forwards aria-label to the role=tree element (#325)", () => {
75
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(tree_1.Tree, { data: data, "aria-label": "File explorer" }));
76
+ expect(react_1.screen.getByRole("tree").getAttribute("aria-label")).toBe("File explorer");
77
+ });
78
+ test("forwards aria-labelledby to the role=tree element (#325)", () => {
79
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(tree_1.Tree, { data: data, "aria-labelledby": "heading-id" }));
80
+ expect(react_1.screen.getByRole("tree").getAttribute("aria-labelledby")).toBe("heading-id");
81
+ });
82
+ test("marks the tree aria-multiselectable by default (#325)", () => {
83
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(tree_1.Tree, { data: data }));
84
+ expect(react_1.screen.getByRole("tree").getAttribute("aria-multiselectable")).toBe("true");
85
+ });
86
+ test("omits aria-multiselectable when multi-select is disabled (#325)", () => {
87
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(tree_1.Tree, { data: data, disableMultiSelection: true }));
88
+ expect(react_1.screen.getByRole("tree").hasAttribute("aria-multiselectable")).toBe(false);
89
+ });
@@ -61,7 +61,13 @@ exports.RowContainer = react_1.default.memo(function RowContainer({ index, style
61
61
  const nodeStyle = (0, react_1.useMemo)(() => ({ paddingLeft: indent }), [indent]);
62
62
  const rowStyle = (0, react_1.useMemo)(() => {
63
63
  var _a, _b;
64
- return (Object.assign(Object.assign({}, style), { top: parseFloat(style.top) + ((_b = (_a = tree.props.padding) !== null && _a !== void 0 ? _a : tree.props.paddingTop) !== null && _b !== void 0 ? _b : 0) }));
64
+ return (Object.assign(Object.assign({}, style), { top: parseFloat(style.top) + ((_b = (_a = tree.props.padding) !== null && _a !== void 0 ? _a : tree.props.paddingTop) !== null && _b !== void 0 ? _b : 0),
65
+ // react-window gives the row width: 100% of the viewport. When a deeply
66
+ // nested (or long) node overflows horizontally, that clips the row's
67
+ // background/selection highlight at the viewport edge. min-width:
68
+ // max-content lets the row grow with its content so the highlight spans
69
+ // the full scrollable width (#10).
70
+ minWidth: "max-content" }));
65
71
  }, [style, tree.props.padding, tree.props.paddingTop]);
66
72
  const rowAttrs = {
67
73
  role: "treeitem",
@@ -5,7 +5,7 @@ const create_root_1 = require("../data/create-root");
5
5
  class NodeApi {
6
6
  constructor(params) {
7
7
  this.handleClick = (e) => {
8
- if (e.metaKey && !this.tree.props.disableMultiSelection) {
8
+ if ((e.metaKey || e.ctrlKey) && !this.tree.props.disableMultiSelection) {
9
9
  if (this.isSelected)
10
10
  this.deselect();
11
11
  else
@@ -463,12 +463,13 @@ class TreeApi {
463
463
  safeRun(this.props.onSelect, this.selectedNodes);
464
464
  }
465
465
  deselectAll() {
466
+ // setSelection fires onSelect; don't fire it again here (see #332).
466
467
  this.setSelection({ ids: [], anchor: null, mostRecent: null });
467
- safeRun(this.props.onSelect, this.selectedNodes);
468
468
  }
469
469
  selectAll() {
470
470
  var _a, _b, _c;
471
471
  const allSelectableNodes = this.filterSelectableNodes(Object.keys(this.idToIndex));
472
+ // setSelection fires onSelect; don't fire it again here (see #332).
472
473
  this.setSelection({
473
474
  ids: allSelectableNodes,
474
475
  anchor: (_a = allSelectableNodes[0]) !== null && _a !== void 0 ? _a : null,
@@ -477,7 +478,6 @@ class TreeApi {
477
478
  this.dispatch((0, focus_slice_1.focus)((_c = this.lastNode) === null || _c === void 0 ? void 0 : _c.id));
478
479
  if (this.focusedNode)
479
480
  safeRun(this.props.onFocus, this.focusedNode);
480
- safeRun(this.props.onSelect, this.selectedNodes);
481
481
  }
482
482
  filterSelectableNodes(nodes) {
483
483
  return nodes
@@ -43,3 +43,54 @@ test("variable rowHeight function", () => {
43
43
  // Out-of-range index falls back to the default height, never an invalid 0.
44
44
  expect(api.rowHeightAt(99)).toBe(24);
45
45
  });
46
+ describe("onSelect fires exactly once per selection method (#332)", () => {
47
+ function setupWithSpy() {
48
+ const onSelect = jest.fn();
49
+ const api = setupApi({ data: rowData, onSelect });
50
+ return { api, onSelect };
51
+ }
52
+ test("setSelection", () => {
53
+ const { api, onSelect } = setupWithSpy();
54
+ api.setSelection({ ids: ["a"], anchor: "a", mostRecent: "a" });
55
+ expect(onSelect).toHaveBeenCalledTimes(1);
56
+ });
57
+ test("select", () => {
58
+ const { api, onSelect } = setupWithSpy();
59
+ api.select("a");
60
+ expect(onSelect).toHaveBeenCalledTimes(1);
61
+ });
62
+ test("selectMulti", () => {
63
+ const { api, onSelect } = setupWithSpy();
64
+ api.selectMulti("a");
65
+ expect(onSelect).toHaveBeenCalledTimes(1);
66
+ });
67
+ test("selectContiguous", () => {
68
+ const { api, onSelect } = setupWithSpy();
69
+ api.select("a");
70
+ onSelect.mockClear();
71
+ api.selectContiguous("c");
72
+ expect(onSelect).toHaveBeenCalledTimes(1);
73
+ });
74
+ test("selectAll", () => {
75
+ const { api, onSelect } = setupWithSpy();
76
+ api.selectAll();
77
+ expect(api.selectedIds.size).toBe(3);
78
+ expect(onSelect).toHaveBeenCalledTimes(1);
79
+ });
80
+ test("deselectAll", () => {
81
+ const { api, onSelect } = setupWithSpy();
82
+ api.selectAll();
83
+ onSelect.mockClear();
84
+ api.deselectAll();
85
+ expect(api.selectedIds.size).toBe(0);
86
+ expect(onSelect).toHaveBeenCalledTimes(1);
87
+ });
88
+ test("deselect", () => {
89
+ const { api, onSelect } = setupWithSpy();
90
+ api.selectMulti("a");
91
+ api.selectMulti("b");
92
+ onSelect.mockClear();
93
+ api.deselect("a");
94
+ expect(onSelect).toHaveBeenCalledTimes(1);
95
+ });
96
+ });
@@ -50,6 +50,8 @@ export interface TreeProps<T> {
50
50
  initialOpenState?: OpenMap;
51
51
  searchTerm?: string;
52
52
  searchMatch?: (node: NodeApi<T>, searchTerm: string) => boolean;
53
+ "aria-label"?: string;
54
+ "aria-labelledby"?: string;
53
55
  className?: string | undefined;
54
56
  rowClassName?: string | undefined;
55
57
  dndRootElement?: globalThis.Node | null;
@@ -15,7 +15,7 @@ let timeoutId = null;
15
15
  export function DefaultContainer() {
16
16
  useDataUpdates();
17
17
  const tree = useTreeApi();
18
- return (_jsx("div", { role: "tree", style: {
18
+ return (_jsx("div", { role: "tree", "aria-label": tree.props["aria-label"], "aria-labelledby": tree.props["aria-labelledby"], "aria-multiselectable": !tree.props.disableMultiSelection || undefined, style: {
19
19
  height: tree.height,
20
20
  width: tree.width,
21
21
  minHeight: 0,
@@ -141,16 +141,16 @@ export function DefaultContainer() {
141
141
  }
142
142
  return;
143
143
  }
144
- if (e.key === "a" && e.metaKey && !tree.props.disableMultiSelection) {
144
+ if (e.key === "a" && (e.metaKey || e.ctrlKey) && !tree.props.disableMultiSelection) {
145
145
  e.preventDefault();
146
146
  tree.selectAll();
147
147
  return;
148
148
  }
149
- if (e.key === "a" && !e.metaKey && tree.props.onCreate) {
149
+ if (e.key === "a" && !e.metaKey && !e.ctrlKey && tree.props.onCreate) {
150
150
  tree.createLeaf();
151
151
  return;
152
152
  }
153
- if (e.key === "A" && !e.metaKey) {
153
+ if (e.key === "A" && !e.metaKey && !e.ctrlKey) {
154
154
  if (!tree.props.onCreate)
155
155
  return;
156
156
  tree.createInternal();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import { jsx as _jsx } from "react/jsx-runtime";
11
+ import { act, fireEvent, render, screen } from "@testing-library/react";
12
+ import { Tree } from "./tree";
13
+ const data = [
14
+ {
15
+ id: "1",
16
+ name: "root",
17
+ children: [
18
+ { id: "2", name: "a" },
19
+ { id: "3", name: "b", children: [{ id: "4", name: "c" }] },
20
+ ],
21
+ },
22
+ ];
23
+ /* Selecting a row kicks off tree.scrollTo(), whose promise resolves on a
24
+ microtask after fireEvent's synchronous act() scope has exited — the
25
+ resulting List scrollToItem() update would otherwise warn about not being
26
+ wrapped in act(). Awaiting an async act flushes that trailing update. */
27
+ function click(el, init) {
28
+ return __awaiter(this, void 0, void 0, function* () {
29
+ yield act(() => __awaiter(this, void 0, void 0, function* () {
30
+ fireEvent.click(el, init);
31
+ }));
32
+ });
33
+ }
34
+ /* #303: multi-select should respond to Ctrl+Click (Windows) as well as
35
+ Cmd/Meta+Click (macOS). */
36
+ test("Ctrl+Click adds a row to the selection (#303)", () => __awaiter(void 0, void 0, void 0, function* () {
37
+ render(_jsx(Tree, { data: data, openByDefault: true }));
38
+ const [, a, b] = screen.getAllByRole("treeitem");
39
+ yield click(a);
40
+ expect(a.getAttribute("aria-selected")).toBe("true");
41
+ yield click(b, { ctrlKey: true });
42
+ expect(a.getAttribute("aria-selected")).toBe("true");
43
+ expect(b.getAttribute("aria-selected")).toBe("true");
44
+ }));
45
+ test("Ctrl+Click toggles an already-selected row off (#303)", () => __awaiter(void 0, void 0, void 0, function* () {
46
+ render(_jsx(Tree, { data: data, openByDefault: true }));
47
+ const [, a, b] = screen.getAllByRole("treeitem");
48
+ yield click(a);
49
+ yield click(b, { ctrlKey: true });
50
+ yield click(b, { ctrlKey: true });
51
+ expect(a.getAttribute("aria-selected")).toBe("true");
52
+ expect(b.getAttribute("aria-selected")).toBe("false");
53
+ }));
54
+ test("Ctrl+Click falls through to a plain select when multi-select is disabled (#303)", () => __awaiter(void 0, void 0, void 0, function* () {
55
+ render(_jsx(Tree, { data: data, openByDefault: true, disableMultiSelection: true }));
56
+ const [, a, b] = screen.getAllByRole("treeitem");
57
+ yield click(a);
58
+ yield click(b, { ctrlKey: true });
59
+ expect(a.getAttribute("aria-selected")).toBe("false");
60
+ expect(b.getAttribute("aria-selected")).toBe("true");
61
+ }));
62
+ /* #10: a row's background/selection highlight must span the full scrollable
63
+ width, not stop at the viewport edge, when content overflows horizontally. */
64
+ test("rows get min-width: max-content so the highlight spans overflow (#10)", () => {
65
+ render(_jsx(Tree, { data: data, openByDefault: true }));
66
+ for (const row of screen.getAllByRole("treeitem")) {
67
+ expect(row.style.minWidth).toBe("max-content");
68
+ }
69
+ });
70
+ /* #325: forward an accessible name and multiselectable state onto the
71
+ role="tree" element. */
72
+ test("forwards aria-label to the role=tree element (#325)", () => {
73
+ render(_jsx(Tree, { data: data, "aria-label": "File explorer" }));
74
+ expect(screen.getByRole("tree").getAttribute("aria-label")).toBe("File explorer");
75
+ });
76
+ test("forwards aria-labelledby to the role=tree element (#325)", () => {
77
+ render(_jsx(Tree, { data: data, "aria-labelledby": "heading-id" }));
78
+ expect(screen.getByRole("tree").getAttribute("aria-labelledby")).toBe("heading-id");
79
+ });
80
+ test("marks the tree aria-multiselectable by default (#325)", () => {
81
+ render(_jsx(Tree, { data: data }));
82
+ expect(screen.getByRole("tree").getAttribute("aria-multiselectable")).toBe("true");
83
+ });
84
+ test("omits aria-multiselectable when multi-select is disabled (#325)", () => {
85
+ render(_jsx(Tree, { data: data, disableMultiSelection: true }));
86
+ expect(screen.getByRole("tree").hasAttribute("aria-multiselectable")).toBe(false);
87
+ });
@@ -35,7 +35,13 @@ export const RowContainer = React.memo(function RowContainer({ index, style }) {
35
35
  const nodeStyle = useMemo(() => ({ paddingLeft: indent }), [indent]);
36
36
  const rowStyle = useMemo(() => {
37
37
  var _a, _b;
38
- return (Object.assign(Object.assign({}, style), { top: parseFloat(style.top) + ((_b = (_a = tree.props.padding) !== null && _a !== void 0 ? _a : tree.props.paddingTop) !== null && _b !== void 0 ? _b : 0) }));
38
+ return (Object.assign(Object.assign({}, style), { top: parseFloat(style.top) + ((_b = (_a = tree.props.padding) !== null && _a !== void 0 ? _a : tree.props.paddingTop) !== null && _b !== void 0 ? _b : 0),
39
+ // react-window gives the row width: 100% of the viewport. When a deeply
40
+ // nested (or long) node overflows horizontally, that clips the row's
41
+ // background/selection highlight at the viewport edge. min-width:
42
+ // max-content lets the row grow with its content so the highlight spans
43
+ // the full scrollable width (#10).
44
+ minWidth: "max-content" }));
39
45
  }, [style, tree.props.padding, tree.props.paddingTop]);
40
46
  const rowAttrs = {
41
47
  role: "treeitem",
@@ -2,7 +2,7 @@ import { ROOT_ID } from "../data/create-root";
2
2
  export class NodeApi {
3
3
  constructor(params) {
4
4
  this.handleClick = (e) => {
5
- if (e.metaKey && !this.tree.props.disableMultiSelection) {
5
+ if ((e.metaKey || e.ctrlKey) && !this.tree.props.disableMultiSelection) {
6
6
  if (this.isSelected)
7
7
  this.deselect();
8
8
  else
@@ -437,12 +437,13 @@ export class TreeApi {
437
437
  safeRun(this.props.onSelect, this.selectedNodes);
438
438
  }
439
439
  deselectAll() {
440
+ // setSelection fires onSelect; don't fire it again here (see #332).
440
441
  this.setSelection({ ids: [], anchor: null, mostRecent: null });
441
- safeRun(this.props.onSelect, this.selectedNodes);
442
442
  }
443
443
  selectAll() {
444
444
  var _a, _b, _c;
445
445
  const allSelectableNodes = this.filterSelectableNodes(Object.keys(this.idToIndex));
446
+ // setSelection fires onSelect; don't fire it again here (see #332).
446
447
  this.setSelection({
447
448
  ids: allSelectableNodes,
448
449
  anchor: (_a = allSelectableNodes[0]) !== null && _a !== void 0 ? _a : null,
@@ -451,7 +452,6 @@ export class TreeApi {
451
452
  this.dispatch(focus((_c = this.lastNode) === null || _c === void 0 ? void 0 : _c.id));
452
453
  if (this.focusedNode)
453
454
  safeRun(this.props.onFocus, this.focusedNode);
454
- safeRun(this.props.onSelect, this.selectedNodes);
455
455
  }
456
456
  filterSelectableNodes(nodes) {
457
457
  return nodes
@@ -41,3 +41,54 @@ test("variable rowHeight function", () => {
41
41
  // Out-of-range index falls back to the default height, never an invalid 0.
42
42
  expect(api.rowHeightAt(99)).toBe(24);
43
43
  });
44
+ describe("onSelect fires exactly once per selection method (#332)", () => {
45
+ function setupWithSpy() {
46
+ const onSelect = jest.fn();
47
+ const api = setupApi({ data: rowData, onSelect });
48
+ return { api, onSelect };
49
+ }
50
+ test("setSelection", () => {
51
+ const { api, onSelect } = setupWithSpy();
52
+ api.setSelection({ ids: ["a"], anchor: "a", mostRecent: "a" });
53
+ expect(onSelect).toHaveBeenCalledTimes(1);
54
+ });
55
+ test("select", () => {
56
+ const { api, onSelect } = setupWithSpy();
57
+ api.select("a");
58
+ expect(onSelect).toHaveBeenCalledTimes(1);
59
+ });
60
+ test("selectMulti", () => {
61
+ const { api, onSelect } = setupWithSpy();
62
+ api.selectMulti("a");
63
+ expect(onSelect).toHaveBeenCalledTimes(1);
64
+ });
65
+ test("selectContiguous", () => {
66
+ const { api, onSelect } = setupWithSpy();
67
+ api.select("a");
68
+ onSelect.mockClear();
69
+ api.selectContiguous("c");
70
+ expect(onSelect).toHaveBeenCalledTimes(1);
71
+ });
72
+ test("selectAll", () => {
73
+ const { api, onSelect } = setupWithSpy();
74
+ api.selectAll();
75
+ expect(api.selectedIds.size).toBe(3);
76
+ expect(onSelect).toHaveBeenCalledTimes(1);
77
+ });
78
+ test("deselectAll", () => {
79
+ const { api, onSelect } = setupWithSpy();
80
+ api.selectAll();
81
+ onSelect.mockClear();
82
+ api.deselectAll();
83
+ expect(api.selectedIds.size).toBe(0);
84
+ expect(onSelect).toHaveBeenCalledTimes(1);
85
+ });
86
+ test("deselect", () => {
87
+ const { api, onSelect } = setupWithSpy();
88
+ api.selectMulti("a");
89
+ api.selectMulti("b");
90
+ onSelect.mockClear();
91
+ api.deselect("a");
92
+ expect(onSelect).toHaveBeenCalledTimes(1);
93
+ });
94
+ });
@@ -50,6 +50,8 @@ export interface TreeProps<T> {
50
50
  initialOpenState?: OpenMap;
51
51
  searchTerm?: string;
52
52
  searchMatch?: (node: NodeApi<T>, searchTerm: string) => boolean;
53
+ "aria-label"?: string;
54
+ "aria-labelledby"?: string;
53
55
  className?: string | undefined;
54
56
  rowClassName?: string | undefined;
55
57
  dndRootElement?: globalThis.Node | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-arborist",
3
- "version": "3.9.0",
3
+ "version": "3.10.1",
4
4
  "keywords": [
5
5
  "arborist",
6
6
  "dnd",
@@ -0,0 +1,93 @@
1
+ import { act, fireEvent, render, screen } from "@testing-library/react";
2
+ import { Tree } from "./tree";
3
+
4
+ type Datum = { id: string; name: string; children?: Datum[] };
5
+
6
+ const data: Datum[] = [
7
+ {
8
+ id: "1",
9
+ name: "root",
10
+ children: [
11
+ { id: "2", name: "a" },
12
+ { id: "3", name: "b", children: [{ id: "4", name: "c" }] },
13
+ ],
14
+ },
15
+ ];
16
+
17
+ /* Selecting a row kicks off tree.scrollTo(), whose promise resolves on a
18
+ microtask after fireEvent's synchronous act() scope has exited — the
19
+ resulting List scrollToItem() update would otherwise warn about not being
20
+ wrapped in act(). Awaiting an async act flushes that trailing update. */
21
+ async function click(el: Element, init?: MouseEventInit) {
22
+ await act(async () => {
23
+ fireEvent.click(el, init);
24
+ });
25
+ }
26
+
27
+ /* #303: multi-select should respond to Ctrl+Click (Windows) as well as
28
+ Cmd/Meta+Click (macOS). */
29
+ test("Ctrl+Click adds a row to the selection (#303)", async () => {
30
+ render(<Tree<Datum> data={data} openByDefault />);
31
+ const [, a, b] = screen.getAllByRole("treeitem");
32
+
33
+ await click(a);
34
+ expect(a.getAttribute("aria-selected")).toBe("true");
35
+
36
+ await click(b, { ctrlKey: true });
37
+ expect(a.getAttribute("aria-selected")).toBe("true");
38
+ expect(b.getAttribute("aria-selected")).toBe("true");
39
+ });
40
+
41
+ test("Ctrl+Click toggles an already-selected row off (#303)", async () => {
42
+ render(<Tree<Datum> data={data} openByDefault />);
43
+ const [, a, b] = screen.getAllByRole("treeitem");
44
+
45
+ await click(a);
46
+ await click(b, { ctrlKey: true });
47
+ await click(b, { ctrlKey: true });
48
+
49
+ expect(a.getAttribute("aria-selected")).toBe("true");
50
+ expect(b.getAttribute("aria-selected")).toBe("false");
51
+ });
52
+
53
+ test("Ctrl+Click falls through to a plain select when multi-select is disabled (#303)", async () => {
54
+ render(<Tree<Datum> data={data} openByDefault disableMultiSelection />);
55
+ const [, a, b] = screen.getAllByRole("treeitem");
56
+
57
+ await click(a);
58
+ await click(b, { ctrlKey: true });
59
+
60
+ expect(a.getAttribute("aria-selected")).toBe("false");
61
+ expect(b.getAttribute("aria-selected")).toBe("true");
62
+ });
63
+
64
+ /* #10: a row's background/selection highlight must span the full scrollable
65
+ width, not stop at the viewport edge, when content overflows horizontally. */
66
+ test("rows get min-width: max-content so the highlight spans overflow (#10)", () => {
67
+ render(<Tree<Datum> data={data} openByDefault />);
68
+ for (const row of screen.getAllByRole("treeitem")) {
69
+ expect((row as HTMLElement).style.minWidth).toBe("max-content");
70
+ }
71
+ });
72
+
73
+ /* #325: forward an accessible name and multiselectable state onto the
74
+ role="tree" element. */
75
+ test("forwards aria-label to the role=tree element (#325)", () => {
76
+ render(<Tree<Datum> data={data} aria-label="File explorer" />);
77
+ expect(screen.getByRole("tree").getAttribute("aria-label")).toBe("File explorer");
78
+ });
79
+
80
+ test("forwards aria-labelledby to the role=tree element (#325)", () => {
81
+ render(<Tree<Datum> data={data} aria-labelledby="heading-id" />);
82
+ expect(screen.getByRole("tree").getAttribute("aria-labelledby")).toBe("heading-id");
83
+ });
84
+
85
+ test("marks the tree aria-multiselectable by default (#325)", () => {
86
+ render(<Tree<Datum> data={data} />);
87
+ expect(screen.getByRole("tree").getAttribute("aria-multiselectable")).toBe("true");
88
+ });
89
+
90
+ test("omits aria-multiselectable when multi-select is disabled (#325)", () => {
91
+ render(<Tree<Datum> data={data} disableMultiSelection />);
92
+ expect(screen.getByRole("tree").hasAttribute("aria-multiselectable")).toBe(false);
93
+ });
@@ -19,6 +19,9 @@ export function DefaultContainer() {
19
19
  return (
20
20
  <div
21
21
  role="tree"
22
+ aria-label={tree.props["aria-label"]}
23
+ aria-labelledby={tree.props["aria-labelledby"]}
24
+ aria-multiselectable={!tree.props.disableMultiSelection || undefined}
22
25
  style={{
23
26
  height: tree.height,
24
27
  width: tree.width,
@@ -133,16 +136,16 @@ export function DefaultContainer() {
133
136
  }
134
137
  return;
135
138
  }
136
- if (e.key === "a" && e.metaKey && !tree.props.disableMultiSelection) {
139
+ if (e.key === "a" && (e.metaKey || e.ctrlKey) && !tree.props.disableMultiSelection) {
137
140
  e.preventDefault();
138
141
  tree.selectAll();
139
142
  return;
140
143
  }
141
- if (e.key === "a" && !e.metaKey && tree.props.onCreate) {
144
+ if (e.key === "a" && !e.metaKey && !e.ctrlKey && tree.props.onCreate) {
142
145
  tree.createLeaf();
143
146
  return;
144
147
  }
145
- if (e.key === "A" && !e.metaKey) {
148
+ if (e.key === "A" && !e.metaKey && !e.ctrlKey) {
146
149
  if (!tree.props.onCreate) return;
147
150
  tree.createInternal();
148
151
  return;
@@ -48,6 +48,12 @@ export const RowContainer = React.memo(function RowContainer<T>({ index, style }
48
48
  () => ({
49
49
  ...style,
50
50
  top: parseFloat(style.top as string) + (tree.props.padding ?? tree.props.paddingTop ?? 0),
51
+ // react-window gives the row width: 100% of the viewport. When a deeply
52
+ // nested (or long) node overflows horizontally, that clips the row's
53
+ // background/selection highlight at the viewport edge. min-width:
54
+ // max-content lets the row grow with its content so the highlight spans
55
+ // the full scrollable width (#10).
56
+ minWidth: "max-content",
51
57
  }),
52
58
  [style, tree.props.padding, tree.props.paddingTop],
53
59
  );
@@ -200,7 +200,7 @@ export class NodeApi<T = any> {
200
200
  }
201
201
 
202
202
  handleClick = (e: React.MouseEvent) => {
203
- if (e.metaKey && !this.tree.props.disableMultiSelection) {
203
+ if ((e.metaKey || e.ctrlKey) && !this.tree.props.disableMultiSelection) {
204
204
  if (this.isSelected) this.deselect();
205
205
  else this.selectMulti();
206
206
  } else if (e.shiftKey && !this.tree.props.disableMultiSelection) {
@@ -48,3 +48,62 @@ test("variable rowHeight function", () => {
48
48
  // Out-of-range index falls back to the default height, never an invalid 0.
49
49
  expect(api.rowHeightAt(99)).toBe(24);
50
50
  });
51
+
52
+ describe("onSelect fires exactly once per selection method (#332)", () => {
53
+ function setupWithSpy() {
54
+ const onSelect = jest.fn();
55
+ const api = setupApi({ data: rowData, onSelect });
56
+ return { api, onSelect };
57
+ }
58
+
59
+ test("setSelection", () => {
60
+ const { api, onSelect } = setupWithSpy();
61
+ api.setSelection({ ids: ["a"], anchor: "a", mostRecent: "a" });
62
+ expect(onSelect).toHaveBeenCalledTimes(1);
63
+ });
64
+
65
+ test("select", () => {
66
+ const { api, onSelect } = setupWithSpy();
67
+ api.select("a");
68
+ expect(onSelect).toHaveBeenCalledTimes(1);
69
+ });
70
+
71
+ test("selectMulti", () => {
72
+ const { api, onSelect } = setupWithSpy();
73
+ api.selectMulti("a");
74
+ expect(onSelect).toHaveBeenCalledTimes(1);
75
+ });
76
+
77
+ test("selectContiguous", () => {
78
+ const { api, onSelect } = setupWithSpy();
79
+ api.select("a");
80
+ onSelect.mockClear();
81
+ api.selectContiguous("c");
82
+ expect(onSelect).toHaveBeenCalledTimes(1);
83
+ });
84
+
85
+ test("selectAll", () => {
86
+ const { api, onSelect } = setupWithSpy();
87
+ api.selectAll();
88
+ expect(api.selectedIds.size).toBe(3);
89
+ expect(onSelect).toHaveBeenCalledTimes(1);
90
+ });
91
+
92
+ test("deselectAll", () => {
93
+ const { api, onSelect } = setupWithSpy();
94
+ api.selectAll();
95
+ onSelect.mockClear();
96
+ api.deselectAll();
97
+ expect(api.selectedIds.size).toBe(0);
98
+ expect(onSelect).toHaveBeenCalledTimes(1);
99
+ });
100
+
101
+ test("deselect", () => {
102
+ const { api, onSelect } = setupWithSpy();
103
+ api.selectMulti("a");
104
+ api.selectMulti("b");
105
+ onSelect.mockClear();
106
+ api.deselect("a");
107
+ expect(onSelect).toHaveBeenCalledTimes(1);
108
+ });
109
+ });
@@ -455,12 +455,13 @@ export class TreeApi<T> {
455
455
  }
456
456
 
457
457
  deselectAll() {
458
+ // setSelection fires onSelect; don't fire it again here (see #332).
458
459
  this.setSelection({ ids: [], anchor: null, mostRecent: null });
459
- safeRun(this.props.onSelect, this.selectedNodes);
460
460
  }
461
461
 
462
462
  selectAll() {
463
463
  const allSelectableNodes = this.filterSelectableNodes(Object.keys(this.idToIndex));
464
+ // setSelection fires onSelect; don't fire it again here (see #332).
464
465
  this.setSelection({
465
466
  ids: allSelectableNodes,
466
467
  anchor: allSelectableNodes[0] ?? null,
@@ -468,7 +469,6 @@ export class TreeApi<T> {
468
469
  });
469
470
  this.dispatch(focus(this.lastNode?.id));
470
471
  if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode);
471
- safeRun(this.props.onSelect, this.selectedNodes);
472
472
  }
473
473
 
474
474
  private filterSelectableNodes(nodes: (IdObj | string)[]) {
@@ -69,6 +69,10 @@ export interface TreeProps<T> {
69
69
  searchTerm?: string;
70
70
  searchMatch?: (node: NodeApi<T>, searchTerm: string) => boolean;
71
71
 
72
+ /* Accessibility */
73
+ "aria-label"?: string;
74
+ "aria-labelledby"?: string;
75
+
72
76
  /* Extra */
73
77
  className?: string | undefined;
74
78
  rowClassName?: string | undefined;