Skip to content

Commit 3702738

Browse files
committed
feat: Add MyPopoverFilterElement.js custom element
1 parent 1737e73 commit 3702738

3 files changed

Lines changed: 219 additions & 3 deletions

File tree

LICENSE.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
The MIT License (MIT)
22

3-
Copyright © 2021-2025 Nick Freear and contributors.
3+
Copyright © 2021-2026 Nick Freear and contributors.
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ See also: [web-vitals-element][].
2828

2929
## Usage
3030

31-
Available on [Unpkg][] and [Skypack][] CDNs. Note, templates can't currently be accessed from Skypack.
31+
Available on [esm.sh][], [Unpkg][] and [Skypack][] CDNs. Note, templates can't currently be accessed from Skypack.
3232

3333
```html
3434
<my-skip-link></my-skip-link>
@@ -92,7 +92,7 @@ Then:
9292
[ci-img]: https://github.com/nfreear/elements/actions/workflows/node.js.yml/badge.svg
9393
[demo]: https://nfreear.github.io/elements/demo/
9494
[pen]: https://codepen.io/collection/mrpzOQ
95-
[mit]: https://nfreear.mit-license.org/#2021
95+
[mit]: https://nfreear.mit-license.org/2021-2026
9696
[npm]: https://www.npmjs.com/package/ndf-elements
9797
[npm-img]: https://img.shields.io/npm/v/ndf-elements
9898
[unpkg]: https://unpkg.com
@@ -101,5 +101,6 @@ Then:
101101
[skypack]: https://cdn.skypack.dev
102102
"A JavaScript Delivery Network for modern web apps"
103103
[sp-cdn]: https://cdn.skypack.dev/ndf-elements
104+
[esm.sh]: https://esm.sh/
104105
[mdn]: https://developer.mozilla.org/en-US/docs/Web/Web_Components
105106
[web-vitals-element]: https://github.com/stefanjudis/web-vitals-element
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
/**
2+
* Filter a collection of options/elements, based on the value of a search input field.
3+
*
4+
* @customElement popover-filter
5+
* @demo https://codepen.io/nfreear/pen/QwEBORL
6+
* @status
7+
*/
8+
export class MyPopoverFilterElement extends HTMLElement {
9+
#optionElements;
10+
#inputElem;
11+
#popoverElem;
12+
#outputElem;
13+
14+
/*
15+
Public API.
16+
*/
17+
/** @return {string} */
18+
static getTag () {
19+
return 'my-popover-filter';
20+
}
21+
22+
/** Attribute (required) - a CSS selector for the collection of elements to filter.
23+
* (Queried relative to `this` element - `this.querySelectorAll(this.selector)`.)
24+
* @return {string}
25+
*/
26+
get #selector () {
27+
const selector = this.getAttribute('selector') ?? 'button';
28+
if (!selector) {
29+
throw new Error('"selector" is a required attribute (CSS selector syntax).');
30+
}
31+
return selector;
32+
}
33+
34+
/** Attribute (optional)- text label for the filter input field.
35+
* @return {string}
36+
*/
37+
get #label () { return this.getAttribute('label') || 'Filter'; }
38+
get #type () { return this.getAttribute('type') ?? 'text'; }
39+
get #autocomplete () { return this.getAttribute('autocomplete') ?? 'off'; }
40+
41+
/** @return {boolean} */
42+
get #addAria () { return this.hasAttribute('add-aria'); }
43+
get #setInput () { return this.hasAttribute('set-input'); }
44+
45+
/** @return {number} */
46+
get #minlength () { return parseInt(this.getAttribute('minlength') || 1); }
47+
get #selectDelayMs () { return parseInt(this.getAttribute('select-delay') || 100); }
48+
49+
/** @return {string} */
50+
get #outputTemplate () {
51+
return this.getAttribute('output-template') || '%d results';
52+
}
53+
54+
/** Get/ set the value of the filter input field.
55+
* @return {string}
56+
*/
57+
get value () { return this.#inputElem.value.trim(); }
58+
59+
set value (strValue) {
60+
this.#inputEventHandler(this.#mockEvent(strValue));
61+
this.#inputElem.value = strValue;
62+
}
63+
64+
/** Life cycle callbacks.
65+
* @return {void}
66+
*/
67+
connectedCallback () {
68+
const rootElem = this.attachShadow({ mode: 'open' });
69+
const { input, output, popover } = this.#createElements(rootElem);
70+
71+
this.#inputElem = input;
72+
this.#outputElem = output;
73+
this.#popoverElem = popover;
74+
75+
this.#inputElem.addEventListener('input', (ev) => this.#inputEventHandler(ev));
76+
this.#popoverElem.addEventListener('click', (ev) => this.#clickEventHandler(ev));
77+
this.#popoverElem.addEventListener('toggle', (ev) => this.#toggleEventHandler(ev));
78+
79+
this.dataset.ready = true;
80+
81+
console.debug('my-popover-filter:', [this]);
82+
}
83+
84+
/*
85+
Private helpers.
86+
*/
87+
88+
/** @return {HTMLInputElement} */
89+
90+
#lazyLoadElements () {
91+
if (!this.#optionElements) {
92+
this.#optionElements = this.querySelectorAll(this.#selector);
93+
}
94+
if (!this.#optionElements) {
95+
throw new Error(`No elements found with selector: ${this.#selector}`);
96+
}
97+
if (this.#addAria) {
98+
this.#optionElements.forEach((el) => el.setAttribute('role', 'option'));
99+
}
100+
}
101+
102+
#resetOptions () {
103+
this.#optionElements.forEach((el) => el.removeAttribute('aria-selected'));
104+
}
105+
106+
#inputEventHandler (ev) {
107+
// Late initialization - allow other (custom element) JS to run first!
108+
this.#lazyLoadElements();
109+
110+
const { count } = this.#filterElements(this.value);
111+
112+
this.#setPopoverState(count, ev);
113+
this.#setOutput(count);
114+
115+
this.dataset.query = this.value;
116+
this.dataset.count = count;
117+
this.dataset.total = this.#optionElements.length;
118+
119+
console.debug('input:', count, this.value, ev);
120+
}
121+
122+
#clickEventHandler (ev) {
123+
const { target } = ev;
124+
if (target.tagName === 'BUTTON' && this.#setInput) {
125+
this.#resetOptions();
126+
target.setAttribute('aria-selected', 'true');
127+
this.#inputElem.value = target.textContent;
128+
setTimeout(() => this.#setPopoverState(false), this.#selectDelayMs);
129+
}
130+
console.debug('click:', target, ev);
131+
}
132+
133+
#toggleEventHandler (ev) {
134+
const { newState } = ev;
135+
if (newState === 'closed') {
136+
this.#setPopoverState(false);
137+
}
138+
console.debug('toggle:', newState, ev);
139+
}
140+
141+
#setPopoverState (show, ev = null) {
142+
if (show) {
143+
this.#popoverElem.showPopover({ source: ev.target });
144+
} else {
145+
this.#popoverElem.hidePopover();
146+
}
147+
this.#inputElem.setAttribute('aria-expanded', !!show);
148+
}
149+
150+
#filterElements (queryStr) {
151+
const filtered = [...this.#optionElements].filter((el) => {
152+
const text = el.textContent;
153+
const value = el.value;
154+
const found = this.#defaultFilter(queryStr, { text, value });
155+
if (found) {
156+
el.removeAttribute('hidden');
157+
} else {
158+
el.setAttribute('hidden', '');
159+
}
160+
return found;
161+
});
162+
return { filtered, count: filtered.length };
163+
}
164+
165+
#defaultFilter (query, { text }) {
166+
const found = text.toLowerCase().includes(query.toLowerCase());
167+
return !!found;
168+
}
169+
170+
#mockEvent (value) { return { mockEvent: true, target: { value } }; }
171+
172+
#setOutput (count) {
173+
this.#outputElem.value = this.#outputTemplate.replace('%d', count);
174+
}
175+
176+
#createElement (tagName, partAttr, attributes = []) {
177+
const elem = document.createElement(tagName);
178+
const part = typeof partAttr === 'undefined' || partAttr === -1 ? tagName : partAttr;
179+
if (partAttr !== false) {
180+
elem.setAttribute('part', part);
181+
}
182+
attributes.forEach(([attr, value]) => { elem.setAttribute(attr, value); });
183+
return elem;
184+
}
185+
186+
#createElements (rootElem) {
187+
const label = this.#createElement('label', -1, [['for', 'search'], ['id', 'labelID']]);
188+
const input = this.#createElement('input', -1, [['id', 'search'], ['role', 'combobox'], ['aria-expanded', 'false']]);
189+
const output = this.#createElement('output');
190+
const popover = this.#createElement('div', 'popover', [['popover', ''], ['aria-labelledby', 'labelID']]);
191+
const listBox = this.#createElement('div', false, [['aria-labelledby', 'labelID']]);
192+
const slotElem = document.createElement('slot');
193+
194+
label.textContent = this.#label;
195+
196+
input.type = this.#type;
197+
input.setAttribute('autocomplete', this.#autocomplete);
198+
199+
listBox.appendChild(slotElem);
200+
popover.appendChild(listBox);
201+
202+
rootElem.appendChild(label);
203+
rootElem.appendChild(input);
204+
rootElem.appendChild(output);
205+
rootElem.appendChild(popover);
206+
207+
if (this.#addAria) {
208+
listBox.setAttribute('role', 'listbox');
209+
}
210+
211+
return { label, input, output, popover };
212+
}
213+
}
214+
215+
export default MyPopoverFilterElement;

0 commit comments

Comments
 (0)