├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── nodejs.yml │ └── publish.yml ├── .gitignore ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── custom-elements.json ├── examples └── index.html ├── package-lock.json ├── package.json ├── src ├── filter-input-element-define.ts ├── filter-input-element.ts └── index.ts ├── test ├── .eslintrc.json └── test.js ├── tsconfig.json └── web-test-runner.config.js /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:github/browser", 4 | "plugin:github/recommended", 5 | "plugin:github/typescript", 6 | "plugin:custom-elements/recommended" 7 | ], 8 | "rules": { 9 | "custom-elements/tag-name-matches-class": [ 10 | "error", 11 | { 12 | "suffix": "Element" 13 | } 14 | ], 15 | "custom-elements/define-tag-after-class-definition": "off", 16 | "custom-elements/no-method-prefixed-with-on": "off", 17 | "custom-elements/expose-class-on-global": "off", 18 | "import/extensions": ["error", "always"], 19 | "import/no-unresolved": "off" 20 | }, 21 | "globals": { 22 | "FilterInputElement": "readable" 23 | }, 24 | "overrides": [ 25 | { 26 | "files": "src/*-define.ts", 27 | "rules": { 28 | "@typescript-eslint/no-namespace": "off" 29 | } 30 | }, 31 | { 32 | "files": "test/**/*.js", 33 | "rules": { 34 | "github/unescaped-html-literal": "off", 35 | "github/no-inner-html": "off", 36 | "i18n-text/no-en": "off" 37 | }, 38 | "env": { 39 | "mocha": true 40 | } 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | jobs: 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Use Node.js 12.x 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 12.x 18 | - name: npm install, build, and test 19 | run: | 20 | npm install 21 | npm run build --if-present 22 | npm test 23 | env: 24 | CI: true 25 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish-npm: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 14 15 | registry-url: https://registry.npmjs.org/ 16 | cache: npm 17 | - run: npm ci 18 | - run: npm test 19 | - run: npm version ${TAG_NAME} --git-tag-version=false 20 | env: 21 | TAG_NAME: ${{ github.event.release.tag_name }} 22 | - run: npm whoami; npm --ignore-scripts publish 23 | env: 24 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @github/primer-reviewers 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [fork]: https://github.com/github/filter-input-element/fork 4 | [pr]: https://github.com/github/filter-input-element/compare 5 | [code-of-conduct]: CODE_OF_CONDUCT.md 6 | 7 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 8 | 9 | Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE). 10 | 11 | Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. 12 | 13 | ## Submitting a pull request 14 | 15 | 0. [Fork][fork] and clone the repository 16 | 0. Configure and install the dependencies: `script/bootstrap` 17 | 0. Make sure the tests pass on your machine: `rake` 18 | 0. Create a new branch: `git checkout -b my-branch-name` 19 | 0. Make your change, add tests, and make sure the tests still pass 20 | 0. Push to your fork and [submit a pull request][pr] 21 | 0. Pat your self on the back and wait for your pull request to be reviewed and merged. 22 | 23 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 24 | 25 | - Write tests. 26 | - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 27 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 28 | 29 | ## Resources 30 | 31 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 32 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 33 | - [GitHub Help](https://help.github.com) 34 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [fork]: https://github.com/github/filter-input-element/fork 4 | [pr]: https://github.com/github/filter-input-element/compare 5 | [code-of-conduct]: CODE_OF_CONDUCT.md 6 | 7 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 8 | 9 | Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE). 10 | 11 | Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. 12 | 13 | ## Submitting a pull request 14 | 15 | 0. [Fork][fork] and clone the repository 16 | 0. Configure and install the dependencies: `script/bootstrap` 17 | 0. Make sure the tests pass on your machine: `rake` 18 | 0. Create a new branch: `git checkout -b my-branch-name` 19 | 0. Make your change, add tests, and make sure the tests still pass 20 | 0. Push to your fork and [submit a pull request][pr] 21 | 0. Pat your self on the back and wait for your pull request to be reviewed and merged. 22 | 23 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 24 | 25 | - Write tests. 26 | - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 27 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 28 | 29 | ## Resources 30 | 31 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 32 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 33 | - [GitHub Help](https://help.github.com) 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 GitHub, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # <filter-input> element ![](https://github.com/github/filter-input-element/workflows/Node%20CI/badge.svg) 2 | 3 | Display elements in a subtree that match filter input text. 4 | 5 | ## Installation 6 | 7 | ``` 8 | $ npm install @github/filter-input-element 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```html 14 | 15 | 19 | 20 |
21 | 28 | 29 |
30 | ``` 31 | 32 | ## Elements and attributes 33 | 34 | ### Required 35 | 36 | - `filter-input[aria-owns]` should point to the container ID that wraps all `` related elements. 37 | - `filter-input` should have one `input` child element that is used to filter. 38 | - `[id]` should be set on a container that either contains or has `[data-filter-list]` attribute. 39 | - `[data-filter-list]` should be set on the element whose **direct child elements** are to be filtered. 40 | 41 | ### Optional 42 | 43 | #### Specify filter text 44 | 45 | Use `[data-filter-item-text]` to specify an element whose text should be used for filtering. In the following example, the text `(current)` would not be matched. 46 | 47 | ```html 48 |
49 | Bender 50 | 51 | Hubot 52 | (current) 53 | 54 |
55 | ``` 56 | 57 | #### Blankslate 58 | 59 | Use `[data-filter-empty-state]` to specify an element to be displayed when no results were found. This element must be inside of the container `aria-owns` points to. 60 | 61 | ```html 62 |
63 |
64 | Bender 65 | Hubot 66 |
67 | 68 |
69 | ``` 70 | 71 | #### Create new item 72 | 73 | Use `[data-filter-new-item]` to include an item to create a new instance when no exact match were found. The element with `[data-filter-new-text]`'s text content will be set to the input value. You can also use `[data-filter-new-value]` to set an input value to the query param. 74 | 75 | ```html 76 |
77 |
78 | Bender 79 | Hubot 80 |
81 | 86 |
87 | ``` 88 | 89 | ## Methods 90 | 91 | `filterInputElement.filter` can be optionally set to provide an alternative filtering logic. The default is substring. 92 | 93 | ```js 94 | const fuzzyFilterInput = document.querySelector('.js-fuzzy-filter-input') 95 | fuzzyFilterInput.filter = (element, elementText, query) => { 96 | // fuzzy filtering logic 97 | return {match: boolean, hideNew: boolean} 98 | } 99 | ``` 100 | 101 | `match`(required) indicates whether the item should be shown. `hideNew` (optional) will determine whether the "Create new item" element should be hidden. For example, when an exact match is found, the "create new item" option should be hidden. 102 | 103 | ## Events 104 | 105 | - `filter-input-start` (bubbles) - fired on `` when a filter action is about to start. 106 | - `filter-input-updated` (bubbles) - fired on `` when filter has finished. `event.detail.count` is the number of matches found, and `event.detail.total` is the total number of elements. 107 | 108 | To ensure that the client side update is communicated to assistive technologies, `filter-input-updated` event can be used to update filter results to screen readers. For example: 109 | 110 | ```js 111 | const ariaLiveContainer = document.querySelector('[aria-live="assertive"]') 112 | document.addEventListener('filter-input-updated', event => { 113 | ariaLiveContainer.textContent = `${event.detail.count} results found.` 114 | }) 115 | ``` 116 | 117 | For more details on this technique, check out [Improving accessibility on GOV.UK search](https://technology.blog.gov.uk/2014/08/14/improving-accessibility-on-gov-uk-search/). 118 | 119 | ## Browser support 120 | 121 | Browsers without native [custom element support][support] require a [polyfill][]. 122 | 123 | - Chrome 124 | - Firefox 125 | - Safari 126 | - Microsoft Edge 127 | 128 | [support]: https://caniuse.com/#feat=custom-elementsv1 129 | [polyfill]: https://github.com/webcomponents/custom-elements 130 | 131 | ## Development 132 | 133 | ``` 134 | npm install 135 | npm test 136 | ``` 137 | 138 | ## License 139 | 140 | Distributed under the MIT license. See LICENSE for details. 141 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | If you discover a security issue in this repository, please submit it through the [GitHub Security Bug Bounty](https://hackerone.com/github). 2 | -------------------------------------------------------------------------------- /custom-elements.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": "1.0.0", 3 | "readme": "", 4 | "modules": [ 5 | { 6 | "kind": "javascript-module", 7 | "path": "dist/bundle.js", 8 | "declarations": [], 9 | "exports": [ 10 | { 11 | "kind": "js", 12 | "name": "default", 13 | "declaration": { 14 | "module": "dist/bundle.js" 15 | } 16 | } 17 | ] 18 | }, 19 | { 20 | "kind": "javascript-module", 21 | "path": "dist/filter-input-element-define.js", 22 | "declarations": [], 23 | "exports": [] 24 | }, 25 | { 26 | "kind": "javascript-module", 27 | "path": "dist/filter-input-element.js", 28 | "declarations": [], 29 | "exports": [] 30 | }, 31 | { 32 | "kind": "javascript-module", 33 | "path": "dist/index.js", 34 | "declarations": [], 35 | "exports": [] 36 | }, 37 | { 38 | "kind": "javascript-module", 39 | "path": "src/filter-input-element-define.ts", 40 | "declarations": [], 41 | "exports": [ 42 | { 43 | "kind": "js", 44 | "name": "default", 45 | "declaration": { 46 | "name": "FilterInputElement", 47 | "module": "src/filter-input-element-define.ts" 48 | } 49 | }, 50 | { 51 | "kind": "js", 52 | "name": "*", 53 | "declaration": { 54 | "name": "*", 55 | "package": "./filter-input-element.js" 56 | } 57 | } 58 | ] 59 | }, 60 | { 61 | "kind": "javascript-module", 62 | "path": "src/filter-input-element.ts", 63 | "declarations": [ 64 | { 65 | "kind": "class", 66 | "description": "", 67 | "name": "FilterInputElement", 68 | "members": [ 69 | { 70 | "kind": "method", 71 | "name": "define", 72 | "static": true, 73 | "parameters": [ 74 | { 75 | "name": "tag", 76 | "default": "'filter-input'" 77 | }, 78 | { 79 | "name": "registry", 80 | "default": "customElements" 81 | } 82 | ] 83 | }, 84 | { 85 | "kind": "field", 86 | "name": "currentQuery", 87 | "type": { 88 | "text": "string | null" 89 | }, 90 | "default": "null" 91 | }, 92 | { 93 | "kind": "field", 94 | "name": "debounceInputChange", 95 | "type": { 96 | "text": "() => void" 97 | } 98 | }, 99 | { 100 | "kind": "field", 101 | "name": "boundFilterResults", 102 | "type": { 103 | "text": "() => void" 104 | } 105 | }, 106 | { 107 | "kind": "field", 108 | "name": "filter", 109 | "type": { 110 | "text": "MatchFunction | null" 111 | }, 112 | "default": "null" 113 | }, 114 | { 115 | "kind": "field", 116 | "name": "input", 117 | "type": { 118 | "text": "HTMLInputElement | null" 119 | }, 120 | "readonly": true 121 | }, 122 | { 123 | "kind": "method", 124 | "name": "reset" 125 | } 126 | ], 127 | "attributes": [ 128 | { 129 | "name": "aria-owns" 130 | } 131 | ], 132 | "superclass": { 133 | "name": "HTMLElement" 134 | }, 135 | "customElement": true 136 | } 137 | ], 138 | "exports": [ 139 | { 140 | "kind": "js", 141 | "name": "FilterInputElement", 142 | "declaration": { 143 | "name": "FilterInputElement", 144 | "module": "src/filter-input-element.ts" 145 | } 146 | }, 147 | { 148 | "kind": "js", 149 | "name": "default", 150 | "declaration": { 151 | "name": "FilterInputElement", 152 | "module": "src/filter-input-element.ts" 153 | } 154 | } 155 | ] 156 | }, 157 | { 158 | "kind": "javascript-module", 159 | "path": "src/index.ts", 160 | "declarations": [], 161 | "exports": [ 162 | { 163 | "kind": "js", 164 | "name": "FilterInputElement", 165 | "declaration": { 166 | "name": "FilterInputElement", 167 | "module": "src/index.ts" 168 | } 169 | }, 170 | { 171 | "kind": "js", 172 | "name": "default", 173 | "declaration": { 174 | "name": "FilterInputElement", 175 | "module": "src/index.ts" 176 | } 177 | }, 178 | { 179 | "kind": "js", 180 | "name": "*", 181 | "declaration": { 182 | "name": "*", 183 | "package": "./filter-input-element-define.js" 184 | } 185 | } 186 | ] 187 | }, 188 | { 189 | "kind": "javascript-module", 190 | "path": "test/test.js", 191 | "declarations": [], 192 | "exports": [] 193 | } 194 | ] 195 | } 196 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | filter-input demo 6 | 7 | 8 | 9 | 13 | 14 |
15 |
    16 |
  • Bender
  • 17 |
  • Hubot
  • 18 |
  • Wall-E
  • 19 |
  • BB-8
  • 20 |
  • R2-D2
  • 21 |
22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@github/filter-input-element", 3 | "version": "0.1.1", 4 | "description": "Display elements in a subtree that match filter input text.", 5 | "main": "dist/index.js", 6 | "type": "module", 7 | "module": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "exports": { 10 | ".": "./dist/index.js", 11 | "./define": "./dist/index.js", 12 | "./clipboard-copy": "./dist/clipboard-copy-element.js", 13 | "./clipboard-copy/define": "./dist/clipboard-copy-element-define.js" 14 | }, 15 | "license": "MIT", 16 | "repository": "github/filter-input-element", 17 | "files": [ 18 | "dist" 19 | ], 20 | "scripts": { 21 | "clean": "rm -rf dist", 22 | "lint": "eslint . --ext .js,.ts && tsc --noEmit", 23 | "lint:fix": "npm run lint -- --fix", 24 | "prebuild": "npm run clean && npm run lint && mkdir dist", 25 | "bundle": "esbuild --bundle dist/index.js --keep-names --outfile=dist/bundle.js --format=esm", 26 | "build": "tsc && npm run bundle && npm run manifest", 27 | "prepublishOnly": "npm run build", 28 | "pretest": "npm run build", 29 | "test": "web-test-runner", 30 | "postpublish": "npm publish --ignore-scripts --@github:registry='https://npm.pkg.github.com'", 31 | "manifest": "custom-elements-manifest analyze" 32 | }, 33 | "prettier": "@github/prettier-config", 34 | "devDependencies": { 35 | "@custom-elements-manifest/analyzer": "^0.9.0", 36 | "@github/prettier-config": "^0.0.6", 37 | "@open-wc/testing": "^3.2.0", 38 | "@typescript-eslint/eslint-plugin": "^6.8.0", 39 | "@typescript-eslint/parser": "^6.8.0", 40 | "@web/dev-server-esbuild": "^0.4.3", 41 | "@web/test-runner": "^0.19.0", 42 | "@web/test-runner-playwright": "^0.11.0", 43 | "esbuild": "^0.25.0", 44 | "eslint": "^8.51.0", 45 | "eslint-plugin-custom-elements": "^0.0.8", 46 | "eslint-plugin-github": "^4.10.1", 47 | "typescript": "^5.2.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/filter-input-element-define.ts: -------------------------------------------------------------------------------- 1 | import {FilterInputElement} from './filter-input-element.js' 2 | 3 | const root = (typeof globalThis !== 'undefined' ? globalThis : window) as typeof window 4 | try { 5 | root.FilterInputElement = FilterInputElement.define() 6 | } catch (e: unknown) { 7 | if ( 8 | !(root.DOMException && e instanceof DOMException && e.name === 'NotSupportedError') && 9 | !(e instanceof ReferenceError) 10 | ) { 11 | throw e 12 | } 13 | } 14 | 15 | type JSXBase = JSX.IntrinsicElements extends {span: unknown} 16 | ? JSX.IntrinsicElements 17 | : Record> 18 | declare global { 19 | interface Window { 20 | FilterInputElement: typeof FilterInputElement 21 | } 22 | interface HTMLElementTagNameMap { 23 | 'filter-input': FilterInputElement 24 | } 25 | namespace JSX { 26 | interface IntrinsicElements { 27 | ['filter-input']: JSXBase['span'] & Partial> 28 | } 29 | } 30 | } 31 | 32 | export default FilterInputElement 33 | export * from './filter-input-element.js' 34 | -------------------------------------------------------------------------------- /src/filter-input-element.ts: -------------------------------------------------------------------------------- 1 | interface MatchFunction { 2 | (item: HTMLElement, itemText: string, query: string): MatchResult 3 | } 4 | 5 | interface MatchResult { 6 | match: boolean 7 | hideNew?: boolean 8 | } 9 | 10 | export class FilterInputElement extends HTMLElement { 11 | static define(tag = 'filter-input', registry = customElements) { 12 | registry.define(tag, this) 13 | return this 14 | } 15 | 16 | currentQuery: string | null = null 17 | debounceInputChange: () => void = debounce(() => filterResults(this, true)) 18 | boundFilterResults: () => void = () => { 19 | filterResults(this, false) 20 | } 21 | filter: MatchFunction | null = null 22 | 23 | static get observedAttributes() { 24 | return ['aria-owns'] 25 | } 26 | 27 | attributeChangedCallback(name: string, oldValue: string) { 28 | if (oldValue && name === 'aria-owns') { 29 | filterResults(this, false) 30 | } 31 | } 32 | 33 | connectedCallback() { 34 | const input = this.input 35 | if (!input) return 36 | 37 | input.setAttribute('autocomplete', 'off') 38 | input.setAttribute('spellcheck', 'false') 39 | 40 | input.addEventListener('focus', this.boundFilterResults) 41 | input.addEventListener('change', this.boundFilterResults) 42 | input.addEventListener('input', this.debounceInputChange) 43 | } 44 | 45 | disconnectedCallback() { 46 | const input = this.input 47 | if (!input) return 48 | 49 | input.removeEventListener('focus', this.boundFilterResults) 50 | input.removeEventListener('change', this.boundFilterResults) 51 | input.removeEventListener('input', this.debounceInputChange) 52 | } 53 | 54 | get input(): HTMLInputElement | null { 55 | const input = this.querySelector('input') 56 | return input instanceof HTMLInputElement ? input : null 57 | } 58 | 59 | reset() { 60 | const input = this.input 61 | if (input) { 62 | input.value = '' 63 | input.dispatchEvent(new Event('change', {bubbles: true})) 64 | } 65 | } 66 | } 67 | 68 | async function filterResults(filterInput: FilterInputElement, checkCurrentQuery: boolean = false) { 69 | const input = filterInput.input 70 | if (!input) return 71 | const query = input.value.trim() 72 | const id = filterInput.getAttribute('aria-owns') 73 | if (!id) return 74 | const container = document.getElementById(id) 75 | if (!container) return 76 | const list = container.hasAttribute('data-filter-list') ? container : container.querySelector('[data-filter-list]') 77 | if (!list) return 78 | 79 | filterInput.dispatchEvent( 80 | new CustomEvent('filter-input-start', { 81 | bubbles: true, 82 | }), 83 | ) 84 | 85 | if (checkCurrentQuery && filterInput.currentQuery === query) return 86 | filterInput.currentQuery = query 87 | 88 | const filter = filterInput.filter || matchSubstring 89 | const total = list.childElementCount 90 | let count = 0 91 | let hideNew = false 92 | 93 | for (const item of Array.from(list.children)) { 94 | if (!(item instanceof HTMLElement)) continue 95 | const itemText = getText(item) 96 | const result = filter(item, itemText, query) 97 | if (result.hideNew === true) hideNew = result.hideNew 98 | 99 | item.hidden = !result.match 100 | if (result.match) count++ 101 | } 102 | 103 | const newItem = container.querySelector('[data-filter-new-item]') 104 | const showCreateOption = !!newItem && query.length > 0 && !hideNew 105 | if (newItem instanceof HTMLElement) { 106 | newItem.hidden = !showCreateOption 107 | if (showCreateOption) updateNewItem(newItem, query) 108 | } 109 | 110 | toggleBlankslate(container, count > 0 || showCreateOption) 111 | 112 | filterInput.dispatchEvent( 113 | new CustomEvent('filter-input-updated', { 114 | bubbles: true, 115 | detail: { 116 | count, 117 | total, 118 | }, 119 | }), 120 | ) 121 | } 122 | 123 | function matchSubstring(_item: HTMLElement, itemText: string, query: string): MatchResult { 124 | const match = itemText.toLowerCase().indexOf(query.toLowerCase()) !== -1 125 | return { 126 | match, 127 | hideNew: itemText === query, 128 | } 129 | } 130 | 131 | function getText(filterableItem: HTMLElement) { 132 | const target = filterableItem.querySelector('[data-filter-item-text]') || filterableItem 133 | return (target.textContent || '').trim() 134 | } 135 | 136 | function updateNewItem(newItem: HTMLElement, query: string) { 137 | const newItemText = newItem.querySelector('[data-filter-new-item-text]') 138 | if (newItemText) newItemText.textContent = query 139 | const newItemValue = newItem.querySelector('[data-filter-new-item-value]') 140 | if (newItemValue instanceof HTMLInputElement || newItemValue instanceof HTMLButtonElement) { 141 | newItemValue.value = query 142 | } 143 | } 144 | 145 | function toggleBlankslate(container: HTMLElement, force: boolean) { 146 | const emptyState = container.querySelector('[data-filter-empty-state]') 147 | if (emptyState instanceof HTMLElement) emptyState.hidden = force 148 | } 149 | 150 | function debounce(callback: () => void) { 151 | let timeout: ReturnType 152 | return function () { 153 | clearTimeout(timeout) 154 | timeout = setTimeout(() => { 155 | clearTimeout(timeout) 156 | callback() 157 | }, 300) 158 | } 159 | } 160 | 161 | export default FilterInputElement 162 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import FilterInputElement from './filter-input-element.js' 2 | 3 | export {FilterInputElement} 4 | export default FilterInputElement 5 | export * from './filter-input-element-define.js' 6 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "globals": { 6 | "assert": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | import {assert} from '@open-wc/testing' 2 | import {FilterInputElement} from '../src/index.ts' 3 | 4 | describe('filter-input', function () { 5 | describe('element creation', function () { 6 | it('creates from document.createElement', function () { 7 | const el = document.createElement('filter-input') 8 | assert.equal('FILTER-INPUT', el.nodeName) 9 | }) 10 | 11 | it('creates from constructor', function () { 12 | const el = new FilterInputElement() 13 | assert.equal('FILTER-INPUT', el.nodeName) 14 | }) 15 | }) 16 | 17 | describe('after tree insertion', function () { 18 | let filterInput 19 | let input 20 | let list 21 | let emptyState 22 | let newItem 23 | beforeEach(function () { 24 | document.body.innerHTML = ` 25 | 26 | 30 | 31 |
32 |
    33 |
  • Bender
  • 34 |
  • Hubot
  • 35 |
  • Wall-E
  • 36 |
  • BB-8
  • 37 |
38 | 39 | 42 |
43 | ` 44 | 45 | filterInput = document.querySelector('filter-input') 46 | input = filterInput.querySelector('input') 47 | list = document.querySelector('[data-filter-list]') 48 | emptyState = document.querySelector('[data-filter-empty-state]') 49 | newItem = document.querySelector('[data-filter-new-item]') 50 | }) 51 | 52 | afterEach(function () { 53 | document.body.innerHTML = '' 54 | }) 55 | 56 | it('filters and toggles new item form', async function () { 57 | const listener = once('filter-input-updated') 58 | changeValue(input, 'hu') 59 | const customEvent = await listener 60 | assert.equal(customEvent.detail.count, 1) 61 | assert.equal(customEvent.detail.total, 4) 62 | 63 | changeValue(input, 'BB-8 robot') 64 | assert.notOk(newItem.hidden, 'New item form should be shown') 65 | assert.equal(newItem.querySelector('[data-filter-new-item-value]').value, 'BB-8 robot') 66 | assert.equal(newItem.querySelector('[data-filter-new-item-text]').textContent, 'BB-8 robot') 67 | }) 68 | 69 | it('filters and toggles blankslate', async function () { 70 | // Remove new item form, which is prioritized over blankslate 71 | newItem.remove() 72 | 73 | const listener = once('filter-input-updated') 74 | changeValue(input, 'hu') 75 | const customEvent = await listener 76 | const results = Array.from(list.children).filter(el => !el.hidden) 77 | assert.equal(results.length, 1) 78 | assert.equal(results[0].textContent, 'Hubot') 79 | assert.equal(customEvent.detail.count, 1) 80 | assert.equal(customEvent.detail.total, 4) 81 | changeValue(input, 'boom') 82 | assert.notOk(emptyState.hidden, 'Empty state should be shown') 83 | }) 84 | 85 | it('filters with custom filter', async function () { 86 | filterInput.filter = (_item, itemText) => { 87 | return {match: itemText.indexOf('-') >= 0} 88 | } 89 | const listener = once('filter-input-updated') 90 | changeValue(input, ':)') 91 | const customEvent = await listener 92 | const results = Array.from(list.children).filter(el => !el.hidden) 93 | assert.equal(results.length, 2) 94 | assert.equal(results[0].textContent, 'Wall-E') 95 | assert.equal(customEvent.detail.count, 2) 96 | assert.equal(customEvent.detail.total, 4) 97 | }) 98 | 99 | it('filters again with the same value when a change event is fired', async function () { 100 | const listener = once('filter-input-updated') 101 | changeValue(input, '-') 102 | const customEvent = await listener 103 | assert.equal(customEvent.detail.count, 2) 104 | assert.equal(customEvent.detail.total, 4) 105 | 106 | const newRobot = document.createElement('li') 107 | newRobot.textContent = 'R2-D2' 108 | list.append(newRobot) 109 | 110 | const listener2 = once('filter-input-updated') 111 | changeValue(input, '-') 112 | const customEvent2 = await listener2 113 | assert.equal(customEvent2.detail.count, 3) 114 | assert.equal(customEvent2.detail.total, 5) 115 | }) 116 | }) 117 | }) 118 | 119 | function changeValue(input, value) { 120 | input.value = value 121 | input.dispatchEvent(new Event('change', {bubbles: true})) 122 | } 123 | 124 | function once(eventName) { 125 | return new Promise(resolve => { 126 | document.addEventListener(eventName, resolve, {once: true}) 127 | }) 128 | } 129 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "nodenext", 4 | "target": "es2017", 5 | "lib": ["es2018", "dom"], 6 | "strict": true, 7 | "declaration": true, 8 | "outDir": "dist", 9 | "removeComments": true, 10 | "preserveConstEnums": true 11 | }, 12 | "files": ["src/index.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /web-test-runner.config.js: -------------------------------------------------------------------------------- 1 | import {esbuildPlugin} from '@web/dev-server-esbuild' 2 | import {playwrightLauncher} from '@web/test-runner-playwright' 3 | const browser = product => playwrightLauncher({product}) 4 | 5 | export default { 6 | files: ['test/*'], 7 | nodeResolve: true, 8 | plugins: [esbuildPlugin({ts: true, target: 'es2020'})], 9 | browsers: [browser('chromium')], 10 | testFramework: { 11 | config: { 12 | ui: 'bdd', 13 | timeout: 500, 14 | }, 15 | }, 16 | } 17 | --------------------------------------------------------------------------------