├── LICENSE ├── README.md ├── demo.html ├── filter-container.js └── package.json /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Zach Leatherman @zachleat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Filter Container 2 | 3 | A themeless zero-dependency web component to filter visible child elements based on form field values. 4 | 5 | * [Demo](https://zachleat.github.io/filter-container/demo.html) 6 | * [Demo on jamstack.org](https://jamstack.org/generators/) (Filter by Language, Template, or License) 7 | * [Demo on zachleat.com](https://www.zachleat.com/web/) (Filter by blog post category) 8 | 9 | ## Installation 10 | 11 | ``` 12 | npm install @zachleat/filter-container 13 | ``` 14 | 15 | Please see the demo for sample code. Use: 16 | 17 | * `` 18 | * `` (try text, radio, checkbox, etc) 20 | * Add a `data-filter-KEY_NAME="CATEGORY_VALUE"` attribute to _any_ child element of `` to assign both a filter key and category to match on. 21 | 22 | ### Optional Features 23 | 24 | * You can add the CSS for each `KEY_NAME` yourself if you’re server rendering (or not happy with the [browser support of `replaceSync`](https://caniuse.com/mdn-api_cssstylesheet_replacesync)). Prepopulate the server-rendered content on each individual item using this too if you’d like (maybe your page has a server-rendered filter applied). 25 | 26 | ```css 27 | .filter-KEY_NAME--hide { 28 | display: none; 29 | } 30 | ``` 31 | 32 | * This component will not filter on initialization unless you use ``. By default the form field needs to change for filtering to take place. 33 | * Add the `data-filter-results` attribute to any child element of the component if you’d like us to populate it with the number of results. 34 | * Add a string to this attribute value to customize your Results labels (delimited by `/`). e.g. `data-filter-results="Country/Countries"` 35 | * Add `aria-live="polite"` to this element and screen readers will announce when the text changes. 36 | * Use `` if your content elements may have more than one filter value assigned (in this example delimited by a comma). 37 | * For example, Egypt is in both Africa and Asia: `
  • Egypt
  • ` 38 | 39 | ## Changelog 40 | 41 | ### v4.0.0 42 | 43 | - `filter-KEY_NAME--hide` CSS is now added automatically via the component—works alongside manually added CSS for proper progressive enhancement. 44 | 45 | ### v3.0.4 46 | 47 | - Add support for `filter-mode="all"` on `` to enable AND-ing filters for all multi-select form elements (checkboxes). Use `filter-mode-KEY_NAME="any"` to override back to the default. 48 | 49 | ### v3.0.3 50 | 51 | - Add support for AND-ing filters across multiple checkboxes. Previously only OR operations were supported. 52 | - Use `filter-mode-KEY_NAME="all"` on `` to enable. 53 | 54 | ### v3.0.0 55 | 56 | - Added support for radio and checkbox inputs for filtering. 57 | - Renamed attributes: 58 | - `data-oninit` renamed to `oninit` 59 | - `data-filter-delimiter` renamed to `delimiter` (only supported on ``) 60 | - `data-filter-skip-url` renamed to `leave-url-alone` (only supported on ``) 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 |
    21 | 22 |

    23 |
    24 | Form Input 25 | 29 |
    30 |
    31 | Checkboxes (all) 32 | Only show Flags with all of these colors: 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
    41 |
    42 | Checkboxes (any) 43 | Show countries on any of these continents: 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 |
    52 |
    53 | Radios 54 | Filter by First Letter: 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 |
    64 |
    65 | Select 66 | Words in the name: 67 | 75 |
    76 |
    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 | --------------------------------------------------------------------------------