Skip to content

Commit fb74e74

Browse files
authored
feat: add new option infiniteScroll auto-scroll back to top (#224)
- when reaching the end of the list, it will automatically reset it back to the top of the list - the scroll can also be activated by using arrow down (highlight) to scroll 1 item at a time - this is not to be confused with Virtual Scroll which is similar but only renders a subset of large collection until we reach the end at which point it will stop, however the infinite scroll never stops (at least not until the user stops scrolling)
1 parent 764aedc commit fb74e74

File tree

9 files changed

+201
-2
lines changed

9 files changed

+201
-2
lines changed

packages/demo/src/app-routing.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import Options32 from './options/options32';
6262
import Options33 from './options/options33';
6363
import Options34 from './options/options34';
6464
import Options35 from './options/options35';
65+
import Options36 from './options/options36';
6566

6667
export const navbarRouting = [
6768
{ name: 'getting-started', view: '/src/getting-started.html', viewModel: GettingStarted, title: 'Getting Started' },
@@ -126,6 +127,7 @@ export const exampleRouting = [
126127
{ name: 'options33', view: '/src/options/options33.html', viewModel: Options33, title: 'Classes' },
127128
{ name: 'options34', view: '/src/options/options34.html', viewModel: Options34, title: 'Show Search Clear' },
128129
{ name: 'options35', view: '/src/options/options35.html', viewModel: Options35, title: 'Custom Diacritic Parser' },
130+
{ name: 'options36', view: '/src/options/options36.html', viewModel: Options36, title: 'Infinite Scroll' },
129131
],
130132
},
131133
{
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<div class="row mb-2">
2+
<div class="col-md-12 title-desc">
3+
<h2 class="bd-title">
4+
Infinite Scroll
5+
<span class="float-end links">
6+
Code <span class="fa fa-link"></span>
7+
<span class="small">
8+
<a
9+
target="_blank"
10+
href="https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/demo/src/options/options36.html"
11+
>html</a
12+
>
13+
|
14+
<a target="_blank" href="https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/demo/src/options/options36.ts"
15+
>ts</a
16+
>
17+
</span>
18+
</span>
19+
</h2>
20+
<div class="demo-subtitle">
21+
Enabling <code>infiniteScroll</code> will automatically scroll back to the top whenever reaching the end of the list (scrolling through either the mouse and/or arrow down). Note that this is not to be confused
22+
with Virtual Scroll which itself is enabled by default whenever the list is bigger than 200 items (the last list select below does use Virtual Scroll)
23+
</div>
24+
</div>
25+
</div>
26+
27+
<div>
28+
<div class="mb-3 row">
29+
<label class="col-sm-2">
30+
Short List (25)
31+
</label>
32+
33+
<div class="col-sm-10">
34+
<select data-test="select1" id="select1" class="full-width"></select>
35+
</div>
36+
</div>
37+
38+
<div class="mb-3 row">
39+
<label class="col-sm-2 col-form-label">Large List (2,000)</label>
40+
41+
<div class="col-sm-10">
42+
<select multiple="multiple" data-test="select2" id="select2" class="full-width"></select>
43+
</div>
44+
</div>
45+
</div>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { type MultipleSelectInstance, multipleSelect } from 'multiple-select-vanilla';
2+
3+
export default class Example {
4+
ms1?: MultipleSelectInstance;
5+
ms2?: MultipleSelectInstance;
6+
7+
mount() {
8+
const data1 = [];
9+
const data2 = [];
10+
for (let i = 0; i < 25; i++) {
11+
data1.push({ text: `Title ${i}`, value: i });
12+
}
13+
for (let i = 0; i < 2000; i++) {
14+
data2.push({ text: `<i class="fa fa-star"></i> Task ${i}`, value: i });
15+
}
16+
17+
this.ms1 = multipleSelect('#select1', {
18+
data: data1,
19+
infiniteScroll: true,
20+
}) as MultipleSelectInstance;
21+
22+
this.ms2 = multipleSelect('#select2', {
23+
filter: true,
24+
data: data2,
25+
showSearchClear: true,
26+
useSelectOptionLabelToHtml: true,
27+
infiniteScroll: true,
28+
}) as MultipleSelectInstance;
29+
}
30+
31+
unmount() {
32+
// destroy ms instance(s) to avoid DOM leaks
33+
this.ms1?.destroy();
34+
this.ms2?.destroy();
35+
this.ms1 = undefined;
36+
this.ms2 = undefined;
37+
}
38+
}

packages/multiple-select-vanilla/src/MultipleSelectInstance.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,7 @@ export class MultipleSelectInstance {
498498
this.updateDataEnd = this.updateData.length;
499499
this.virtualScroll = null;
500500
}
501+
501502
this.events();
502503

503504
return rows;
@@ -507,6 +508,17 @@ export class MultipleSelectInstance {
507508
const rows: HtmlStruct[] = [];
508509
this.updateData = [];
509510
this.data?.forEach(dataRow => rows.push(...this.initListItem(dataRow)));
511+
512+
// when infinite scroll is enabled, we'll add an empty <li> element (that will never be clickable)
513+
// so that scrolling to the last valid item will NOT automatically scroll back to the top of the list.
514+
// However scrolling by 1 more item (the last invisible item) will at that time trigger the scroll back to the top of the list
515+
if (this.options.infiniteScroll) {
516+
rows.push({
517+
tagName: 'li',
518+
props: { className: 'ms-infinite-option', role: 'option', dataset: { key: 'infinite' } },
519+
});
520+
}
521+
510522
rows.push({ tagName: 'li', props: { className: 'ms-no-results', textContent: this.formatNoMatchesFound() } });
511523

512524
return rows;
@@ -703,6 +715,7 @@ export class MultipleSelectInstance {
703715
'group-checkbox-list',
704716
'hover-highlight',
705717
'arrow-highlight',
718+
'option-list-scroll',
706719
]);
707720

708721
this.closeSearchElm = this.filterParentElm?.querySelector('.icon-close');
@@ -906,8 +919,8 @@ export class MultipleSelectInstance {
906919
'input-checkbox-list',
907920
);
908921

909-
// if we previously had an item focused and the VirtualScroll recreates the list, we need to refocus on last item by its input data-key
910922
if (this.lastFocusedItemKey) {
923+
// if we previously had an item focused and the VirtualScroll recreates the list, we need to refocus on last item by its input data-key
911924
const input = this.dropElm.querySelector<HTMLInputElement>(`li[data-key=${this.lastFocusedItemKey}]`);
912925
input?.focus();
913926
}
@@ -969,6 +982,30 @@ export class MultipleSelectInstance {
969982
undefined,
970983
'arrow-highlight',
971984
);
985+
986+
if (this.ulElm && this.options.infiniteScroll) {
987+
this._bindEventService.bind(this.ulElm, 'scroll', this.infiniteScrollHandler.bind(this) as EventListener, undefined, 'option-list-scroll');
988+
}
989+
}
990+
991+
/**
992+
* Checks if user reached the end of the list through mouse scrolling and/or arrow down,
993+
* then scroll back to the top whenever that happens.
994+
*/
995+
protected infiniteScrollHandler(e: MouseEvent & { target: HTMLElement }) {
996+
if (e.target && this.ulElm) {
997+
const scrollPos = e.target.scrollTop + e.target.clientHeight;
998+
999+
if (scrollPos === this.ulElm.scrollHeight) {
1000+
if (this.virtualScroll) {
1001+
this.initListItems();
1002+
} else {
1003+
this.ulElm.scrollTop = 0;
1004+
}
1005+
this._currentHighlightIndex = 0;
1006+
this.highlightCurrentOption();
1007+
}
1008+
}
9721009
}
9731010

9741011
/**

packages/multiple-select-vanilla/src/interfaces/multipleSelectOption.interface.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ export interface MultipleSelectOption extends MultipleSelectLocale {
9191
/** Hide the option groupd checkboses. By default this is set to false. */
9292
hideOptgroupCheckboxes?: boolean;
9393

94+
/** Infinite Scroll will automatically reset the list (scroll back to top) whenever the scroll reaches the last item (end of the list) */
95+
infiniteScroll?: boolean;
96+
9497
/** Whether or not Multiple Select open the select dropdown. */
9598
isOpen?: boolean;
9699

packages/multiple-select-vanilla/src/styles/_variables.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ $ms-drop-list-item-disabled-filter: Alpha(Opacity = 35) !default;
4444
$ms-drop-list-item-disabled-opacity: 0.35 !default;
4545
$ms-drop-zindex: 1050 !default;
4646
$ms-input-focus-outline: none !default;
47+
$ms-infinite-empty-option-height: 20px !default;
4748
$ms-label-margin-bottom: 0 !default;
4849
$ms-label-min-height: 1.25rem !default;
4950
$ms-label-padding: 0 0 0 1.25rem !default;

packages/multiple-select-vanilla/src/styles/multiple-select.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,4 +333,8 @@
333333
.ms-no-results {
334334
display: none;
335335
}
336+
337+
.ms-infinite-option {
338+
height: var(--ms-infinite-empty-option-height, $ms-infinite-empty-option-height);
339+
}
336340
}

playwright/e2e/methods01.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ test.describe('Methods 01 - getOptions()', () => {
1111
await page.goto('#/methods01');
1212
await page.getByRole('button', { name: 'getOptions' }).click();
1313
const strArray = [
14-
`{`,
14+
'{',
1515
`"name": "",`,
1616
`"placeholder": "",`,
1717
`"classes": "",`,

playwright/e2e/options36.spec.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test.describe('Options 36 - Infinite Scroll', () => {
4+
test('select should use infinite scroll', async ({ page }) => {
5+
await page.goto('#/options36');
6+
7+
// -- 1st Select
8+
await page.locator('[data-test="select1"].ms-parent').click();
9+
10+
const ulElm1 = await page.locator('[data-test="select1"] .ms-drop ul');
11+
const liElms1 = await page.locator('[data-test="select1"] .ms-drop ul li');
12+
await expect(liElms1.nth(0)).toContainText('Title 0');
13+
await liElms1.nth(0).click();
14+
await expect(page.locator('[data-test=select1].ms-parent .ms-choice span')).toHaveText('Title 0');
15+
16+
// scroll near the end of the list
17+
await page.locator('[data-test="select1"].ms-parent').click();
18+
await ulElm1.evaluate(e => (e.scrollTop = e.scrollHeight - 10));
19+
await page.locator('[data-test="select1"] .ms-drop label').filter({ hasText: 'Title 24' }).click();
20+
21+
// scroll completely to the end of the list & expect scrolling back to top
22+
await page.locator('[data-test="select1"].ms-parent').click();
23+
await ulElm1.evaluate(e => (e.scrollTop = e.scrollHeight));
24+
const firstTitleLoc = await page.locator('div[data-test=select1] .ms-drop li:nth-of-type(1)');
25+
await expect(firstTitleLoc).toContainText('Title 0');
26+
await expect(firstTitleLoc).toHaveClass('hide-radio highlighted');
27+
await page.keyboard.press('Enter');
28+
29+
// -- 2nd Select
30+
await page.locator('[data-test=select2].ms-parent').click();
31+
const ulElm2 = await page.locator('[data-test="select2"] .ms-drop ul');
32+
const liElms2 = await page.locator('[data-test="select2"] .ms-drop ul li');
33+
await expect(await liElms2.nth(4).locator('span').innerHTML()).toBe('<i class="fa fa-star"></i> Task 4');
34+
await liElms2.nth(4).click();
35+
await expect(await liElms2.nth(5).locator('span').innerHTML()).toBe('<i class="fa fa-star"></i> Task 5');
36+
await liElms2.nth(5).click();
37+
await page.getByRole('button', { name: '4, 5' }).click();
38+
39+
// scroll to the middle and click 1003
40+
await page.locator('[data-test="select2"].ms-parent').click();
41+
await ulElm2.evaluate(e => (e.scrollTop = e.scrollHeight / 2));
42+
await page.locator('[data-test="select2"] .ms-drop label').filter({ hasText: '1003' }).click();
43+
await page.getByRole('button', { name: '4, 5, 1003' });
44+
45+
// scroll to near the end and select last 2 labels
46+
await ulElm2.evaluate(e => (e.scrollTop = e.scrollHeight - 300));
47+
await expect(await page.locator('[data-test="select2"] .ms-drop li[data-key=option_1995] label span').innerHTML()).toBe(
48+
'<i class="fa fa-star"></i> Task 1995',
49+
);
50+
await expect(await page.locator('[data-test="select2"] .ms-drop li[data-key=option_1996] label span').innerHTML()).toBe(
51+
'<i class="fa fa-star"></i> Task 1996',
52+
);
53+
await page.locator('[data-test="select2"] .ms-drop label').filter({ hasText: '1995' }).click();
54+
await page.locator('[data-test="select2"] .ms-drop label').filter({ hasText: '1996' }).click();
55+
await page.getByRole('button', { name: '5 of 2000 selected' });
56+
57+
// pressing arrow down until we reach the end will scroll back to top of the list
58+
page.keyboard.press('ArrowDown');
59+
page.keyboard.press('ArrowDown');
60+
page.keyboard.press('ArrowDown');
61+
await expect(await page.locator('[data-test="select2"] .ms-drop li[data-key=option_1999]')).toHaveClass('highlighted');
62+
63+
page.keyboard.press('ArrowDown'); // Task 0 (scrolled back to top)
64+
65+
const firstTaskLoc = await page.locator('div[data-test=select2] .ms-drop li:nth-of-type(1)');
66+
await expect(firstTaskLoc).toContainText('Task 0');
67+
// await expect(await page.locator('[data-test="select2"] .ms-drop li[data-key=option_0]')).toHaveClass('highlighted');
68+
});
69+
});

0 commit comments

Comments
 (0)