[
  {
    "path": ".devcontainer/Dockerfile",
    "content": "# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node/.devcontainer/base.Dockerfile\n\n# [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\nARG VARIANT=\"16\"\nFROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT}\n\n# [Optional] Uncomment this section to install additional OS packages.\n# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \\\n#     && apt-get -y install --no-install-recommends <your-package-list-here>\n\n# [Optional] Uncomment if you want to install an additional version of node using nvm\n# ARG EXTRA_NODE_VERSION=10\n# RUN su node -c \"source/usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}\"\n\n# [Optional] Uncomment if you want to install more global node modules\n# RUN su node -c \"npm install -g <your-package-list-here>\" \n"
  },
  {
    "path": ".devcontainer/devcontainer.json",
    "content": "// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:\n// https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node\n{\n\t\"name\": \"Node.js\",\n\t\"build\": {\n\t\t\"dockerfile\": \"Dockerfile\",\n\t\t// Update 'VARIANT' to pick a Node version: 16, 14, 12.\n\t\t// Append -bullseye or -buster to pin to an OS version.\n\t\t// Use -bullseye variants on local arm64/Apple Silicon.\n\t\t\"args\": { \"VARIANT\": \"20\" }\n\t},\n\n\t// Set *default* container specific settings.json values on container create.\n\t\"settings\": {},\n\n\t// Add the IDs of extensions you want installed when the container is created.\n\t\"extensions\": [\n\t\t\"dbaeumer.vscode-eslint\"\n\t],\n\n\t// Use 'forwardPorts' to make a list of ports inside the container available locally.\n\t// \"forwardPorts\": [],\n\n\t// Use 'postCreateCommand' to run commands after the container is created.\n\t// \"postCreateCommand\": \"yarn install\",\n\n\t// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.\n\t\"remoteUser\": \"node\",\n\t\"features\": {\n\t\t\"git\": \"latest\"\n\t}\n}\n"
  },
  {
    "path": ".eslintrc.json",
    "content": "{\n  \"root\": true,\n  \"plugins\": [\"github\"],\n  \"extends\": [\"plugin:github/browser\", \"plugin:github/recommended\", \"plugin:github/typescript\"],\n  \"globals\": {\n    \"TextExpanderElement\": \"readonly\"\n  },\n  \"rules\": {\n    \"no-invalid-this\": \"off\"\n  },\n  \"overrides\": [\n    {\n      \"files\": \"test/**/*.js\",\n      \"rules\": {\n        \"github/unescaped-html-literal\": \"off\"\n      }\n    },\n    {\n      \"files\": \"test/**/*.js\",\n      \"excludedFiles\": \"test/karma.config.js\",\n      \"env\": {\n        \"mocha\": true\n      },\n      \"globals\": {\n        \"assert\": true\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": ".github/workflows/nodejs.yml",
    "content": "name: Node CI\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  build:\n    permissions:\n      contents: read\n    runs-on: ubuntu-22.04\n\n    steps:\n      - uses: actions/checkout@v1\n      - name: Use Node.js\n        uses: actions/setup-node@v1\n        with:\n          node-version: '20.x'\n      - name: npm install, build, and test\n        run: |\n          npm install\n          npm run build --if-present\n          npm test\n        env:\n          CI: true\n          CHROME_BIN: google-chrome\n"
  },
  {
    "path": ".github/workflows/publish.yml",
    "content": "name: Publish\npermissions:\n  contents: read\n  packages: write\n  id-token: write # Required for OIDC\n\non:\n  release:\n    types: [created]\n  workflow_dispatch:\n    inputs:\n      version:\n        description: 'Version to publish (e.g., v1.2.3)'\n        required: true\n        type: string\n\njobs:\n  publish-npm:\n    runs-on: ubuntu-22.04\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 24\n          registry-url: https://registry.npmjs.org/\n          cache: npm\n      - run: npm ci\n      - run: npm test\n      - run: npm version ${TAG_NAME} --git-tag-version=false\n        env:\n          TAG_NAME: ${{ github.event.release.tag_name || github.event.inputs.version }}\n      - run: npm --ignore-scripts publish\n"
  },
  {
    "path": ".gitignore",
    "content": "build/\ndist/\nnode_modules/\n"
  },
  {
    "path": ".travis.yml",
    "content": "language: node_js\nsudo: required\nnode_js:\n  - \"node\"\naddons:\n  chrome: stable\ncache:\n  directories:\n    - node_modules\n"
  },
  {
    "path": "CODEOWNERS",
    "content": "* @github/primer-reviewers\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright (c) 2019 GitHub, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# &lt;text-expander&gt; element\n\nActivates a suggestion menu to expand text snippets as you type.\n\n## Installation\n\n```\n$ npm install --save @github/text-expander-element\n```\n\n## Usage\n\n### Script\n\nImport as ES modules:\n\n```js\nimport '@github/text-expander-element'\n```\n\nWith a script tag:\n\n```html\n<script type=\"module\" src=\"./node_modules/@github/text-expander-element/dist/bundle.js\">\n```\n\n### Markup\n\n```html\n<text-expander keys=\": @ #\" multiword=\"#\">\n  <textarea></textarea>\n</text-expander>\n```\n\n## Attributes\n\n- `keys` is a space separated list of menu activation keys\n- `multiword` defines whether the expansion should use several words or not\n  - you can provide a space separated list of activation keys that should support multi-word matching\n- `suffix` is a string that is appended to the value during expansion, default is a single space character\n\n## Events\n\n**`text-expander-change`** is fired when a key is matched. In `event.detail` you can find:\n\n- `key`: The matched key; for example: `:`.\n- `text`: The matched text; for example: `cat`, for `:cat`.\n  - If the `key` is specified in the `multiword` attribute then the matched text can contain multiple words; for example `cat and dog` for `:cat and dog`.\n- `provide`: A function to be called when you have the menu results. Takes a `Promise` with `{matched: boolean, fragment: HTMLElement}` where `matched` tells the element whether a suggestion is available, and `fragment` is the menu content to be displayed on the page.\n\n```js\nconst expander = document.querySelector('text-expander')\n\nexpander.addEventListener('text-expander-change', function(event) {\n  const {key, provide, text} = event.detail\n  if (key !== ':') return\n\n  const suggestions = document.querySelector('.emoji-suggestions').cloneNode(true)\n  suggestions.hidden = false\n  for (const suggestion of suggestions.children) {\n    if (!suggestion.textContent.match(text)) {\n      suggestion.remove()\n    }\n  }\n  provide(Promise.resolve({matched: suggestions.childElementCount > 0, fragment: suggestions}))\n})\n```\n\nThe returned fragment should be consisted of filtered `[role=option]` items to be selected. For example:\n\n```html\n<ul class=\"emoji-suggestions\" hidden>\n  <li role=\"option\" data-value=\"🐈\">🐈 :cat2:</li>\n  <li role=\"option\" data-value=\"🐕\">🐕 :dog:</li>\n</ul>\n```\n\n**`text-expander-value`** is fired when an item is selected. In `event.detail` you can find:\n\n- `key`: The matched key; for example: `:`.\n- `item`: The selected item. This would be one of the `[role=option]`. Use this to work out the `value`.\n- `value`: A null value placeholder to replace the query. To replace the text query, simply re-assign this value.\n- `continue`: A boolean value to specify whether to continue autocompletion after inserting a value. Defaults to `false`. If set to `true`, will not add a space after inserted value and will keep firing the `text-expander-change` event.\n\n```js\nconst expander = document.querySelector('text-expander')\n\nexpander.addEventListener('text-expander-value', function(event) {\n  const {key, item}  = event.detail\n  if (key === ':') {\n    event.detail.value = item.getAttribute('data-value')\n  }\n})\n```\n\n**`text-expander-committed`** is fired after the underlying `input` value has been updated in the DOM. In `event.detail` you can find:\n\n- `input`: The `HTMLInputElement` or `HTMLTextAreaElement` that just had `value` changes committed to the DOM.\n\n```js\nconst expander = document.querySelector('text-expander')\n\nexpander.addEventListener('text-expander-committed', function(event) {\n  const {input}  = event.detail\n})\n```\n\n**`text-expander-activate`** is fired just after the menu has been assigned and appended to the DOM, and just before it is about to be positioned near the text to expand. This is useful for assigning classes or calling imperative methods to show the menu, such as `.showPopover()`.\n\n**`text-expander-deactivate`** is fired just before the menu is going to be unassigned and removed from the DOM. This is useful for removing classes or running cleanup like removing from caches.\n\n## Browser support\n\nBrowsers without native [custom element support][support] require a [polyfill][].\n\n- Chrome\n- Firefox\n- Safari\n- Microsoft Edge\n\n[support]: https://caniuse.com/#feat=custom-elementsv1\n[polyfill]: https://github.com/webcomponents/custom-elements\n\n## Development\n\n```\nnpm install\nnpm test\n```\n\n## License\n\nDistributed under the MIT license. See LICENSE for details.\n"
  },
  {
    "path": "examples/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <title>text-expander demo</title>\n    <style>\n      .menu {\n        position: absolute;\n        list-style-type: none;\n        padding: 0;\n        background: lightgray;\n\n        [aria-selected='true'] {\n          background: #eee;\n        }\n      }\n    </style>\n  </head>\n  <body>\n    <h1>text-expanded element examples</h1>\n    <p>Use <code>#</code> to trigger the expander</p>\n\n    <h2>Normal text-expander element</h2>\n    <text-expander keys=\"#\">\n      <textarea autofocus rows=\"10\" cols=\"40\"></textarea>\n    </text-expander>\n\n    <h2>Multiword text-expander element</h2>\n    <text-expander keys=\"#\" multiword=\"#\">\n      <textarea autofocus rows=\"10\" cols=\"40\"></textarea>\n    </text-expander>\n\n    <h2>Multiword and multikey text-expander element</h2>\n    <text-expander keys=\": #\" multiword=\"#\">\n      <textarea autofocus rows=\"10\" cols=\"40\"></textarea>\n    </text-expander>\n\n    <h2>Multiword and multikey text-expander element with random delay</h2>\n    <text-expander keys=\": #\" multiword=\"#\" _random_delay=\"\">\n      <textarea autofocus rows=\"10\" cols=\"40\"></textarea>\n    </text-expander>    \n\n    <script type=\"text/javascript\">\n      const emojis = [\n        { emoji: \"😀\", names: [\"smile\", \"happy\"] },\n        { emoji: \"❤️\", names: [\"heart\", \"love\"] },\n        { emoji: \"🔥\", names: [\"fire\", \"hot\"] },\n        { emoji: \"⭐\", names: [\"star\", \"favorite\"] },\n        { emoji: \"🚀\", names: [\"rocket\", \"fast\"] }\n      ];\n      \n      const expanders = document.querySelectorAll('text-expander')\n      for (const expander of expanders) {\n        expander.addEventListener('text-expander-change', event => {\n          const {key, provide, text} = event.detail\n          if (key === ':') {\n            const menu = document.createElement('ul')\n            menu.classList.add('menu')\n            menu.role = 'listbox'\n            for (const {emoji, names} of emojis) {\n              if (names.some(name => name.includes(text.toLowerCase()))) {\n                const item = document.createElement('li')\n                item.setAttribute('role', 'option')\n                item.textContent = `${emoji} ${names[0]}`\n                item.setAttribute('data-value', emoji)\n                menu.append(item)\n              }\n            }\n            // Async test with random delay\n            if (expander.hasAttribute('_random_delay')) {\n              provide(new Promise(resolve => {\n                setTimeout(() => resolve({matched: true, fragment: menu}), Math.random() * 1000)\n              }))\n            } else {\n              provide(Promise.resolve({matched: true, fragment: menu}))\n            }\n          } else if (key === '#') {\n            const menu = document.createElement('ul')\n            menu.classList.add('menu')\n            menu.role = 'listbox'\n            for (const issue of [\n              '#1 Implement a text-expander element',\n              '#2 Implement multi word option',\n              '#3 Fix tpoy',\n              '#4 Implement #12',\n              '#5 Implement #123 and #456',\n            ]) {\n              if (issue.toLowerCase().includes(text.toLowerCase())) {\n                const item = document.createElement('li')\n                item.setAttribute('role', 'option')\n                item.textContent = issue\n                item.setAttribute('data-value', issue.split(' ')[0])\n                item.id = `option-${issue}`\n                menu.append(item)\n              }\n            }\n            // Async test with random delay\n            if (expander.hasAttribute('_random_delay')) {\n              provide(new Promise(resolve => {\n                setTimeout(() => resolve({matched: true, fragment: menu}), Math.random() * 1000)\n              }))\n            } else {\n              // For normal expander - synchronous response\n              provide(Promise.resolve({matched: true, fragment: menu}))\n            }\n          }\n        })\n\n        expander.addEventListener('text-expander-value', function(event) {\n          const {key, item} = event.detail\n          if (key === '#') {\n            event.detail.value = item.getAttribute('data-value') || item.textContent\n          } else if (key === ':') {\n            event.detail.value = item.getAttribute('data-value')\n          }\n        })\n      }\n    </script>\n    <script type=\"module\" src=\"https://unpkg.com/@github/text-expander-element@latest/dist/bundle.js\"></script>\n    <!-- <script src=\"../dist/bundle.js\" type=\"module\"></script> -->\n  </body>\n</html>\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"@github/text-expander-element\",\n  \"version\": \"2.2.2\",\n  \"description\": \"Activates a suggestion menu to expand text snippets as you type.\",\n  \"repository\": \"github/text-expander-element\",\n  \"type\": \"module\",\n  \"main\": \"dist/index.js\",\n  \"module\": \"dist/index.js\",\n  \"types\": \"dist/index.d.ts\",\n  \"scripts\": {\n    \"clean\": \"rm -rf dist build\",\n    \"compile\": \"tsc\",\n    \"lint\": \"eslint .\",\n    \"prebuild\": \"npm run clean && npm run lint && npm run compile\",\n    \"build\": \"rollup -c\",\n    \"pretest\": \"npm run build && rollup -c rollup.config.test.js\",\n    \"test\": \"karma start test/karma.config.cjs\",\n    \"prepublishOnly\": \"npm run build\",\n    \"postpublish\": \"npm publish --ignore-scripts --@github:registry='https://npm.pkg.github.com'\"\n  },\n  \"keywords\": [\n    \"auto-complete\",\n    \"suggestions\",\n    \"menu\"\n  ],\n  \"license\": \"MIT\",\n  \"files\": [\n    \"dist\"\n  ],\n  \"prettier\": \"@github/prettier-config\",\n  \"dependencies\": {\n    \"@github/combobox-nav\": \"^2.0.2\",\n    \"dom-input-range\": \"^2.0.0\"\n  },\n  \"devDependencies\": {\n    \"@github/prettier-config\": \"0.0.4\",\n    \"chai\": \"^4.3.4\",\n    \"eslint\": \"^8.0.1\",\n    \"eslint-plugin-github\": \"^4.10.2\",\n    \"karma\": \"^6.3.2\",\n    \"karma-chai\": \"^0.1.0\",\n    \"karma-chrome-launcher\": \"^3.1.0\",\n    \"karma-mocha\": \"^2.0.1\",\n    \"karma-mocha-reporter\": \"^2.2.5\",\n    \"mocha\": \"^10.7.3\",\n    \"rollup\": \"^2.45.1\",\n    \"rollup-plugin-node-resolve\": \"^5.2.0\",\n    \"typescript\": \"^5.4.5\"\n  },\n  \"eslintIgnore\": [\n    \"build/\",\n    \"dist/\",\n    \"test/karma.config.js\",\n    \"rollup.config.js\",\n    \"rollup.config.test.js\",\n    \"prettier.config.js\"\n  ]\n}\n"
  },
  {
    "path": "rollup.config.js",
    "content": "const pkg = require('./package.json')\nconst resolve = require('rollup-plugin-node-resolve')\n\nexport default [\n  {\n    external: ['@github/combobox-nav'],\n    input: 'dist/index.js',\n    output: {\n      file: pkg['module'],\n      format: 'es'\n    },\n    plugins: [resolve()]\n  },\n  {\n    input: 'dist/index.js',\n    output: {\n      file: 'dist/bundle.js',\n      format: 'es',\n    },\n    plugins: [resolve()]\n  }\n]\n"
  },
  {
    "path": "rollup.config.test.js",
    "content": "import resolve from 'rollup-plugin-node-resolve'\n\nexport default {\n  input: 'test/test.js',\n  output: [\n    {\n      file: 'build/test.js',\n      format: 'es',\n    }\n  ],\n  plugins: [\n    resolve()\n  ]\n}\n"
  },
  {
    "path": "src/index.ts",
    "content": "import TextExpanderElement from './text-expander-element'\nexport {TextExpanderElement as default}\nexport type * from './text-expander-element'\n\ndeclare global {\n  interface Window {\n    TextExpanderElement: typeof TextExpanderElement\n  }\n}\n\nif (!window.customElements.get('text-expander')) {\n  window.TextExpanderElement = TextExpanderElement\n  window.customElements.define('text-expander', TextExpanderElement)\n}\n"
  },
  {
    "path": "src/query.ts",
    "content": "type Query = {\n  text: string\n  position: number\n}\n\ntype QueryOptions = {\n  lookBackIndex: number\n  multiWord: boolean\n  lastMatchPosition: number | null\n}\n\nconst boundary = /\\s|\\(|\\[/\n\n// Extracts a keyword from the source text, backtracking from the cursor position.\nexport default function query(\n  text: string,\n  key: string,\n  cursor: number,\n  {multiWord, lookBackIndex, lastMatchPosition}: QueryOptions = {\n    multiWord: false,\n    lookBackIndex: 0,\n    lastMatchPosition: null\n  }\n): Query | void {\n  // Activation key not found in front of the cursor.\n  let keyIndex = text.lastIndexOf(key, cursor - 1)\n  if (keyIndex === -1) return\n\n  // Stop matching at the lookBackIndex\n  if (keyIndex < lookBackIndex) return\n\n  if (multiWord) {\n    if (lastMatchPosition != null) {\n      // If the current activation key is the same as last match\n      // i.e. consecutive activation keys, then return.\n      if (lastMatchPosition === keyIndex) return\n      keyIndex = lastMatchPosition - key.length\n    }\n\n    // Space immediately after activation key followed by the cursor\n    const charAfterKey = text[keyIndex + 1]\n    if (charAfterKey === ' ' && cursor >= keyIndex + key.length + 1) return\n\n    // New line the cursor and previous activation key.\n    const newLineIndex = text.lastIndexOf('\\n', cursor - 1)\n    if (newLineIndex > keyIndex) return\n\n    // Dot between the cursor and previous activation key.\n    const dotIndex = text.lastIndexOf('.', cursor - 1)\n    if (dotIndex > keyIndex) return\n  } else {\n    // Space between the cursor and previous activation key.\n    const spaceIndex = text.lastIndexOf(' ', cursor - 1)\n    if (spaceIndex > keyIndex) return\n  }\n\n  // Activation key must occur at word boundary.\n  const pre = text[keyIndex - 1]\n  if (pre && !boundary.test(pre)) return\n\n  // Extract matched keyword.\n  const queryString = text.substring(keyIndex + key.length, cursor)\n  return {\n    text: queryString,\n    position: keyIndex + key.length\n  }\n}\n"
  },
  {
    "path": "src/text-expander-element.ts",
    "content": "import Combobox from '@github/combobox-nav'\nimport query from './query'\nimport {InputRange} from 'dom-input-range'\n\nexport type TextExpanderMatch = {\n  text: string\n  key: string\n  position: number\n}\n\nexport type TextExpanderResult = {\n  fragment?: HTMLElement\n  matched: boolean\n}\n\nexport type TextExpanderKey = {\n  key: string\n  multiWord: boolean\n}\n\nexport type TextExpanderChangeEvent = Event & {\n  detail?: {\n    key: string\n    text: string\n    provide: (result: Promise<TextExpanderResult> | TextExpanderResult) => void\n  }\n}\n\nconst states = new WeakMap()\n\nclass TextExpander {\n  expander: TextExpanderElement\n  input: HTMLInputElement | HTMLTextAreaElement\n  menu: HTMLElement | null\n  oninput: (event: Event) => void\n  onkeydown: (event: KeyboardEvent) => void\n  onpaste: (event: Event) => void\n  oncommit: (event: Event) => void\n  onblur: (event: Event) => void\n  onmousedown: (event: Event) => void\n  combobox: Combobox | null\n  match: TextExpanderMatch | null\n  justPasted: boolean\n  lookBackIndex: number\n  interactingWithList: boolean\n\n  constructor(expander: TextExpanderElement, input: HTMLInputElement | HTMLTextAreaElement) {\n    this.expander = expander\n    this.input = input\n    this.combobox = null\n    this.menu = null\n    this.match = null\n    this.justPasted = false\n    this.lookBackIndex = 0\n    this.oninput = this.onInput.bind(this)\n    this.onpaste = this.onPaste.bind(this)\n    this.onkeydown = this.onKeydown.bind(this)\n    this.oncommit = this.onCommit.bind(this)\n    this.onmousedown = this.onMousedown.bind(this)\n    this.onblur = this.onBlur.bind(this)\n    this.interactingWithList = false\n    input.addEventListener('paste', this.onpaste)\n    input.addEventListener('input', this.oninput)\n    ;(input as HTMLElement).addEventListener('keydown', this.onkeydown)\n    input.addEventListener('blur', this.onblur)\n  }\n\n  destroy() {\n    this.input.removeEventListener('paste', this.onpaste)\n    this.input.removeEventListener('input', this.oninput)\n    ;(this.input as HTMLElement).removeEventListener('keydown', this.onkeydown)\n    this.input.removeEventListener('blur', this.onblur)\n  }\n\n  dismissMenu() {\n    if (this.deactivate()) {\n      this.lookBackIndex = this.input.selectionEnd || this.lookBackIndex\n    }\n  }\n\n  private activate(match: TextExpanderMatch, menu: HTMLElement) {\n    if (this.input !== document.activeElement && this.input !== document.activeElement?.shadowRoot?.activeElement) {\n      return\n    }\n\n    this.deactivate()\n    this.menu = menu\n\n    if (!menu.id) menu.id = `text-expander-${Math.floor(Math.random() * 100000).toString()}`\n    this.expander.append(menu)\n    this.combobox = new Combobox(this.input, menu)\n\n    this.expander.dispatchEvent(new Event('text-expander-activate'))\n\n    this.positionMenu(menu, match.position)\n\n    this.combobox.start()\n    menu.addEventListener('combobox-commit', this.oncommit)\n    menu.addEventListener('mousedown', this.onmousedown)\n\n    // Focus first menu item.\n    this.combobox.navigate(1)\n  }\n\n  private positionMenu(menu: HTMLElement, position: number) {\n    // Clamp position to valid range to avoid IndexSizeError when input text changes\n    const clampedPosition = Math.min(position, this.input.value.length)\n    const caretRect = new InputRange(this.input, clampedPosition).getBoundingClientRect()\n    const targetPosition = {left: caretRect.left, top: caretRect.top + caretRect.height}\n\n    const currentPosition = menu.getBoundingClientRect()\n\n    const delta = {\n      left: targetPosition.left - currentPosition.left,\n      top: targetPosition.top - currentPosition.top\n    }\n\n    if (delta.left !== 0 || delta.top !== 0) {\n      // Use computedStyle to avoid nesting calc() deeper and deeper\n      const currentStyle = getComputedStyle(menu)\n\n      // Using `calc` avoids having to parse the current pixel value\n      menu.style.left = currentStyle.left ? `calc(${currentStyle.left} + ${delta.left}px)` : `${delta.left}px`\n      menu.style.top = currentStyle.top ? `calc(${currentStyle.top} + ${delta.top}px)` : `${delta.top}px`\n    }\n  }\n\n  private deactivate() {\n    const menu = this.menu\n    if (!menu || !this.combobox) return false\n\n    this.expander.dispatchEvent(new Event('text-expander-deactivate'))\n\n    this.menu = null\n\n    menu.removeEventListener('combobox-commit', this.oncommit)\n    menu.removeEventListener('mousedown', this.onmousedown)\n\n    this.combobox.destroy()\n    this.combobox = null\n    menu.remove()\n\n    return true\n  }\n\n  private onCommit({target}: Event) {\n    const item = target\n    if (!(item instanceof HTMLElement)) return\n    if (!this.combobox) return\n\n    const match = this.match\n    if (!match) return\n\n    const beginning = this.input.value.substring(0, match.position - match.key.length)\n    const remaining = this.input.value.substring(match.position + match.text.length)\n\n    const detail = {item, key: match.key, value: null, continue: false}\n    const canceled = !this.expander.dispatchEvent(new CustomEvent('text-expander-value', {cancelable: true, detail}))\n    if (canceled) return\n\n    if (!detail.value) return\n\n    let suffix = this.expander.getAttribute('suffix') ?? ' '\n\n    if (detail.continue) {\n      suffix = ''\n    }\n\n    const value = `${detail.value}${suffix}`\n\n    this.input.value = beginning + value + remaining\n\n    const cursor = beginning.length + value.length\n\n    this.deactivate()\n    this.input.focus({\n      preventScroll: true\n    })\n\n    this.input.selectionStart = cursor\n    this.input.selectionEnd = cursor\n\n    if (!detail.continue) {\n      this.lookBackIndex = cursor\n      this.match = null\n    }\n\n    this.expander.dispatchEvent(\n      new CustomEvent('text-expander-committed', {cancelable: false, detail: {input: this.input}})\n    )\n  }\n\n  private onBlur() {\n    if (this.interactingWithList) {\n      this.interactingWithList = false\n      return\n    }\n\n    this.deactivate()\n  }\n\n  private onPaste() {\n    this.justPasted = true\n  }\n\n  private isMatchStillValid(match: TextExpanderMatch): boolean {\n    return match.position <= this.input.value.length\n  }\n\n  async onInput() {\n    if (this.justPasted) {\n      this.justPasted = false\n      return\n    }\n\n    const match = this.findMatch()\n    if (match) {\n      this.match = match\n      const menu = await this.notifyProviders(match)\n\n      // Text was cleared while waiting on async providers.\n      if (!this.match || !this.isMatchStillValid(match)) {\n        this.match = null\n        this.deactivate()\n        return\n      }\n\n      if (menu) {\n        this.activate(match, menu)\n      } else {\n        this.deactivate()\n      }\n    } else {\n      this.match = null\n      this.deactivate()\n    }\n  }\n\n  findMatch(): TextExpanderMatch | void {\n    const cursor = this.input.selectionEnd || 0\n    const text = this.input.value\n    if (cursor <= this.lookBackIndex) {\n      this.lookBackIndex = cursor - 1\n    }\n    for (const {key, multiWord} of this.expander.keys) {\n      const found = query(text, key, cursor, {\n        multiWord,\n        lookBackIndex: this.lookBackIndex,\n        lastMatchPosition: this.match ? this.match.position : null\n      })\n      if (found) {\n        return {text: found.text, key, position: found.position}\n      }\n    }\n  }\n\n  async notifyProviders(match: TextExpanderMatch): Promise<HTMLElement | void> {\n    const providers: Array<Promise<TextExpanderResult> | TextExpanderResult> = []\n    const provide = (result: Promise<TextExpanderResult> | TextExpanderResult) => providers.push(result)\n    const changeEvent = new CustomEvent('text-expander-change', {\n      cancelable: true,\n      detail: {provide, text: match.text, key: match.key}\n    }) as TextExpanderChangeEvent\n    const canceled = !this.expander.dispatchEvent(changeEvent)\n    if (canceled) return\n\n    const all = await Promise.all(providers)\n    const fragments = all.filter(x => x.matched).map(x => x.fragment)\n    return fragments[0]\n  }\n\n  private onMousedown() {\n    this.interactingWithList = true\n  }\n\n  private onKeydown(event: KeyboardEvent) {\n    if (event.key === 'Escape') {\n      this.match = null\n      if (this.deactivate()) {\n        this.lookBackIndex = this.input.selectionEnd || this.lookBackIndex\n        event.stopImmediatePropagation()\n        event.preventDefault()\n      }\n    }\n  }\n}\nexport default class TextExpanderElement extends HTMLElement {\n  get keys(): TextExpanderKey[] {\n    const keysAttr = this.getAttribute('keys')\n    const keys = keysAttr ? keysAttr.split(' ') : []\n\n    const multiWordAttr = this.getAttribute('multiword')\n    const multiWord = multiWordAttr ? multiWordAttr.split(' ') : []\n    const globalMultiWord = multiWord.length === 0 && this.hasAttribute('multiword')\n\n    return keys.map(key => ({key, multiWord: globalMultiWord || multiWord.includes(key)}))\n  }\n\n  set keys(value: string) {\n    this.setAttribute('keys', value)\n  }\n\n  connectedCallback(): void {\n    const input = this.querySelector('input[type=\"text\"], textarea')\n    if (!(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement)) return\n    const state = new TextExpander(this, input)\n    states.set(this, state)\n  }\n\n  disconnectedCallback(): void {\n    const state: TextExpander = states.get(this)\n    if (!state) return\n    state.destroy()\n    states.delete(this)\n  }\n\n  dismiss(): void {\n    const state: TextExpander = states.get(this)\n    if (!state) return\n    state.dismissMenu()\n  }\n}\n"
  },
  {
    "path": "test/.eslintrc",
    "content": "{\n  \"parser\": \"espree\",\n  \"parserOptions\": {\n    \"ecmaVersion\": 8\n  },\n  \"env\": {\n    \"mocha\": true\n  },\n  \"globals\": {\n    \"assert\": true\n  },\n  \"rules\": {\n    \"github/no-inner-html\": \"off\",\n    \"filenames/match-regex\": \"off\",\n    // Linting currently runs as a pre-build step, so the dependent files may not exist yet by the time linting is run\n    \"import/no-unresolved\": \"off\"\n  }\n}"
  },
  {
    "path": "test/WrapperComponent.js",
    "content": "export class WrapperComponent extends HTMLElement {\n  constructor() {\n    super()\n    const shadow = this.attachShadow({mode: 'open'})\n    const textExpander = document.createElement('text-expander')\n    textExpander.setAttribute('keys', '@')\n    const textarea = document.createElement('textarea')\n    textExpander.append(textarea)\n    shadow.appendChild(textExpander)\n  }\n\n  connectedCallback() {\n    const textExpander = this.shadowRoot.querySelector('text-expander')\n    textExpander.addEventListener('text-expander-change', function (event) {\n      const {key, provide} = event.detail\n\n      if (key !== '@') return\n\n      const suggestions = document.createElement('ul')\n      suggestions.innerHTML = `\n        <li role=\"option\" data-value=\"a\">a</li>\n        <li role=\"option\" data-value=\"aa\">aa</li>\n      `\n      provide(Promise.resolve({matched: true, fragment: suggestions}))\n    })\n  }\n}\n"
  },
  {
    "path": "test/karma.config.cjs",
    "content": "module.exports = function (config) {\n  config.set({\n    frameworks: ['mocha', 'chai'],\n    files: [\n      { pattern: '../dist/bundle.js', type: 'module' },\n      { pattern: '../build/test.js', type: 'module' }\n    ],\n    reporters: ['mocha'],\n    port: 9876,\n    colors: true,\n    logLevel: config.LOG_INFO,\n    browsers: ['ChromeHeadless'],\n    autoWatch: false,\n    singleRun: true,\n    concurrency: Infinity,\n    middleware: [],\n    plugins: ['karma-*']\n  })\n}\n"
  },
  {
    "path": "test/query-test.js",
    "content": "import query from '../dist/query'\n\ndescribe('text-expander single word parsing', function () {\n  it('does not match empty text', function () {\n    const found = query('', ':', 0)\n    assert(found == null)\n  })\n\n  it('does not match without activation key', function () {\n    const found = query('cat', ':', 3)\n    assert(found == null)\n  })\n\n  it('matches only activation key', function () {\n    const found = query(':', ':', 1)\n    assert.deepEqual(found, {text: '', position: 1})\n  })\n\n  it('matches trailing activation key', function () {\n    const found = query('hi :', ':', 4)\n    assert.deepEqual(found, {text: '', position: 4})\n  })\n\n  it('matches start of text', function () {\n    const found = query(':cat', ':', 4)\n    assert.deepEqual(found, {text: 'cat', position: 1})\n  })\n\n  it('matches end of text', function () {\n    const found = query('hi :cat', ':', 7)\n    assert.deepEqual(found, {text: 'cat', position: 4})\n  })\n\n  it('matches middle of text', function () {\n    const found = query('hi :cat bye', ':', 7)\n    assert.deepEqual(found, {text: 'cat', position: 4})\n  })\n\n  it('matches only at word boundary', function () {\n    const found = query('hi:cat', ':', 6)\n    assert(found == null)\n  })\n\n  it('matches last activation key word', function () {\n    const found = query('hi :cat bye :dog', ':', 16)\n    assert.deepEqual(found, {text: 'dog', position: 13})\n  })\n\n  it('matches closest activation key word', function () {\n    const found = query('hi :cat bye :dog', ':', 7)\n    assert.deepEqual(found, {text: 'cat', position: 4})\n  })\n\n  it('does not match with a space between cursor and activation key', function () {\n    const found = query('hi :cat bye', ':', 11)\n    assert(found == null)\n  })\n})\n\ndescribe('text-expander multi word parsing', function () {\n  it('does not match empty text', function () {\n    const found = query('', ':', 0, {multiWord: true})\n    assert(found == null)\n  })\n\n  it('does not match without activation key', function () {\n    const found = query('cat', ':', 3, {multiWord: true})\n    assert(found == null)\n  })\n\n  it('matches only activation key', function () {\n    const found = query(':', ':', 1, {multiWord: true})\n    assert.deepEqual(found, {text: '', position: 1})\n  })\n\n  it('matches trailing activation key', function () {\n    const found = query('hi :', ':', 4, {multiWord: true})\n    assert.deepEqual(found, {text: '', position: 4})\n  })\n\n  it('matches start of text', function () {\n    const found = query(':cat', ':', 4, {multiWord: true})\n    assert.deepEqual(found, {text: 'cat', position: 1})\n  })\n\n  it('matches end of text', function () {\n    const found = query('hi :cat', ':', 7, {multiWord: true})\n    assert.deepEqual(found, {text: 'cat', position: 4})\n  })\n\n  it('matches middle of text', function () {\n    const found = query('hi :cat bye', ':', 7, {multiWord: true})\n    assert.deepEqual(found, {text: 'cat', position: 4})\n  })\n\n  it('matches only at word boundary', function () {\n    const found = query('hi:cat', ':', 6, {multiWord: true})\n    assert(found == null)\n  })\n\n  it('matches last activation key word', function () {\n    const found = query('hi :cat bye :dog', ':', 16, {multiWord: true})\n    assert.deepEqual(found, {text: 'dog', position: 13})\n  })\n\n  it('matches closest activation key word', function () {\n    const found = query('hi :cat bye :dog', ':', 7, {multiWord: true})\n    assert.deepEqual(found, {text: 'cat', position: 4})\n  })\n\n  it('matches with a space between cursor and activation key', function () {\n    const found = query('hi :cat bye', ':', 11, {multiWord: true})\n    assert.deepEqual(found, {text: 'cat bye', position: 4})\n  })\n\n  it('does not match with a dot between cursor and activation key', function () {\n    const found = query('hi :cat. bye', ':', 11, {multiWord: true})\n    assert(found == null)\n  })\n\n  it('does not match with a space between text and activation key', function () {\n    const found = query('hi : cat bye', ':', 7, {multiWord: true})\n    assert(found == null)\n  })\n})\n\ndescribe('text-expander multi word parsing with multiple activation keys', function () {\n  it('does not match consecutive activation keys', function () {\n    let found = query('::', ':', 2, {multiWord: true})\n    assert(found == null)\n\n    found = query('::', ':', 3, {multiWord: true})\n    assert(found == null)\n\n    found = query('hi :: there', ':', 5, {multiWord: true})\n    assert(found == null)\n\n    found = query('hi ::: there', ':', 6, {multiWord: true})\n    assert(found == null)\n\n    found = query('hi ::', ':', 5, {multiWord: true})\n    assert(found == null)\n\n    found = query('hi :::', ':', 6, {multiWord: true})\n    assert(found == null)\n  })\n\n  it('uses lastMatchPosition to match', function () {\n    let found = query('hi :cat :bye', ':', 12, {multiWord: true, lastMatchPosition: 4})\n    assert.deepEqual(found, {text: 'cat :bye', position: 4})\n\n    found = query('hi :cat :bye :::', ':', 16, {multiWord: true, lastMatchPosition: 4})\n    assert.deepEqual(found, {text: 'cat :bye :::', position: 4})\n\n    found = query(':hi :cat :bye :::', ':', 17, {multiWord: true, lastMatchPosition: 1})\n    assert.deepEqual(found, {text: 'hi :cat :bye :::', position: 1})\n  })\n})\n\ndescribe('text-expander limits the lookBack after commit', function () {\n  it('does not match if lookBackIndex is bigger than activation key index', function () {\n    const found = query('hi :cat bye', ':', 11, {multiWord: true, lookBackIndex: 7})\n    assert(found == null)\n  })\n\n  it('matches if lookBackIndex is lower than activation key index', function () {\n    const found = query('hi :cat bye :dog', ':', 16, {multiWord: true, lookBackIndex: 7})\n    assert(found, {text: 'dog', position: 13})\n  })\n})\n"
  },
  {
    "path": "test/test.js",
    "content": "import './query-test'\nimport './text-expander-element-test'\n"
  },
  {
    "path": "test/text-expander-element-test.js",
    "content": "import {WrapperComponent} from './WrapperComponent'\n\ndescribe('text-expander element', function () {\n  describe('element creation', function () {\n    it('creates from document.createElement', function () {\n      const el = document.createElement('text-expander')\n      assert.equal('TEXT-EXPANDER', el.nodeName)\n      assert(el instanceof window.TextExpanderElement)\n    })\n\n    it('creates from constructor', function () {\n      const el = new window.TextExpanderElement()\n      assert.equal('TEXT-EXPANDER', el.nodeName)\n    })\n  })\n\n  describe('after tree insertion', function () {\n    beforeEach(function () {\n      const container = document.createElement('div')\n      container.innerHTML = `\n        <text-expander keys=\": @ [[\">\n          <textarea></textarea>\n        </text-expander>\n      `\n      document.body.append(container)\n    })\n\n    afterEach(function () {\n      document.body.innerHTML = ''\n    })\n\n    it('has activation keys', function () {\n      const expander = document.querySelector('text-expander')\n      assert.deepEqual(\n        [\n          {key: ':', multiWord: false},\n          {key: '@', multiWord: false},\n          {key: '[[', multiWord: false}\n        ],\n        expander.keys\n      )\n    })\n\n    it('dispatches change event', async function () {\n      const expander = document.querySelector('text-expander')\n      const input = expander.querySelector('textarea')\n      const result = once(expander, 'text-expander-change')\n      triggerInput(input, ':')\n      const event = await result\n      const {key} = event.detail\n      assert.equal(':', key)\n    })\n\n    it('dismisses the menu when dismiss() is called', async function () {\n      const expander = document.querySelector('text-expander')\n      const input = expander.querySelector('textarea')\n      const menu = document.createElement('ul')\n      menu.appendChild(document.createElement('li'))\n\n      expander.addEventListener('text-expander-change', event => {\n        const {provide} = event.detail\n        provide(Promise.resolve({matched: true, fragment: menu}))\n      })\n\n      input.focus()\n      triggerInput(input, ':')\n      await waitForAnimationFrame()\n      assert.exists(expander.querySelector('ul'))\n\n      expander.dismiss()\n      await waitForAnimationFrame()\n      assert.isNull(expander.querySelector('ul'))\n    })\n\n    it('dispatches change events for 2 char activation keys', async function () {\n      const expander = document.querySelector('text-expander')\n      const input = expander.querySelector('textarea')\n\n      const receivedText = []\n      const expectedText = ['', 'a', 'ab', 'abc', 'abcd']\n\n      expander.addEventListener('text-expander-change', event => {\n        const {key, text} = event.detail\n        assert.equal('[[', key)\n        receivedText.push(text)\n      })\n      triggerInput(input, '[[')\n      triggerInput(input, '[[a')\n      triggerInput(input, '[[ab')\n      triggerInput(input, '[[abc')\n      triggerInput(input, '[[abcd')\n\n      assert.deepEqual(receivedText, expectedText)\n    })\n\n    it('dispatches value event after selecting item and closes', async function () {\n      const expander = document.querySelector('text-expander')\n      const input = expander.querySelector('textarea')\n      const menu = document.createElement('ul')\n      const item = document.createElement('li')\n      item.setAttribute('role', 'option')\n      menu.appendChild(item)\n\n      expander.addEventListener('text-expander-change', event => {\n        const {provide} = event.detail\n        provide(Promise.resolve({matched: true, fragment: menu}))\n      })\n\n      expander.addEventListener('text-expander-value', event => {\n        event.detail.value = ':1'\n      })\n\n      input.focus()\n      triggerInput(input, ':')\n      await waitForAnimationFrame()\n      assert.exists(expander.querySelector('ul'))\n\n      const result = once(expander, 'text-expander-value')\n      expander.querySelector('li').click()\n      const event = await result\n      assert.equal(false, event.detail.continue)\n\n      assert.equal(input.value, ':1 ')\n\n      await waitForAnimationFrame()\n      assert.isNull(expander.querySelector('ul'))\n    })\n\n    it('dispatches value event after selecting item and keeps menu open', async function () {\n      const expander = document.querySelector('text-expander')\n      const input = expander.querySelector('textarea')\n      const menu = document.createElement('ul')\n      const item = document.createElement('li')\n      item.setAttribute('role', 'option')\n      menu.appendChild(item)\n\n      expander.addEventListener('text-expander-change', event => {\n        const {provide} = event.detail\n        // eslint-disable-next-line no-console\n        console.log('ASDFSDF', event.detail)\n        provide(Promise.resolve({matched: true, fragment: menu}))\n      })\n\n      expander.addEventListener('text-expander-value', event => {\n        event.detail.value = ':1'\n        event.detail.continue = true\n      })\n\n      input.focus()\n      triggerInput(input, ':')\n      await waitForAnimationFrame()\n      assert.exists(expander.querySelector('ul'))\n\n      const result = once(expander, 'text-expander-value')\n      expander.querySelector('li').click()\n      const event = await result\n      assert.equal(true, event.detail.continue)\n\n      triggerInput(input, '#1', true)\n\n      assert.equal(input.value, ':1#1')\n\n      await waitForAnimationFrame()\n      assert.exists(expander.querySelector('ul'))\n    })\n  })\n\n  describe('multi-word scenarios', function () {\n    beforeEach(function () {\n      const container = document.createElement('div')\n      container.innerHTML = `\n        <text-expander keys=\"@ # [[\" multiword=\"# [[\">\n          <textarea></textarea>\n        </text-expander>\n      `\n      document.body.append(container)\n    })\n\n    afterEach(function () {\n      document.body.innerHTML = ''\n    })\n\n    it('has activation keys', function () {\n      const expander = document.querySelector('text-expander')\n      assert.deepEqual(\n        [\n          {key: '@', multiWord: false},\n          {key: '#', multiWord: true},\n          {key: '[[', multiWord: true}\n        ],\n        expander.keys\n      )\n    })\n\n    it('sets keys', function () {\n      const expander = document.querySelector('text-expander')\n      assert.deepEqual(\n        [\n          {key: '@', multiWord: false},\n          {key: '#', multiWord: true},\n          {key: '[[', multiWord: true}\n        ],\n        expander.keys\n      )\n\n      expander.keys = '@ [['\n\n      assert.deepEqual(\n        [\n          {key: '@', multiWord: false},\n          {key: '[[', multiWord: true}\n        ],\n        expander.keys\n      )\n    })\n\n    it('dispatches change event for multi-word', async function () {\n      const expander = document.querySelector('text-expander')\n      const input = expander.querySelector('textarea')\n      const result = once(expander, 'text-expander-change')\n      triggerInput(input, '@match #some text')\n      const event = await result\n      const {key, text} = event.detail\n      assert.equal('#', key)\n      assert.equal('some text', text)\n    })\n\n    it('dispatches change events for 2 char activation keys for multi-word', async function () {\n      const expander = document.querySelector('text-expander')\n      const input = expander.querySelector('textarea')\n\n      const receivedText = []\n      const expectedText = ['', 'a', 'ab', 'abc', 'abcd', 'abcd def']\n\n      expander.addEventListener('text-expander-change', event => {\n        const {key, text} = event.detail\n        assert.equal('[[', key)\n        receivedText.push(text)\n      })\n      triggerInput(input, '[[')\n      triggerInput(input, '[[a')\n      triggerInput(input, '[[ab')\n      triggerInput(input, '[[abc')\n      triggerInput(input, '[[abcd')\n      triggerInput(input, '[[abcd def')\n\n      assert.deepEqual(receivedText, expectedText)\n    })\n\n    it('dispatches change event for single word match after multi-word', async function () {\n      const expander = document.querySelector('text-expander')\n      const input = expander.querySelector('textarea')\n      const result = once(expander, 'text-expander-change')\n      triggerInput(input, '#some text @match')\n      const event = await result\n      const {key, text} = event.detail\n      assert.equal('@', key)\n      assert.equal('match', text)\n    })\n\n    it('dispatches change event for multi-word with single word inside', async function () {\n      const expander = document.querySelector('text-expander')\n      const input = expander.querySelector('textarea')\n      const result = once(expander, 'text-expander-change')\n      triggerInput(input, '#some text @match word')\n      const event = await result\n      const {key, text} = event.detail\n      assert.equal('#', key)\n      assert.equal('some text @match word', text)\n    })\n\n    it('dispatches change event for the first activation key even if it is typed again', async function () {\n      const expander = document.querySelector('text-expander')\n      const input = expander.querySelector('textarea')\n\n      let result = once(expander, 'text-expander-change')\n      triggerInput(input, '#step 1')\n      let event = await result\n      let {key, text} = event.detail\n      assert.equal('#', key)\n      assert.equal('step 1', text)\n\n      await waitForAnimationFrame()\n\n      result = once(expander, 'text-expander-change')\n      triggerInput(input, ' #step 2', true) //<-- At this point the text inside the input field is \"#step 1 #step 2\"\n      event = await result\n      ;({key, text} = event.detail)\n      assert.equal('#', key)\n      assert.equal('step 1 #step 2', text)\n\n      await waitForAnimationFrame()\n\n      result = once(expander, 'text-expander-change')\n      triggerInput(input, ' #step 3', true) //<-- At this point the text inside the input field is \"#step 1 #step 2 #step 3\"\n      event = await result\n      ;({key, text} = event.detail)\n      assert.equal('#', key)\n      assert.equal('step 1 #step 2 #step 3', text)\n    })\n  })\n\n  describe('use inside a ShadowDOM', function () {\n    before(function () {\n      customElements.define('wrapper-component', WrapperComponent)\n    })\n\n    beforeEach(function () {\n      const container = document.createElement('div')\n      container.innerHTML = '<wrapper-component></wrapper-component>'\n      document.body.append(container)\n    })\n\n    afterEach(function () {\n      document.body.innerHTML = ''\n    })\n\n    it('show results on input', async function () {\n      const component = document.querySelector('wrapper-component')\n      const input = component.shadowRoot.querySelector('textarea')\n      input.focus()\n      triggerInput(input, '@a')\n      await waitForAnimationFrame()\n      assert.exists(component.shadowRoot.querySelector('ul'))\n    })\n  })\n})\n\nfunction once(element, eventName) {\n  return new Promise(resolve => {\n    element.addEventListener(eventName, resolve, {once: true})\n  })\n}\n\nfunction triggerInput(input, value, onlyAppend = false) {\n  input.value = onlyAppend ? input.value + value : value\n  return input.dispatchEvent(new InputEvent('input'))\n}\n\nasync function waitForAnimationFrame() {\n  return new Promise(resolve => {\n    window.requestAnimationFrame(resolve)\n  })\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"module\": \"esnext\",\n    \"target\": \"es2017\",\n    \"strict\": true,\n    \"declaration\": true,\n    \"outDir\": \"dist\",\n    \"removeComments\": true,\n    \"moduleResolution\": \"node\"\n  },\n  \"files\": [\n    \"src/index.ts\"\n  ]\n}\n"
  }
]