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 # [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 " ================================================ 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 ================================================ 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) => 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 { const providers: Array | TextExpanderResult> = [] const provide = (result: Promise | 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 = `
  • a
  • aa
  • ` 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 = ` ` 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 = ` ` 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 = '' 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" ] }