`)
61 |
62 | ## Credits
63 |
64 | * [MIT](./LICENSE)
--------------------------------------------------------------------------------
/demo.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | filter-container Demo
8 |
15 |
16 |
17 | filter-container
Web Component Demo
18 | Go back to the repository on GitHub.
19 |
20 |
77 |
78 | - 🇦🇫 Afghanistan
79 | - 🇦🇱 Albania
80 | - 🇩🇿 Algeria
81 | - 🇦🇷 Argentina
82 | - 🇦🇺 Australia
83 | - 🇧🇩 Bangladesh
84 | - 🇧🇪 Belgium
85 | - 🇧🇾 Belarus
86 | - 🇧🇷 Brazil
87 | - 🇨🇲 Cameroon
88 | - 🇨🇦 Canada
89 | - 🇨🇳 China
90 | - 🇨🇴 Colombia
91 | - 🇩🇰 Denmark
92 | - 🇪🇬 Egypt
93 | - 🇪🇹 Ethiopia
94 | - 🇫🇷 France
95 | - 🇩🇪 Germany
96 | - 🇬🇭 Ghana
97 | - 🇭🇰 Hong Kong
98 | - 🇮🇳 India
99 | - 🇮🇩 Indonesia
100 | - 🇮🇪 Ireland
101 | - 🇨🇮 Ivory Coast
102 | - 🇮🇹 Italy
103 | - 🇯🇵 Japan
104 | - 🇲🇬 Madagascar
105 | - 🇲🇽 Mexico
106 | - 🇳🇬 Nigeria
107 | - 🇵🇸 Palestine
108 | - 🇵🇦 Panama
109 | - 🇵🇬 Papua New Guinea
110 | - 🇵🇭 Philippines
111 | - 🇵🇹 Portugal
112 | - 🇷🇺 Russia
113 | - 🇰🇷 South Korea
114 | - 🇪🇸 Spain
115 | - 🇹🇼 Taiwan
116 | - 🇬🇧 United Kingdom
117 | - 🇺🇸 United States
118 | - 🇿🇼 Zimbabwe
119 | - Item excluded from count (for demo purposes)
120 |
121 | Sorry if your country is missing, I typed this out by hand 😅
122 |
123 |
124 |
125 |
--------------------------------------------------------------------------------
/filter-container.js:
--------------------------------------------------------------------------------
1 | class FilterContainer extends HTMLElement {
2 | static attrs = {
3 | oninit: "oninit",
4 | valueDelimiter: "delimiter",
5 | leaveUrlAlone: "leave-url-alone",
6 | mode: "filter-mode",
7 | bind: "data-filter-key",
8 | results: "data-filter-results",
9 | resultsExclude: "data-filter-results-exclude",
10 | };
11 |
12 | static register(tagName) {
13 | if("customElements" in window) {
14 | customElements.define(tagName || "filter-container", FilterContainer);
15 | }
16 | }
17 |
18 | getCss(keys) {
19 | return `${keys.map(key => `.filter-${key}--hide`).join(", ")} {
20 | display: none;
21 | }`;
22 | }
23 |
24 | connectedCallback() {
25 | this._lookedFor = {};
26 |
27 | this.bindEvents(this.formElements);
28 |
29 | // even if this isn’t supported, folks can still add the CSS manually.
30 | if(("replaceSync" in CSSStyleSheet.prototype) && !this._cssAdded) {
31 | let sheet = new CSSStyleSheet();
32 | let css = this.getCss(Object.keys(this.formElements));
33 | sheet.replaceSync(css);
34 | document.adoptedStyleSheets.push(sheet);
35 | this._cssAdded = true;
36 | }
37 |
38 | if(this.hasAttribute(FilterContainer.attrs.oninit)) {
39 | // This timeout was necessary to fix a bug with Google Chrome 93
40 | // Navigate to a filterable page, navigate away, use the back button to return
41 | // (connectedCallback would filter before the DOM was ready)
42 | window.setTimeout(() => {
43 | for(let key in this.formElements) {
44 | this.initFormElements(this.formElements[key]);
45 | this.applyFilterForKey(key);
46 | this.renderResultCount(true);
47 | }
48 | }, 0);
49 | }
50 | }
51 |
52 | get valueDelimiter() {
53 | if(!this._valueDelimiter) {
54 | this._valueDelimiter = this.getAttribute(FilterContainer.attrs.valueDelimiter) || ",";
55 | }
56 |
57 | return this._valueDelimiter;
58 | }
59 |
60 | get formElements() {
61 | if(!this._lookedFor.formElements) {
62 | let selector = `:scope [${FilterContainer.attrs.bind}]`;
63 | let results = {};
64 | for(let node of this.querySelectorAll(selector)) {
65 | let attr = node.getAttribute(FilterContainer.attrs.bind);
66 | if(!results[attr]) {
67 | results[attr] = [];
68 | }
69 | results[attr].push(node);
70 | }
71 | this._formElements = results;
72 | this._lookedFor.formElements = true;
73 | }
74 |
75 | return this._formElements;
76 | }
77 |
78 | getAllKeys() {
79 | return Object.keys(this.formElements);
80 | }
81 |
82 | getElementSelector(key) {
83 | return `data-filter-${key}`
84 | }
85 |
86 | getKeyFromAttributeName(attributeName) {
87 | return attributeName.substr("data-filter-".length);
88 | }
89 |
90 | getFilterMode(key) {
91 | if(!this.modes) {
92 | this.modes = {};
93 | }
94 | if(!this.modes[key]) {
95 | this.modes[key] = this.getAttribute(`${FilterContainer.attrs.mode}-${key}`);
96 | }
97 | if(!this.modes[key]) {
98 | if(!this.globalMode) {
99 | this.globalMode = this.getAttribute(FilterContainer.attrs.mode);
100 | }
101 | return this.globalMode;
102 | }
103 |
104 | return this.modes[key];
105 | }
106 |
107 | bindEvents() {
108 | this.addEventListener("input", e => {
109 | let closest = e.target.closest(`[${FilterContainer.attrs.bind}]`);
110 | if(closest) {
111 | this.applyFilterForElement(closest);
112 | requestAnimationFrame(() => {
113 | this.renderResultCount();
114 | });
115 | }
116 | }, false);
117 | }
118 |
119 | initFormElements(formElements) {
120 | for(let el of formElements) {
121 | let urlParamValues = this.getUrlFilterValues(el);
122 | for(let value of urlParamValues) {
123 | let type = el.getAttribute("type");
124 | if(el.tagName === "INPUT" && (type === "checkbox" || type === "radio")) {
125 | if(el.value === value) {
126 | el.checked = true;
127 | }
128 | } else {
129 | el.value = value;
130 | }
131 | }
132 | }
133 | }
134 |
135 | getFormElementKey(formElement) {
136 | return formElement.getAttribute(FilterContainer.attrs.bind);
137 | }
138 |
139 | _getMap(key) {
140 | let values = [];
141 | for(let formElement of this.formElements[key]) {
142 | let type = formElement.getAttribute("type");
143 | if(formElement.tagName === "INPUT" && (type === "checkbox" || type === "radio")) {
144 | if(formElement.checked) {
145 | values.push(formElement.value);
146 | }
147 | } else {
148 | values.push(formElement.value);
149 | }
150 | }
151 |
152 | if(!this.hasAttribute(FilterContainer.attrs.leaveUrlAlone)) {
153 | this.updateUrl(key, values);
154 | }
155 |
156 | let elementsSelectorAttr = this.getElementSelector(key);
157 | let selector = `:scope [${elementsSelectorAttr}]`;
158 | let elements = this.querySelectorAll(selector);
159 |
160 | let map = new Map();
161 | for(let element of Array.from(elements)) {
162 | let isValid = this.elementIsValid(element, elementsSelectorAttr, values);
163 | map.set(element, isValid)
164 | }
165 | return map;
166 | }
167 |
168 | _applyMapForKey(key, map) {
169 | if(!key) {
170 | return;
171 | }
172 |
173 | for(let [element, isVisible] of map) {
174 | let cls = `filter-${key}--hide`;
175 | if(isVisible) {
176 | element.classList.remove(cls);
177 | } else {
178 | element.classList.add(cls);
179 | }
180 | }
181 | }
182 |
183 | applyFilterForElement(formElement) {
184 | let key = this.getFormElementKey(formElement);
185 | this.applyFilterForKey(key);
186 | }
187 |
188 | applyFilterForKey(key) {
189 | let firstFormElementForDelimiter = this.formElements[key][0];
190 | if(!firstFormElementForDelimiter) {
191 | return;
192 | }
193 | let map = this._getMap(key);
194 | this._applyMapForKey(key, map);
195 | }
196 |
197 | _hasValue(needle, haystack = [], mode = "any") {
198 | if(!haystack || !haystack.length || !Array.isArray(haystack)) {
199 | return false;
200 | }
201 |
202 | if(!Array.isArray(needle)) {
203 | needle = [needle];
204 | }
205 |
206 | // all must match
207 | if(mode === "all") {
208 | let found = true;
209 | for(let lookingFor of haystack) {
210 | if(!needle.some((val) => val === lookingFor)) {
211 | found = false;
212 | }
213 | }
214 | return found;
215 | }
216 |
217 | for(let lookingFor of needle) {
218 | // has any, return true
219 | if(haystack.some((val) => val === lookingFor)) {
220 | return true;
221 | }
222 | }
223 | return false;
224 | }
225 |
226 | elementIsValid(element, attributeName, values) {
227 | let hasAttr = element.hasAttribute(attributeName);
228 | if(hasAttr && (!values.length || !values.join(""))) { // [] or [''] for value="" radio
229 | return true;
230 | }
231 | let haystack = (element.getAttribute(attributeName) || "").split(this.valueDelimiter);
232 | let key = this.getKeyFromAttributeName(attributeName);
233 | let mode = this.getFilterMode(key);
234 | if(hasAttr && this._hasValue(haystack, values, mode)) {
235 | return true;
236 | }
237 | return false;
238 | }
239 |
240 | /*
241 | * Feature: Result count
242 | */
243 |
244 | get resultsCounter() {
245 | if(!this._lookedFor.resultsCounter) {
246 | this._results = this.querySelector(`:scope [${FilterContainer.attrs.results}]`);
247 | this._lookedFor.resultsCounter = true;
248 | }
249 |
250 | return this._results;
251 | }
252 |
253 | getGlobalCount() {
254 | let keys = this.getAllKeys();
255 | let selector = keys.map(key => {
256 | return `:scope [${this.getElementSelector(key)}]`;
257 | }).join(",");
258 | let elements = this.querySelectorAll(selector);
259 |
260 | return Array.from(elements)
261 | .filter(entry => this.elementIsVisible(entry))
262 | .filter(entry => !this.elementIsExcluded(entry))
263 | .length;
264 | }
265 |
266 | elementIsVisible(element) {
267 | for(let cls of element.classList) {
268 | if(cls.startsWith("filter-") && cls.endsWith("--hide")) {
269 | return false;
270 | }
271 | }
272 | return true;
273 | }
274 |
275 | elementIsExcluded(element) {
276 | return element.hasAttribute(FilterContainer.attrs.resultsExclude);
277 | }
278 |
279 | getLabels() {
280 | if(this.resultsCounter) {
281 | let attrValue = this.resultsCounter.getAttribute(FilterContainer.attrs.results);
282 | let split = attrValue.split("/");
283 | if(split.length === 2) {
284 | return split;
285 | }
286 | }
287 | return ["Result", "Results"];
288 | }
289 |
290 | _renderResultCount(count) {
291 | if(!this.resultsCounter) {
292 | return;
293 | }
294 | if(!count) {
295 | count = this.getGlobalCount();
296 | }
297 |
298 | let labels = this.getLabels();
299 | this.resultsCounter.innerText = `${count} ${count !== 1 ? labels[1] : labels[0]}`;
300 | }
301 |
302 | renderResultCount(isOnload = false) {
303 | if(!this.resultsCounter) {
304 | return;
305 | }
306 |
307 | if(!isOnload && this.resultsCounter.hasAttribute("aria-live")) {
308 | // This timeout helped VoiceOver
309 | clearTimeout(this.timeout);
310 | this.timeout = setTimeout(() => {
311 | this._renderResultCount()
312 | }, 250);
313 | } else {
314 | this._renderResultCount();
315 | }
316 | }
317 |
318 | /*
319 | * Feature: Work with URLs
320 | */
321 |
322 | getUrlSearchValue() {
323 | let s = window.location.search;
324 | if(s.startsWith("?")) {
325 | return s.substr(1);
326 | }
327 | return s;
328 | }
329 |
330 | getUrlFilterValues(formElement) {
331 | let params = new URLSearchParams(this.getUrlSearchValue());
332 | let key = this.getFormElementKey(formElement);
333 | return params.getAll(key);
334 | }
335 |
336 | // Future improvement: url updates currently once per key (we could group these into one)
337 | updateUrl(key, values) {
338 | let params = new URLSearchParams(this.getUrlSearchValue());
339 | let keyParamsStr = params.getAll(key).sort().join(",");
340 | let valuesStr = values.slice().sort().join(",");
341 |
342 | if(keyParamsStr !== valuesStr) {
343 | params.delete(key);
344 | for(let value of values) {
345 | if(value) { // ignore ""
346 | params.append(key, value);
347 | }
348 | }
349 |
350 | let baseUrl = window.location.pathname;
351 | history.replaceState({}, '', `${baseUrl}${params.toString().length > 0 ? `?${params}`: ""}` );
352 | }
353 | }
354 | }
355 |
356 | FilterContainer.register();
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@zachleat/filter-container",
3 | "version": "4.0.0",
4 | "description": "Filtering visible child elements based on form field values.",
5 | "main": "filter-container.js",
6 | "publishConfig": {
7 | "access": "public"
8 | },
9 | "scripts": {
10 | "start": "npx http-server ."
11 | },
12 | "repository": {
13 | "type": "git",
14 | "url": "git+https://github.com/zachleat/filter-container.git"
15 | },
16 | "author": {
17 | "name": "Zach Leatherman",
18 | "email": "zachleatherman@gmail.com",
19 | "url": "https://zachleat.com/"
20 | },
21 | "license": "MIT",
22 | "bugs": {
23 | "url": "https://github.com/zachleat/filter-container/issues"
24 | },
25 | "homepage": "https://github.com/zachleat/filter-container#readme"
26 | }
27 |
--------------------------------------------------------------------------------