├── src ├── auto-complete-event.ts ├── index.ts ├── debounce.ts ├── auto-complete-element-define.ts ├── auto-complete-element.ts └── autocomplete.ts ├── CODEOWNERS ├── .gitignore ├── custom-elements-manifest.config.js ├── tsconfig.json ├── .github └── workflows │ ├── nodejs.yml │ └── publish.yml ├── .eslintrc.json ├── LICENSE ├── .devcontainer ├── devcontainer.json └── Dockerfile ├── web-test-runner.config.js ├── test ├── axe-test.js ├── validation-tests.js └── auto-complete-element.js ├── package.json ├── validator.js ├── custom-elements.json ├── examples └── index.html └── README.md /src/auto-complete-event.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @github/primer-reviewers 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | 4 | # copied bundle if the example is run locally 5 | examples/dist/bundle.js 6 | -------------------------------------------------------------------------------- /custom-elements-manifest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | packagejson: true, 3 | globs: ['src/*-element.ts'], 4 | plugins: [], 5 | } 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {AutoCompleteElement} from './auto-complete-element-define.js' 2 | 3 | export {AutoCompleteElement} 4 | export default AutoCompleteElement 5 | export * from './auto-complete-element-define.js' 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es2020", 4 | "target": "es2017", 5 | "strict": true, 6 | "moduleResolution": "node", 7 | "declaration": true, 8 | "outDir": "dist", 9 | "lib": ["dom", "dom.iterable", "es2020"], 10 | "removeComments": true 11 | }, 12 | "files": ["src/index.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /src/debounce.ts: -------------------------------------------------------------------------------- 1 | export default function debounce( 2 | callback: (...Rest: T) => unknown, 3 | wait = 0, 4 | ): (...Rest: T) => void { 5 | let timeout: number 6 | return function (...Rest) { 7 | clearTimeout(timeout) 8 | timeout = window.setTimeout(() => { 9 | clearTimeout(timeout) 10 | callback(...Rest) 11 | }, wait) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | build: 10 | runs-on: ${{ matrix.os }} 11 | 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | os: [ubuntu-latest, macos-latest] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Use Node.js 18.x 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: 18.x 23 | - name: npm install, build, and test 24 | run: | 25 | npm install 26 | npm test 27 | env: 28 | CI: true 29 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | publish-npm: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 18 18 | registry-url: https://registry.npmjs.org/ 19 | cache: npm 20 | - run: npm ci 21 | - run: npm test 22 | - run: | 23 | echo "Publishing $TAG_NAME" 24 | npm version ${TAG_NAME} --git-tag-version=false 25 | env: 26 | TAG_NAME: ${{github.event.release.tag_name}} 27 | - run: npm whoami; npm --ignore-scripts publish 28 | env: 29 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 30 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "plugin:github/browser", 5 | "plugin:github/recommended", 6 | "plugin:github/typescript", 7 | "plugin:custom-elements/recommended" 8 | ], 9 | "rules": { 10 | "custom-elements/tag-name-matches-class": [ 11 | "error", 12 | { 13 | "suffix": "Element" 14 | } 15 | ], 16 | "custom-elements/define-tag-after-class-definition": "off", 17 | "custom-elements/no-method-prefixed-with-on": "off", 18 | "custom-elements/expose-class-on-global": "off", 19 | "import/extensions": ["error", "always"], 20 | "import/no-unresolved": "off" 21 | }, 22 | "overrides": [ 23 | { 24 | "files": "src/*-define.ts", 25 | "rules": { 26 | "@typescript-eslint/no-namespace": "off" 27 | } 28 | }, 29 | { 30 | "files": "test/**/*.js", 31 | "rules": { 32 | "github/unescaped-html-literal": "off", 33 | "github/no-inner-html": "off", 34 | "i18n-text/no-en": "off" 35 | }, 36 | "env": { 37 | "mocha": true 38 | } 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 GitHub, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/auto-complete-element-define.ts: -------------------------------------------------------------------------------- 1 | import {AutoCompleteElement} from './auto-complete-element.js' 2 | 3 | const root = (typeof globalThis !== 'undefined' ? globalThis : window) as typeof window 4 | try { 5 | // Lowercase C is here for backwards compat 6 | root.AutocompleteElement = root.AutoCompleteElement = AutoCompleteElement.define() 7 | } catch (e: unknown) { 8 | if ( 9 | !(root.DOMException && e instanceof DOMException && e.name === 'NotSupportedError') && 10 | !(e instanceof ReferenceError) 11 | ) { 12 | throw e 13 | } 14 | } 15 | 16 | type JSXBase = JSX.IntrinsicElements extends {span: unknown} 17 | ? JSX.IntrinsicElements 18 | : Record> 19 | declare global { 20 | interface Window { 21 | AutoCompleteElement: typeof AutoCompleteElement 22 | AutocompleteElement: typeof AutoCompleteElement 23 | } 24 | interface HTMLElementTagNameMap { 25 | 'auto-complete': AutoCompleteElement 26 | } 27 | namespace JSX { 28 | interface IntrinsicElements { 29 | ['auto-complete']: JSXBase['span'] & Partial> 30 | } 31 | } 32 | } 33 | 34 | export default AutoCompleteElement 35 | export * from './auto-complete-element.js' 36 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node 3 | { 4 | "name": "Node.js", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick a Node version: 16, 14, 12. 8 | // Append -bullseye or -buster to pin to an OS version. 9 | // Use -bullseye variants on local arm64/Apple Silicon. 10 | "args": { "VARIANT": "16" } 11 | }, 12 | 13 | // Set *default* container specific settings.json values on container create. 14 | "settings": {}, 15 | 16 | // Add the IDs of extensions you want installed when the container is created. 17 | "extensions": [ 18 | "dbaeumer.vscode-eslint" 19 | ], 20 | 21 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 22 | // "forwardPorts": [], 23 | 24 | // Use 'postCreateCommand' to run commands after the container is created. 25 | // "postCreateCommand": "yarn install", 26 | 27 | // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 28 | "remoteUser": "node", 29 | "features": { 30 | "git": "latest", 31 | "github-cli": "latest" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster 4 | ARG VARIANT="16" 5 | FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT} 6 | 7 | # [Optional] Uncomment this section to install additional OS packages. 8 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | # && apt-get -y install --no-install-recommends 10 | 11 | # [Optional] Uncomment if you want to install an additional version of node using nvm 12 | # ARG EXTRA_NODE_VERSION=10 13 | # RUN su node -c "source/usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 14 | 15 | # [Optional] Uncomment if you want to install more global node modules 16 | # RUN su node -c "npm install -g " 17 | 18 | RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ 19 | && echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list 20 | RUN apt-get update && apt-get -y install google-chrome-stable 21 | -------------------------------------------------------------------------------- /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 => 4 | playwrightLauncher({ 5 | product, 6 | }) 7 | 8 | export default { 9 | files: ['test/*.js'], 10 | nodeResolve: true, 11 | plugins: [esbuildPlugin({ts: true, target: 'es2020'})], 12 | browsers: [browser('chromium')], 13 | testFramework: { 14 | config: { 15 | timeout: 1000, 16 | }, 17 | }, 18 | 19 | middleware: [ 20 | async ({request, response}, next) => { 21 | const {method, url} = request 22 | if (method === 'GET' && url.startsWith('/search?q=hub')) { 23 | response.status = 200 24 | response.body = ` 25 |
  • first
  • 26 |
  • second
  • 27 |
  • third
  • 28 |
  • fourth
  • 29 |
  • link
  • 30 | ` 31 | } else if (method === 'GET' && url.startsWith('/noresults?q=none')) { 32 | response.status = 200 33 | response.body = ` 34 | 35 | ` 36 | } 37 | await next() 38 | }, 39 | ], 40 | } 41 | -------------------------------------------------------------------------------- /test/axe-test.js: -------------------------------------------------------------------------------- 1 | import {assert, chai} from '@open-wc/testing' 2 | import 'axe-core' 3 | import autoCompleteRulesBuilder from '../validator.js' 4 | chai.config.truncateThreshold = Infinity 5 | 6 | const autoCompleteRules = autoCompleteRulesBuilder() 7 | 8 | function makeDOMNode(htmlString) { 9 | const parser = new DOMParser() 10 | return parser.parseFromString(htmlString, 'text/html') 11 | } 12 | 13 | function getAxeResult(htmlString) { 14 | const {axe} = globalThis 15 | axe.reset() 16 | axe.configure(autoCompleteRules) 17 | const document = makeDOMNode(htmlString) 18 | const justTheBody = document.querySelector('body') 19 | return axe.run(justTheBody) 20 | } 21 | 22 | const documentString = ` 23 | 24 |
    25 |

    My Example Auto Complete Element

    26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |
    34 | 35 | ` 36 | 37 | describe('axe accessibility run', function () { 38 | describe('correct usage', function () { 39 | describe('does not block standard axe checks', function () { 40 | it('axe check passes', async function () { 41 | const result = await getAxeResult(documentString) 42 | assert.lengthOf(result.violations, 0) 43 | }) 44 | }) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@github/auto-complete-element", 3 | "version": "3.2.2", 4 | "description": "Auto-complete input values from server results", 5 | "repository": "github/auto-complete-element", 6 | "type": "module", 7 | "main": "dist/index.js", 8 | "module": "dist/index.js", 9 | "types": "dist/index.d.ts", 10 | "scripts": { 11 | "clean": "rm -rf dist/", 12 | "examples": "http-server -c-1 ./examples", 13 | "lint": "eslint . --ext .js,.ts && tsc --noEmit", 14 | "lint:fix": "npm run lint -- --fix", 15 | "prebuild": "npm run clean && npm run lint && mkdir dist", 16 | "bundle": "esbuild --bundle dist/index.js --keep-names --outfile=dist/bundle.js --format=esm", 17 | "build": "tsc && npm run bundle && npm run manifest", 18 | "prepublishOnly": "npm run build", 19 | "pretest": "npm run build; npx playwright install", 20 | "test": "web-test-runner", 21 | "postpublish": "npm publish --ignore-scripts --@github:registry='https://npm.pkg.github.com'", 22 | "manifest": "custom-elements-manifest analyze" 23 | }, 24 | "prettier": "@github/prettier-config", 25 | "keywords": [ 26 | "auto-complete" 27 | ], 28 | "license": "MIT", 29 | "files": [ 30 | "dist", 31 | "validator.js" 32 | ], 33 | "dependencies": { 34 | "@github/combobox-nav": "^2.1.7" 35 | }, 36 | "devDependencies": { 37 | "@custom-elements-manifest/analyzer": "^0.9.0", 38 | "@github/prettier-config": "^0.0.6", 39 | "@open-wc/testing": "^3.1.8", 40 | "@web/dev-server-esbuild": "^0.4.1", 41 | "@web/test-runner": "^0.19.0", 42 | "@web/test-runner-playwright": "^0.11.0", 43 | "axe-core": "^4.4.1", 44 | "esbuild": "^0.25.0", 45 | "eslint": "^8.39.0", 46 | "eslint-plugin-custom-elements": "^0.0.8", 47 | "eslint-plugin-github": "^4.7.0", 48 | "http-server": "^14.1.1", 49 | "karma-chai": "^0.1.0", 50 | "karma-mocha": "^2.0.1", 51 | "karma-mocha-reporter": "^2.2.5", 52 | "rollup-plugin-node-resolve": "^5.2.0", 53 | "typescript": "^5.2.2" 54 | }, 55 | "customElements": "custom-elements.json" 56 | } 57 | -------------------------------------------------------------------------------- /validator.js: -------------------------------------------------------------------------------- 1 | const SELECTOR = 'auto-complete' 2 | const INPUT_RULE_ID = 'required-input-element-child' 3 | // eslint-disable-next-line i18n-text/no-en 4 | const INPUT_HELP_TEXT = 'This component requires an input field to be provided.' 5 | const CLEAR_BUTTON_RULE_ID = 'optional-clear-must-be-button' 6 | // eslint-disable-next-line i18n-text/no-en 7 | const CLEAR_BUTTON_HELP_TEXT = 'If provided with clear button, it must be a button element.' 8 | 9 | function checkForInput(autoCompleteElement) { 10 | return autoCompleteElement.querySelectorAll('input').length === 1 11 | } 12 | 13 | function checkForOptionalClearButton(autoCompleteElement) { 14 | const [input] = autoCompleteElement.querySelectorAll('input') 15 | if (!input) { 16 | return true 17 | } 18 | const clearButtonId = `${input.id || input.getAttribute('name')}-clear` 19 | const clearButton = autoCompleteElement.ownerDocument.getElementById(clearButtonId) 20 | if (!clearButton) { 21 | return true 22 | } 23 | if (clearButton instanceof HTMLButtonElement) { 24 | return true 25 | } 26 | return false 27 | } 28 | 29 | const rules = [ 30 | { 31 | id: INPUT_RULE_ID, 32 | excludeHidden: true, 33 | selector: SELECTOR, 34 | metadata: { 35 | help: INPUT_HELP_TEXT, 36 | helpUrl: '', 37 | }, 38 | all: [`${INPUT_RULE_ID}_0`], 39 | }, 40 | { 41 | id: CLEAR_BUTTON_RULE_ID, 42 | excludeHidden: true, 43 | selector: SELECTOR, 44 | metadata: { 45 | help: CLEAR_BUTTON_HELP_TEXT, 46 | helpUrl: '', 47 | }, 48 | all: [`${CLEAR_BUTTON_RULE_ID}_0`], 49 | }, 50 | ] 51 | 52 | const checks = [ 53 | { 54 | id: `${INPUT_RULE_ID}_0`, 55 | evaluate: checkForInput, 56 | metadata: {impact: 'critical'}, 57 | }, 58 | { 59 | id: `${CLEAR_BUTTON_RULE_ID}_0`, 60 | evaluate: checkForOptionalClearButton, 61 | metadata: {impact: 'critical'}, 62 | }, 63 | ] 64 | 65 | export function validator(domNode) { 66 | const result = { 67 | passes: [], 68 | violations: [], 69 | } 70 | for (const element of domNode.getElementsByTagName(SELECTOR)) { 71 | for (const rule of rules) { 72 | for (const checkId of rule.all) { 73 | const thisCheck = checks.find(check => check.id === checkId) 74 | const checkResult = thisCheck.evaluate(element) 75 | 76 | result[checkResult ? 'passes' : 'violations'].push({ 77 | id: rule.id, 78 | help: rule.metadata.help, 79 | helpUrl: rule.metadata.helpUrl, 80 | nodes: [element], 81 | }) 82 | } 83 | } 84 | } 85 | return result 86 | } 87 | 88 | /** 89 | * 90 | * @param {import('axe-core').Spec} ruleset 91 | * @returns {import('axe-core').Spec} 92 | */ 93 | export default function combineRules(ruleset = {}) { 94 | return Object.assign({}, ruleset, { 95 | checks: (ruleset.checks || []).concat(checks), 96 | rules: (ruleset.rules || []).concat(rules), 97 | }) 98 | } 99 | -------------------------------------------------------------------------------- /src/auto-complete-element.ts: -------------------------------------------------------------------------------- 1 | import Autocomplete from './autocomplete.js' 2 | const HTMLElement = globalThis.HTMLElement || (null as unknown as (typeof window)['HTMLElement']) 3 | 4 | type AutoCompleteEventInit = EventInit & { 5 | relatedTarget: HTMLInputElement 6 | } 7 | 8 | export class AutoCompleteEvent extends Event { 9 | relatedTarget: HTMLInputElement 10 | 11 | constructor(type: 'auto-complete-change', {relatedTarget, ...init}: AutoCompleteEventInit) { 12 | super(type, init) 13 | this.relatedTarget = relatedTarget 14 | } 15 | } 16 | 17 | const state = new WeakMap() 18 | 19 | export interface CSPTrustedTypesPolicy { 20 | createHTML: (s: string, response: Response) => CSPTrustedHTMLToStringable 21 | } 22 | 23 | // Note: basically every object (and some primitives) in JS satisfy this 24 | // `CSPTrustedHTMLToStringable` interface, but this is the most compatible shape 25 | // we can use. 26 | interface CSPTrustedHTMLToStringable { 27 | toString: () => string 28 | } 29 | 30 | let cspTrustedTypesPolicyPromise: Promise | null = null 31 | 32 | export class AutoCompleteElement extends HTMLElement { 33 | static define(tag = 'auto-complete', registry = customElements) { 34 | registry.define(tag, this) 35 | return this 36 | } 37 | 38 | static setCSPTrustedTypesPolicy(policy: CSPTrustedTypesPolicy | Promise | null): void { 39 | cspTrustedTypesPolicyPromise = policy === null ? policy : Promise.resolve(policy) 40 | } 41 | 42 | #forElement: HTMLElement | null = null 43 | get forElement(): HTMLElement | null { 44 | if (this.#forElement?.isConnected) { 45 | return this.#forElement 46 | } 47 | const id = this.getAttribute('for') 48 | const root = this.getRootNode() 49 | if (id && (root instanceof Document || root instanceof ShadowRoot)) { 50 | return root.getElementById(id) 51 | } 52 | return null 53 | } 54 | 55 | set forElement(element: HTMLElement | null) { 56 | this.#forElement = element 57 | this.setAttribute('for', '') 58 | } 59 | 60 | #inputElement: HTMLInputElement | null = null 61 | get inputElement(): HTMLInputElement | null { 62 | if (this.#inputElement?.isConnected) { 63 | return this.#inputElement 64 | } 65 | return this.querySelector('input') 66 | } 67 | 68 | set inputElement(input: HTMLInputElement | null) { 69 | this.#inputElement = input 70 | this.#reattachState() 71 | } 72 | 73 | connectedCallback(): void { 74 | if (!this.isConnected) return 75 | this.#reattachState() 76 | 77 | new MutationObserver(() => { 78 | if (!state.get(this)) { 79 | this.#reattachState() 80 | } 81 | }).observe(this, {subtree: true, childList: true}) 82 | } 83 | 84 | disconnectedCallback(): void { 85 | const autocomplete = state.get(this) 86 | if (autocomplete) { 87 | autocomplete.destroy() 88 | state.delete(this) 89 | } 90 | } 91 | 92 | #reattachState() { 93 | state.get(this)?.destroy() 94 | const {forElement, inputElement} = this 95 | if (!forElement || !inputElement) return 96 | const autoselectEnabled = this.getAttribute('data-autoselect') === 'true' 97 | state.set(this, new Autocomplete(this, inputElement, forElement, autoselectEnabled)) 98 | forElement.setAttribute('role', 'listbox') 99 | } 100 | 101 | get src(): string { 102 | return this.getAttribute('src') || '' 103 | } 104 | 105 | set src(url: string) { 106 | this.setAttribute('src', url) 107 | } 108 | 109 | get value(): string { 110 | return this.getAttribute('value') || '' 111 | } 112 | 113 | set value(value: string) { 114 | this.setAttribute('value', value) 115 | } 116 | 117 | get open(): boolean { 118 | return this.hasAttribute('open') 119 | } 120 | 121 | set open(value: boolean) { 122 | if (value) { 123 | this.setAttribute('open', '') 124 | } else { 125 | this.removeAttribute('open') 126 | } 127 | } 128 | 129 | // HEAD 130 | get fetchOnEmpty(): boolean { 131 | return this.hasAttribute('fetch-on-empty') 132 | } 133 | 134 | set fetchOnEmpty(fetchOnEmpty: boolean) { 135 | this.toggleAttribute('fetch-on-empty', fetchOnEmpty) 136 | } 137 | 138 | #requestController?: AbortController 139 | async fetchResult(url: URL): Promise { 140 | this.#requestController?.abort() 141 | const {signal} = (this.#requestController = new AbortController()) 142 | const res = await fetch(url.toString(), { 143 | signal, 144 | headers: { 145 | Accept: 'text/fragment+html', 146 | }, 147 | }) 148 | if (!res.ok) { 149 | throw new Error(await res.text()) 150 | } 151 | if (cspTrustedTypesPolicyPromise) { 152 | const cspTrustedTypesPolicy = await cspTrustedTypesPolicyPromise 153 | return cspTrustedTypesPolicy.createHTML(await res.text(), res) 154 | } 155 | return await res.text() 156 | } 157 | //f21528e (add csp trusted types policy) 158 | static get observedAttributes(): string[] { 159 | return ['open', 'value', 'for'] 160 | } 161 | 162 | attributeChangedCallback(name: string, oldValue: string, newValue: string): void { 163 | if (oldValue === newValue) return 164 | 165 | const autocomplete = state.get(this) 166 | if (!autocomplete) return 167 | 168 | if (this.forElement !== state.get(this)?.results || this.inputElement !== state.get(this)?.input) { 169 | this.#reattachState() 170 | } 171 | 172 | switch (name) { 173 | case 'open': 174 | newValue === null ? autocomplete.close() : autocomplete.open() 175 | break 176 | case 'value': 177 | if (newValue !== null) { 178 | autocomplete.input.value = newValue 179 | } 180 | this.dispatchEvent( 181 | new AutoCompleteEvent('auto-complete-change', { 182 | bubbles: true, 183 | relatedTarget: autocomplete.input, 184 | }), 185 | ) 186 | break 187 | } 188 | } 189 | } 190 | 191 | export default AutoCompleteElement 192 | -------------------------------------------------------------------------------- /custom-elements.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": "1.0.0", 3 | "readme": "", 4 | "modules": [ 5 | { 6 | "kind": "javascript-module", 7 | "path": "src/auto-complete-element.ts", 8 | "declarations": [ 9 | { 10 | "kind": "class", 11 | "description": "", 12 | "name": "AutoCompleteEvent", 13 | "members": [ 14 | { 15 | "kind": "field", 16 | "name": "relatedTarget", 17 | "type": { 18 | "text": "HTMLInputElement" 19 | }, 20 | "default": "relatedTarget" 21 | } 22 | ], 23 | "superclass": { 24 | "name": "Event", 25 | "module": "src/auto-complete-element.ts" 26 | } 27 | }, 28 | { 29 | "kind": "class", 30 | "description": "", 31 | "name": "AutoCompleteElement", 32 | "members": [ 33 | { 34 | "kind": "method", 35 | "name": "define", 36 | "static": true, 37 | "parameters": [ 38 | { 39 | "name": "tag", 40 | "default": "'auto-complete'" 41 | }, 42 | { 43 | "name": "registry", 44 | "default": "customElements" 45 | } 46 | ] 47 | }, 48 | { 49 | "kind": "method", 50 | "name": "setCSPTrustedTypesPolicy", 51 | "static": true, 52 | "return": { 53 | "type": { 54 | "text": "void" 55 | } 56 | }, 57 | "parameters": [ 58 | { 59 | "name": "policy", 60 | "type": { 61 | "text": "CSPTrustedTypesPolicy | Promise | null" 62 | } 63 | } 64 | ] 65 | }, 66 | { 67 | "kind": "field", 68 | "name": "#forElement", 69 | "privacy": "private", 70 | "type": { 71 | "text": "HTMLElement | null" 72 | }, 73 | "default": "null" 74 | }, 75 | { 76 | "kind": "field", 77 | "name": "forElement", 78 | "type": { 79 | "text": "HTMLElement | null" 80 | } 81 | }, 82 | { 83 | "kind": "field", 84 | "name": "#inputElement", 85 | "privacy": "private", 86 | "type": { 87 | "text": "HTMLInputElement | null" 88 | }, 89 | "default": "null" 90 | }, 91 | { 92 | "kind": "field", 93 | "name": "inputElement", 94 | "type": { 95 | "text": "HTMLInputElement | null" 96 | } 97 | }, 98 | { 99 | "kind": "method", 100 | "name": "#reattachState" 101 | }, 102 | { 103 | "kind": "field", 104 | "name": "src", 105 | "type": { 106 | "text": "string" 107 | } 108 | }, 109 | { 110 | "kind": "field", 111 | "name": "value", 112 | "type": { 113 | "text": "string" 114 | } 115 | }, 116 | { 117 | "kind": "field", 118 | "name": "open", 119 | "type": { 120 | "text": "boolean" 121 | } 122 | }, 123 | { 124 | "kind": "field", 125 | "name": "fetchOnEmpty", 126 | "type": { 127 | "text": "boolean" 128 | } 129 | }, 130 | { 131 | "kind": "field", 132 | "name": "#requestController", 133 | "privacy": "private", 134 | "type": { 135 | "text": "AbortController | undefined" 136 | } 137 | }, 138 | { 139 | "kind": "method", 140 | "name": "fetchResult", 141 | "return": { 142 | "type": { 143 | "text": "Promise" 144 | } 145 | }, 146 | "parameters": [ 147 | { 148 | "name": "url", 149 | "type": { 150 | "text": "URL" 151 | } 152 | } 153 | ] 154 | } 155 | ], 156 | "events": [ 157 | { 158 | "name": "auto-complete-change", 159 | "type": { 160 | "text": "AutoCompleteEvent" 161 | } 162 | } 163 | ], 164 | "attributes": [ 165 | { 166 | "name": "open" 167 | }, 168 | { 169 | "name": "value" 170 | }, 171 | { 172 | "name": "for" 173 | } 174 | ], 175 | "superclass": { 176 | "name": "HTMLElement" 177 | }, 178 | "customElement": true 179 | } 180 | ], 181 | "exports": [ 182 | { 183 | "kind": "js", 184 | "name": "AutoCompleteEvent", 185 | "declaration": { 186 | "name": "AutoCompleteEvent", 187 | "module": "src/auto-complete-element.ts" 188 | } 189 | }, 190 | { 191 | "kind": "js", 192 | "name": "AutoCompleteElement", 193 | "declaration": { 194 | "name": "AutoCompleteElement", 195 | "module": "src/auto-complete-element.ts" 196 | } 197 | }, 198 | { 199 | "kind": "js", 200 | "name": "default", 201 | "declaration": { 202 | "name": "AutoCompleteElement", 203 | "module": "src/auto-complete-element.ts" 204 | } 205 | } 206 | ] 207 | } 208 | ] 209 | } 210 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Auto-complete examples 5 | 6 | 15 | 40 | 41 | 42 |
    43 | 44 | 45 | 46 | 47 | 48 | 49 |
      50 | 54 |
      55 |
      56 | 57 |
      58 | 59 | 60 |
      61 | 62 | 63 | 64 | 65 | 66 | 67 |
        68 | 72 |
        73 |
        74 | 75 |
        76 | 77 | 78 |
        79 | 80 | 81 | 82 |
          83 |
          84 |
          85 | 86 |
          87 | 88 | 89 |
          90 | 91 | 98 | 99 |
            100 |
            101 |
            102 | 103 |
            104 | 115 | 116 |
            117 | 118 | 119 | 120 | 121 | 122 |
              123 |
              124 |
              125 | 126 |
              127 | 128 | 129 | 130 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # <auto-complete> element 2 | 3 | Auto-complete input values from server search results. 4 | 5 | ## Installation 6 | 7 | ``` 8 | $ npm install --save @github/auto-complete-element 9 | ``` 10 | 11 | ## Usage 12 | 13 | ### Script 14 | 15 | Import as ES modules: 16 | 17 | ```js 18 | import '@github/auto-complete-element' 19 | ``` 20 | 21 | With a script tag: 22 | 23 | ```html 24 |