Skip to content

Commit 2b287c9

Browse files
authored
Merge pull request #1 from github/init
<filter-input>
2 parents 2b6aa2e + 0251e8d commit 2b287c9

File tree

5 files changed

+375
-23
lines changed

5 files changed

+375
-23
lines changed

README.md

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,121 @@
1-
# &lt;custom-element&gt; element
1+
# &lt;filter-input&gt; element
22

3-
Boilerplate for creating a custom element.
3+
Display elements in a subtree that match filter input text.
44

55
## Installation
66

77
```
8-
$ npm install @github/custom-element-element
8+
$ npm install @github/filter-input-element
99
```
1010

1111
## Usage
1212

13-
```js
14-
import '@github/custom-element-element'
13+
```html
14+
<filter-input aria-owns="robots">
15+
<label>
16+
Filter robots
17+
<input type="text" autofocus autocomplete="off">
18+
</label>
19+
</filter-input>
20+
<div id="robots">
21+
<ul data-filter-list>
22+
<li>Bender</li>
23+
<li>Hubot</li>
24+
<li>Wall-E</li>
25+
<li>BB-8</li>
26+
<li>R2-D2</li>
27+
</ul>
28+
<p data-filter-empty-state hidden>0 robots found.</p>
29+
</div>
30+
```
31+
32+
## Elements and attributes
33+
34+
### Required
35+
36+
- `filter-input[aria-owns]` should point to the container ID that wraps all `<filter-input>` related elements.
37+
- `filter-input` should have one `input` child element that is used to filter.
38+
- `[id]` should be set on a container that either contains or has `[data-filter-list]` attribute.
39+
- `[data-filter-list]` should be set on the element whose **direct child elements** are to be filtered.
40+
41+
### Optional
42+
43+
#### Specify filter text
44+
45+
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.
46+
47+
```html
48+
<div data-filter-list>
49+
<a href="/bender">Bender</a>
50+
<a href="/hubot">
51+
<span data-filter-item-text>Hubot</span>
52+
(current)
53+
</a>
54+
</div>
1555
```
1656

57+
#### Blankslate
58+
59+
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.
60+
1761
```html
18-
<custom-element></custom-element>
62+
<div id="filter-list">
63+
<div data-filter-list>
64+
<a href="/bender">Bender</a>
65+
<a href="/hubot">Hubot</a>
66+
</div>
67+
<div data-filter-empty-state hidden>No results found.</div>
68+
</div>
1969
```
2070

71+
#### Create new item
72+
73+
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.
74+
75+
```html
76+
<div id="filter-list">
77+
<div data-filter-list>
78+
<a href="/bender">Bender</a>
79+
<a href="/hubot">Hubot</a>
80+
</div>
81+
<form action="/new" data-filter-new-item hidden>
82+
<button name="robot" data-filter-new-item-value>
83+
Create robot "<span data-filter-new-item-text></span>"
84+
</button>
85+
</form>
86+
</div>
87+
```
88+
89+
## Methods
90+
91+
`filterInputElement.filter` can be optionally set to provide an alternative filtering logic. The default is substring.
92+
93+
```js
94+
const fuzzyFilterInput = document.querySelector('.js-fuzzy-filter-input')
95+
fuzzyFilterInput.filter = (element, elementText, query) => {
96+
// fuzzy filtering logic
97+
return {match: boolean, hideNew: boolean}
98+
}
99+
```
100+
101+
`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.
102+
103+
## Events
104+
105+
- `filter-input-start` (bubbles) - fired on `<filter-input>` when a filter action is about to start.
106+
- `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.
107+
108+
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:
109+
110+
```js
111+
const ariaLiveContainer = document.querySelector('[aria-live="assertive"]')
112+
document.addEventListener('filter-input-updated', event => {
113+
ariaLiveContainer.textContent = `${event.detail.count} results found.`
114+
})
115+
```
116+
117+
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/).
118+
21119
## Browser support
22120

23121
Browsers without native [custom element support][support] require a [polyfill][].

examples/index.html

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,24 @@
55
<title>filter-input demo</title>
66
</head>
77
<body>
8-
<filter-input></filter-input>
8+
<filter-input aria-owns="robots">
9+
<label>
10+
Filter robots
11+
<input type="text" autofocus autocomplete="off">
12+
</label>
13+
</filter-input>
14+
<div id="robots">
15+
<ul data-filter-list>
16+
<li>Bender</li>
17+
<li>Hubot</li>
18+
<li>Wall-E</li>
19+
<li>BB-8</li>
20+
<li>R2-D2</li>
21+
</ul>
22+
<p data-filter-empty-state hidden>0 robots found.</p>
23+
</div>
924

10-
<script>
11-
const script = document.createElement('script')
12-
if (window.location.hostname.endsWith('github.io')) {
13-
script.src = "https://unpkg.com/@github/filter-input-boilerplate@latest/dist/index.umd.js"
14-
} else {
15-
script.src = "../dist/index.umd.js"
16-
}
17-
document.body.appendChild(script)
18-
</script>
25+
<script type="module" src="https://unpkg.com/@github/filter-input-element@latest/dist/index.js"></script>
26+
<!-- <script type="module" src="../dist/index.js"></script> -->
1927
</body>
2028
</html>

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
22
"name": "@github/filter-input-element",
33
"version": "0.0.1",
4-
"description": "Custom element used to create a filterable input.",
4+
"description": "Display elements in a subtree that match filter input text.",
55
"main": "dist/umd/index.js",
6-
"module": "dist/index.esm.js",
6+
"module": "dist/index.js",
77
"types": "dist/index.d.ts",
88
"license": "MIT",
99
"repository": "github/filter-input-element",

src/index.ts

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,163 @@
1+
interface MatchFunction {
2+
(item: HTMLElement, itemText: string, query: string): MatchResult
3+
}
4+
5+
interface MatchResult {
6+
match: boolean
7+
hideNew?: boolean
8+
}
9+
110
class FilterInputElement extends HTMLElement {
11+
currentQuery: string | null
12+
debounceInputChange: () => void
13+
boundFilterResults: () => void
14+
filter: MatchFunction | null
15+
216
constructor() {
317
super()
18+
this.currentQuery = null
19+
this.filter = null
20+
this.debounceInputChange = debounce(() => filterResults(this, true))
21+
this.boundFilterResults = () => {
22+
filterResults(this, false)
23+
}
24+
}
25+
26+
static get observedAttributes() {
27+
return ['aria-owns']
28+
}
29+
30+
attributeChangedCallback(name: string, oldValue: string) {
31+
if (oldValue && name === 'aria-owns') {
32+
filterResults(this, false)
33+
}
434
}
535

636
connectedCallback() {
7-
this.textContent = ':wave:'
37+
const input = this.input
38+
if (!input) return
39+
40+
input.setAttribute('autocomplete', 'off')
41+
input.setAttribute('spellcheck', 'false')
42+
43+
input.addEventListener('focus', this.boundFilterResults)
44+
input.addEventListener('change', this.boundFilterResults)
45+
input.addEventListener('input', this.debounceInputChange)
46+
}
47+
48+
disconnectedCallback() {
49+
const input = this.input
50+
if (!input) return
51+
52+
input.removeEventListener('focus', this.boundFilterResults)
53+
input.removeEventListener('change', this.boundFilterResults)
54+
input.removeEventListener('input', this.debounceInputChange)
55+
}
56+
57+
get input(): HTMLInputElement | null {
58+
const input = this.querySelector('input')
59+
return input instanceof HTMLInputElement ? input : null
60+
}
61+
62+
reset() {
63+
const input = this.input
64+
if (input) {
65+
input.value = ''
66+
input.dispatchEvent(new Event('change', {bubbles: true}))
67+
}
68+
}
69+
}
70+
71+
async function filterResults(filterInput: FilterInputElement, checkCurrentQuery: boolean = false) {
72+
const input = filterInput.input
73+
if (!input) return
74+
const query = input.value.toLowerCase()
75+
const id = filterInput.getAttribute('aria-owns')
76+
if (!id) return
77+
const container = document.getElementById(id)
78+
if (!container) return
79+
const list = container.hasAttribute('data-filter-list') ? container : container.querySelector('[data-filter-list]')
80+
if (!list) return
81+
82+
filterInput.dispatchEvent(
83+
new CustomEvent('filter-input-start', {
84+
bubbles: true
85+
})
86+
)
87+
88+
if (checkCurrentQuery && filterInput.currentQuery === query) return
89+
filterInput.currentQuery = query
90+
91+
const filter = filterInput.filter || matchSubstring
92+
const total = list.childElementCount
93+
let count = 0
94+
let hideNew = false
95+
96+
for (const item of Array.from(list.children)) {
97+
if (!(item instanceof HTMLElement)) continue
98+
const itemText = getText(item)
99+
const result = filter(item, itemText, query)
100+
if (result.hideNew === true) hideNew = result.hideNew
101+
102+
item.hidden = !result.match
103+
if (result.match) count++
104+
}
105+
106+
const newItem = container.querySelector('[data-filter-new-item]')
107+
const showCreateOption = !!newItem && query.length > 0 && !hideNew
108+
if (newItem instanceof HTMLElement) {
109+
newItem.hidden = !showCreateOption
110+
if (showCreateOption) updateNewItem(newItem, query)
111+
}
112+
113+
toggleBlankslate(container, count > 0 || showCreateOption)
114+
115+
filterInput.dispatchEvent(
116+
new CustomEvent('filter-input-updated', {
117+
bubbles: true,
118+
detail: {
119+
count,
120+
total
121+
}
122+
})
123+
)
124+
}
125+
126+
function matchSubstring(_item: HTMLElement, itemText: string, query: string): MatchResult {
127+
const match = itemText.indexOf(query) !== -1
128+
return {
129+
match,
130+
hideNew: itemText === query
131+
}
132+
}
133+
134+
function getText(filterableItem: HTMLElement) {
135+
const target = filterableItem.querySelector('[data-filter-item-text]') || filterableItem
136+
return (target.textContent || '').trim().toLowerCase()
137+
}
138+
139+
function updateNewItem(newItem: HTMLElement, query: string) {
140+
const newItemText = newItem.querySelector('[data-filter-new-item-text]')
141+
if (newItemText) newItemText.textContent = query
142+
const newItemValue = newItem.querySelector('[data-filter-new-item-value]')
143+
if (newItemValue instanceof HTMLInputElement || newItemValue instanceof HTMLButtonElement) {
144+
newItemValue.value = query
145+
}
146+
}
147+
148+
function toggleBlankslate(container: HTMLElement, force: boolean) {
149+
const emptyState = container.querySelector('[data-filter-empty-state]')
150+
if (emptyState instanceof HTMLElement) emptyState.hidden = force
151+
}
152+
153+
function debounce(callback: () => void) {
154+
let timeout: number
155+
return function() {
156+
clearTimeout(timeout)
157+
timeout = setTimeout(() => {
158+
clearTimeout(timeout)
159+
callback()
160+
}, 300)
8161
}
9162
}
10163

0 commit comments

Comments
 (0)