react-arborist 3.10.5 → 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.
@@ -229,6 +229,12 @@ export declare class TreeApi<T> {
229
229
  openAll(): void;
230
230
  closeAll(): void;
231
231
  scrollTo(identity: Identity | T, align?: Align): Promise<void> | undefined;
232
+ /**
233
+ * Horizontally scroll the list so the node's indented content is in view.
234
+ * A no-op when the list doesn't overflow horizontally (the common case), so
235
+ * it never disturbs scrolling for trees that fit their width.
236
+ */
237
+ private scrollToNodeHorizontally;
232
238
  get isEditing(): boolean;
233
239
  get isFiltered(): boolean;
234
240
  get hasFocus(): boolean;
@@ -683,11 +683,38 @@ class TreeApi {
683
683
  if (index === undefined)
684
684
  return;
685
685
  (_a = this.list.current) === null || _a === void 0 ? void 0 : _a.scrollToItem(index, align);
686
+ /* react-window only scrolls vertically. A deeply nested node is
687
+ indented by level * indent and can sit past the right edge when rows
688
+ overflow horizontally, so bring it into view ourselves (#220). */
689
+ this.scrollToNodeHorizontally(this.get(id));
686
690
  })
687
691
  .catch(() => {
688
692
  // Id: ${id} never appeared in the list.
689
693
  });
690
694
  }
695
+ /**
696
+ * Horizontally scroll the list so the node's indented content is in view.
697
+ * A no-op when the list doesn't overflow horizontally (the common case), so
698
+ * it never disturbs scrolling for trees that fit their width.
699
+ */
700
+ scrollToNodeHorizontally(node) {
701
+ const el = this.listEl.current;
702
+ if (!node || !el)
703
+ return;
704
+ const maxScroll = el.scrollWidth - el.clientWidth;
705
+ if (maxScroll <= 0)
706
+ return; // nothing to scroll
707
+ const left = node.level * this.indent;
708
+ const viewLeft = el.scrollLeft;
709
+ const viewRight = el.scrollLeft + el.clientWidth;
710
+ /* The visible range is half-open [viewLeft, viewRight): a pixel at viewRight
711
+ is already clipped. Only move when the node's indentation falls outside
712
+ it, aligning its content start to the left edge so the label is revealed,
713
+ clamped to the list's scrollable range. */
714
+ if (left < viewLeft || left >= viewRight) {
715
+ el.scrollLeft = Math.max(0, Math.min(left, maxScroll));
716
+ }
717
+ }
691
718
  /* State Checks */
692
719
  get isEditing() {
693
720
  return this.state.nodes.edit.id !== null;
@@ -167,3 +167,55 @@ describe("onSelect fires exactly once per selection method (#332)", () => {
167
167
  expect(onSelect).toHaveBeenCalledTimes(1);
168
168
  });
169
169
  });
170
+ describe("scrollTo brings a deeply nested node into view horizontally (#220)", () => {
171
+ // A folder tree where "deep" sits at level 2 (indented 2 * 24 = 48px).
172
+ const nestedData = [{ id: "root", children: [{ id: "mid", children: [{ id: "deep" }] }] }];
173
+ // react-window's scrollToItem only scrolls vertically; the horizontal scroll
174
+ // happens on the outer list element, which we stub here.
175
+ function setupWithListEl(el) {
176
+ const store = (0, redux_1.createStore)(root_reducer_1.rootReducer);
177
+ const list = { current: { scrollToItem: jest.fn() } };
178
+ const listEl = { current: el };
179
+ return new tree_api_1.TreeApi(store, { data: nestedData }, list, listEl);
180
+ }
181
+ test("scrolls right when the node is past the right edge", () => __awaiter(void 0, void 0, void 0, function* () {
182
+ const el = { scrollWidth: 500, clientWidth: 40, scrollLeft: 0 };
183
+ const api = setupWithListEl(el);
184
+ yield api.scrollTo("deep");
185
+ // Aligns the node's indentation start (level 2 * indent 24) to the left edge.
186
+ expect(el.scrollLeft).toBe(48);
187
+ }));
188
+ test("scrolls left when the node is past the left edge", () => __awaiter(void 0, void 0, void 0, function* () {
189
+ const el = { scrollWidth: 500, clientWidth: 100, scrollLeft: 200 };
190
+ const api = setupWithListEl(el);
191
+ yield api.scrollTo("deep");
192
+ expect(el.scrollLeft).toBe(48);
193
+ }));
194
+ test("scrolls when the node's start sits exactly on the right edge", () => __awaiter(void 0, void 0, void 0, function* () {
195
+ // viewRight === left (48): the visible range is half-open, so the content
196
+ // start is already clipped and must be scrolled into view.
197
+ const el = { scrollWidth: 500, clientWidth: 48, scrollLeft: 0 };
198
+ const api = setupWithListEl(el);
199
+ yield api.scrollTo("deep");
200
+ expect(el.scrollLeft).toBe(48);
201
+ }));
202
+ test("clamps the target to the maximum scrollable distance", () => __awaiter(void 0, void 0, void 0, function* () {
203
+ // left (48) exceeds maxScroll (60 - 40 = 20), so scrollLeft is clamped.
204
+ const el = { scrollWidth: 60, clientWidth: 40, scrollLeft: 0 };
205
+ const api = setupWithListEl(el);
206
+ yield api.scrollTo("deep");
207
+ expect(el.scrollLeft).toBe(20);
208
+ }));
209
+ test("leaves scroll untouched when the node is already in view", () => __awaiter(void 0, void 0, void 0, function* () {
210
+ const el = { scrollWidth: 500, clientWidth: 200, scrollLeft: 0 };
211
+ const api = setupWithListEl(el);
212
+ yield api.scrollTo("deep");
213
+ expect(el.scrollLeft).toBe(0);
214
+ }));
215
+ test("no-ops when the list does not overflow horizontally", () => __awaiter(void 0, void 0, void 0, function* () {
216
+ const el = { scrollWidth: 100, clientWidth: 100, scrollLeft: 0 };
217
+ const api = setupWithListEl(el);
218
+ yield api.scrollTo("deep");
219
+ expect(el.scrollLeft).toBe(0);
220
+ }));
221
+ });
@@ -229,6 +229,12 @@ export declare class TreeApi<T> {
229
229
  openAll(): void;
230
230
  closeAll(): void;
231
231
  scrollTo(identity: Identity | T, align?: Align): Promise<void> | undefined;
232
+ /**
233
+ * Horizontally scroll the list so the node's indented content is in view.
234
+ * A no-op when the list doesn't overflow horizontally (the common case), so
235
+ * it never disturbs scrolling for trees that fit their width.
236
+ */
237
+ private scrollToNodeHorizontally;
232
238
  get isEditing(): boolean;
233
239
  get isFiltered(): boolean;
234
240
  get hasFocus(): boolean;
@@ -657,11 +657,38 @@ export class TreeApi {
657
657
  if (index === undefined)
658
658
  return;
659
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));
660
664
  })
661
665
  .catch(() => {
662
666
  // Id: ${id} never appeared in the list.
663
667
  });
664
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
+ }
665
692
  /* State Checks */
666
693
  get isEditing() {
667
694
  return this.state.nodes.edit.id !== null;
@@ -165,3 +165,55 @@ describe("onSelect fires exactly once per selection method (#332)", () => {
165
165
  expect(onSelect).toHaveBeenCalledTimes(1);
166
166
  });
167
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-arborist",
3
- "version": "3.10.5",
3
+ "version": "3.10.6",
4
4
  "keywords": [
5
5
  "arborist",
6
6
  "dnd",
@@ -184,3 +184,63 @@ describe("onSelect fires exactly once per selection method (#332)", () => {
184
184
  expect(onSelect).toHaveBeenCalledTimes(1);
185
185
  });
186
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
+ });
@@ -675,12 +675,38 @@ export class TreeApi<T> {
675
675
  const index = this.idToIndex[id];
676
676
  if (index === undefined) return;
677
677
  this.list.current?.scrollToItem(index, align);
678
+ /* react-window only scrolls vertically. A deeply nested node is
679
+ indented by level * indent and can sit past the right edge when rows
680
+ overflow horizontally, so bring it into view ourselves (#220). */
681
+ this.scrollToNodeHorizontally(this.get(id));
678
682
  })
679
683
  .catch(() => {
680
684
  // Id: ${id} never appeared in the list.
681
685
  });
682
686
  }
683
687
 
688
+ /**
689
+ * Horizontally scroll the list so the node's indented content is in view.
690
+ * A no-op when the list doesn't overflow horizontally (the common case), so
691
+ * it never disturbs scrolling for trees that fit their width.
692
+ */
693
+ private scrollToNodeHorizontally(node: NodeApi<T> | null) {
694
+ const el = this.listEl.current;
695
+ if (!node || !el) return;
696
+ const maxScroll = el.scrollWidth - el.clientWidth;
697
+ if (maxScroll <= 0) return; // nothing to scroll
698
+ const left = node.level * this.indent;
699
+ const viewLeft = el.scrollLeft;
700
+ const viewRight = el.scrollLeft + el.clientWidth;
701
+ /* The visible range is half-open [viewLeft, viewRight): a pixel at viewRight
702
+ is already clipped. Only move when the node's indentation falls outside
703
+ it, aligning its content start to the left edge so the label is revealed,
704
+ clamped to the list's scrollable range. */
705
+ if (left < viewLeft || left >= viewRight) {
706
+ el.scrollLeft = Math.max(0, Math.min(left, maxScroll));
707
+ }
708
+ }
709
+
684
710
  /* State Checks */
685
711
 
686
712
  get isEditing() {