Skip to content

Commit c779578

Browse files
Use custom multi select dropdowns for the tech selector (#1043)
* prototype multiselect dropdown with search * load technologies into new dropdown * move focus to correct position after filtering * add icons * add icons and limit selection to 10 * update roles * remove console * move icon, replace keycodes, remember selection when updating url * update styling sidebar * code cleanup * disable dropdowns until api is loaded * improve scrollable filter area, fix VoiceOver bug * Update src/js/techreport/combobox.js Co-authored-by: Barry Pollard <barry_pollard@hotmail.com> * Update src/js/techreport/combobox.js Co-authored-by: Barry Pollard <barry_pollard@hotmail.com> * add loading=lazy to logos * Update src/js/techreport/combobox.js --------- Co-authored-by: Barry Pollard <barry_pollard@hotmail.com> Co-authored-by: Barry Pollard <barrypollard@google.com>
1 parent 958d640 commit c779578

File tree

7 files changed

+580
-51
lines changed

7 files changed

+580
-51
lines changed

src/js/components/filters.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import ComboBox from "../techreport/combobox";
2+
13
const { DataUtils } = require("../techreport/utils/data");
24
class Filters {
35
constructor(filterData, filters) {
@@ -111,6 +113,11 @@ class Filters {
111113
});
112114
}
113115
});
116+
117+
const combo = document.querySelectorAll('[data-component="combobox"]');
118+
const url = new URL(location.href);
119+
const selected = url.searchParams.get('tech')?.split(',') || [];
120+
combo.forEach(box => new ComboBox(box, this.technologies, selected));
114121
}
115122

116123
/* Update the list with geographies */

src/js/techreport/combobox.js

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
class ComboBox {
2+
constructor(element, data, selected) {
3+
this.element = element;
4+
5+
this.data = data;
6+
this.filteredData = data;
7+
this.focusedOption = -1;
8+
this.focusedOptionStr = '';
9+
this.selected = selected || [];
10+
this.maxSelected = 10;
11+
12+
this.updateContent();
13+
this.monitorInput();
14+
15+
this.selected.forEach(name => this.showSelectedElement(name));
16+
this.element.querySelector('input[type="text"][disabled]').removeAttribute('disabled');
17+
}
18+
19+
updateContent(data) {
20+
const rows = data || this.data;
21+
// Add options in the dropdown list
22+
const listbox = this.element.querySelector('[role="listbox"]');
23+
rows.forEach((row, index) => {
24+
const option = document.createElement('div');
25+
option.setAttribute('role', 'option');
26+
option.setAttribute('aria-selected', 'false');
27+
option.dataset.name = row;
28+
option.dataset.key = index;
29+
option.textContent = row;
30+
option.id = `${this.element.dataset.id}-${row.replaceAll(' ','-')}`;
31+
const logo = document.createElement('img');
32+
logo.setAttribute('alt', '');
33+
logo.setAttribute('src', `https://cdn.httparchive.org/static/icons/${row}.png`);
34+
logo.setAttribute('loading', 'lazy');
35+
option.append(logo);
36+
if(this.selected.includes(row)) {
37+
option.setAttribute('aria-selected', true);
38+
}
39+
listbox.append(option);
40+
});
41+
}
42+
43+
monitorInput() {
44+
const input = this.element.querySelector('input[type="text"]');
45+
const listbox = this.element.querySelector('[role="listbox"]');
46+
listbox.addEventListener('mousedown', (e) => {
47+
e.preventDefault();
48+
if(e.target.getAttribute('role') === 'option') {
49+
const selected = e.target.getAttribute('aria-selected');
50+
if(selected === 'true') {
51+
this.unselectElement(e.target.dataset.name);
52+
} else if(!e.target.getAttribute('disabled') || e.target.getAttribute('disabled') === 'false') {
53+
this.selectElement(e.target);
54+
}
55+
}
56+
});
57+
input.addEventListener('click', this.showOptions.bind(this));
58+
input.addEventListener('input', this.filterOptions.bind(this));
59+
input.addEventListener('blur', this.inputBlur.bind(this));
60+
input.addEventListener('keydown', this.navigateOptions.bind(this));
61+
}
62+
63+
inputBlur() {
64+
this.hideOptions();
65+
}
66+
67+
showOptions() {
68+
const input = this.element.querySelector('[role="combobox"]');
69+
input.setAttribute('aria-expanded', 'true');
70+
const listbox = this.element.querySelector('[role="listbox"]');
71+
listbox.classList.remove('hidden');
72+
}
73+
74+
hideOptions() {
75+
const input = this.element.querySelector('[role="combobox"]');
76+
input.setAttribute('aria-expanded', 'false');
77+
const options = this.element.querySelector('[role="listbox"]');
78+
options.classList.add('hidden');
79+
}
80+
81+
filterOptions(e) {
82+
const search = e.target.value;
83+
const options = this.element.querySelector('[role="listbox"]');
84+
85+
this.filteredData = this.data.filter(row => {
86+
return row.toLowerCase().includes(search.toLowerCase());
87+
});
88+
89+
options.textContent = '';
90+
this.updateContent(this.filteredData);
91+
92+
if(options.classList.contains('hidden')) {
93+
this.showOptions();
94+
}
95+
96+
if(this.filteredData.includes(this.focusedOptionStr)) {
97+
this.focusedOption = this.filteredData.indexOf(this.focusedOptionStr);
98+
this.updateHighlightedOption();
99+
} else {
100+
this.focusedOption = -1;
101+
}
102+
}
103+
104+
navigateOptions(e) {
105+
const listbox = this.element.querySelector('[role="listbox"]');
106+
const options = this.element.querySelectorAll('[role="option"]');
107+
const key = e.key;
108+
109+
if(listbox.classList.contains('hidden') && key !== 'ArrowLeft' && key !== 'ArrowRight') {
110+
this.showOptions();
111+
}
112+
113+
switch(key) {
114+
case 'ArrowDown':
115+
this.highlightNext(options.length);
116+
break;
117+
case 'ArrowUp':
118+
this.highlightPrevious(-1);
119+
break;
120+
case 'Escape':
121+
this.hideOptions();
122+
break;
123+
case 'Enter':
124+
e.preventDefault();
125+
this.selectOption(options[this.focusedOption]);
126+
}
127+
}
128+
129+
highlightNext(max) {
130+
const nextKey = this.focusedOption + 1;
131+
if(nextKey < max) {
132+
this.focusedOption = nextKey;
133+
this.updateHighlightedOption();
134+
}
135+
}
136+
137+
highlightPrevious(min) {
138+
const previousKey = this.focusedOption - 1;
139+
if(previousKey > min) {
140+
this.focusedOption = previousKey;
141+
this.updateHighlightedOption();
142+
}
143+
}
144+
145+
selectOption(option) {
146+
const listbox = this.element.querySelector('[role="listbox"]');
147+
if(!listbox.classList.contains('hidden')) {
148+
const selected = option.getAttribute('aria-selected');
149+
if(selected === 'true') {
150+
this.unselectElement(option.dataset.name);
151+
} else {
152+
this.selectElement(option);
153+
}
154+
}
155+
}
156+
157+
updateHighlightedOption() {
158+
const options = this.element.querySelector('[role="listbox"]');
159+
const currentlySelected = options.querySelectorAll('.highlighted[role="option"]');
160+
currentlySelected.forEach(selected => selected.classList.remove('highlighted'));
161+
const option = options.querySelector(`[data-key="${this.focusedOption}"]`);
162+
option.classList.add('highlighted');
163+
this.scrollToElement(option);
164+
this.focusedOptionStr = this.filteredData[this.focusedOption];
165+
const input = this.element.querySelector('input[type="text"]');
166+
input.setAttribute('aria-activedescendant',`${option.id}`);
167+
}
168+
169+
selectElement(option) {
170+
/* Set the state of the element to selected */
171+
option.setAttribute('aria-selected', 'true');
172+
173+
174+
/* Keep track of the selected apps */
175+
if(!this.selected.includes(option.dataset.name)) {
176+
this.selected.push(option.dataset.name);
177+
}
178+
179+
/* Don't allow more than 10 selected */
180+
if(this.selected.length >= this.maxSelected) {
181+
const unselected = this.element.querySelectorAll('[role="listbox"] [role="option"][aria-selected="false"]');
182+
unselected.forEach(element => element.setAttribute('disabled', true));
183+
}
184+
185+
this.showSelectedElement(option.dataset.name);
186+
}
187+
188+
showSelectedElement(name) {
189+
/* Add selected element to an overview list */
190+
const selection = document.createElement('li');
191+
selection.dataset.name = name;
192+
193+
const deleteSelection = document.createElement('button');
194+
deleteSelection.setAttribute('type', 'button');
195+
deleteSelection.textContent = `${name}`;
196+
deleteSelection.dataset.name = name;
197+
deleteSelection.addEventListener('click', () => this.unselectElement(name));
198+
199+
/* Add the app logo */
200+
const appIcon = document.createElement('img');
201+
appIcon.setAttribute('src', `https://cdn.httparchive.org/static/icons/${encodeURI(name)}.png`);
202+
appIcon.setAttribute('alt', '');
203+
appIcon.classList.add('logo');
204+
deleteSelection.append(appIcon);
205+
206+
/* Add the delete icon */
207+
const deleteIcon = document.createElement('img');
208+
deleteIcon.setAttribute('src', '/static/img/close-filters.svg');
209+
deleteIcon.setAttribute('alt', 'delete');
210+
deleteIcon.classList.add('delete');
211+
deleteSelection.append(deleteIcon);
212+
213+
/* Add the delete app button to the list */
214+
selection.append(deleteSelection);
215+
const selectionContainer = this.element.querySelector('#combobox-tech-selected');
216+
selectionContainer.append(selection);
217+
218+
/* Add an invisible input field so the selected techs get submitted */
219+
const submitOption = document.createElement('input');
220+
submitOption.setAttribute('value', name);
221+
submitOption.setAttribute('type', 'checkbox');
222+
submitOption.setAttribute('name', 'tech');
223+
submitOption.setAttribute('checked', true);
224+
submitOption.setAttribute('tabindex', '-1');
225+
submitOption.textContent = name;
226+
const submitOptions = this.element.querySelector('[data-component="submit-options"]');
227+
submitOptions.append(submitOption);
228+
}
229+
230+
unselectElement(optionName) {
231+
const option = document.querySelector(`[role="option"][data-name="${optionName}"]`);
232+
if(option) {
233+
option.setAttribute('aria-selected', 'false');
234+
}
235+
const selection = this.element.querySelector(`#combobox-tech-selected li[data-name="${optionName}"]`);
236+
selection.remove();
237+
if(this.selected.includes(optionName)) {
238+
const index = this.selected.indexOf(optionName);
239+
this.selected.splice(index, 1);
240+
}
241+
const submitOption = this.element.querySelector(`[data-component="submit-options"] input[value="${optionName}"]`);
242+
submitOption.remove();
243+
244+
if(this.selected.length < this.maxSelected) {
245+
const disabled = this.element.querySelectorAll('[role="listbox"] [role="option"][disabled="true"]');
246+
disabled.forEach(element => element.setAttribute('disabled', false));
247+
}
248+
}
249+
250+
scrollToElement(element) {
251+
const container = this.element.querySelector('[role="listbox"]');
252+
const isAbove = element.offsetTop < container.scrollTop;
253+
const isBelow = element.offsetTop > container.offsetHeight;
254+
if(isAbove || isBelow) {
255+
container.scrollTop = element.offsetTop;
256+
}
257+
}
258+
}
259+
260+
export default ComboBox;

0 commit comments

Comments
 (0)