Skip to content

Commit 9da0baa

Browse files
authored
feat: add navigationHighlight to optionally disable built-in feature (#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
1 parent a87f7f8 commit 9da0baa

File tree

8 files changed

+288
-53
lines changed

8 files changed

+288
-53
lines changed

packages/demo/src/app-routing.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import Options33 from './options/options33';
6464
import Options34 from './options/options34';
6565
import Options35 from './options/options35';
6666
import Options36 from './options/options36';
67+
import Options37 from './options/options37';
6768

6869
export const navbarRouting = [
6970
{ name: 'getting-started', view: '/src/getting-started.html', viewModel: GettingStarted, title: 'Getting Started' },
@@ -130,6 +131,7 @@ export const exampleRouting = [
130131
{ name: 'options34', view: '/src/options/options34.html', viewModel: Options34, title: 'Show Search Clear' },
131132
{ name: 'options35', view: '/src/options/options35.html', viewModel: Options35, title: 'Custom Diacritic Parser' },
132133
{ name: 'options36', view: '/src/options/options36.html', viewModel: Options36, title: 'Infinite Scroll' },
134+
{ name: 'options37', view: '/src/options/options37.html', viewModel: Options37, title: 'Navigation Highlight' },
133135
],
134136
},
135137
{
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
<div class="row">
2+
<div class="col-md-12 title-desc">
3+
<h2 class="bd-title">
4+
Navigation Highlight
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/options02.html"
11+
>html</a
12+
>
13+
|
14+
<a target="_blank" href="https://github.com/ghiscoding/multiple-select-vanilla/blob/main/packages/demo/src/options/options02.ts"
15+
>ts</a
16+
>
17+
</span>
18+
</span>
19+
</h2>
20+
<div class="demo-subtitle">
21+
The <code>navigationHighlight</code> is enabled by default and is very similar to <code>tabIndex</code>,
22+
it listens to up/down arrows and mouse hovering.
23+
You can then click on Enter key to select the highlighted option without even losing your current focus.
24+
</div>
25+
</div>
26+
</div>
27+
28+
<div>
29+
<div class="mb-3 row">
30+
<label class="col-sm-2">Single Group Select</label>
31+
32+
<div class="col-sm-10">
33+
<select class="multiple-select full-width" data-test="select1">
34+
<optgroup label="Group 1">
35+
<option value="1">Option 1</option>
36+
<option value="2">Option 2</option>
37+
<option value="3">Option 3</option>
38+
</optgroup>
39+
<optgroup label="Group 2">
40+
<option value="4">Option 4</option>
41+
<option value="5">Option 5</option>
42+
<option value="6">Option 6</option>
43+
</optgroup>
44+
<optgroup label="Group 3">
45+
<option value="7">Option 7</option>
46+
<option value="8">Option 8</option>
47+
<option value="9">Option 9</option>
48+
</optgroup>
49+
</select>
50+
</div>
51+
</div>
52+
53+
<div class="mb-3 row">
54+
<label class="col-sm-2">Multiple Select</label>
55+
56+
<div class="col-sm-10">
57+
<select multiple="multiple" class="multiple-select full-width" data-test="select2">
58+
<option value="1">January</option>
59+
<option value="2">February</option>
60+
<option value="3">March</option>
61+
<option value="4">April</option>
62+
<option value="5">May</option>
63+
<option value="6">June</option>
64+
<option value="7">July</option>
65+
<option value="8">August</option>
66+
<option value="9">September</option>
67+
<option value="10">October</option>
68+
<option value="11">November</option>
69+
<option value="12">December</option>
70+
</select>
71+
</div>
72+
</div>
73+
74+
<div class="mb-3 row">
75+
<label class="col-sm-2"> Group Select </label>
76+
77+
<div class="col-sm-10">
78+
<select multiple="multiple" class="full-width" data-test="select3">
79+
<optgroup label="Group 1" disabled="disabled">
80+
<option value="1" selected>Option 1</option>
81+
<option value="2">Option 2</option>
82+
<option value="3">Option 3</option>
83+
</optgroup>
84+
<optgroup label="Group 2">
85+
<option value="4">Option 4</option>
86+
<option value="5" selected>Option 5</option>
87+
<option value="6">Option 6</option>
88+
</optgroup>
89+
<optgroup label="Group 3">
90+
<option value="7" disabled="disabled">Option 7</option>
91+
<option value="8">Option 8</option>
92+
<option value="9">Option 9</option>
93+
</optgroup>
94+
</select>
95+
</div>
96+
</div>
97+
98+
<div class="mb-3 row">
99+
<label class="col-sm-2">Select with Filter</label>
100+
101+
<div class="col-sm-10">
102+
<select multiple="multiple" class="full-width" data-test="select4">
103+
<option value="1">abc</option>
104+
<option value="2">bcd</option>
105+
<option value="3">cde</option>
106+
<option value="4">def</option>
107+
<option value="5">efg</option>
108+
<option value="6">fgh</option>
109+
<option value="7">ghi</option>
110+
<option value="8">hij</option>
111+
<option value="9">ijk</option>
112+
<option value="10">jkl</option>
113+
<option value="11">klm</option>
114+
<option value="12">lmn</option>
115+
<option value="13">mno</option>
116+
<option value="14">nop</option>
117+
<option value="15">opq</option>
118+
<option value="16">pqr</option>
119+
<option value="17">qrs</option>
120+
<option value="18">rst</option>
121+
<option value="19">stu</option>
122+
<option value="20">tuv</option>
123+
<option value="21">uvw</option>
124+
<option value="22">vwx</option>
125+
<option value="23">wxy</option>
126+
<option value="24">xyz</option>
127+
<option value="25">123</option>
128+
<option value="26">234</option>
129+
<option value="27">345</option>
130+
<option value="28">456</option>
131+
<option value="29">567</option>
132+
<option value="30">678</option>
133+
<option value="31">789</option>
134+
</select>
135+
</div>
136+
</div>
137+
</div>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { type MultipleSelectInstance, multipleSelect } from 'multiple-select-vanilla';
2+
3+
export default class Example {
4+
ms1?: MultipleSelectInstance;
5+
ms2?: MultipleSelectInstance;
6+
ms3?: MultipleSelectInstance;
7+
ms4?: MultipleSelectInstance;
8+
9+
mount() {
10+
this.ms1 = multipleSelect('select[data-test=select1]') as MultipleSelectInstance;
11+
this.ms2 = multipleSelect('select[data-test=select2]') as MultipleSelectInstance;
12+
this.ms3 = multipleSelect('select[data-test=select3]') as MultipleSelectInstance;
13+
this.ms4 = multipleSelect('select[data-test=select4]', { filter: true }) as MultipleSelectInstance;
14+
}
15+
16+
unmount() {
17+
// destroy ms instance(s) to avoid DOM leaks
18+
this.ms1?.destroy();
19+
this.ms2?.destroy();
20+
this.ms3?.destroy();
21+
this.ms4?.destroy();
22+
this.ms1 = undefined;
23+
this.ms2 = undefined;
24+
this.ms3 = undefined;
25+
this.ms4 = undefined;
26+
}
27+
}

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

Lines changed: 54 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -939,63 +939,65 @@ export class MultipleSelectInstance {
939939
input?.focus();
940940
}
941941

942-
// when hovering an select option, we will also change the highlight to that option
943-
this._bindEventService.bind(
944-
this.dropElm,
945-
'mouseover',
946-
((e: KeyboardEvent & { target: HTMLDivElement | HTMLLIElement }) => {
947-
const liElm = (e.target.closest('.ms-select-all') || e.target.closest('li')) as HTMLLIElement;
948-
if (this.dropElm.contains(liElm)) {
949-
const optionElms = this.dropElm?.querySelectorAll<HTMLLIElement>(OPTIONS_LIST_SELECTOR) || [];
950-
const newIdx = Array.from(optionElms).findIndex(el => el.dataset.key === liElm.dataset.key);
951-
if (this._currentHighlightIndex !== newIdx && !liElm.classList.contains('disabled')) {
952-
this._currentSelectedElm = liElm;
953-
this._currentHighlightIndex = newIdx;
954-
this.changeCurrentOptionHighlight(liElm);
942+
if (this.options.navigationHighlight) {
943+
// when hovering an select option, we will also change the highlight to that option
944+
this._bindEventService.bind(
945+
this.dropElm,
946+
'mouseover',
947+
((e: KeyboardEvent & { target: HTMLDivElement | HTMLLIElement }) => {
948+
const liElm = (e.target.closest('.ms-select-all') || e.target.closest('li')) as HTMLLIElement;
949+
if (this.dropElm.contains(liElm)) {
950+
const optionElms = this.dropElm?.querySelectorAll<HTMLLIElement>(OPTIONS_LIST_SELECTOR) || [];
951+
const newIdx = Array.from(optionElms).findIndex(el => el.dataset.key === liElm.dataset.key);
952+
if (this._currentHighlightIndex !== newIdx && !liElm.classList.contains('disabled')) {
953+
this._currentSelectedElm = liElm;
954+
this._currentHighlightIndex = newIdx;
955+
this.changeCurrentOptionHighlight(liElm);
956+
}
955957
}
956-
}
957-
}) as EventListener,
958-
undefined,
959-
'hover-highlight',
960-
);
958+
}) as EventListener,
959+
undefined,
960+
'hover-highlight',
961+
);
961962

962-
// add keydown event listeners to watch for up/down arrows and focus on previous/next item
963-
// we will ignore divider and if key pressed is the Enter/Space key then we'll instead select/deselect input checkbox
964-
// we will also remove any previous bindings that might exist which happen when we use VirtualScroll
965-
this._bindEventService.bind(
966-
this.dropElm,
967-
'keydown',
968-
((e: KeyboardEvent & { target: HTMLDivElement | HTMLLIElement }) => {
969-
switch (e.key) {
970-
case 'ArrowUp':
971-
e.preventDefault();
972-
this.moveFocusUp();
973-
break;
974-
case 'ArrowDown':
975-
e.preventDefault();
976-
this.moveFocusDown();
977-
break;
978-
case 'Enter':
979-
case ' ': {
980-
const liElm = e.target.closest('.ms-select-all') || e.target.closest('li');
981-
if ((e.key === ' ' && this.options.filter) || (this.options.filterAcceptOnEnter && !liElm)) {
982-
return;
983-
}
984-
e.preventDefault();
985-
this._currentSelectedElm?.querySelector('input')?.click();
963+
// add keydown event listeners to watch for up/down arrows and focus on previous/next item
964+
// we will ignore divider and if key pressed is the Enter/Space key then we'll instead select/deselect input checkbox
965+
// we will also remove any previous bindings that might exist which happen when we use VirtualScroll
966+
this._bindEventService.bind(
967+
this.dropElm,
968+
'keydown',
969+
((e: KeyboardEvent & { target: HTMLDivElement | HTMLLIElement }) => {
970+
switch (e.key) {
971+
case 'ArrowUp':
972+
e.preventDefault();
973+
this.moveFocusUp();
974+
break;
975+
case 'ArrowDown':
976+
e.preventDefault();
977+
this.moveFocusDown();
978+
break;
979+
case 'Enter':
980+
case ' ': {
981+
const liElm = e.target.closest('.ms-select-all') || e.target.closest('li');
982+
if ((e.key === ' ' && this.options.filter) || (this.options.filterAcceptOnEnter && !liElm)) {
983+
return;
984+
}
985+
e.preventDefault();
986+
this._currentSelectedElm?.querySelector('input')?.click();
986987

987-
// on single select, we should focus directly
988-
if (this.options.single) {
989-
this.choiceElm.focus();
990-
this.lastFocusedItemKey = this.choiceElm?.dataset.key || '';
988+
// on single select, we should focus directly
989+
if (this.options.single) {
990+
this.choiceElm.focus();
991+
this.lastFocusedItemKey = this.choiceElm?.dataset.key || '';
992+
}
993+
break;
991994
}
992-
break;
993995
}
994-
}
995-
}) as EventListener,
996-
undefined,
997-
'arrow-highlight',
998-
);
996+
}) as EventListener,
997+
undefined,
998+
'arrow-highlight',
999+
);
1000+
}
9991001

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

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const DEFAULTS: Partial<MultipleSelectOption> = {
5555
useSelectOptionLabel: false,
5656
useSelectOptionLabelToHtml: false,
5757

58+
navigationHighlight: true,
5859
infiniteScroll: false,
5960
virtualScroll: true,
6061

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ export interface MultipleSelectOption extends MultipleSelectLocale {
133133
/** Provide a name to the multiple select element. By default this option is set to ''. */
134134
name?: string;
135135

136+
/** Defaults to True, arrow navigation (and mouse hover) to highlight and possibly change selected option(s). */
137+
navigationHighlight?: boolean;
138+
136139
/** Use optional string to override text when filtering "No matches found" instead of `formatNoMatchesFound()`, the latter should be preferred */
137140
noMatchesFoundText?: string;
138141

playwright/e2e/methods01.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { test, expect } from '@playwright/test';
33
test.describe('Methods 01 - getOptions()', () => {
44
test('method returns default options when calling the method', async ({ page }) => {
55
let dialogText = '';
6-
page.on('dialog', async (alert) => {
6+
page.on('dialog', async alert => {
77
dialogText = alert.message();
88
await alert.dismiss();
99
});
@@ -45,6 +45,7 @@ test.describe('Methods 01 - getOptions()', () => {
4545
`"adjustedHeightPadding": 10,`,
4646
`"useSelectOptionLabel": false,`,
4747
`"useSelectOptionLabelToHtml": false,`,
48+
`"navigationHighlight": true,`,
4849
`"infiniteScroll": false,`,
4950
`"virtualScroll": true\n}`,
5051
].join('\n ');

playwright/e2e/options37.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test.describe('Option 37 - Navigation Highlight', () => {
4+
test('first select with single group select', async ({ page }) => {
5+
await page.goto('#/options37');
6+
7+
// 1st select
8+
await page.locator('[data-test="select1"].ms-parent').click();
9+
const optGroups = await page.locator('.group.hide-radio .optgroup.disabled');
10+
const liElms = await page.locator('div[data-test=select1] .option-level-1');
11+
await expect(optGroups).toBeDefined();
12+
await expect(optGroups).toHaveCount(3);
13+
await expect(liElms).toHaveCount(9);
14+
await expect(optGroups.nth(0)).toContainText('Group 1');
15+
await expect(optGroups.nth(1)).toContainText('Group 2');
16+
await expect(optGroups.nth(2)).toContainText('Group 3');
17+
page.keyboard.press('ArrowDown');
18+
page.keyboard.press('ArrowDown');
19+
page.keyboard.press('Space');
20+
await expect(page.locator('[data-test=select1].ms-parent .ms-choice span')).toHaveText('Option 3');
21+
await expect(await page.locator('div[data-test=select1].ms-parent')).not.toHaveClass('ms-parent-open');
22+
23+
// 2nd select
24+
await page.locator('[data-test="select2"].ms-parent').click();
25+
page.keyboard.press('ArrowDown');
26+
page.keyboard.press('ArrowDown');
27+
page.keyboard.press('Space');
28+
page.keyboard.press('ArrowDown');
29+
page.keyboard.press('Enter');
30+
await expect(page.locator('[data-test=select2].ms-parent .ms-choice span')).toHaveText('February, March');
31+
await page.locator('[data-test="select2"].ms-parent').click();
32+
await expect(await page.locator('div[data-test=select2].ms-parent')).not.toHaveClass('ms-parent-open');
33+
34+
// 3rd select
35+
await page.locator('[data-test="select3"].ms-parent').click();
36+
page.keyboard.press('ArrowDown');
37+
page.keyboard.press('Space');
38+
await expect(page.locator('[data-test=select3].ms-parent .ms-choice span')).toHaveText('4 of 9 selected');
39+
page.keyboard.press('ArrowDown');
40+
page.keyboard.press('Enter');
41+
await expect(page.locator('[data-test=select3].ms-parent .ms-choice span')).toHaveText('[Group 1: Option 1], [Group 2: Option 5, Option 6]');
42+
page.keyboard.press('ArrowUp');
43+
page.keyboard.press('Space');
44+
await expect(page.locator('[data-test=select3].ms-parent .ms-choice span')).toHaveText('4 of 9 selected');
45+
await expect(await page.locator('div[data-test=select3].ms-parent')).not.toHaveClass('ms-parent-open');
46+
await page.locator('[data-test="select3"].ms-parent').click();
47+
48+
// 4th select
49+
await page.locator('[data-test="select4"].ms-parent').click();
50+
page.keyboard.press('ArrowDown');
51+
page.keyboard.press('Enter');
52+
await page.keyboard.type('de');
53+
await page.getByLabel('def').check();
54+
await page.getByLabel('cde').check();
55+
const selectAllLoc = await page.locator('[data-test=select4] .ms-select-all input[type=checkbox]');
56+
expect(selectAllLoc).toBeChecked();
57+
await expect(page.locator('[data-test=select4].ms-drop input[data-name="selectItem"]')).toHaveCount(2);
58+
await expect(page.locator('[data-test=select4].ms-drop ul li.selected input[data-name="selectItem"]')).toHaveCount(2);
59+
await expect(page.locator('[data-test=select4] .ms-choice span')).toHaveText('abc, cde, def');
60+
await expect(page.getByRole('button', { name: 'cde, def' })).toHaveCount(1);
61+
});
62+
});

0 commit comments

Comments
 (0)