react-arborist 3.10.4 → 3.10.6

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.
@@ -11,6 +11,7 @@ import * as utils from "../utils";
11
11
  import { DefaultCursor } from "../components/default-cursor";
12
12
  import { DefaultRow } from "../components/default-row";
13
13
  import { DefaultNode } from "../components/default-node";
14
+ import { NodeApi } from "./node-api";
14
15
  import { edit } from "../state/edit-slice";
15
16
  import { focus, treeBlur } from "../state/focus-slice";
16
17
  import { createRoot, ROOT_ID } from "../data/create-root";
@@ -21,7 +22,7 @@ import { DefaultDragPreview } from "../components/default-drag-preview";
21
22
  import { DefaultContainer } from "../components/default-container";
22
23
  import { createList } from "../data/create-list";
23
24
  import { createIndex } from "../data/create-index";
24
- const { safeRun, identify, identifyNull } = utils;
25
+ const { safeRun } = utils;
25
26
  export class TreeApi {
26
27
  constructor(store, props, list, listEl) {
27
28
  this.store = store;
@@ -163,6 +164,30 @@ export class TreeApi {
163
164
  throw new Error("Data must contain an 'id' property or props.idAccessor must return a string");
164
165
  return id;
165
166
  }
167
+ /**
168
+ * Resolve an identifier to a node id. Public methods accept an id string, a
169
+ * NodeApi, or the raw row data; this is the one place that turns any of those
170
+ * into the string id used internally. Raw data is run through the configured
171
+ * `idAccessor` so a custom accessor (e.g. `uuid`) is honored everywhere, not
172
+ * just where nodes were built. A NodeApi already carries its accessor-derived
173
+ * `id`, so it is used directly rather than re-accessed (the accessor reads the
174
+ * underlying data, which a NodeApi does not expose under that key). Unlike
175
+ * `accessId`, an unresolved id comes back as `undefined` rather than throwing,
176
+ * preserving the previous behavior of the `id`-only lookup.
177
+ */
178
+ identify(identity) {
179
+ if (typeof identity === "string")
180
+ return identity;
181
+ if (identity instanceof NodeApi)
182
+ return identity.id;
183
+ const get = this.props.idAccessor || "id";
184
+ return utils.access(identity, get);
185
+ }
186
+ identifyNull(identity) {
187
+ if (identity === null || identity === undefined)
188
+ return null;
189
+ return this.identify(identity);
190
+ }
166
191
  /* Node Access */
167
192
  get firstNode() {
168
193
  var _a;
@@ -218,7 +243,7 @@ export class TreeApi {
218
243
  return this.visibleNodes.slice(start, end + 1);
219
244
  }
220
245
  indexOf(id) {
221
- const key = utils.identifyNull(id);
246
+ const key = this.identifyNull(id);
222
247
  if (!key)
223
248
  return null;
224
249
  return this.idToIndex[key];
@@ -261,7 +286,7 @@ export class TreeApi {
261
286
  if (!node)
262
287
  return;
263
288
  const idents = Array.isArray(node) ? node : [node];
264
- const ids = idents.map(identify);
289
+ const ids = idents.map((i) => this.identify(i));
265
290
  const nodes = ids.map((id) => this.get(id)).filter((n) => !!n);
266
291
  /* Guard against Math.min(...[]) === Infinity when no ids resolve to nodes. */
267
292
  const fromIndex = nodes.length ? Math.min(...nodes.map((n) => { var _a; return (_a = n.rowIndex) !== null && _a !== void 0 ? _a : 0; })) : 0;
@@ -271,7 +296,7 @@ export class TreeApi {
271
296
  }
272
297
  edit(node) {
273
298
  var _a, _b;
274
- const id = identify(node);
299
+ const id = this.identify(node);
275
300
  this.resolveEdit({ cancelled: true });
276
301
  this.scrollTo(id);
277
302
  this.dispatch(edit(id));
@@ -285,7 +310,7 @@ export class TreeApi {
285
310
  var _a, _b;
286
311
  if (!identity)
287
312
  return;
288
- const id = identify(identity);
313
+ const id = this.identify(identity);
289
314
  yield safeRun(this.props.onRename, {
290
315
  id,
291
316
  name: value,
@@ -304,7 +329,7 @@ export class TreeApi {
304
329
  setTimeout(() => this.onFocus()); // Return focus to element;
305
330
  }
306
331
  activate(id) {
307
- const node = this.get(identifyNull(id));
332
+ const node = this.get(this.identifyNull(id));
308
333
  if (!node)
309
334
  return;
310
335
  safeRun(this.props.onActivate, node);
@@ -338,7 +363,7 @@ export class TreeApi {
338
363
  this.select(node);
339
364
  }
340
365
  else {
341
- this.dispatch(focus(identify(node)));
366
+ this.dispatch(focus(this.identify(node)));
342
367
  if (opts.scroll !== false)
343
368
  this.scrollTo(node);
344
369
  if (this.focusedNode)
@@ -378,7 +403,7 @@ export class TreeApi {
378
403
  if (!node)
379
404
  return;
380
405
  const changeFocus = opts.focus !== false;
381
- const id = identify(node);
406
+ const id = this.identify(node);
382
407
  if (changeFocus)
383
408
  this.dispatch(focus(id));
384
409
  if ((_a = this.get(id)) === null || _a === void 0 ? void 0 : _a.isSelectable) {
@@ -396,12 +421,12 @@ export class TreeApi {
396
421
  deselect(node) {
397
422
  if (!node)
398
423
  return;
399
- const id = identify(node);
424
+ const id = this.identify(node);
400
425
  this.dispatch(selection.remove(id));
401
426
  safeRun(this.props.onSelect, this.selectedNodes);
402
427
  }
403
428
  selectMulti(identity, opts = {}) {
404
- const node = this.get(identifyNull(identity));
429
+ const node = this.get(this.identifyNull(identity));
405
430
  if (!node)
406
431
  return;
407
432
  const changeFocus = opts.focus !== false;
@@ -422,11 +447,11 @@ export class TreeApi {
422
447
  var _a;
423
448
  if (!identity)
424
449
  return;
425
- const id = identify(identity);
450
+ const id = this.identify(identity);
426
451
  this.dispatch(focus(id));
427
452
  if ((_a = this.get(id)) === null || _a === void 0 ? void 0 : _a.isSelectable) {
428
453
  const { anchor, mostRecent } = this.state.nodes.selection;
429
- const selectableNodes = this.filterSelectableNodes(this.nodesBetween(anchor, identifyNull(id)));
454
+ const selectableNodes = this.filterSelectableNodes(this.nodesBetween(anchor, this.identifyNull(id)));
430
455
  this.dispatch(selection.remove(this.nodesBetween(anchor, mostRecent)));
431
456
  this.dispatch(selection.add(selectableNodes));
432
457
  this.dispatch(selection.mostRecent(id));
@@ -455,14 +480,14 @@ export class TreeApi {
455
480
  }
456
481
  filterSelectableNodes(nodes) {
457
482
  return nodes
458
- .map((n) => this.get(identify(n)))
483
+ .map((n) => this.get(this.identify(n)))
459
484
  .filter((n) => !!n && n.isSelectable);
460
485
  }
461
486
  setSelection(args) {
462
487
  var _a;
463
- const ids = new Set((_a = args.ids) === null || _a === void 0 ? void 0 : _a.map(identify));
464
- const anchor = identifyNull(args.anchor);
465
- const mostRecent = identifyNull(args.mostRecent);
488
+ const ids = new Set((_a = args.ids) === null || _a === void 0 ? void 0 : _a.map((i) => this.identify(i)));
489
+ const anchor = this.identifyNull(args.anchor);
490
+ const mostRecent = this.identifyNull(args.mostRecent);
466
491
  this.dispatch(selection.set({ ids, anchor, mostRecent }));
467
492
  safeRun(this.props.onSelect, this.selectedNodes);
468
493
  }
@@ -545,7 +570,7 @@ export class TreeApi {
545
570
  /* Visibility */
546
571
  open(identity, redraw = true) {
547
572
  var _a, _b;
548
- const id = identifyNull(identity);
573
+ const id = this.identifyNull(identity);
549
574
  if (!id)
550
575
  return;
551
576
  if (this.isOpen(id))
@@ -557,7 +582,7 @@ export class TreeApi {
557
582
  }
558
583
  close(identity, redraw = true) {
559
584
  var _a, _b;
560
- const id = identifyNull(identity);
585
+ const id = this.identifyNull(identity);
561
586
  if (!id)
562
587
  return;
563
588
  if (!this.isOpen(id))
@@ -568,13 +593,13 @@ export class TreeApi {
568
593
  safeRun(this.props.onToggle, id);
569
594
  }
570
595
  toggle(identity) {
571
- const id = identifyNull(identity);
596
+ const id = this.identifyNull(identity);
572
597
  if (!id)
573
598
  return;
574
599
  return this.isOpen(id) ? this.close(id) : this.open(id);
575
600
  }
576
601
  openParents(identity) {
577
- const id = identifyNull(identity);
602
+ const id = this.identifyNull(identity);
578
603
  if (!id)
579
604
  return;
580
605
  const node = utils.dfs(this.root, id);
@@ -622,7 +647,7 @@ export class TreeApi {
622
647
  scrollTo(identity, align = "smart") {
623
648
  if (!identity)
624
649
  return;
625
- const id = identify(identity);
650
+ const id = this.identify(identity);
626
651
  this.openParents(id);
627
652
  return utils
628
653
  .waitFor(() => id in this.idToIndex)
@@ -632,11 +657,38 @@ export class TreeApi {
632
657
  if (index === undefined)
633
658
  return;
634
659
  (_a = this.list.current) === null || _a === void 0 ? void 0 : _a.scrollToItem(index, align);
660
+ /* react-window only scrolls vertically. A deeply nested node is
661
+ indented by level * indent and can sit past the right edge when rows
662
+ overflow horizontally, so bring it into view ourselves (#220). */
663
+ this.scrollToNodeHorizontally(this.get(id));
635
664
  })
636
665
  .catch(() => {
637
666
  // Id: ${id} never appeared in the list.
638
667
  });
639
668
  }
669
+ /**
670
+ * Horizontally scroll the list so the node's indented content is in view.
671
+ * A no-op when the list doesn't overflow horizontally (the common case), so
672
+ * it never disturbs scrolling for trees that fit their width.
673
+ */
674
+ scrollToNodeHorizontally(node) {
675
+ const el = this.listEl.current;
676
+ if (!node || !el)
677
+ return;
678
+ const maxScroll = el.scrollWidth - el.clientWidth;
679
+ if (maxScroll <= 0)
680
+ return; // nothing to scroll
681
+ const left = node.level * this.indent;
682
+ const viewLeft = el.scrollLeft;
683
+ const viewRight = el.scrollLeft + el.clientWidth;
684
+ /* The visible range is half-open [viewLeft, viewRight): a pixel at viewRight
685
+ is already clipped. Only move when the node's indentation falls outside
686
+ it, aligning its content start to the left edge so the label is revealed,
687
+ clamped to the list's scrollable range. */
688
+ if (left < viewLeft || left >= viewRight) {
689
+ el.scrollLeft = Math.max(0, Math.min(left, maxScroll));
690
+ }
691
+ }
640
692
  /* State Checks */
641
693
  get isEditing() {
642
694
  return this.state.nodes.edit.id !== null;
@@ -689,7 +741,7 @@ export class TreeApi {
689
741
  return !utils.access(data, disabler);
690
742
  }
691
743
  isDragging(node) {
692
- const id = identifyNull(node);
744
+ const id = this.identifyNull(node);
693
745
  if (!id)
694
746
  return false;
695
747
  return this.state.nodes.drag.id === id;
@@ -701,7 +753,7 @@ export class TreeApi {
701
753
  return this.matchFn(node);
702
754
  }
703
755
  willReceiveDrop(node) {
704
- const id = identifyNull(node);
756
+ const id = this.identifyNull(node);
705
757
  if (!id)
706
758
  return false;
707
759
  const { destinationParentId, destinationIndex } = this.state.nodes.drag;
@@ -1,3 +1,12 @@
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
+ };
1
10
  import { createStore } from "redux";
2
11
  import { rootReducer } from "../state/root-reducer";
3
12
  import { actions as dnd } from "../state/dnd-slice";
@@ -36,6 +45,45 @@ describe("tree.drop() fires onMove (#313)", () => {
36
45
  expect(onMove).toHaveBeenCalledWith(expect.objectContaining({ parentId: "folder", index: 0 }));
37
46
  });
38
47
  });
48
+ describe("custom idAccessor is honored when methods receive raw data (#347)", () => {
49
+ const uuidData = [{ uuid: "a" }, { uuid: "b" }, { uuid: "c" }];
50
+ test("select(data) resolves the id through idAccessor", () => {
51
+ const onSelect = jest.fn();
52
+ const api = setupApi({ data: uuidData, idAccessor: "uuid", onSelect });
53
+ api.select(uuidData[1]);
54
+ expect(api.selectedIds.has("b")).toBe(true);
55
+ expect(api.selectedNodes.map((n) => n.id)).toEqual(["b"]);
56
+ });
57
+ test("focus(data) resolves the id through idAccessor", () => {
58
+ var _a;
59
+ const api = setupApi({ data: uuidData, idAccessor: "uuid" });
60
+ api.focus(uuidData[2]);
61
+ expect((_a = api.focusedNode) === null || _a === void 0 ? void 0 : _a.id).toBe("c");
62
+ });
63
+ test("delete(data) passes the accessor-derived id to onDelete", () => {
64
+ const onDelete = jest.fn();
65
+ const api = setupApi({ data: uuidData, idAccessor: "uuid", onDelete });
66
+ api.delete(uuidData[0]);
67
+ expect(onDelete).toHaveBeenCalledWith(expect.objectContaining({ ids: ["a"] }));
68
+ });
69
+ test("create() focuses the new node by its accessor-derived id", () => __awaiter(void 0, void 0, void 0, function* () {
70
+ // create() passes the raw row data returned by onCreate straight to
71
+ // focus/edit/select; before the fix these read `.id` and lost the node.
72
+ const onCreate = () => ({ uuid: "new" });
73
+ const api = setupApi({ data: uuidData, idAccessor: "uuid", onCreate });
74
+ yield api.create();
75
+ expect(api.state.nodes.focus.id).toBe("new");
76
+ }));
77
+ test("a function idAccessor is honored too", () => {
78
+ const fnData = [{ meta: { key: "x" } }, { meta: { key: "y" } }];
79
+ const api = setupApi({
80
+ data: fnData,
81
+ idAccessor: (d) => d.meta.key,
82
+ });
83
+ api.select(fnData[1]);
84
+ expect(api.selectedIds.has("y")).toBe(true);
85
+ });
86
+ });
39
87
  test("rowHeight defaults to 24", () => {
40
88
  const api = setupApi({});
41
89
  expect(api.rowHeight).toBe(24);
@@ -117,3 +165,55 @@ describe("onSelect fires exactly once per selection method (#332)", () => {
117
165
  expect(onSelect).toHaveBeenCalledTimes(1);
118
166
  });
119
167
  });
168
+ describe("scrollTo brings a deeply nested node into view horizontally (#220)", () => {
169
+ // A folder tree where "deep" sits at level 2 (indented 2 * 24 = 48px).
170
+ const nestedData = [{ id: "root", children: [{ id: "mid", children: [{ id: "deep" }] }] }];
171
+ // react-window's scrollToItem only scrolls vertically; the horizontal scroll
172
+ // happens on the outer list element, which we stub here.
173
+ function setupWithListEl(el) {
174
+ const store = createStore(rootReducer);
175
+ const list = { current: { scrollToItem: jest.fn() } };
176
+ const listEl = { current: el };
177
+ return new TreeApi(store, { data: nestedData }, list, listEl);
178
+ }
179
+ test("scrolls right when the node is past the right edge", () => __awaiter(void 0, void 0, void 0, function* () {
180
+ const el = { scrollWidth: 500, clientWidth: 40, scrollLeft: 0 };
181
+ const api = setupWithListEl(el);
182
+ yield api.scrollTo("deep");
183
+ // Aligns the node's indentation start (level 2 * indent 24) to the left edge.
184
+ expect(el.scrollLeft).toBe(48);
185
+ }));
186
+ test("scrolls left when the node is past the left edge", () => __awaiter(void 0, void 0, void 0, function* () {
187
+ const el = { scrollWidth: 500, clientWidth: 100, scrollLeft: 200 };
188
+ const api = setupWithListEl(el);
189
+ yield api.scrollTo("deep");
190
+ expect(el.scrollLeft).toBe(48);
191
+ }));
192
+ test("scrolls when the node's start sits exactly on the right edge", () => __awaiter(void 0, void 0, void 0, function* () {
193
+ // viewRight === left (48): the visible range is half-open, so the content
194
+ // start is already clipped and must be scrolled into view.
195
+ const el = { scrollWidth: 500, clientWidth: 48, scrollLeft: 0 };
196
+ const api = setupWithListEl(el);
197
+ yield api.scrollTo("deep");
198
+ expect(el.scrollLeft).toBe(48);
199
+ }));
200
+ test("clamps the target to the maximum scrollable distance", () => __awaiter(void 0, void 0, void 0, function* () {
201
+ // left (48) exceeds maxScroll (60 - 40 = 20), so scrollLeft is clamped.
202
+ const el = { scrollWidth: 60, clientWidth: 40, scrollLeft: 0 };
203
+ const api = setupWithListEl(el);
204
+ yield api.scrollTo("deep");
205
+ expect(el.scrollLeft).toBe(20);
206
+ }));
207
+ test("leaves scroll untouched when the node is already in view", () => __awaiter(void 0, void 0, void 0, function* () {
208
+ const el = { scrollWidth: 500, clientWidth: 200, scrollLeft: 0 };
209
+ const api = setupWithListEl(el);
210
+ yield api.scrollTo("deep");
211
+ expect(el.scrollLeft).toBe(0);
212
+ }));
213
+ test("no-ops when the list does not overflow horizontally", () => __awaiter(void 0, void 0, void 0, function* () {
214
+ const el = { scrollWidth: 100, clientWidth: 100, scrollLeft: 0 };
215
+ const api = setupWithListEl(el);
216
+ yield api.scrollTo("deep");
217
+ expect(el.scrollLeft).toBe(0);
218
+ }));
219
+ });
@@ -5,7 +5,7 @@ export type CreateHandler<T> = (args: {
5
5
  parentNode: NodeApi<T> | null;
6
6
  index: number;
7
7
  type: "internal" | "leaf";
8
- }) => (IdObj | null) | Promise<IdObj | null>;
8
+ }) => (T | IdObj | null) | Promise<T | IdObj | null>;
9
9
  export type MoveHandler<T> = (args: {
10
10
  dragIds: string[];
11
11
  dragNodes: NodeApi<T>[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-arborist",
3
- "version": "3.10.4",
3
+ "version": "3.10.6",
4
4
  "keywords": [
5
5
  "arborist",
6
6
  "dnd",
@@ -47,6 +47,52 @@ describe("tree.drop() fires onMove (#313)", () => {
47
47
  });
48
48
  });
49
49
 
50
+ describe("custom idAccessor is honored when methods receive raw data (#347)", () => {
51
+ const uuidData = [{ uuid: "a" }, { uuid: "b" }, { uuid: "c" }];
52
+
53
+ test("select(data) resolves the id through idAccessor", () => {
54
+ const onSelect = jest.fn();
55
+ const api = setupApi({ data: uuidData, idAccessor: "uuid", onSelect });
56
+ api.select(uuidData[1]);
57
+ expect(api.selectedIds.has("b")).toBe(true);
58
+ expect(api.selectedNodes.map((n) => n.id)).toEqual(["b"]);
59
+ });
60
+
61
+ test("focus(data) resolves the id through idAccessor", () => {
62
+ const api = setupApi({ data: uuidData, idAccessor: "uuid" });
63
+ api.focus(uuidData[2]);
64
+ expect(api.focusedNode?.id).toBe("c");
65
+ });
66
+
67
+ test("delete(data) passes the accessor-derived id to onDelete", () => {
68
+ const onDelete = jest.fn();
69
+ const api = setupApi({ data: uuidData, idAccessor: "uuid", onDelete });
70
+ api.delete(uuidData[0]);
71
+ expect(onDelete).toHaveBeenCalledWith(
72
+ expect.objectContaining({ ids: ["a"] }),
73
+ );
74
+ });
75
+
76
+ test("create() focuses the new node by its accessor-derived id", async () => {
77
+ // create() passes the raw row data returned by onCreate straight to
78
+ // focus/edit/select; before the fix these read `.id` and lost the node.
79
+ const onCreate = () => ({ uuid: "new" });
80
+ const api = setupApi({ data: uuidData, idAccessor: "uuid", onCreate });
81
+ await api.create();
82
+ expect(api.state.nodes.focus.id).toBe("new");
83
+ });
84
+
85
+ test("a function idAccessor is honored too", () => {
86
+ const fnData = [{ meta: { key: "x" } }, { meta: { key: "y" } }];
87
+ const api = setupApi({
88
+ data: fnData,
89
+ idAccessor: (d: any) => d.meta.key,
90
+ });
91
+ api.select(fnData[1]);
92
+ expect(api.selectedIds.has("y")).toBe(true);
93
+ });
94
+ });
95
+
50
96
  test("rowHeight defaults to 24", () => {
51
97
  const api = setupApi({});
52
98
  expect(api.rowHeight).toBe(24);
@@ -138,3 +184,63 @@ describe("onSelect fires exactly once per selection method (#332)", () => {
138
184
  expect(onSelect).toHaveBeenCalledTimes(1);
139
185
  });
140
186
  });
187
+
188
+ describe("scrollTo brings a deeply nested node into view horizontally (#220)", () => {
189
+ // A folder tree where "deep" sits at level 2 (indented 2 * 24 = 48px).
190
+ const nestedData = [{ id: "root", children: [{ id: "mid", children: [{ id: "deep" }] }] }];
191
+
192
+ // react-window's scrollToItem only scrolls vertically; the horizontal scroll
193
+ // happens on the outer list element, which we stub here.
194
+ function setupWithListEl(el: Partial<HTMLDivElement>) {
195
+ const store = createStore(rootReducer);
196
+ const list = { current: { scrollToItem: jest.fn() } as any };
197
+ const listEl = { current: el as HTMLDivElement };
198
+ return new TreeApi(store, { data: nestedData }, list, listEl);
199
+ }
200
+
201
+ test("scrolls right when the node is past the right edge", async () => {
202
+ const el = { scrollWidth: 500, clientWidth: 40, scrollLeft: 0 };
203
+ const api = setupWithListEl(el);
204
+ await api.scrollTo("deep");
205
+ // Aligns the node's indentation start (level 2 * indent 24) to the left edge.
206
+ expect(el.scrollLeft).toBe(48);
207
+ });
208
+
209
+ test("scrolls left when the node is past the left edge", async () => {
210
+ const el = { scrollWidth: 500, clientWidth: 100, scrollLeft: 200 };
211
+ const api = setupWithListEl(el);
212
+ await api.scrollTo("deep");
213
+ expect(el.scrollLeft).toBe(48);
214
+ });
215
+
216
+ test("scrolls when the node's start sits exactly on the right edge", async () => {
217
+ // viewRight === left (48): the visible range is half-open, so the content
218
+ // start is already clipped and must be scrolled into view.
219
+ const el = { scrollWidth: 500, clientWidth: 48, scrollLeft: 0 };
220
+ const api = setupWithListEl(el);
221
+ await api.scrollTo("deep");
222
+ expect(el.scrollLeft).toBe(48);
223
+ });
224
+
225
+ test("clamps the target to the maximum scrollable distance", async () => {
226
+ // left (48) exceeds maxScroll (60 - 40 = 20), so scrollLeft is clamped.
227
+ const el = { scrollWidth: 60, clientWidth: 40, scrollLeft: 0 };
228
+ const api = setupWithListEl(el);
229
+ await api.scrollTo("deep");
230
+ expect(el.scrollLeft).toBe(20);
231
+ });
232
+
233
+ test("leaves scroll untouched when the node is already in view", async () => {
234
+ const el = { scrollWidth: 500, clientWidth: 200, scrollLeft: 0 };
235
+ const api = setupWithListEl(el);
236
+ await api.scrollTo("deep");
237
+ expect(el.scrollLeft).toBe(0);
238
+ });
239
+
240
+ test("no-ops when the list does not overflow horizontally", async () => {
241
+ const el = { scrollWidth: 100, clientWidth: 100, scrollLeft: 0 };
242
+ const api = setupWithListEl(el);
243
+ await api.scrollTo("deep");
244
+ expect(el.scrollLeft).toBe(0);
245
+ });
246
+ });