Skip to content

Commit

Permalink
feat: add navigationHighlight to optionally disable built-in feature (
Browse files Browse the repository at this point in the history
#235)

* feat: add `navigationHighlight` to optionally disable built-in feature
- for whatever reason, the user might want to disable the feature, hence this new option
  • Loading branch information
ghiscoding authored Mar 5, 2024
1 parent a87f7f8 commit 9da0baa
Show file tree
Hide file tree
Showing 8 changed files with 288 additions and 53 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 @@ -64,6 +64,7 @@ import Options33 from './options/options33';
import Options34 from './options/options34';
import Options35 from './options/options35';
import Options36 from './options/options36';
import Options37 from './options/options37';

export const navbarRouting = [
{ name: 'getting-started', view: '/src/getting-started.html', viewModel: GettingStarted, title: 'Getting Started' },
Expand Down Expand Up @@ -130,6 +131,7 @@ export const exampleRouting = [
{ 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' },
{ name: 'options37', view: '/src/options/options37.html', viewModel: Options37, title: 'Navigation Highlight' },
],
},
{
Expand Down
137 changes: 137 additions & 0 deletions packages/demo/src/options/options37.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<div class="row">
<div class="col-md-12 title-desc">
<h2 class="bd-title">
Navigation Highlight
<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/options02.html"
>html</a
>
|
<a target="_blank" href="https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/demo/src/options/options02.ts"
>ts</a
>
</span>
</span>
</h2>
<div class="demo-subtitle">
The <code>navigationHighlight</code> is enabled by default and is very similar to <code>tabIndex</code>,
it listens to up/down arrows and mouse hovering.
You can then click on Enter key to select the highlighted option without even losing your current focus.
</div>
</div>
</div>

<div>
<div class="mb-3 row">
<label class="col-sm-2">Single Group Select</label>

<div class="col-sm-10">
<select class="multiple-select full-width" data-test="select1">
<optgroup label="Group 1">
<option value="1">Option 1</option>
<option value="2">Option 2</option>
<option value="3">Option 3</option>
</optgroup>
<optgroup label="Group 2">
<option value="4">Option 4</option>
<option value="5">Option 5</option>
<option value="6">Option 6</option>
</optgroup>
<optgroup label="Group 3">
<option value="7">Option 7</option>
<option value="8">Option 8</option>
<option value="9">Option 9</option>
</optgroup>
</select>
</div>
</div>

<div class="mb-3 row">
<label class="col-sm-2">Multiple Select</label>

<div class="col-sm-10">
<select multiple="multiple" class="multiple-select full-width" data-test="select2">
<option value="1">January</option>
<option value="2">February</option>
<option value="3">March</option>
<option value="4">April</option>
<option value="5">May</option>
<option value="6">June</option>
<option value="7">July</option>
<option value="8">August</option>
<option value="9">September</option>
<option value="10">October</option>
<option value="11">November</option>
<option value="12">December</option>
</select>
</div>
</div>

<div class="mb-3 row">
<label class="col-sm-2"> Group Select </label>

<div class="col-sm-10">
<select multiple="multiple" class="full-width" data-test="select3">
<optgroup label="Group 1" disabled="disabled">
<option value="1" selected>Option 1</option>
<option value="2">Option 2</option>
<option value="3">Option 3</option>
</optgroup>
<optgroup label="Group 2">
<option value="4">Option 4</option>
<option value="5" selected>Option 5</option>
<option value="6">Option 6</option>
</optgroup>
<optgroup label="Group 3">
<option value="7" disabled="disabled">Option 7</option>
<option value="8">Option 8</option>
<option value="9">Option 9</option>
</optgroup>
</select>
</div>
</div>

<div class="mb-3 row">
<label class="col-sm-2">Select with Filter</label>

<div class="col-sm-10">
<select multiple="multiple" class="full-width" data-test="select4">
<option value="1">abc</option>
<option value="2">bcd</option>
<option value="3">cde</option>
<option value="4">def</option>
<option value="5">efg</option>
<option value="6">fgh</option>
<option value="7">ghi</option>
<option value="8">hij</option>
<option value="9">ijk</option>
<option value="10">jkl</option>
<option value="11">klm</option>
<option value="12">lmn</option>
<option value="13">mno</option>
<option value="14">nop</option>
<option value="15">opq</option>
<option value="16">pqr</option>
<option value="17">qrs</option>
<option value="18">rst</option>
<option value="19">stu</option>
<option value="20">tuv</option>
<option value="21">uvw</option>
<option value="22">vwx</option>
<option value="23">wxy</option>
<option value="24">xyz</option>
<option value="25">123</option>
<option value="26">234</option>
<option value="27">345</option>
<option value="28">456</option>
<option value="29">567</option>
<option value="30">678</option>
<option value="31">789</option>
</select>
</div>
</div>
</div>
27 changes: 27 additions & 0 deletions packages/demo/src/options/options37.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { type MultipleSelectInstance, multipleSelect } from 'multiple-select-vanilla';

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

mount() {
this.ms1 = multipleSelect('select[data-test=select1]') as MultipleSelectInstance;
this.ms2 = multipleSelect('select[data-test=select2]') as MultipleSelectInstance;
this.ms3 = multipleSelect('select[data-test=select3]') as MultipleSelectInstance;
this.ms4 = multipleSelect('select[data-test=select4]', { filter: true }) as MultipleSelectInstance;
}

unmount() {
// destroy ms instance(s) to avoid DOM leaks
this.ms1?.destroy();
this.ms2?.destroy();
this.ms3?.destroy();
this.ms4?.destroy();
this.ms1 = undefined;
this.ms2 = undefined;
this.ms3 = undefined;
this.ms4 = undefined;
}
}
106 changes: 54 additions & 52 deletions packages/multiple-select-vanilla/src/MultipleSelectInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -939,63 +939,65 @@ export class MultipleSelectInstance {
input?.focus();
}

// when hovering an select option, we will also change the highlight to that option
this._bindEventService.bind(
this.dropElm,
'mouseover',
((e: KeyboardEvent & { target: HTMLDivElement | HTMLLIElement }) => {
const liElm = (e.target.closest('.ms-select-all') || e.target.closest('li')) as HTMLLIElement;
if (this.dropElm.contains(liElm)) {
const optionElms = this.dropElm?.querySelectorAll<HTMLLIElement>(OPTIONS_LIST_SELECTOR) || [];
const newIdx = Array.from(optionElms).findIndex(el => el.dataset.key === liElm.dataset.key);
if (this._currentHighlightIndex !== newIdx && !liElm.classList.contains('disabled')) {
this._currentSelectedElm = liElm;
this._currentHighlightIndex = newIdx;
this.changeCurrentOptionHighlight(liElm);
if (this.options.navigationHighlight) {
// when hovering an select option, we will also change the highlight to that option
this._bindEventService.bind(
this.dropElm,
'mouseover',
((e: KeyboardEvent & { target: HTMLDivElement | HTMLLIElement }) => {
const liElm = (e.target.closest('.ms-select-all') || e.target.closest('li')) as HTMLLIElement;
if (this.dropElm.contains(liElm)) {
const optionElms = this.dropElm?.querySelectorAll<HTMLLIElement>(OPTIONS_LIST_SELECTOR) || [];
const newIdx = Array.from(optionElms).findIndex(el => el.dataset.key === liElm.dataset.key);
if (this._currentHighlightIndex !== newIdx && !liElm.classList.contains('disabled')) {
this._currentSelectedElm = liElm;
this._currentHighlightIndex = newIdx;
this.changeCurrentOptionHighlight(liElm);
}
}
}
}) as EventListener,
undefined,
'hover-highlight',
);
}) as EventListener,
undefined,
'hover-highlight',
);

// add keydown event listeners to watch for up/down arrows and focus on previous/next item
// we will ignore divider and if key pressed is the Enter/Space key then we'll instead select/deselect input checkbox
// we will also remove any previous bindings that might exist which happen when we use VirtualScroll
this._bindEventService.bind(
this.dropElm,
'keydown',
((e: KeyboardEvent & { target: HTMLDivElement | HTMLLIElement }) => {
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
this.moveFocusUp();
break;
case 'ArrowDown':
e.preventDefault();
this.moveFocusDown();
break;
case 'Enter':
case ' ': {
const liElm = e.target.closest('.ms-select-all') || e.target.closest('li');
if ((e.key === ' ' && this.options.filter) || (this.options.filterAcceptOnEnter && !liElm)) {
return;
}
e.preventDefault();
this._currentSelectedElm?.querySelector('input')?.click();
// add keydown event listeners to watch for up/down arrows and focus on previous/next item
// we will ignore divider and if key pressed is the Enter/Space key then we'll instead select/deselect input checkbox
// we will also remove any previous bindings that might exist which happen when we use VirtualScroll
this._bindEventService.bind(
this.dropElm,
'keydown',
((e: KeyboardEvent & { target: HTMLDivElement | HTMLLIElement }) => {
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
this.moveFocusUp();
break;
case 'ArrowDown':
e.preventDefault();
this.moveFocusDown();
break;
case 'Enter':
case ' ': {
const liElm = e.target.closest('.ms-select-all') || e.target.closest('li');
if ((e.key === ' ' && this.options.filter) || (this.options.filterAcceptOnEnter && !liElm)) {
return;
}
e.preventDefault();
this._currentSelectedElm?.querySelector('input')?.click();

// on single select, we should focus directly
if (this.options.single) {
this.choiceElm.focus();
this.lastFocusedItemKey = this.choiceElm?.dataset.key || '';
// on single select, we should focus directly
if (this.options.single) {
this.choiceElm.focus();
this.lastFocusedItemKey = this.choiceElm?.dataset.key || '';
}
break;
}
break;
}
}
}) as EventListener,
undefined,
'arrow-highlight',
);
}) as EventListener,
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');
Expand Down
1 change: 1 addition & 0 deletions packages/multiple-select-vanilla/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const DEFAULTS: Partial<MultipleSelectOption> = {
useSelectOptionLabel: false,
useSelectOptionLabelToHtml: false,

navigationHighlight: true,
infiniteScroll: false,
virtualScroll: true,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ export interface MultipleSelectOption extends MultipleSelectLocale {
/** Provide a name to the multiple select element. By default this option is set to ''. */
name?: string;

/** Defaults to True, arrow navigation (and mouse hover) to highlight and possibly change selected option(s). */
navigationHighlight?: boolean;

/** Use optional string to override text when filtering "No matches found" instead of `formatNoMatchesFound()`, the latter should be preferred */
noMatchesFoundText?: string;

Expand Down
3 changes: 2 additions & 1 deletion playwright/e2e/methods01.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { test, expect } from '@playwright/test';
test.describe('Methods 01 - getOptions()', () => {
test('method returns default options when calling the method', async ({ page }) => {
let dialogText = '';
page.on('dialog', async (alert) => {
page.on('dialog', async alert => {
dialogText = alert.message();
await alert.dismiss();
});
Expand Down Expand Up @@ -45,6 +45,7 @@ test.describe('Methods 01 - getOptions()', () => {
`"adjustedHeightPadding": 10,`,
`"useSelectOptionLabel": false,`,
`"useSelectOptionLabelToHtml": false,`,
`"navigationHighlight": true,`,
`"infiniteScroll": false,`,
`"virtualScroll": true\n}`,
].join('\n ');
Expand Down
62 changes: 62 additions & 0 deletions playwright/e2e/options37.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { test, expect } from '@playwright/test';

test.describe('Option 37 - Navigation Highlight', () => {
test('first select with single group select', async ({ page }) => {
await page.goto('#/options37');

// 1st select
await page.locator('[data-test="select1"].ms-parent').click();
const optGroups = await page.locator('.group.hide-radio .optgroup.disabled');
const liElms = await page.locator('div[data-test=select1] .option-level-1');
await expect(optGroups).toBeDefined();
await expect(optGroups).toHaveCount(3);
await expect(liElms).toHaveCount(9);
await expect(optGroups.nth(0)).toContainText('Group 1');
await expect(optGroups.nth(1)).toContainText('Group 2');
await expect(optGroups.nth(2)).toContainText('Group 3');
page.keyboard.press('ArrowDown');
page.keyboard.press('ArrowDown');
page.keyboard.press('Space');
await expect(page.locator('[data-test=select1].ms-parent .ms-choice span')).toHaveText('Option 3');
await expect(await page.locator('div[data-test=select1].ms-parent')).not.toHaveClass('ms-parent-open');

// 2nd select
await page.locator('[data-test="select2"].ms-parent').click();
page.keyboard.press('ArrowDown');
page.keyboard.press('ArrowDown');
page.keyboard.press('Space');
page.keyboard.press('ArrowDown');
page.keyboard.press('Enter');
await expect(page.locator('[data-test=select2].ms-parent .ms-choice span')).toHaveText('February, March');
await page.locator('[data-test="select2"].ms-parent').click();
await expect(await page.locator('div[data-test=select2].ms-parent')).not.toHaveClass('ms-parent-open');

// 3rd select
await page.locator('[data-test="select3"].ms-parent').click();
page.keyboard.press('ArrowDown');
page.keyboard.press('Space');
await expect(page.locator('[data-test=select3].ms-parent .ms-choice span')).toHaveText('4 of 9 selected');
page.keyboard.press('ArrowDown');
page.keyboard.press('Enter');
await expect(page.locator('[data-test=select3].ms-parent .ms-choice span')).toHaveText('[Group 1: Option 1], [Group 2: Option 5, Option 6]');
page.keyboard.press('ArrowUp');
page.keyboard.press('Space');
await expect(page.locator('[data-test=select3].ms-parent .ms-choice span')).toHaveText('4 of 9 selected');
await expect(await page.locator('div[data-test=select3].ms-parent')).not.toHaveClass('ms-parent-open');
await page.locator('[data-test="select3"].ms-parent').click();

// 4th select
await page.locator('[data-test="select4"].ms-parent').click();
page.keyboard.press('ArrowDown');
page.keyboard.press('Enter');
await page.keyboard.type('de');
await page.getByLabel('def').check();
await page.getByLabel('cde').check();
const selectAllLoc = await page.locator('[data-test=select4] .ms-select-all input[type=checkbox]');
expect(selectAllLoc).toBeChecked();
await expect(page.locator('[data-test=select4].ms-drop input[data-name="selectItem"]')).toHaveCount(2);
await expect(page.locator('[data-test=select4].ms-drop ul li.selected input[data-name="selectItem"]')).toHaveCount(2);
await expect(page.locator('[data-test=select4] .ms-choice span')).toHaveText('abc, cde, def');
await expect(page.getByRole('button', { name: 'cde, def' })).toHaveCount(1);
});
});

0 comments on commit 9da0baa

Please sign in to comment.