From 10c965a9233b10ce1858e4c420b7734ba059350d Mon Sep 17 00:00:00 2001 From: Mu-An Chiou Date: Mon, 25 Nov 2019 18:04:40 -0500 Subject: [PATCH 1/9] Import filter-input code and add basic tests --- README.md | 25 ++++++-- examples/index.html | 28 +++++--- package.json | 4 +- src/index.ts | 153 +++++++++++++++++++++++++++++++++++++++++++- test/test.js | 64 ++++++++++++++++-- 5 files changed, 252 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 7c92752..44d11d0 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,36 @@ -# <custom-element> element +# <filter-input> element -Boilerplate for creating a custom element. +Custom element used to create a filter input. ## Installation ``` -$ npm install @github/custom-element-element +$ npm install @github/filter-input-element ``` ## Usage ```js -import '@github/custom-element-element' +import '@github/filter-input-element' ``` ```html - + + + +
+ + +
``` ## Browser support diff --git a/examples/index.html b/examples/index.html index 58d61c7..5127eda 100644 --- a/examples/index.html +++ b/examples/index.html @@ -5,16 +5,24 @@ filter-input demo - + + + +
+ + +
- + + diff --git a/package.json b/package.json index 7319166..7b3f1b0 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "@github/filter-input-element", "version": "0.0.1", - "description": "Custom element used to create a filterable input.", + "description": "Custom element used to create a filter input.", "main": "dist/umd/index.js", - "module": "dist/index.esm.js", + "module": "dist/index.js", "types": "dist/index.d.ts", "license": "MIT", "repository": "github/filter-input-element", diff --git a/src/index.ts b/src/index.ts index 6e4de97..b77fc86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,161 @@ +interface MatchFunction { + (item: HTMLElement, itemText: string, query: string): MatchResult +} + +interface MatchResult { + match: boolean + hideNew?: boolean +} + class FilterInputElement extends HTMLElement { + currentQuery: string | null + debounceInputChange: () => void + boundFilterResults: () => void + filter: MatchFunction | null + constructor() { super() + this.currentQuery = null + this.filter = null + this.debounceInputChange = debounce(() => filterResults(this)) + this.boundFilterResults = () => { + filterResults(this) + } + } + + static get observedAttributes() { + return ['aria-owns'] + } + + attributeChangedCallback(name: string, oldValue: string) { + if (oldValue && name === 'aria-owns') { + filterResults(this, false) + } } connectedCallback() { - this.textContent = ':wave:' + const input = this.input + if (!input) return + + input.setAttribute('autocomplete', 'off') + input.setAttribute('spellcheck', 'false') + + input.addEventListener('focus', this.boundFilterResults) + input.addEventListener('change', this.boundFilterResults) + input.addEventListener('input', this.debounceInputChange) + } + + disconnectedCallback() { + const input = this.input + if (!input) return + + input.removeEventListener('focus', this.boundFilterResults) + input.removeEventListener('change', this.boundFilterResults) + input.removeEventListener('input', this.debounceInputChange) + } + + get input(): HTMLInputElement | null { + const input = this.querySelector('input') + return input instanceof HTMLInputElement ? input : null + } + + reset() { + const input = this.input + if (input) { + input.value = '' + input.dispatchEvent(new Event('change', {bubbles: true})) + } + } +} + +async function filterResults(filterInput: FilterInputElement, checkCurrentQuery: boolean = true) { + const input = filterInput.input + if (!input) return + const query = input.value.toLowerCase() + const id = filterInput.getAttribute('aria-owns') + if (!id) return + const container = document.getElementById(id) + if (!container) return + const list = container.hasAttribute('data-filter-list') ? container : container.querySelector('[data-filter-list]') + if (!list) return + + filterInput.dispatchEvent( + new CustomEvent('filter-input-start', { + bubbles: true + }) + ) + + if (checkCurrentQuery && filterInput.currentQuery === query) return + filterInput.currentQuery = query + + const filter = filterInput.filter || matchSubstring + const total = list.childElementCount + let count = 0 + let hideNew = false + + for (const item of Array.from(list.children)) { + if (!(item instanceof HTMLElement)) continue + const itemText = getText(item) + const result = filter(item, itemText, query) + if (result.hideNew === true) hideNew = result.hideNew + + item.hidden = !result.match + if (result.match) count++ + } + + const newItem = container.querySelector('[data-filter-new-item]') + const showCreateOption = !!newItem && query.length > 0 && !hideNew + if (newItem instanceof HTMLElement) { + newItem.hidden = !showCreateOption + if (showCreateOption) updateNewItem(newItem, query) + } + + toggleBlankslate(container, count > 0 || showCreateOption) + + filterInput.dispatchEvent( + new CustomEvent('filter-input-updated', { + bubbles: true, + detail: { + count, + total + } + }) + ) +} + +function matchSubstring(_item: HTMLElement, itemText: string, query: string): MatchResult { + const match = itemText.indexOf(query) !== -1 + return { + match, + hideNew: itemText === query + } +} + +function getText(filterableItem: HTMLElement) { + const target = filterableItem.querySelector('[data-filter-item-text]') || filterableItem + return (target.textContent || '').trim().toLowerCase() +} + +function updateNewItem(newItem: HTMLElement, query: string) { + const newItemText = newItem.querySelector('[data-filter-new-item-text]') + if (newItemText) newItemText.textContent = query + const newItemInput = newItem.querySelector('[data-filter-new-item-value]') + if (newItemInput instanceof HTMLInputElement) newItemInput.value = query +} + +function toggleBlankslate(container: HTMLElement, force: boolean) { + const emptyState = container.querySelector('[data-filter-empty-state]') + if (emptyState instanceof HTMLElement) emptyState.hidden = force +} + +function debounce(callback: () => void) { + let timeout: number + return function() { + clearTimeout(timeout) + timeout = setTimeout(() => { + clearTimeout(timeout) + callback() + }, 300) } } diff --git a/test/test.js b/test/test.js index 35a1f73..d05ecba 100644 --- a/test/test.js +++ b/test/test.js @@ -12,17 +12,73 @@ describe('filter-input', function() { }) describe('after tree insertion', function() { + let filterInput, input, list, emptyState beforeEach(function() { - document.body.innerHTML = '' + document.body.innerHTML = ` + + + +
+ + +
+ ` + + filterInput = document.querySelector('filter-input') + input = filterInput.querySelector('input') + list = document.querySelector('[data-filter-list]') + emptyState = document.querySelector('[data-filter-empty-state]') }) afterEach(function() { document.body.innerHTML = '' }) - it('initiates', function() { - const ce = document.querySelector('filter-input') - assert.equal(ce.textContent, ':wave:') + it('filters', async function() { + const listener = once('filter-input-updated') + changeValue(input, 'hu') + const customEvent = await listener + const results = Array.from(list.children).filter(el => !el.hidden) + assert.equal(results.length, 1) + assert.equal(results[0].textContent, 'Hubot') + assert.equal(customEvent.detail.count, 1) + assert.equal(customEvent.detail.total, 5) + changeValue(input, 'boom') + assert.notOk(emptyState.hidden, 'Empty state should be shown') + }) + + it('filters with custom filter', async function() { + filterInput.filter = (_item, itemText) => { + return {match: itemText.indexOf('-') >= 0} + } + const listener = once('filter-input-updated') + changeValue(input, ':)') + const customEvent = await listener + const results = Array.from(list.children).filter(el => !el.hidden) + assert.equal(results.length, 3) + assert.equal(results[0].textContent, 'Wall-E') + assert.equal(customEvent.detail.count, 3) + assert.equal(customEvent.detail.total, 5) }) }) }) + +function changeValue(input, value) { + input.value = value + input.dispatchEvent(new Event('change', {bubbles: true})) +} + +function once(eventName) { + return new Promise(resolve => { + document.addEventListener(eventName, resolve, {once: true}) + }) +} From 87fb7d9614427060a96654a0d641449b8f008006 Mon Sep 17 00:00:00 2001 From: Mu-An Chiou Date: Tue, 26 Nov 2019 16:49:58 -0500 Subject: [PATCH 2/9] Imporove readme --- README.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 44d11d0..3c4b253 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,6 @@ $ npm install @github/filter-input-element ## Usage -```js -import '@github/filter-input-element' -``` - ```html