Skip to content

Commit

Permalink
feat: add new option infiniteScroll auto-scroll back to top (#224)
Browse files Browse the repository at this point in the history
- 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)
  • Loading branch information
ghiscoding authored Feb 21, 2024
1 parent 764aedc commit fb74e74
Show file tree
Hide file tree
Showing 9 changed files with 201 additions and 2 deletions.
2 changes: 2 additions & 0 deletions packages/demo/src/app-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import Options32 from './options/options32';
import Options33 from './options/options33';
import Options34 from './options/options34';
import Options35 from './options/options35';
import Options36 from './options/options36';

export const navbarRouting = [
{ name: 'getting-started', view: '/src/getting-started.html', viewModel: GettingStarted, title: 'Getting Started' },
Expand Down Expand Up @@ -126,6 +127,7 @@ export const exampleRouting = [
{ name: 'options33', view: '/src/options/options33.html', viewModel: Options33, title: 'Classes' },
{ name: 'options34', view: '/src/options/options34.html', viewModel: Options34, title: 'Show Search Clear' },
{ name: 'options35', view: '/src/options/options35.html', viewModel: Options35, title: 'Custom Diacritic Parser' },
{ name: 'options36', view: '/src/options/options36.html', viewModel: Options36, title: 'Infinite Scroll' },
],
},
{
Expand Down
45 changes: 45 additions & 0 deletions packages/demo/src/options/options36.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<div class="row mb-2">
<div class="col-md-12 title-desc">
<h2 class="bd-title">
Infinite Scroll
<span class="float-end links">
Code <span class="fa fa-link"></span>
<span class="small">
<a
target="_blank"
href="https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/demo/src/options/options36.html"
>html</a
>
|
<a target="_blank" href="https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/demo/src/options/options36.ts"
>ts</a
>
</span>
</span>
</h2>
<div class="demo-subtitle">
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
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)
</div>
</div>
</div>

<div>
<div class="mb-3 row">
<label class="col-sm-2">
Short List (25)
</label>

<div class="col-sm-10">
<select data-test="select1" id="select1" class="full-width"></select>
</div>
</div>

<div class="mb-3 row">
<label class="col-sm-2 col-form-label">Large List (2,000)</label>

<div class="col-sm-10">
<select multiple="multiple" data-test="select2" id="select2" class="full-width"></select>
</div>
</div>
</div>
38 changes: 38 additions & 0 deletions packages/demo/src/options/options36.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { type MultipleSelectInstance, multipleSelect } from 'multiple-select-vanilla';

export default class Example {
ms1?: MultipleSelectInstance;
ms2?: MultipleSelectInstance;

mount() {
const data1 = [];
const data2 = [];
for (let i = 0; i < 25; i++) {
data1.push({ text: `Title ${i}`, value: i });
}
for (let i = 0; i < 2000; i++) {
data2.push({ text: `<i class="fa fa-star"></i> Task ${i}`, value: i });
}

this.ms1 = multipleSelect('#select1', {
data: data1,
infiniteScroll: true,
}) as MultipleSelectInstance;

this.ms2 = multipleSelect('#select2', {
filter: true,
data: data2,
showSearchClear: true,
useSelectOptionLabelToHtml: true,
infiniteScroll: true,
}) as MultipleSelectInstance;
}

unmount() {
// destroy ms instance(s) to avoid DOM leaks
this.ms1?.destroy();
this.ms2?.destroy();
this.ms1 = undefined;
this.ms2 = undefined;
}
}
39 changes: 38 additions & 1 deletion packages/multiple-select-vanilla/src/MultipleSelectInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ export class MultipleSelectInstance {
this.updateDataEnd = this.updateData.length;
this.virtualScroll = null;
}

this.events();

return rows;
Expand All @@ -507,6 +508,17 @@ export class MultipleSelectInstance {
const rows: HtmlStruct[] = [];
this.updateData = [];
this.data?.forEach(dataRow => rows.push(...this.initListItem(dataRow)));

// when infinite scroll is enabled, we'll add an empty <li> element (that will never be clickable)
// so that scrolling to the last valid item will NOT automatically scroll back to the top of the list.
// However scrolling by 1 more item (the last invisible item) will at that time trigger the scroll back to the top of the list
if (this.options.infiniteScroll) {
rows.push({
tagName: 'li',
props: { className: 'ms-infinite-option', role: 'option', dataset: { key: 'infinite' } },
});
}

rows.push({ tagName: 'li', props: { className: 'ms-no-results', textContent: this.formatNoMatchesFound() } });

return rows;
Expand Down Expand Up @@ -703,6 +715,7 @@ export class MultipleSelectInstance {
'group-checkbox-list',
'hover-highlight',
'arrow-highlight',
'option-list-scroll',
]);

this.closeSearchElm = this.filterParentElm?.querySelector('.icon-close');
Expand Down Expand Up @@ -906,8 +919,8 @@ export class MultipleSelectInstance {
'input-checkbox-list',
);

// 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
if (this.lastFocusedItemKey) {
// 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
const input = this.dropElm.querySelector<HTMLInputElement>(`li[data-key=${this.lastFocusedItemKey}]`);
input?.focus();
}
Expand Down Expand Up @@ -969,6 +982,30 @@ export class MultipleSelectInstance {
undefined,
'arrow-highlight',
);

if (this.ulElm && this.options.infiniteScroll) {
this._bindEventService.bind(this.ulElm, 'scroll', this.infiniteScrollHandler.bind(this) as EventListener, undefined, 'option-list-scroll');
}
}

/**
* Checks if user reached the end of the list through mouse scrolling and/or arrow down,
* then scroll back to the top whenever that happens.
*/
protected infiniteScrollHandler(e: MouseEvent & { target: HTMLElement }) {
if (e.target && this.ulElm) {
const scrollPos = e.target.scrollTop + e.target.clientHeight;

if (scrollPos === this.ulElm.scrollHeight) {
if (this.virtualScroll) {
this.initListItems();
} else {
this.ulElm.scrollTop = 0;
}
this._currentHighlightIndex = 0;
this.highlightCurrentOption();
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ export interface MultipleSelectOption extends MultipleSelectLocale {
/** Hide the option groupd checkboses. By default this is set to false. */
hideOptgroupCheckboxes?: boolean;

/** Infinite Scroll will automatically reset the list (scroll back to top) whenever the scroll reaches the last item (end of the list) */
infiniteScroll?: boolean;

/** Whether or not Multiple Select open the select dropdown. */
isOpen?: boolean;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ $ms-drop-list-item-disabled-filter: Alpha(Opacity = 35) !default;
$ms-drop-list-item-disabled-opacity: 0.35 !default;
$ms-drop-zindex: 1050 !default;
$ms-input-focus-outline: none !default;
$ms-infinite-empty-option-height: 20px !default;
$ms-label-margin-bottom: 0 !default;
$ms-label-min-height: 1.25rem !default;
$ms-label-padding: 0 0 0 1.25rem !default;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,4 +333,8 @@
.ms-no-results {
display: none;
}

.ms-infinite-option {
height: var(--ms-infinite-empty-option-height, $ms-infinite-empty-option-height);
}
}
2 changes: 1 addition & 1 deletion playwright/e2e/methods01.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ test.describe('Methods 01 - getOptions()', () => {
await page.goto('#/methods01');
await page.getByRole('button', { name: 'getOptions' }).click();
const strArray = [
`{`,
'{',
`"name": "",`,
`"placeholder": "",`,
`"classes": "",`,
Expand Down
69 changes: 69 additions & 0 deletions playwright/e2e/options36.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { test, expect } from '@playwright/test';

test.describe('Options 36 - Infinite Scroll', () => {
test('select should use infinite scroll', async ({ page }) => {
await page.goto('#/options36');

// -- 1st Select
await page.locator('[data-test="select1"].ms-parent').click();

const ulElm1 = await page.locator('[data-test="select1"] .ms-drop ul');
const liElms1 = await page.locator('[data-test="select1"] .ms-drop ul li');
await expect(liElms1.nth(0)).toContainText('Title 0');
await liElms1.nth(0).click();
await expect(page.locator('[data-test=select1].ms-parent .ms-choice span')).toHaveText('Title 0');

// scroll near the end of the list
await page.locator('[data-test="select1"].ms-parent').click();
await ulElm1.evaluate(e => (e.scrollTop = e.scrollHeight - 10));
await page.locator('[data-test="select1"] .ms-drop label').filter({ hasText: 'Title 24' }).click();

// scroll completely to the end of the list & expect scrolling back to top
await page.locator('[data-test="select1"].ms-parent').click();
await ulElm1.evaluate(e => (e.scrollTop = e.scrollHeight));
const firstTitleLoc = await page.locator('div[data-test=select1] .ms-drop li:nth-of-type(1)');
await expect(firstTitleLoc).toContainText('Title 0');
await expect(firstTitleLoc).toHaveClass('hide-radio highlighted');
await page.keyboard.press('Enter');

// -- 2nd Select
await page.locator('[data-test=select2].ms-parent').click();
const ulElm2 = await page.locator('[data-test="select2"] .ms-drop ul');
const liElms2 = await page.locator('[data-test="select2"] .ms-drop ul li');
await expect(await liElms2.nth(4).locator('span').innerHTML()).toBe('<i class="fa fa-star"></i> Task 4');
await liElms2.nth(4).click();
await expect(await liElms2.nth(5).locator('span').innerHTML()).toBe('<i class="fa fa-star"></i> Task 5');
await liElms2.nth(5).click();
await page.getByRole('button', { name: '4, 5' }).click();

// scroll to the middle and click 1003
await page.locator('[data-test="select2"].ms-parent').click();
await ulElm2.evaluate(e => (e.scrollTop = e.scrollHeight / 2));
await page.locator('[data-test="select2"] .ms-drop label').filter({ hasText: '1003' }).click();
await page.getByRole('button', { name: '4, 5, 1003' });

// scroll to near the end and select last 2 labels
await ulElm2.evaluate(e => (e.scrollTop = e.scrollHeight - 300));
await expect(await page.locator('[data-test="select2"] .ms-drop li[data-key=option_1995] label span').innerHTML()).toBe(
'<i class="fa fa-star"></i> Task 1995',
);
await expect(await page.locator('[data-test="select2"] .ms-drop li[data-key=option_1996] label span').innerHTML()).toBe(
'<i class="fa fa-star"></i> Task 1996',
);
await page.locator('[data-test="select2"] .ms-drop label').filter({ hasText: '1995' }).click();
await page.locator('[data-test="select2"] .ms-drop label').filter({ hasText: '1996' }).click();
await page.getByRole('button', { name: '5 of 2000 selected' });

// pressing arrow down until we reach the end will scroll back to top of the list
page.keyboard.press('ArrowDown');
page.keyboard.press('ArrowDown');
page.keyboard.press('ArrowDown');
await expect(await page.locator('[data-test="select2"] .ms-drop li[data-key=option_1999]')).toHaveClass('highlighted');

page.keyboard.press('ArrowDown'); // Task 0 (scrolled back to top)

const firstTaskLoc = await page.locator('div[data-test=select2] .ms-drop li:nth-of-type(1)');
await expect(firstTaskLoc).toContainText('Task 0');
// await expect(await page.locator('[data-test="select2"] .ms-drop li[data-key=option_0]')).toHaveClass('highlighted');
});
});

0 comments on commit fb74e74

Please sign in to comment.