Skip to content

Commit 630e7b6

Browse files
fix(Select): prevent interrupting scroll when virtual select items are added (#1830)
Co-authored-by: Hunter Johnston <johnstonhuntera@gmail.com>
1 parent f5dd8e0 commit 630e7b6

File tree

2 files changed

+55
-39
lines changed

2 files changed

+55
-39
lines changed

.changeset/thick-bats-grab.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"bits-ui": patch
3+
---
4+
5+
fix(Select): prevent interrupting scroll when virtual select items are added

packages/bits-ui/src/lib/bits/select/select.svelte.ts

Lines changed: 50 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ abstract class SelectBaseRootState {
9595
touchedInput = $state(false);
9696
inputNode = $state<HTMLElement | null>(null);
9797
contentNode = $state<HTMLElement | null>(null);
98+
viewportNode = $state<HTMLElement | null>(null);
9899
triggerNode = $state<HTMLElement | null>(null);
99100
valueId = $state("");
100101
highlightedNode = $state<HTMLElement | null>(null);
@@ -148,11 +149,32 @@ abstract class SelectBaseRootState {
148149
);
149150
}
150151

151-
setHighlightedToFirstCandidate() {
152+
setHighlightedToFirstCandidate(initial = false) {
152153
this.setHighlightedNode(null);
153-
const candidateNodes = this.getCandidateNodes();
154-
if (!candidateNodes.length) return;
155-
this.setHighlightedNode(candidateNodes[0]!);
154+
155+
let nodes = this.getCandidateNodes();
156+
if (!nodes.length) return;
157+
158+
// don't consider nodes that aren't visible within the viewport
159+
if (this.viewportNode) {
160+
const viewportRect = this.viewportNode.getBoundingClientRect();
161+
162+
nodes = nodes.filter((node) => {
163+
if (!this.viewportNode) return false;
164+
165+
const nodeRect = node.getBoundingClientRect();
166+
167+
const isNodeFullyVisible =
168+
nodeRect.right < viewportRect.right &&
169+
nodeRect.left > viewportRect.left &&
170+
nodeRect.bottom < viewportRect.bottom &&
171+
nodeRect.top > viewportRect.top;
172+
173+
return isNodeFullyVisible;
174+
});
175+
}
176+
177+
this.setHighlightedNode(nodes[0]!, initial);
156178
}
157179

158180
getNodeByValue(value: string): HTMLElement | null {
@@ -261,9 +283,7 @@ export class SelectSingleRootState extends SelectBaseRootState {
261283
}
262284
}
263285
// if no value is set, we want to highlight the first item
264-
const firstCandidate = this.getCandidateNodes()[0];
265-
if (!firstCandidate) return;
266-
this.setHighlightedNode(firstCandidate, true);
286+
this.setHighlightedToFirstCandidate(true);
267287
});
268288
}
269289
}
@@ -328,9 +348,7 @@ class SelectMultipleRootState extends SelectBaseRootState {
328348
}
329349
}
330350
// if no value is set, we want to highlight the first item
331-
const firstCandidate = this.getCandidateNodes()[0];
332-
if (!firstCandidate) return;
333-
this.setHighlightedNode(firstCandidate, true);
351+
this.setHighlightedToFirstCandidate(true);
334352
});
335353
}
336354
}
@@ -869,7 +887,6 @@ export class SelectContentState {
869887
readonly opts: SelectContentStateOpts;
870888
readonly root: SelectRoot;
871889
readonly attachment: RefAttachment;
872-
viewportNode = $state<HTMLElement | null>(null);
873890
isPositioned = $state(false);
874891
domContext: DOMContext;
875892

@@ -1237,7 +1254,9 @@ export class SelectViewportState {
12371254
this.opts = opts;
12381255
this.content = content;
12391256
this.root = content.root;
1240-
this.attachment = attachRef(opts.ref, (v) => (this.content.viewportNode = v));
1257+
this.attachment = attachRef(opts.ref, (v) => {
1258+
this.root.viewportNode = v;
1259+
});
12411260
}
12421261

12431262
readonly props = $derived.by(
@@ -1371,11 +1390,11 @@ export class SelectScrollDownButtonState {
13711390
this.root = scrollButtonState.root;
13721391
this.scrollButtonState.onAutoScroll = this.handleAutoScroll;
13731392

1374-
watch([() => this.content.viewportNode, () => this.content.isPositioned], () => {
1375-
if (!this.content.viewportNode || !this.content.isPositioned) return;
1393+
watch([() => this.root.viewportNode, () => this.content.isPositioned], () => {
1394+
if (!this.root.viewportNode || !this.content.isPositioned) return;
13761395
this.handleScroll(true);
13771396

1378-
return on(this.content.viewportNode, "scroll", () => this.handleScroll());
1397+
return on(this.root.viewportNode, "scroll", () => this.handleScroll());
13791398
});
13801399

13811400
/**
@@ -1385,11 +1404,11 @@ export class SelectScrollDownButtonState {
13851404
watch(
13861405
[
13871406
() => this.root.opts.inputValue.current,
1388-
() => this.content.viewportNode,
1407+
() => this.root.viewportNode,
13891408
() => this.content.isPositioned,
13901409
],
13911410
() => {
1392-
if (!this.content.viewportNode || !this.content.isPositioned) return;
1411+
if (!this.root.viewportNode || !this.content.isPositioned) return;
13931412
this.handleScroll(true);
13941413
}
13951414
);
@@ -1416,20 +1435,15 @@ export class SelectScrollDownButtonState {
14161435
if (!manual) {
14171436
this.scrollButtonState.handleUserScroll();
14181437
}
1419-
if (!this.content.viewportNode) return;
1420-
const maxScroll =
1421-
this.content.viewportNode.scrollHeight - this.content.viewportNode.clientHeight;
1422-
const paddingTop = Number.parseInt(
1423-
getComputedStyle(this.content.viewportNode).paddingTop,
1424-
10
1425-
);
1438+
if (!this.root.viewportNode) return;
1439+
const maxScroll = this.root.viewportNode.scrollHeight - this.root.viewportNode.clientHeight;
1440+
const paddingTop = Number.parseInt(getComputedStyle(this.root.viewportNode).paddingTop, 10);
14261441

1427-
this.canScrollDown =
1428-
Math.ceil(this.content.viewportNode.scrollTop) < maxScroll - paddingTop;
1442+
this.canScrollDown = Math.ceil(this.root.viewportNode.scrollTop) < maxScroll - paddingTop;
14291443
};
14301444

14311445
handleAutoScroll = () => {
1432-
const viewport = this.content.viewportNode;
1446+
const viewport = this.root.viewportNode;
14331447
const selectedItem = this.root.highlightedNode;
14341448
if (!viewport || !selectedItem) return;
14351449
viewport.scrollTop = viewport.scrollTop + selectedItem.offsetHeight;
@@ -1461,11 +1475,11 @@ export class SelectScrollUpButtonState {
14611475
this.root = scrollButtonState.root;
14621476
this.scrollButtonState.onAutoScroll = this.handleAutoScroll;
14631477

1464-
watch([() => this.content.viewportNode, () => this.content.isPositioned], () => {
1465-
if (!this.content.viewportNode || !this.content.isPositioned) return;
1478+
watch([() => this.root.viewportNode, () => this.content.isPositioned], () => {
1479+
if (!this.root.viewportNode || !this.content.isPositioned) return;
14661480

14671481
this.handleScroll(true);
1468-
return on(this.content.viewportNode, "scroll", () => this.handleScroll());
1482+
return on(this.root.viewportNode, "scroll", () => this.handleScroll());
14691483
});
14701484
}
14711485

@@ -1477,18 +1491,15 @@ export class SelectScrollUpButtonState {
14771491
if (!manual) {
14781492
this.scrollButtonState.handleUserScroll();
14791493
}
1480-
if (!this.content.viewportNode) return;
1481-
const paddingTop = Number.parseInt(
1482-
getComputedStyle(this.content.viewportNode).paddingTop,
1483-
10
1484-
);
1485-
this.canScrollUp = this.content.viewportNode.scrollTop - paddingTop > 0.1;
1494+
if (!this.root.viewportNode) return;
1495+
const paddingTop = Number.parseInt(getComputedStyle(this.root.viewportNode).paddingTop, 10);
1496+
this.canScrollUp = this.root.viewportNode.scrollTop - paddingTop > 0.1;
14861497
};
14871498

14881499
handleAutoScroll = () => {
1489-
if (!this.content.viewportNode || !this.root.highlightedNode) return;
1490-
this.content.viewportNode.scrollTop =
1491-
this.content.viewportNode.scrollTop - this.root.highlightedNode.offsetHeight;
1500+
if (!this.root.viewportNode || !this.root.highlightedNode) return;
1501+
this.root.viewportNode.scrollTop =
1502+
this.root.viewportNode.scrollTop - this.root.highlightedNode.offsetHeight;
14921503
};
14931504

14941505
readonly props = $derived.by(

0 commit comments

Comments
 (0)