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
================================================
# <text-expander> 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"
]
}
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
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": "# <text-expander> 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.