diff --git a/README.md b/README.md index 7c92752..97f4017 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,121 @@ -# <custom-element> element +# <filter-input> element -Boilerplate for creating a custom element. +Display elements in a subtree that match filter input text. ## Installation ``` -$ npm install @github/custom-element-element +$ npm install @github/filter-input-element ``` ## Usage -```js -import '@github/custom-element-element' +```html + + + +
+ + +
+``` + +## Elements and attributes + +### Required + +- `filter-input[aria-owns]` should point to the container ID that wraps all `` related elements. +- `filter-input` should have one `input` child element that is used to filter. +- `[id]` should be set on a container that either contains or has `[data-filter-list]` attribute. +- `[data-filter-list]` should be set on the element whose **direct child elements** are to be filtered. + +### Optional + +#### Specify filter text + +Use `[data-filter-item-text]` to specify an element whose text should be used for filtering. In the following example, the text `(current)` would not be matched. + +```html +
+ Bender + + Hubot + (current) + +
``` +#### Blankslate + +Use `[data-filter-empty-state]` to specify an element to be displayed when no results were found. This element must be inside of the container `aria-owns` points to. + ```html - +
+
+ Bender + Hubot +
+ +
``` +#### Create new item + +Use `[data-filter-new-item]` to include an item to create a new instance when no exact match were found. The element with `[data-filter-new-text]`'s text content will be set to the input value. You can also use `[data-filter-new-value]` to set an input value to the query param. + +```html +
+
+ Bender + Hubot +
+ +
+``` + +## Methods + +`filterInputElement.filter` can be optionally set to provide an alternative filtering logic. The default is substring. + +```js +const fuzzyFilterInput = document.querySelector('.js-fuzzy-filter-input') +fuzzyFilterInput.filter = (element, elementText, query) => { + // fuzzy filtering logic + return {match: boolean, hideNew: boolean} +} +``` + +`match`(required) indicates whether the item should be shown. `hideNew` (optional) will determine whether the "Create new item" element should be hidden. For example, when an exact match is found, the "create new item" option should be hidden. + +## Events + +- `filter-input-start` (bubbles) - fired on `` when a filter action is about to start. +- `filter-input-updated` (bubbles) - fired on `` when filter has finished. `event.detail.count` is the number of matches found, and `event.detail.total` is the total number of elements. + +To ensure that the client side update is communicated to assistive technologies, `filter-input-updated` event can be used to update filter results to screen readers. For example: + +```js +const ariaLiveContainer = document.querySelector('[aria-live="assertive"]') +document.addEventListener('filter-input-updated', event => { + ariaLiveContainer.textContent = `${event.detail.count} results found.` +}) +``` + +For more details on this technique, check out [Improving accessibility on GOV.UK search](https://technology.blog.gov.uk/2014/08/14/improving-accessibility-on-gov-uk-search/). + ## Browser support Browsers without native [custom element support][support] require a [polyfill][]. diff --git a/examples/index.html b/examples/index.html index 58d61c7..8c755d4 100644 --- a/examples/index.html +++ b/examples/index.html @@ -5,16 +5,24 @@ filter-input demo - + + + +
+
    +
  • Bender
  • +
  • Hubot
  • +
  • Wall-E
  • +
  • BB-8
  • +
  • R2-D2
  • +
+ +
- + + diff --git a/package.json b/package.json index 7319166..5cc25de 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": "Display elements in a subtree that match filter input text.", "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..6a6e3dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,163 @@ +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, true)) + this.boundFilterResults = () => { + filterResults(this, false) + } + } + + 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 = false) { + 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 newItemValue = newItem.querySelector('[data-filter-new-item-value]') + if (newItemValue instanceof HTMLInputElement || newItemValue instanceof HTMLButtonElement) { + newItemValue.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..3329589 100644 --- a/test/test.js +++ b/test/test.js @@ -12,17 +12,110 @@ describe('filter-input', function() { }) describe('after tree insertion', function() { + let filterInput, input, list, emptyState, newItem beforeEach(function() { - document.body.innerHTML = '' + document.body.innerHTML = ` + + + +
+
    +
  • Bender
  • +
  • Hubot
  • +
  • Wall-E
  • +
  • BB-8
  • +
+ + +
+ ` + + filterInput = document.querySelector('filter-input') + input = filterInput.querySelector('input') + list = document.querySelector('[data-filter-list]') + emptyState = document.querySelector('[data-filter-empty-state]') + newItem = document.querySelector('[data-filter-new-item]') }) afterEach(function() { document.body.innerHTML = '' }) - it('initiates', function() { - const ce = document.querySelector('filter-input') - assert.equal(ce.textContent, ':wave:') + it('filters and toggles new item form', async function() { + const listener = once('filter-input-updated') + changeValue(input, 'hu') + const customEvent = await listener + assert.equal(customEvent.detail.count, 1) + assert.equal(customEvent.detail.total, 4) + + changeValue(input, 'boom') + assert.notOk(newItem.hidden, 'New item form should be shown') + assert.equal(newItem.querySelector('[data-filter-new-item-value]').value, 'boom') + assert.equal(newItem.querySelector('[data-filter-new-item-text]').textContent, 'boom') + }) + + it('filters and toggles blankslate', async function() { + // Remove new item form, which is prioritized over blankslate + newItem.remove() + + 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, 4) + 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, 2) + assert.equal(results[0].textContent, 'Wall-E') + assert.equal(customEvent.detail.count, 2) + assert.equal(customEvent.detail.total, 4) + }) + + it('filters again with the same value when a change event is fired', async function() { + const listener = once('filter-input-updated') + changeValue(input, '-') + const customEvent = await listener + assert.equal(customEvent.detail.count, 2) + assert.equal(customEvent.detail.total, 4) + + const newRobot = document.createElement('li') + newRobot.textContent = 'R2-D2' + list.append(newRobot) + + const listener2 = once('filter-input-updated') + changeValue(input, '-') + const customEvent2 = await listener2 + assert.equal(customEvent2.detail.count, 3) + assert.equal(customEvent2.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}) + }) +}