Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 104 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
<filter-input aria-owns="robots">
<label>
Filter robots
<input type="text" autofocus autocomplete="off">
</label>
</filter-input>
<div id="robots">
<ul data-filter-list>
<li>Bender</li>
<li>Hubot</li>
<li>Wall-E</li>
<li>BB-8</li>
<li>R2-D2</li>
</ul>
<p data-filter-empty-state hidden>0 robots found.</p>
</div>
```

## Elements and attributes

### Required

- `filter-input[aria-owns]` should point to the container ID that wraps all `<filter-input>` 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
<div data-filter-list>
<a href="/bender">Bender</a>
<a href="/hubot">
<span data-filter-item-text>Hubot</span>
(current)
</a>
</div>
```

#### 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
<custom-element></custom-element>
<div id="filter-list">
<div data-filter-list>
<a href="/bender">Bender</a>
<a href="/hubot">Hubot</a>
</div>
<div data-filter-empty-state hidden>No results found.</div>
</div>
```

#### 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
<div id="filter-list">
<div data-filter-list>
<a href="/bender">Bender</a>
<a href="/hubot">Hubot</a>
</div>
<form action="/new" data-filter-new-item hidden>
<button name="robot" data-filter-new-item-value>
Create robot "<span data-filter-new-item-text></span>"
</button>
</form>
</div>
```

## 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 `<filter-input>` when a filter action is about to start.
- `filter-input-updated` (bubbles) - fired on `<filter-input>` 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][].
Expand Down
28 changes: 18 additions & 10 deletions examples/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,24 @@
<title>filter-input demo</title>
</head>
<body>
<filter-input></filter-input>
<filter-input aria-owns="robots">
<label>
Filter robots
<input type="text" autofocus autocomplete="off">
</label>
</filter-input>
<div id="robots">
<ul data-filter-list>
<li>Bender</li>
<li>Hubot</li>
<li>Wall-E</li>
<li>BB-8</li>
<li>R2-D2</li>
</ul>
<p data-filter-empty-state hidden>0 robots found.</p>
</div>

<script>
const script = document.createElement('script')
if (window.location.hostname.endsWith('github.io')) {
script.src = "https://unpkg.com/@github/filter-input-boilerplate@latest/dist/index.umd.js"
} else {
script.src = "../dist/index.umd.js"
}
document.body.appendChild(script)
</script>
<script type="module" src="https://unpkg.com/@github/filter-input-element@latest/dist/index.js"></script>
<!-- <script type="module" src="../dist/index.js"></script> -->
</body>
</html>
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
155 changes: 154 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}

Expand Down
Loading