Full Code of github/text-expander-element for AI

main 6860e5c76da8 cached
24 files
45.9 KB
12.3k tokens
36 symbols
1 requests
Download .txt
Repository: github/text-expander-element
Branch: main
Commit: 6860e5c76da8
Files: 24
Total size: 45.9 KB

Directory structure:
gitextract_s_7f5qbw/

├── .devcontainer/
│   ├── Dockerfile
│   └── devcontainer.json
├── .eslintrc.json
├── .github/
│   └── workflows/
│       ├── nodejs.yml
│       └── publish.yml
├── .gitignore
├── .travis.yml
├── CODEOWNERS
├── LICENSE
├── README.md
├── examples/
│   └── index.html
├── package.json
├── rollup.config.js
├── rollup.config.test.js
├── src/
│   ├── index.ts
│   ├── query.ts
│   └── text-expander-element.ts
├── test/
│   ├── .eslintrc
│   ├── WrapperComponent.js
│   ├── karma.config.cjs
│   ├── query-test.js
│   ├── test.js
│   └── text-expander-element-test.js
└── tsconfig.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .devcontainer/Dockerfile
================================================
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node/.devcontainer/base.Dockerfile

# [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
ARG VARIANT="16"
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT}

# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
#     && apt-get -y install --no-install-recommends <your-package-list-here>

# [Optional] Uncomment if you want to install an additional version of node using nvm
# ARG EXTRA_NODE_VERSION=10
# RUN su node -c "source/usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"

# [Optional] Uncomment if you want to install more global node modules
# RUN su node -c "npm install -g <your-package-list-here>" 


================================================
FILE: .devcontainer/devcontainer.json
================================================
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node
{
	"name": "Node.js",
	"build": {
		"dockerfile": "Dockerfile",
		// Update 'VARIANT' to pick a Node version: 16, 14, 12.
		// Append -bullseye or -buster to pin to an OS version.
		// Use -bullseye variants on local arm64/Apple Silicon.
		"args": { "VARIANT": "20" }
	},

	// Set *default* container specific settings.json values on container create.
	"settings": {},

	// Add the IDs of extensions you want installed when the container is created.
	"extensions": [
		"dbaeumer.vscode-eslint"
	],

	// Use 'forwardPorts' to make a list of ports inside the container available locally.
	// "forwardPorts": [],

	// Use 'postCreateCommand' to run commands after the container is created.
	// "postCreateCommand": "yarn install",

	// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
	"remoteUser": "node",
	"features": {
		"git": "latest"
	}
}


================================================
FILE: .eslintrc.json
================================================
{
  "root": true,
  "plugins": ["github"],
  "extends": ["plugin:github/browser", "plugin:github/recommended", "plugin:github/typescript"],
  "globals": {
    "TextExpanderElement": "readonly"
  },
  "rules": {
    "no-invalid-this": "off"
  },
  "overrides": [
    {
      "files": "test/**/*.js",
      "rules": {
        "github/unescaped-html-literal": "off"
      }
    },
    {
      "files": "test/**/*.js",
      "excludedFiles": "test/karma.config.js",
      "env": {
        "mocha": true
      },
      "globals": {
        "assert": true
      }
    }
  ]
}


================================================
FILE: .github/workflows/nodejs.yml
================================================
name: Node CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    permissions:
      contents: read
    runs-on: ubuntu-22.04

    steps:
      - uses: actions/checkout@v1
      - name: Use Node.js
        uses: actions/setup-node@v1
        with:
          node-version: '20.x'
      - name: npm install, build, and test
        run: |
          npm install
          npm run build --if-present
          npm test
        env:
          CI: true
          CHROME_BIN: google-chrome


================================================
FILE: .github/workflows/publish.yml
================================================
name: Publish
permissions:
  contents: read
  packages: write
  id-token: write # Required for OIDC

on:
  release:
    types: [created]
  workflow_dispatch:
    inputs:
      version:
        description: 'Version to publish (e.g., v1.2.3)'
        required: true
        type: string

jobs:
  publish-npm:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          registry-url: https://registry.npmjs.org/
          cache: npm
      - run: npm ci
      - run: npm test
      - run: npm version ${TAG_NAME} --git-tag-version=false
        env:
          TAG_NAME: ${{ github.event.release.tag_name || github.event.inputs.version }}
      - run: npm --ignore-scripts publish


================================================
FILE: .gitignore
================================================
build/
dist/
node_modules/


================================================
FILE: .travis.yml
================================================
language: node_js
sudo: required
node_js:
  - "node"
addons:
  chrome: stable
cache:
  directories:
    - node_modules


================================================
FILE: CODEOWNERS
================================================
* @github/primer-reviewers


================================================
FILE: LICENSE
================================================
Copyright (c) 2019 GitHub, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# &lt;text-expander&gt; element

Activates a suggestion menu to expand text snippets as you type.

## Installation

```
$ npm install --save @github/text-expander-element
```

## Usage

### Script

Import as ES modules:

```js
import '@github/text-expander-element'
```

With a script tag:

```html
<script type="module" src="./node_modules/@github/text-expander-element/dist/bundle.js">
```

### Markup

```html
<text-expander keys=": @ #" multiword="#">
  <textarea></textarea>
</text-expander>
```

## Attributes

- `keys` is a space separated list of menu activation keys
- `multiword` defines whether the expansion should use several words or not
  - you can provide a space separated list of activation keys that should support multi-word matching
- `suffix` is a string that is appended to the value during expansion, default is a single space character

## Events

**`text-expander-change`** is fired when a key is matched. In `event.detail` you can find:

- `key`: The matched key; for example: `:`.
- `text`: The matched text; for example: `cat`, for `:cat`.
  - 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`.
- `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.

```js
const expander = document.querySelector('text-expander')

expander.addEventListener('text-expander-change', function(event) {
  const {key, provide, text} = event.detail
  if (key !== ':') return

  const suggestions = document.querySelector('.emoji-suggestions').cloneNode(true)
  suggestions.hidden = false
  for (const suggestion of suggestions.children) {
    if (!suggestion.textContent.match(text)) {
      suggestion.remove()
    }
  }
  provide(Promise.resolve({matched: suggestions.childElementCount > 0, fragment: suggestions}))
})
```

The returned fragment should be consisted of filtered `[role=option]` items to be selected. For example:

```html
<ul class="emoji-suggestions" hidden>
  <li role="option" data-value="🐈">🐈 :cat2:</li>
  <li role="option" data-value="🐕">🐕 :dog:</li>
</ul>
```

**`text-expander-value`** is fired when an item is selected. In `event.detail` you can find:

- `key`: The matched key; for example: `:`.
- `item`: The selected item. This would be one of the `[role=option]`. Use this to work out the `value`.
- `value`: A null value placeholder to replace the query. To replace the text query, simply re-assign this value.
- `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.

```js
const expander = document.querySelector('text-expander')

expander.addEventListener('text-expander-value', function(event) {
  const {key, item}  = event.detail
  if (key === ':') {
    event.detail.value = item.getAttribute('data-value')
  }
})
```

**`text-expander-committed`** is fired after the underlying `input` value has been updated in the DOM. In `event.detail` you can find:

- `input`: The `HTMLInputElement` or `HTMLTextAreaElement` that just had `value` changes committed to the DOM.

```js
const expander = document.querySelector('text-expander')

expander.addEventListener('text-expander-committed', function(event) {
  const {input}  = event.detail
})
```

**`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()`.

**`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.

## Browser support

Browsers without native [custom element support][support] require a [polyfill][].

- Chrome
- Firefox
- Safari
- Microsoft Edge

[support]: https://caniuse.com/#feat=custom-elementsv1
[polyfill]: https://github.com/webcomponents/custom-elements

## Development

```
npm install
npm test
```

## License

Distributed under the MIT license. See LICENSE for details.


================================================
FILE: examples/index.html
================================================
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>text-expander demo</title>
    <style>
      .menu {
        position: absolute;
        list-style-type: none;
        padding: 0;
        background: lightgray;

        [aria-selected='true'] {
          background: #eee;
        }
      }
    </style>
  </head>
  <body>
    <h1>text-expanded element examples</h1>
    <p>Use <code>#</code> to trigger the expander</p>

    <h2>Normal text-expander element</h2>
    <text-expander keys="#">
      <textarea autofocus rows="10" cols="40"></textarea>
    </text-expander>

    <h2>Multiword text-expander element</h2>
    <text-expander keys="#" multiword="#">
      <textarea autofocus rows="10" cols="40"></textarea>
    </text-expander>

    <h2>Multiword and multikey text-expander element</h2>
    <text-expander keys=": #" multiword="#">
      <textarea autofocus rows="10" cols="40"></textarea>
    </text-expander>

    <h2>Multiword and multikey text-expander element with random delay</h2>
    <text-expander keys=": #" multiword="#" _random_delay="">
      <textarea autofocus rows="10" cols="40"></textarea>
    </text-expander>    

    <script type="text/javascript">
      const emojis = [
        { emoji: "😀", names: ["smile", "happy"] },
        { emoji: "❤️", names: ["heart", "love"] },
        { emoji: "🔥", names: ["fire", "hot"] },
        { emoji: "⭐", names: ["star", "favorite"] },
        { emoji: "🚀", names: ["rocket", "fast"] }
      ];
      
      const expanders = document.querySelectorAll('text-expander')
      for (const expander of expanders) {
        expander.addEventListener('text-expander-change', event => {
          const {key, provide, text} = event.detail
          if (key === ':') {
            const menu = document.createElement('ul')
            menu.classList.add('menu')
            menu.role = 'listbox'
            for (const {emoji, names} of emojis) {
              if (names.some(name => name.includes(text.toLowerCase()))) {
                const item = document.createElement('li')
                item.setAttribute('role', 'option')
                item.textContent = `${emoji} ${names[0]}`
                item.setAttribute('data-value', emoji)
                menu.append(item)
              }
            }
            // Async test with random delay
            if (expander.hasAttribute('_random_delay')) {
              provide(new Promise(resolve => {
                setTimeout(() => resolve({matched: true, fragment: menu}), Math.random() * 1000)
              }))
            } else {
              provide(Promise.resolve({matched: true, fragment: menu}))
            }
          } else if (key === '#') {
            const menu = document.createElement('ul')
            menu.classList.add('menu')
            menu.role = 'listbox'
            for (const issue of [
              '#1 Implement a text-expander element',
              '#2 Implement multi word option',
              '#3 Fix tpoy',
              '#4 Implement #12',
              '#5 Implement #123 and #456',
            ]) {
              if (issue.toLowerCase().includes(text.toLowerCase())) {
                const item = document.createElement('li')
                item.setAttribute('role', 'option')
                item.textContent = issue
                item.setAttribute('data-value', issue.split(' ')[0])
                item.id = `option-${issue}`
                menu.append(item)
              }
            }
            // Async test with random delay
            if (expander.hasAttribute('_random_delay')) {
              provide(new Promise(resolve => {
                setTimeout(() => resolve({matched: true, fragment: menu}), Math.random() * 1000)
              }))
            } else {
              // For normal expander - synchronous response
              provide(Promise.resolve({matched: true, fragment: menu}))
            }
          }
        })

        expander.addEventListener('text-expander-value', function(event) {
          const {key, item} = event.detail
          if (key === '#') {
            event.detail.value = item.getAttribute('data-value') || item.textContent
          } else if (key === ':') {
            event.detail.value = item.getAttribute('data-value')
          }
        })
      }
    </script>
    <script type="module" src="https://unpkg.com/@github/text-expander-element@latest/dist/bundle.js"></script>
    <!-- <script src="../dist/bundle.js" type="module"></script> -->
  </body>
</html>


================================================
FILE: package.json
================================================
{
  "name": "@github/text-expander-element",
  "version": "2.2.2",
  "description": "Activates a suggestion menu to expand text snippets as you type.",
  "repository": "github/text-expander-element",
  "type": "module",
  "main": "dist/index.js",
  "module": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "clean": "rm -rf dist build",
    "compile": "tsc",
    "lint": "eslint .",
    "prebuild": "npm run clean && npm run lint && npm run compile",
    "build": "rollup -c",
    "pretest": "npm run build && rollup -c rollup.config.test.js",
    "test": "karma start test/karma.config.cjs",
    "prepublishOnly": "npm run build",
    "postpublish": "npm publish --ignore-scripts --@github:registry='https://npm.pkg.github.com'"
  },
  "keywords": [
    "auto-complete",
    "suggestions",
    "menu"
  ],
  "license": "MIT",
  "files": [
    "dist"
  ],
  "prettier": "@github/prettier-config",
  "dependencies": {
    "@github/combobox-nav": "^2.0.2",
    "dom-input-range": "^2.0.0"
  },
  "devDependencies": {
    "@github/prettier-config": "0.0.4",
    "chai": "^4.3.4",
    "eslint": "^8.0.1",
    "eslint-plugin-github": "^4.10.2",
    "karma": "^6.3.2",
    "karma-chai": "^0.1.0",
    "karma-chrome-launcher": "^3.1.0",
    "karma-mocha": "^2.0.1",
    "karma-mocha-reporter": "^2.2.5",
    "mocha": "^10.7.3",
    "rollup": "^2.45.1",
    "rollup-plugin-node-resolve": "^5.2.0",
    "typescript": "^5.4.5"
  },
  "eslintIgnore": [
    "build/",
    "dist/",
    "test/karma.config.js",
    "rollup.config.js",
    "rollup.config.test.js",
    "prettier.config.js"
  ]
}


================================================
FILE: rollup.config.js
================================================
const pkg = require('./package.json')
const resolve = require('rollup-plugin-node-resolve')

export default [
  {
    external: ['@github/combobox-nav'],
    input: 'dist/index.js',
    output: {
      file: pkg['module'],
      format: 'es'
    },
    plugins: [resolve()]
  },
  {
    input: 'dist/index.js',
    output: {
      file: 'dist/bundle.js',
      format: 'es',
    },
    plugins: [resolve()]
  }
]


================================================
FILE: rollup.config.test.js
================================================
import resolve from 'rollup-plugin-node-resolve'

export default {
  input: 'test/test.js',
  output: [
    {
      file: 'build/test.js',
      format: 'es',
    }
  ],
  plugins: [
    resolve()
  ]
}


================================================
FILE: src/index.ts
================================================
import TextExpanderElement from './text-expander-element'
export {TextExpanderElement as default}
export type * from './text-expander-element'

declare global {
  interface Window {
    TextExpanderElement: typeof TextExpanderElement
  }
}

if (!window.customElements.get('text-expander')) {
  window.TextExpanderElement = TextExpanderElement
  window.customElements.define('text-expander', TextExpanderElement)
}


================================================
FILE: src/query.ts
================================================
type Query = {
  text: string
  position: number
}

type QueryOptions = {
  lookBackIndex: number
  multiWord: boolean
  lastMatchPosition: number | null
}

const boundary = /\s|\(|\[/

// Extracts a keyword from the source text, backtracking from the cursor position.
export default function query(
  text: string,
  key: string,
  cursor: number,
  {multiWord, lookBackIndex, lastMatchPosition}: QueryOptions = {
    multiWord: false,
    lookBackIndex: 0,
    lastMatchPosition: null
  }
): Query | void {
  // Activation key not found in front of the cursor.
  let keyIndex = text.lastIndexOf(key, cursor - 1)
  if (keyIndex === -1) return

  // Stop matching at the lookBackIndex
  if (keyIndex < lookBackIndex) return

  if (multiWord) {
    if (lastMatchPosition != null) {
      // If the current activation key is the same as last match
      // i.e. consecutive activation keys, then return.
      if (lastMatchPosition === keyIndex) return
      keyIndex = lastMatchPosition - key.length
    }

    // Space immediately after activation key followed by the cursor
    const charAfterKey = text[keyIndex + 1]
    if (charAfterKey === ' ' && cursor >= keyIndex + key.length + 1) return

    // New line the cursor and previous activation key.
    const newLineIndex = text.lastIndexOf('\n', cursor - 1)
    if (newLineIndex > keyIndex) return

    // Dot between the cursor and previous activation key.
    const dotIndex = text.lastIndexOf('.', cursor - 1)
    if (dotIndex > keyIndex) return
  } else {
    // Space between the cursor and previous activation key.
    const spaceIndex = text.lastIndexOf(' ', cursor - 1)
    if (spaceIndex > keyIndex) return
  }

  // Activation key must occur at word boundary.
  const pre = text[keyIndex - 1]
  if (pre && !boundary.test(pre)) return

  // Extract matched keyword.
  const queryString = text.substring(keyIndex + key.length, cursor)
  return {
    text: queryString,
    position: keyIndex + key.length
  }
}


================================================
FILE: src/text-expander-element.ts
================================================
import Combobox from '@github/combobox-nav'
import query from './query'
import {InputRange} from 'dom-input-range'

export type TextExpanderMatch = {
  text: string
  key: string
  position: number
}

export type TextExpanderResult = {
  fragment?: HTMLElement
  matched: boolean
}

export type TextExpanderKey = {
  key: string
  multiWord: boolean
}

export type TextExpanderChangeEvent = Event & {
  detail?: {
    key: string
    text: string
    provide: (result: Promise<TextExpanderResult> | TextExpanderResult) => void
  }
}

const states = new WeakMap()

class TextExpander {
  expander: TextExpanderElement
  input: HTMLInputElement | HTMLTextAreaElement
  menu: HTMLElement | null
  oninput: (event: Event) => void
  onkeydown: (event: KeyboardEvent) => void
  onpaste: (event: Event) => void
  oncommit: (event: Event) => void
  onblur: (event: Event) => void
  onmousedown: (event: Event) => void
  combobox: Combobox | null
  match: TextExpanderMatch | null
  justPasted: boolean
  lookBackIndex: number
  interactingWithList: boolean

  constructor(expander: TextExpanderElement, input: HTMLInputElement | HTMLTextAreaElement) {
    this.expander = expander
    this.input = input
    this.combobox = null
    this.menu = null
    this.match = null
    this.justPasted = false
    this.lookBackIndex = 0
    this.oninput = this.onInput.bind(this)
    this.onpaste = this.onPaste.bind(this)
    this.onkeydown = this.onKeydown.bind(this)
    this.oncommit = this.onCommit.bind(this)
    this.onmousedown = this.onMousedown.bind(this)
    this.onblur = this.onBlur.bind(this)
    this.interactingWithList = false
    input.addEventListener('paste', this.onpaste)
    input.addEventListener('input', this.oninput)
    ;(input as HTMLElement).addEventListener('keydown', this.onkeydown)
    input.addEventListener('blur', this.onblur)
  }

  destroy() {
    this.input.removeEventListener('paste', this.onpaste)
    this.input.removeEventListener('input', this.oninput)
    ;(this.input as HTMLElement).removeEventListener('keydown', this.onkeydown)
    this.input.removeEventListener('blur', this.onblur)
  }

  dismissMenu() {
    if (this.deactivate()) {
      this.lookBackIndex = this.input.selectionEnd || this.lookBackIndex
    }
  }

  private activate(match: TextExpanderMatch, menu: HTMLElement) {
    if (this.input !== document.activeElement && this.input !== document.activeElement?.shadowRoot?.activeElement) {
      return
    }

    this.deactivate()
    this.menu = menu

    if (!menu.id) menu.id = `text-expander-${Math.floor(Math.random() * 100000).toString()}`
    this.expander.append(menu)
    this.combobox = new Combobox(this.input, menu)

    this.expander.dispatchEvent(new Event('text-expander-activate'))

    this.positionMenu(menu, match.position)

    this.combobox.start()
    menu.addEventListener('combobox-commit', this.oncommit)
    menu.addEventListener('mousedown', this.onmousedown)

    // Focus first menu item.
    this.combobox.navigate(1)
  }

  private positionMenu(menu: HTMLElement, position: number) {
    // Clamp position to valid range to avoid IndexSizeError when input text changes
    const clampedPosition = Math.min(position, this.input.value.length)
    const caretRect = new InputRange(this.input, clampedPosition).getBoundingClientRect()
    const targetPosition = {left: caretRect.left, top: caretRect.top + caretRect.height}

    const currentPosition = menu.getBoundingClientRect()

    const delta = {
      left: targetPosition.left - currentPosition.left,
      top: targetPosition.top - currentPosition.top
    }

    if (delta.left !== 0 || delta.top !== 0) {
      // Use computedStyle to avoid nesting calc() deeper and deeper
      const currentStyle = getComputedStyle(menu)

      // Using `calc` avoids having to parse the current pixel value
      menu.style.left = currentStyle.left ? `calc(${currentStyle.left} + ${delta.left}px)` : `${delta.left}px`
      menu.style.top = currentStyle.top ? `calc(${currentStyle.top} + ${delta.top}px)` : `${delta.top}px`
    }
  }

  private deactivate() {
    const menu = this.menu
    if (!menu || !this.combobox) return false

    this.expander.dispatchEvent(new Event('text-expander-deactivate'))

    this.menu = null

    menu.removeEventListener('combobox-commit', this.oncommit)
    menu.removeEventListener('mousedown', this.onmousedown)

    this.combobox.destroy()
    this.combobox = null
    menu.remove()

    return true
  }

  private onCommit({target}: Event) {
    const item = target
    if (!(item instanceof HTMLElement)) return
    if (!this.combobox) return

    const match = this.match
    if (!match) return

    const beginning = this.input.value.substring(0, match.position - match.key.length)
    const remaining = this.input.value.substring(match.position + match.text.length)

    const detail = {item, key: match.key, value: null, continue: false}
    const canceled = !this.expander.dispatchEvent(new CustomEvent('text-expander-value', {cancelable: true, detail}))
    if (canceled) return

    if (!detail.value) return

    let suffix = this.expander.getAttribute('suffix') ?? ' '

    if (detail.continue) {
      suffix = ''
    }

    const value = `${detail.value}${suffix}`

    this.input.value = beginning + value + remaining

    const cursor = beginning.length + value.length

    this.deactivate()
    this.input.focus({
      preventScroll: true
    })

    this.input.selectionStart = cursor
    this.input.selectionEnd = cursor

    if (!detail.continue) {
      this.lookBackIndex = cursor
      this.match = null
    }

    this.expander.dispatchEvent(
      new CustomEvent('text-expander-committed', {cancelable: false, detail: {input: this.input}})
    )
  }

  private onBlur() {
    if (this.interactingWithList) {
      this.interactingWithList = false
      return
    }

    this.deactivate()
  }

  private onPaste() {
    this.justPasted = true
  }

  private isMatchStillValid(match: TextExpanderMatch): boolean {
    return match.position <= this.input.value.length
  }

  async onInput() {
    if (this.justPasted) {
      this.justPasted = false
      return
    }

    const match = this.findMatch()
    if (match) {
      this.match = match
      const menu = await this.notifyProviders(match)

      // Text was cleared while waiting on async providers.
      if (!this.match || !this.isMatchStillValid(match)) {
        this.match = null
        this.deactivate()
        return
      }

      if (menu) {
        this.activate(match, menu)
      } else {
        this.deactivate()
      }
    } else {
      this.match = null
      this.deactivate()
    }
  }

  findMatch(): TextExpanderMatch | void {
    const cursor = this.input.selectionEnd || 0
    const text = this.input.value
    if (cursor <= this.lookBackIndex) {
      this.lookBackIndex = cursor - 1
    }
    for (const {key, multiWord} of this.expander.keys) {
      const found = query(text, key, cursor, {
        multiWord,
        lookBackIndex: this.lookBackIndex,
        lastMatchPosition: this.match ? this.match.position : null
      })
      if (found) {
        return {text: found.text, key, position: found.position}
      }
    }
  }

  async notifyProviders(match: TextExpanderMatch): Promise<HTMLElement | void> {
    const providers: Array<Promise<TextExpanderResult> | TextExpanderResult> = []
    const provide = (result: Promise<TextExpanderResult> | TextExpanderResult) => providers.push(result)
    const changeEvent = new CustomEvent('text-expander-change', {
      cancelable: true,
      detail: {provide, text: match.text, key: match.key}
    }) as TextExpanderChangeEvent
    const canceled = !this.expander.dispatchEvent(changeEvent)
    if (canceled) return

    const all = await Promise.all(providers)
    const fragments = all.filter(x => x.matched).map(x => x.fragment)
    return fragments[0]
  }

  private onMousedown() {
    this.interactingWithList = true
  }

  private onKeydown(event: KeyboardEvent) {
    if (event.key === 'Escape') {
      this.match = null
      if (this.deactivate()) {
        this.lookBackIndex = this.input.selectionEnd || this.lookBackIndex
        event.stopImmediatePropagation()
        event.preventDefault()
      }
    }
  }
}
export default class TextExpanderElement extends HTMLElement {
  get keys(): TextExpanderKey[] {
    const keysAttr = this.getAttribute('keys')
    const keys = keysAttr ? keysAttr.split(' ') : []

    const multiWordAttr = this.getAttribute('multiword')
    const multiWord = multiWordAttr ? multiWordAttr.split(' ') : []
    const globalMultiWord = multiWord.length === 0 && this.hasAttribute('multiword')

    return keys.map(key => ({key, multiWord: globalMultiWord || multiWord.includes(key)}))
  }

  set keys(value: string) {
    this.setAttribute('keys', value)
  }

  connectedCallback(): void {
    const input = this.querySelector('input[type="text"], textarea')
    if (!(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement)) return
    const state = new TextExpander(this, input)
    states.set(this, state)
  }

  disconnectedCallback(): void {
    const state: TextExpander = states.get(this)
    if (!state) return
    state.destroy()
    states.delete(this)
  }

  dismiss(): void {
    const state: TextExpander = states.get(this)
    if (!state) return
    state.dismissMenu()
  }
}


================================================
FILE: test/.eslintrc
================================================
{
  "parser": "espree",
  "parserOptions": {
    "ecmaVersion": 8
  },
  "env": {
    "mocha": true
  },
  "globals": {
    "assert": true
  },
  "rules": {
    "github/no-inner-html": "off",
    "filenames/match-regex": "off",
    // Linting currently runs as a pre-build step, so the dependent files may not exist yet by the time linting is run
    "import/no-unresolved": "off"
  }
}

================================================
FILE: test/WrapperComponent.js
================================================
export class WrapperComponent extends HTMLElement {
  constructor() {
    super()
    const shadow = this.attachShadow({mode: 'open'})
    const textExpander = document.createElement('text-expander')
    textExpander.setAttribute('keys', '@')
    const textarea = document.createElement('textarea')
    textExpander.append(textarea)
    shadow.appendChild(textExpander)
  }

  connectedCallback() {
    const textExpander = this.shadowRoot.querySelector('text-expander')
    textExpander.addEventListener('text-expander-change', function (event) {
      const {key, provide} = event.detail

      if (key !== '@') return

      const suggestions = document.createElement('ul')
      suggestions.innerHTML = `
        <li role="option" data-value="a">a</li>
        <li role="option" data-value="aa">aa</li>
      `
      provide(Promise.resolve({matched: true, fragment: suggestions}))
    })
  }
}


================================================
FILE: test/karma.config.cjs
================================================
module.exports = function (config) {
  config.set({
    frameworks: ['mocha', 'chai'],
    files: [
      { pattern: '../dist/bundle.js', type: 'module' },
      { pattern: '../build/test.js', type: 'module' }
    ],
    reporters: ['mocha'],
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    browsers: ['ChromeHeadless'],
    autoWatch: false,
    singleRun: true,
    concurrency: Infinity,
    middleware: [],
    plugins: ['karma-*']
  })
}


================================================
FILE: test/query-test.js
================================================
import query from '../dist/query'

describe('text-expander single word parsing', function () {
  it('does not match empty text', function () {
    const found = query('', ':', 0)
    assert(found == null)
  })

  it('does not match without activation key', function () {
    const found = query('cat', ':', 3)
    assert(found == null)
  })

  it('matches only activation key', function () {
    const found = query(':', ':', 1)
    assert.deepEqual(found, {text: '', position: 1})
  })

  it('matches trailing activation key', function () {
    const found = query('hi :', ':', 4)
    assert.deepEqual(found, {text: '', position: 4})
  })

  it('matches start of text', function () {
    const found = query(':cat', ':', 4)
    assert.deepEqual(found, {text: 'cat', position: 1})
  })

  it('matches end of text', function () {
    const found = query('hi :cat', ':', 7)
    assert.deepEqual(found, {text: 'cat', position: 4})
  })

  it('matches middle of text', function () {
    const found = query('hi :cat bye', ':', 7)
    assert.deepEqual(found, {text: 'cat', position: 4})
  })

  it('matches only at word boundary', function () {
    const found = query('hi:cat', ':', 6)
    assert(found == null)
  })

  it('matches last activation key word', function () {
    const found = query('hi :cat bye :dog', ':', 16)
    assert.deepEqual(found, {text: 'dog', position: 13})
  })

  it('matches closest activation key word', function () {
    const found = query('hi :cat bye :dog', ':', 7)
    assert.deepEqual(found, {text: 'cat', position: 4})
  })

  it('does not match with a space between cursor and activation key', function () {
    const found = query('hi :cat bye', ':', 11)
    assert(found == null)
  })
})

describe('text-expander multi word parsing', function () {
  it('does not match empty text', function () {
    const found = query('', ':', 0, {multiWord: true})
    assert(found == null)
  })

  it('does not match without activation key', function () {
    const found = query('cat', ':', 3, {multiWord: true})
    assert(found == null)
  })

  it('matches only activation key', function () {
    const found = query(':', ':', 1, {multiWord: true})
    assert.deepEqual(found, {text: '', position: 1})
  })

  it('matches trailing activation key', function () {
    const found = query('hi :', ':', 4, {multiWord: true})
    assert.deepEqual(found, {text: '', position: 4})
  })

  it('matches start of text', function () {
    const found = query(':cat', ':', 4, {multiWord: true})
    assert.deepEqual(found, {text: 'cat', position: 1})
  })

  it('matches end of text', function () {
    const found = query('hi :cat', ':', 7, {multiWord: true})
    assert.deepEqual(found, {text: 'cat', position: 4})
  })

  it('matches middle of text', function () {
    const found = query('hi :cat bye', ':', 7, {multiWord: true})
    assert.deepEqual(found, {text: 'cat', position: 4})
  })

  it('matches only at word boundary', function () {
    const found = query('hi:cat', ':', 6, {multiWord: true})
    assert(found == null)
  })

  it('matches last activation key word', function () {
    const found = query('hi :cat bye :dog', ':', 16, {multiWord: true})
    assert.deepEqual(found, {text: 'dog', position: 13})
  })

  it('matches closest activation key word', function () {
    const found = query('hi :cat bye :dog', ':', 7, {multiWord: true})
    assert.deepEqual(found, {text: 'cat', position: 4})
  })

  it('matches with a space between cursor and activation key', function () {
    const found = query('hi :cat bye', ':', 11, {multiWord: true})
    assert.deepEqual(found, {text: 'cat bye', position: 4})
  })

  it('does not match with a dot between cursor and activation key', function () {
    const found = query('hi :cat. bye', ':', 11, {multiWord: true})
    assert(found == null)
  })

  it('does not match with a space between text and activation key', function () {
    const found = query('hi : cat bye', ':', 7, {multiWord: true})
    assert(found == null)
  })
})

describe('text-expander multi word parsing with multiple activation keys', function () {
  it('does not match consecutive activation keys', function () {
    let found = query('::', ':', 2, {multiWord: true})
    assert(found == null)

    found = query('::', ':', 3, {multiWord: true})
    assert(found == null)

    found = query('hi :: there', ':', 5, {multiWord: true})
    assert(found == null)

    found = query('hi ::: there', ':', 6, {multiWord: true})
    assert(found == null)

    found = query('hi ::', ':', 5, {multiWord: true})
    assert(found == null)

    found = query('hi :::', ':', 6, {multiWord: true})
    assert(found == null)
  })

  it('uses lastMatchPosition to match', function () {
    let found = query('hi :cat :bye', ':', 12, {multiWord: true, lastMatchPosition: 4})
    assert.deepEqual(found, {text: 'cat :bye', position: 4})

    found = query('hi :cat :bye :::', ':', 16, {multiWord: true, lastMatchPosition: 4})
    assert.deepEqual(found, {text: 'cat :bye :::', position: 4})

    found = query(':hi :cat :bye :::', ':', 17, {multiWord: true, lastMatchPosition: 1})
    assert.deepEqual(found, {text: 'hi :cat :bye :::', position: 1})
  })
})

describe('text-expander limits the lookBack after commit', function () {
  it('does not match if lookBackIndex is bigger than activation key index', function () {
    const found = query('hi :cat bye', ':', 11, {multiWord: true, lookBackIndex: 7})
    assert(found == null)
  })

  it('matches if lookBackIndex is lower than activation key index', function () {
    const found = query('hi :cat bye :dog', ':', 16, {multiWord: true, lookBackIndex: 7})
    assert(found, {text: 'dog', position: 13})
  })
})


================================================
FILE: test/test.js
================================================
import './query-test'
import './text-expander-element-test'


================================================
FILE: test/text-expander-element-test.js
================================================
import {WrapperComponent} from './WrapperComponent'

describe('text-expander element', function () {
  describe('element creation', function () {
    it('creates from document.createElement', function () {
      const el = document.createElement('text-expander')
      assert.equal('TEXT-EXPANDER', el.nodeName)
      assert(el instanceof window.TextExpanderElement)
    })

    it('creates from constructor', function () {
      const el = new window.TextExpanderElement()
      assert.equal('TEXT-EXPANDER', el.nodeName)
    })
  })

  describe('after tree insertion', function () {
    beforeEach(function () {
      const container = document.createElement('div')
      container.innerHTML = `
        <text-expander keys=": @ [[">
          <textarea></textarea>
        </text-expander>
      `
      document.body.append(container)
    })

    afterEach(function () {
      document.body.innerHTML = ''
    })

    it('has activation keys', function () {
      const expander = document.querySelector('text-expander')
      assert.deepEqual(
        [
          {key: ':', multiWord: false},
          {key: '@', multiWord: false},
          {key: '[[', multiWord: false}
        ],
        expander.keys
      )
    })

    it('dispatches change event', async function () {
      const expander = document.querySelector('text-expander')
      const input = expander.querySelector('textarea')
      const result = once(expander, 'text-expander-change')
      triggerInput(input, ':')
      const event = await result
      const {key} = event.detail
      assert.equal(':', key)
    })

    it('dismisses the menu when dismiss() is called', async function () {
      const expander = document.querySelector('text-expander')
      const input = expander.querySelector('textarea')
      const menu = document.createElement('ul')
      menu.appendChild(document.createElement('li'))

      expander.addEventListener('text-expander-change', event => {
        const {provide} = event.detail
        provide(Promise.resolve({matched: true, fragment: menu}))
      })

      input.focus()
      triggerInput(input, ':')
      await waitForAnimationFrame()
      assert.exists(expander.querySelector('ul'))

      expander.dismiss()
      await waitForAnimationFrame()
      assert.isNull(expander.querySelector('ul'))
    })

    it('dispatches change events for 2 char activation keys', async function () {
      const expander = document.querySelector('text-expander')
      const input = expander.querySelector('textarea')

      const receivedText = []
      const expectedText = ['', 'a', 'ab', 'abc', 'abcd']

      expander.addEventListener('text-expander-change', event => {
        const {key, text} = event.detail
        assert.equal('[[', key)
        receivedText.push(text)
      })
      triggerInput(input, '[[')
      triggerInput(input, '[[a')
      triggerInput(input, '[[ab')
      triggerInput(input, '[[abc')
      triggerInput(input, '[[abcd')

      assert.deepEqual(receivedText, expectedText)
    })

    it('dispatches value event after selecting item and closes', async function () {
      const expander = document.querySelector('text-expander')
      const input = expander.querySelector('textarea')
      const menu = document.createElement('ul')
      const item = document.createElement('li')
      item.setAttribute('role', 'option')
      menu.appendChild(item)

      expander.addEventListener('text-expander-change', event => {
        const {provide} = event.detail
        provide(Promise.resolve({matched: true, fragment: menu}))
      })

      expander.addEventListener('text-expander-value', event => {
        event.detail.value = ':1'
      })

      input.focus()
      triggerInput(input, ':')
      await waitForAnimationFrame()
      assert.exists(expander.querySelector('ul'))

      const result = once(expander, 'text-expander-value')
      expander.querySelector('li').click()
      const event = await result
      assert.equal(false, event.detail.continue)

      assert.equal(input.value, ':1 ')

      await waitForAnimationFrame()
      assert.isNull(expander.querySelector('ul'))
    })

    it('dispatches value event after selecting item and keeps menu open', async function () {
      const expander = document.querySelector('text-expander')
      const input = expander.querySelector('textarea')
      const menu = document.createElement('ul')
      const item = document.createElement('li')
      item.setAttribute('role', 'option')
      menu.appendChild(item)

      expander.addEventListener('text-expander-change', event => {
        const {provide} = event.detail
        // eslint-disable-next-line no-console
        console.log('ASDFSDF', event.detail)
        provide(Promise.resolve({matched: true, fragment: menu}))
      })

      expander.addEventListener('text-expander-value', event => {
        event.detail.value = ':1'
        event.detail.continue = true
      })

      input.focus()
      triggerInput(input, ':')
      await waitForAnimationFrame()
      assert.exists(expander.querySelector('ul'))

      const result = once(expander, 'text-expander-value')
      expander.querySelector('li').click()
      const event = await result
      assert.equal(true, event.detail.continue)

      triggerInput(input, '#1', true)

      assert.equal(input.value, ':1#1')

      await waitForAnimationFrame()
      assert.exists(expander.querySelector('ul'))
    })
  })

  describe('multi-word scenarios', function () {
    beforeEach(function () {
      const container = document.createElement('div')
      container.innerHTML = `
        <text-expander keys="@ # [[" multiword="# [[">
          <textarea></textarea>
        </text-expander>
      `
      document.body.append(container)
    })

    afterEach(function () {
      document.body.innerHTML = ''
    })

    it('has activation keys', function () {
      const expander = document.querySelector('text-expander')
      assert.deepEqual(
        [
          {key: '@', multiWord: false},
          {key: '#', multiWord: true},
          {key: '[[', multiWord: true}
        ],
        expander.keys
      )
    })

    it('sets keys', function () {
      const expander = document.querySelector('text-expander')
      assert.deepEqual(
        [
          {key: '@', multiWord: false},
          {key: '#', multiWord: true},
          {key: '[[', multiWord: true}
        ],
        expander.keys
      )

      expander.keys = '@ [['

      assert.deepEqual(
        [
          {key: '@', multiWord: false},
          {key: '[[', multiWord: true}
        ],
        expander.keys
      )
    })

    it('dispatches change event for multi-word', async function () {
      const expander = document.querySelector('text-expander')
      const input = expander.querySelector('textarea')
      const result = once(expander, 'text-expander-change')
      triggerInput(input, '@match #some text')
      const event = await result
      const {key, text} = event.detail
      assert.equal('#', key)
      assert.equal('some text', text)
    })

    it('dispatches change events for 2 char activation keys for multi-word', async function () {
      const expander = document.querySelector('text-expander')
      const input = expander.querySelector('textarea')

      const receivedText = []
      const expectedText = ['', 'a', 'ab', 'abc', 'abcd', 'abcd def']

      expander.addEventListener('text-expander-change', event => {
        const {key, text} = event.detail
        assert.equal('[[', key)
        receivedText.push(text)
      })
      triggerInput(input, '[[')
      triggerInput(input, '[[a')
      triggerInput(input, '[[ab')
      triggerInput(input, '[[abc')
      triggerInput(input, '[[abcd')
      triggerInput(input, '[[abcd def')

      assert.deepEqual(receivedText, expectedText)
    })

    it('dispatches change event for single word match after multi-word', async function () {
      const expander = document.querySelector('text-expander')
      const input = expander.querySelector('textarea')
      const result = once(expander, 'text-expander-change')
      triggerInput(input, '#some text @match')
      const event = await result
      const {key, text} = event.detail
      assert.equal('@', key)
      assert.equal('match', text)
    })

    it('dispatches change event for multi-word with single word inside', async function () {
      const expander = document.querySelector('text-expander')
      const input = expander.querySelector('textarea')
      const result = once(expander, 'text-expander-change')
      triggerInput(input, '#some text @match word')
      const event = await result
      const {key, text} = event.detail
      assert.equal('#', key)
      assert.equal('some text @match word', text)
    })

    it('dispatches change event for the first activation key even if it is typed again', async function () {
      const expander = document.querySelector('text-expander')
      const input = expander.querySelector('textarea')

      let result = once(expander, 'text-expander-change')
      triggerInput(input, '#step 1')
      let event = await result
      let {key, text} = event.detail
      assert.equal('#', key)
      assert.equal('step 1', text)

      await waitForAnimationFrame()

      result = once(expander, 'text-expander-change')
      triggerInput(input, ' #step 2', true) //<-- At this point the text inside the input field is "#step 1 #step 2"
      event = await result
      ;({key, text} = event.detail)
      assert.equal('#', key)
      assert.equal('step 1 #step 2', text)

      await waitForAnimationFrame()

      result = once(expander, 'text-expander-change')
      triggerInput(input, ' #step 3', true) //<-- At this point the text inside the input field is "#step 1 #step 2 #step 3"
      event = await result
      ;({key, text} = event.detail)
      assert.equal('#', key)
      assert.equal('step 1 #step 2 #step 3', text)
    })
  })

  describe('use inside a ShadowDOM', function () {
    before(function () {
      customElements.define('wrapper-component', WrapperComponent)
    })

    beforeEach(function () {
      const container = document.createElement('div')
      container.innerHTML = '<wrapper-component></wrapper-component>'
      document.body.append(container)
    })

    afterEach(function () {
      document.body.innerHTML = ''
    })

    it('show results on input', async function () {
      const component = document.querySelector('wrapper-component')
      const input = component.shadowRoot.querySelector('textarea')
      input.focus()
      triggerInput(input, '@a')
      await waitForAnimationFrame()
      assert.exists(component.shadowRoot.querySelector('ul'))
    })
  })
})

function once(element, eventName) {
  return new Promise(resolve => {
    element.addEventListener(eventName, resolve, {once: true})
  })
}

function triggerInput(input, value, onlyAppend = false) {
  input.value = onlyAppend ? input.value + value : value
  return input.dispatchEvent(new InputEvent('input'))
}

async function waitForAnimationFrame() {
  return new Promise(resolve => {
    window.requestAnimationFrame(resolve)
  })
}


================================================
FILE: tsconfig.json
================================================
{
  "compilerOptions": {
    "module": "esnext",
    "target": "es2017",
    "strict": true,
    "declaration": true,
    "outDir": "dist",
    "removeComments": true,
    "moduleResolution": "node"
  },
  "files": [
    "src/index.ts"
  ]
}
Download .txt
gitextract_s_7f5qbw/

├── .devcontainer/
│   ├── Dockerfile
│   └── devcontainer.json
├── .eslintrc.json
├── .github/
│   └── workflows/
│       ├── nodejs.yml
│       └── publish.yml
├── .gitignore
├── .travis.yml
├── CODEOWNERS
├── LICENSE
├── README.md
├── examples/
│   └── index.html
├── package.json
├── rollup.config.js
├── rollup.config.test.js
├── src/
│   ├── index.ts
│   ├── query.ts
│   └── text-expander-element.ts
├── test/
│   ├── .eslintrc
│   ├── WrapperComponent.js
│   ├── karma.config.cjs
│   ├── query-test.js
│   ├── test.js
│   └── text-expander-element-test.js
└── tsconfig.json
Download .txt
SYMBOL INDEX (36 symbols across 5 files)

FILE: src/index.ts
  type Window (line 6) | interface Window {

FILE: src/query.ts
  type Query (line 1) | type Query = {
  type QueryOptions (line 6) | type QueryOptions = {
  function query (line 15) | function query(

FILE: src/text-expander-element.ts
  type TextExpanderMatch (line 5) | type TextExpanderMatch = {
  type TextExpanderResult (line 11) | type TextExpanderResult = {
  type TextExpanderKey (line 16) | type TextExpanderKey = {
  type TextExpanderChangeEvent (line 21) | type TextExpanderChangeEvent = Event & {
  class TextExpander (line 31) | class TextExpander {
    method constructor (line 47) | constructor(expander: TextExpanderElement, input: HTMLInputElement | H...
    method destroy (line 68) | destroy() {
    method dismissMenu (line 75) | dismissMenu() {
    method activate (line 81) | private activate(match: TextExpanderMatch, menu: HTMLElement) {
    method positionMenu (line 105) | private positionMenu(menu: HTMLElement, position: number) {
    method deactivate (line 128) | private deactivate() {
    method onCommit (line 146) | private onCommit({target}: Event) {
    method onBlur (line 193) | private onBlur() {
    method onPaste (line 202) | private onPaste() {
    method isMatchStillValid (line 206) | private isMatchStillValid(match: TextExpanderMatch): boolean {
    method onInput (line 210) | async onInput() {
    method findMatch (line 239) | findMatch(): TextExpanderMatch | void {
    method notifyProviders (line 257) | async notifyProviders(match: TextExpanderMatch): Promise<HTMLElement |...
    method onMousedown (line 272) | private onMousedown() {
    method onKeydown (line 276) | private onKeydown(event: KeyboardEvent) {
  class TextExpanderElement (line 287) | class TextExpanderElement extends HTMLElement {
    method keys (line 288) | get keys(): TextExpanderKey[] {
    method keys (line 299) | set keys(value: string) {
    method connectedCallback (line 303) | connectedCallback(): void {
    method disconnectedCallback (line 310) | disconnectedCallback(): void {
    method dismiss (line 317) | dismiss(): void {

FILE: test/WrapperComponent.js
  class WrapperComponent (line 1) | class WrapperComponent extends HTMLElement {
    method constructor (line 2) | constructor() {
    method connectedCallback (line 12) | connectedCallback() {

FILE: test/text-expander-element-test.js
  function once (line 329) | function once(element, eventName) {
  function triggerInput (line 335) | function triggerInput(input, value, onlyAppend = false) {
  function waitForAnimationFrame (line 340) | async function waitForAnimationFrame() {
Condensed preview — 24 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (50K chars).
[
  {
    "path": ".devcontainer/Dockerfile",
    "chars": 959,
    "preview": "# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-no"
  },
  {
    "path": ".devcontainer/devcontainer.json",
    "chars": 1090,
    "preview": "// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:\n// https://github.co"
  },
  {
    "path": ".eslintrc.json",
    "chars": 570,
    "preview": "{\n  \"root\": true,\n  \"plugins\": [\"github\"],\n  \"extends\": [\"plugin:github/browser\", \"plugin:github/recommended\", \"plugin:g"
  },
  {
    "path": ".github/workflows/nodejs.yml",
    "chars": 522,
    "preview": "name: Node CI\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  build:\n    permissions:\n  "
  },
  {
    "path": ".github/workflows/publish.yml",
    "chars": 775,
    "preview": "name: Publish\npermissions:\n  contents: read\n  packages: write\n  id-token: write # Required for OIDC\n\non:\n  release:\n    "
  },
  {
    "path": ".gitignore",
    "chars": 27,
    "preview": "build/\ndist/\nnode_modules/\n"
  },
  {
    "path": ".travis.yml",
    "chars": 119,
    "preview": "language: node_js\nsudo: required\nnode_js:\n  - \"node\"\naddons:\n  chrome: stable\ncache:\n  directories:\n    - node_modules\n"
  },
  {
    "path": "CODEOWNERS",
    "chars": 27,
    "preview": "* @github/primer-reviewers\n"
  },
  {
    "path": "LICENSE",
    "chars": 1056,
    "preview": "Copyright (c) 2019 GitHub, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this so"
  },
  {
    "path": "README.md",
    "chars": 4445,
    "preview": "# &lt;text-expander&gt; element\n\nActivates a suggestion menu to expand text snippets as you type.\n\n## Installation\n\n```\n"
  },
  {
    "path": "examples/index.html",
    "chars": 4531,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <title>text-expander demo</title>\n    <style>"
  },
  {
    "path": "package.json",
    "chars": 1599,
    "preview": "{\n  \"name\": \"@github/text-expander-element\",\n  \"version\": \"2.2.2\",\n  \"description\": \"Activates a suggestion menu to expa"
  },
  {
    "path": "rollup.config.js",
    "chars": 413,
    "preview": "const pkg = require('./package.json')\nconst resolve = require('rollup-plugin-node-resolve')\n\nexport default [\n  {\n    ex"
  },
  {
    "path": "rollup.config.test.js",
    "chars": 203,
    "preview": "import resolve from 'rollup-plugin-node-resolve'\n\nexport default {\n  input: 'test/test.js',\n  output: [\n    {\n      file"
  },
  {
    "path": "src/index.ts",
    "chars": 414,
    "preview": "import TextExpanderElement from './text-expander-element'\nexport {TextExpanderElement as default}\nexport type * from './"
  },
  {
    "path": "src/query.ts",
    "chars": 1973,
    "preview": "type Query = {\n  text: string\n  position: number\n}\n\ntype QueryOptions = {\n  lookBackIndex: number\n  multiWord: boolean\n "
  },
  {
    "path": "src/text-expander-element.ts",
    "chars": 9382,
    "preview": "import Combobox from '@github/combobox-nav'\nimport query from './query'\nimport {InputRange} from 'dom-input-range'\n\nexpo"
  },
  {
    "path": "test/.eslintrc",
    "chars": 386,
    "preview": "{\n  \"parser\": \"espree\",\n  \"parserOptions\": {\n    \"ecmaVersion\": 8\n  },\n  \"env\": {\n    \"mocha\": true\n  },\n  \"globals\": {\n"
  },
  {
    "path": "test/WrapperComponent.js",
    "chars": 899,
    "preview": "export class WrapperComponent extends HTMLElement {\n  constructor() {\n    super()\n    const shadow = this.attachShadow({"
  },
  {
    "path": "test/karma.config.cjs",
    "chars": 464,
    "preview": "module.exports = function (config) {\n  config.set({\n    frameworks: ['mocha', 'chai'],\n    files: [\n      { pattern: '.."
  },
  {
    "path": "test/query-test.js",
    "chars": 5700,
    "preview": "import query from '../dist/query'\n\ndescribe('text-expander single word parsing', function () {\n  it('does not match empt"
  },
  {
    "path": "test/test.js",
    "chars": 60,
    "preview": "import './query-test'\nimport './text-expander-element-test'\n"
  },
  {
    "path": "test/text-expander-element-test.js",
    "chars": 11175,
    "preview": "import {WrapperComponent} from './WrapperComponent'\n\ndescribe('text-expander element', function () {\n  describe('element"
  },
  {
    "path": "tsconfig.json",
    "chars": 242,
    "preview": "{\n  \"compilerOptions\": {\n    \"module\": \"esnext\",\n    \"target\": \"es2017\",\n    \"strict\": true,\n    \"declaration\": true,\n  "
  }
]

About this extraction

This page contains the full source code of the github/text-expander-element GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 24 files (45.9 KB), approximately 12.3k tokens, and a symbol index with 36 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!