react-arborist 3.10.5 → 3.11.0
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 +8 -0
- package/dist/main/interfaces/tree-api.d.ts +15 -0
- package/dist/main/interfaces/tree-api.js +45 -0
- package/dist/main/interfaces/tree-api.test.js +86 -0
- package/dist/module/interfaces/tree-api.d.ts +15 -0
- package/dist/module/interfaces/tree-api.js +45 -0
- package/dist/module/interfaces/tree-api.test.js +86 -0
- package/package.json +1 -1
- package/src/interfaces/tree-api.test.ts +101 -0
- package/src/interfaces/tree-api.ts +44 -0
package/README.md
CHANGED
|
@@ -745,6 +745,14 @@ _tree_.**scrollTo**(_id_, _[align]_)
|
|
|
745
745
|
|
|
746
746
|
Scroll to the node with _id_. If this node is not visible, this method will open all its parents. The align argument can be _"auto" | "smart" | "center" | "end" | "start"_.
|
|
747
747
|
|
|
748
|
+
_tree_.**scrollToOffset**(_offset_)
|
|
749
|
+
|
|
750
|
+
Scroll the list vertically to an exact pixel _offset_ from the top — the offset-based counterpart to _scrollTo_, useful for saving and restoring a scroll position. Negative or non-finite values are clamped to the top, and react-window clamps the upper bound to the scrollable range.
|
|
751
|
+
|
|
752
|
+
_tree_.**scrollOffset** : _number_
|
|
753
|
+
|
|
754
|
+
Returns the list's current vertical scroll offset, in pixels from the top. Pairs with _scrollToOffset_ to persist and restore the scroll position.
|
|
755
|
+
|
|
748
756
|
### Properties
|
|
749
757
|
|
|
750
758
|
_tree_.**isEditing** : _boolean_
|
|
@@ -229,6 +229,21 @@ 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;
|
|
238
|
+
/**
|
|
239
|
+
* Scroll the list vertically to an exact pixel offset from the top. This is
|
|
240
|
+
* the offset-based counterpart to scrollTo(), handy for saving and restoring
|
|
241
|
+
* a scroll position (#194). Negative values are clamped to the top; react-
|
|
242
|
+
* window clamps the upper bound to the scrollable range.
|
|
243
|
+
*/
|
|
244
|
+
scrollToOffset(offset: number): void;
|
|
245
|
+
/** The list's current vertical scroll offset, in pixels from the top. */
|
|
246
|
+
get scrollOffset(): number;
|
|
232
247
|
get isEditing(): boolean;
|
|
233
248
|
get isFiltered(): boolean;
|
|
234
249
|
get hasFocus(): boolean;
|
|
@@ -683,11 +683,56 @@ 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
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Scroll the list vertically to an exact pixel offset from the top. This is
|
|
720
|
+
* the offset-based counterpart to scrollTo(), handy for saving and restoring
|
|
721
|
+
* a scroll position (#194). Negative values are clamped to the top; react-
|
|
722
|
+
* window clamps the upper bound to the scrollable range.
|
|
723
|
+
*/
|
|
724
|
+
scrollToOffset(offset) {
|
|
725
|
+
var _a;
|
|
726
|
+
/* Coerce non-finite offsets (NaN/Infinity, easy to get from malformed
|
|
727
|
+
persisted state) to the top rather than forwarding them to the list. */
|
|
728
|
+
const safe = Number.isFinite(offset) ? Math.max(0, offset) : 0;
|
|
729
|
+
(_a = this.list.current) === null || _a === void 0 ? void 0 : _a.scrollTo(safe);
|
|
730
|
+
}
|
|
731
|
+
/** The list's current vertical scroll offset, in pixels from the top. */
|
|
732
|
+
get scrollOffset() {
|
|
733
|
+
var _a, _b;
|
|
734
|
+
return (_b = (_a = this.listEl.current) === null || _a === void 0 ? void 0 : _a.scrollTop) !== null && _b !== void 0 ? _b : 0;
|
|
735
|
+
}
|
|
691
736
|
/* State Checks */
|
|
692
737
|
get isEditing() {
|
|
693
738
|
return this.state.nodes.edit.id !== null;
|
|
@@ -167,3 +167,89 @@ 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
|
+
});
|
|
222
|
+
describe("scrollToOffset / scrollOffset set and read the vertical position (#194)", () => {
|
|
223
|
+
const data = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
|
224
|
+
function setup(el) {
|
|
225
|
+
const store = (0, redux_1.createStore)(root_reducer_1.rootReducer);
|
|
226
|
+
const list = { current: { scrollTo: jest.fn() } };
|
|
227
|
+
const listEl = { current: (el !== null && el !== void 0 ? el : null) };
|
|
228
|
+
return { api: new tree_api_1.TreeApi(store, { data }, list, listEl), list, listEl };
|
|
229
|
+
}
|
|
230
|
+
test("scrollToOffset forwards the offset to the underlying list", () => {
|
|
231
|
+
const { api, list } = setup();
|
|
232
|
+
api.scrollToOffset(120);
|
|
233
|
+
expect(list.current.scrollTo).toHaveBeenCalledWith(120);
|
|
234
|
+
});
|
|
235
|
+
test("scrollToOffset clamps negative offsets to the top", () => {
|
|
236
|
+
const { api, list } = setup();
|
|
237
|
+
api.scrollToOffset(-50);
|
|
238
|
+
expect(list.current.scrollTo).toHaveBeenCalledWith(0);
|
|
239
|
+
});
|
|
240
|
+
test("scrollToOffset coerces non-finite offsets to the top", () => {
|
|
241
|
+
const { api, list } = setup();
|
|
242
|
+
api.scrollToOffset(NaN);
|
|
243
|
+
api.scrollToOffset(Infinity);
|
|
244
|
+
expect(list.current.scrollTo).toHaveBeenNthCalledWith(1, 0);
|
|
245
|
+
expect(list.current.scrollTo).toHaveBeenNthCalledWith(2, 0);
|
|
246
|
+
});
|
|
247
|
+
test("scrollOffset reads the list element's scrollTop", () => {
|
|
248
|
+
const { api } = setup({ scrollTop: 80 });
|
|
249
|
+
expect(api.scrollOffset).toBe(80);
|
|
250
|
+
});
|
|
251
|
+
test("scrollOffset is 0 before the list element mounts", () => {
|
|
252
|
+
const { api } = setup();
|
|
253
|
+
expect(api.scrollOffset).toBe(0);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -229,6 +229,21 @@ 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;
|
|
238
|
+
/**
|
|
239
|
+
* Scroll the list vertically to an exact pixel offset from the top. This is
|
|
240
|
+
* the offset-based counterpart to scrollTo(), handy for saving and restoring
|
|
241
|
+
* a scroll position (#194). Negative values are clamped to the top; react-
|
|
242
|
+
* window clamps the upper bound to the scrollable range.
|
|
243
|
+
*/
|
|
244
|
+
scrollToOffset(offset: number): void;
|
|
245
|
+
/** The list's current vertical scroll offset, in pixels from the top. */
|
|
246
|
+
get scrollOffset(): number;
|
|
232
247
|
get isEditing(): boolean;
|
|
233
248
|
get isFiltered(): boolean;
|
|
234
249
|
get hasFocus(): boolean;
|
|
@@ -657,11 +657,56 @@ 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
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Scroll the list vertically to an exact pixel offset from the top. This is
|
|
694
|
+
* the offset-based counterpart to scrollTo(), handy for saving and restoring
|
|
695
|
+
* a scroll position (#194). Negative values are clamped to the top; react-
|
|
696
|
+
* window clamps the upper bound to the scrollable range.
|
|
697
|
+
*/
|
|
698
|
+
scrollToOffset(offset) {
|
|
699
|
+
var _a;
|
|
700
|
+
/* Coerce non-finite offsets (NaN/Infinity, easy to get from malformed
|
|
701
|
+
persisted state) to the top rather than forwarding them to the list. */
|
|
702
|
+
const safe = Number.isFinite(offset) ? Math.max(0, offset) : 0;
|
|
703
|
+
(_a = this.list.current) === null || _a === void 0 ? void 0 : _a.scrollTo(safe);
|
|
704
|
+
}
|
|
705
|
+
/** The list's current vertical scroll offset, in pixels from the top. */
|
|
706
|
+
get scrollOffset() {
|
|
707
|
+
var _a, _b;
|
|
708
|
+
return (_b = (_a = this.listEl.current) === null || _a === void 0 ? void 0 : _a.scrollTop) !== null && _b !== void 0 ? _b : 0;
|
|
709
|
+
}
|
|
665
710
|
/* State Checks */
|
|
666
711
|
get isEditing() {
|
|
667
712
|
return this.state.nodes.edit.id !== null;
|
|
@@ -165,3 +165,89 @@ 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
|
+
});
|
|
220
|
+
describe("scrollToOffset / scrollOffset set and read the vertical position (#194)", () => {
|
|
221
|
+
const data = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
|
222
|
+
function setup(el) {
|
|
223
|
+
const store = createStore(rootReducer);
|
|
224
|
+
const list = { current: { scrollTo: jest.fn() } };
|
|
225
|
+
const listEl = { current: (el !== null && el !== void 0 ? el : null) };
|
|
226
|
+
return { api: new TreeApi(store, { data }, list, listEl), list, listEl };
|
|
227
|
+
}
|
|
228
|
+
test("scrollToOffset forwards the offset to the underlying list", () => {
|
|
229
|
+
const { api, list } = setup();
|
|
230
|
+
api.scrollToOffset(120);
|
|
231
|
+
expect(list.current.scrollTo).toHaveBeenCalledWith(120);
|
|
232
|
+
});
|
|
233
|
+
test("scrollToOffset clamps negative offsets to the top", () => {
|
|
234
|
+
const { api, list } = setup();
|
|
235
|
+
api.scrollToOffset(-50);
|
|
236
|
+
expect(list.current.scrollTo).toHaveBeenCalledWith(0);
|
|
237
|
+
});
|
|
238
|
+
test("scrollToOffset coerces non-finite offsets to the top", () => {
|
|
239
|
+
const { api, list } = setup();
|
|
240
|
+
api.scrollToOffset(NaN);
|
|
241
|
+
api.scrollToOffset(Infinity);
|
|
242
|
+
expect(list.current.scrollTo).toHaveBeenNthCalledWith(1, 0);
|
|
243
|
+
expect(list.current.scrollTo).toHaveBeenNthCalledWith(2, 0);
|
|
244
|
+
});
|
|
245
|
+
test("scrollOffset reads the list element's scrollTop", () => {
|
|
246
|
+
const { api } = setup({ scrollTop: 80 });
|
|
247
|
+
expect(api.scrollOffset).toBe(80);
|
|
248
|
+
});
|
|
249
|
+
test("scrollOffset is 0 before the list element mounts", () => {
|
|
250
|
+
const { api } = setup();
|
|
251
|
+
expect(api.scrollOffset).toBe(0);
|
|
252
|
+
});
|
|
253
|
+
});
|
package/package.json
CHANGED
|
@@ -184,3 +184,104 @@ 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
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("scrollToOffset / scrollOffset set and read the vertical position (#194)", () => {
|
|
249
|
+
const data = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
|
250
|
+
|
|
251
|
+
function setup(el?: Partial<HTMLDivElement>) {
|
|
252
|
+
const store = createStore(rootReducer);
|
|
253
|
+
const list = { current: { scrollTo: jest.fn() } as any };
|
|
254
|
+
const listEl = { current: (el ?? null) as HTMLDivElement | null };
|
|
255
|
+
return { api: new TreeApi(store, { data }, list, listEl), list, listEl };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
test("scrollToOffset forwards the offset to the underlying list", () => {
|
|
259
|
+
const { api, list } = setup();
|
|
260
|
+
api.scrollToOffset(120);
|
|
261
|
+
expect(list.current.scrollTo).toHaveBeenCalledWith(120);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("scrollToOffset clamps negative offsets to the top", () => {
|
|
265
|
+
const { api, list } = setup();
|
|
266
|
+
api.scrollToOffset(-50);
|
|
267
|
+
expect(list.current.scrollTo).toHaveBeenCalledWith(0);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("scrollToOffset coerces non-finite offsets to the top", () => {
|
|
271
|
+
const { api, list } = setup();
|
|
272
|
+
api.scrollToOffset(NaN);
|
|
273
|
+
api.scrollToOffset(Infinity);
|
|
274
|
+
expect(list.current.scrollTo).toHaveBeenNthCalledWith(1, 0);
|
|
275
|
+
expect(list.current.scrollTo).toHaveBeenNthCalledWith(2, 0);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("scrollOffset reads the list element's scrollTop", () => {
|
|
279
|
+
const { api } = setup({ scrollTop: 80 });
|
|
280
|
+
expect(api.scrollOffset).toBe(80);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("scrollOffset is 0 before the list element mounts", () => {
|
|
284
|
+
const { api } = setup();
|
|
285
|
+
expect(api.scrollOffset).toBe(0);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
@@ -675,12 +675,56 @@ 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
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Scroll the list vertically to an exact pixel offset from the top. This is
|
|
712
|
+
* the offset-based counterpart to scrollTo(), handy for saving and restoring
|
|
713
|
+
* a scroll position (#194). Negative values are clamped to the top; react-
|
|
714
|
+
* window clamps the upper bound to the scrollable range.
|
|
715
|
+
*/
|
|
716
|
+
scrollToOffset(offset: number) {
|
|
717
|
+
/* Coerce non-finite offsets (NaN/Infinity, easy to get from malformed
|
|
718
|
+
persisted state) to the top rather than forwarding them to the list. */
|
|
719
|
+
const safe = Number.isFinite(offset) ? Math.max(0, offset) : 0;
|
|
720
|
+
this.list.current?.scrollTo(safe);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** The list's current vertical scroll offset, in pixels from the top. */
|
|
724
|
+
get scrollOffset(): number {
|
|
725
|
+
return this.listEl.current?.scrollTop ?? 0;
|
|
726
|
+
}
|
|
727
|
+
|
|
684
728
|
/* State Checks */
|
|
685
729
|
|
|
686
730
|
get isEditing() {
|