Skip to content

Commit 10c965a

Browse files
committed
Import filter-input code and add basic tests
1 parent 2b6aa2e commit 10c965a

File tree

5 files changed

+252
-22
lines changed

5 files changed

+252
-22
lines changed

README.md

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,36 @@
1-
# <custom-element> element
1+
# <filter-input> element
22

3-
Boilerplate for creating a custom element.
3+
Custom element used to create a filter input.
44

55
## Installation
66

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

1111
## Usage
1212

1313
```js
14-
import '@github/custom-element-element'
14+
import '@github/filter-input-element'
1515
```
1616

1717
```html
18-
<custom-element></custom-element>
18+
<filter-input aria-owns="robots">
19+
<label>
20+
Filter robots
21+
<input type="text" autofocus autocomplete="off">
22+
</label>
23+
</filter-input>
24+
<div id="robots">
25+
<ul data-filter-list>
26+
<li>Bender</li>
27+
<li>Hubot</li>
28+
<li>Wall-E</li>
29+
<li>BB-8</li>
30+
<li>R2-D2</li>
31+
</ul>
32+
<p data-filter-empty-state hidden>0 robots found.</p>
33+
</div>
1934
```
2035

2136
## Browser support

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": "Custom element used to create a filter input.",
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: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,161 @@
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))
21+
this.boundFilterResults = () => {
22+
filterResults(this)
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 = true) {
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 newItemInput = newItem.querySelector('[data-filter-new-item-value]')
143+
if (newItemInput instanceof HTMLInputElement) newItemInput.value = query
144+
}
145+
146+
function toggleBlankslate(container: HTMLElement, force: boolean) {
147+
const emptyState = container.querySelector('[data-filter-empty-state]')
148+
if (emptyState instanceof HTMLElement) emptyState.hidden = force
149+
}
150+
151+
function debounce(callback: () => void) {
152+
let timeout: number
153+
return function() {
154+
clearTimeout(timeout)
155+
timeout = setTimeout(() => {
156+
clearTimeout(timeout)
157+
callback()
158+
}, 300)
8159
}
9160
}
10161

test/test.js

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,73 @@ describe('filter-input', function() {
1212
})
1313

1414
describe('after tree insertion', function() {
15+
let filterInput, input, list, emptyState
1516
beforeEach(function() {
16-
document.body.innerHTML = '<filter-input></filter-input>'
17+
document.body.innerHTML = `
18+
<filter-input aria-owns="robots">
19+
<label>
20+
Filter robots
21+
<input type="text" autofocus autocomplete="off">
22+
</label>
23+
</filter-input>
24+
<div id="robots">
25+
<ul data-filter-list>
26+
<li>Bender</li>
27+
<li>Hubot</li>
28+
<li>Wall-E</li>
29+
<li>BB-8</li>
30+
<li>R2-D2</li>
31+
</ul>
32+
<p data-filter-empty-state hidden>0 robots found.</p>
33+
</div>
34+
`
35+
36+
filterInput = document.querySelector('filter-input')
37+
input = filterInput.querySelector('input')
38+
list = document.querySelector('[data-filter-list]')
39+
emptyState = document.querySelector('[data-filter-empty-state]')
1740
})
1841

1942
afterEach(function() {
2043
document.body.innerHTML = ''
2144
})
2245

23-
it('initiates', function() {
24-
const ce = document.querySelector('filter-input')
25-
assert.equal(ce.textContent, ':wave:')
46+
it('filters', async function() {
47+
const listener = once('filter-input-updated')
48+
changeValue(input, 'hu')
49+
const customEvent = await listener
50+
const results = Array.from(list.children).filter(el => !el.hidden)
51+
assert.equal(results.length, 1)
52+
assert.equal(results[0].textContent, 'Hubot')
53+
assert.equal(customEvent.detail.count, 1)
54+
assert.equal(customEvent.detail.total, 5)
55+
changeValue(input, 'boom')
56+
assert.notOk(emptyState.hidden, 'Empty state should be shown')
57+
})
58+
59+
it('filters with custom filter', async function() {
60+
filterInput.filter = (_item, itemText) => {
61+
return {match: itemText.indexOf('-') >= 0}
62+
}
63+
const listener = once('filter-input-updated')
64+
changeValue(input, ':)')
65+
const customEvent = await listener
66+
const results = Array.from(list.children).filter(el => !el.hidden)
67+
assert.equal(results.length, 3)
68+
assert.equal(results[0].textContent, 'Wall-E')
69+
assert.equal(customEvent.detail.count, 3)
70+
assert.equal(customEvent.detail.total, 5)
2671
})
2772
})
2873
})
74+
75+
function changeValue(input, value) {
76+
input.value = value
77+
input.dispatchEvent(new Event('change', {bubbles: true}))
78+
}
79+
80+
function once(eventName) {
81+
return new Promise(resolve => {
82+
document.addEventListener(eventName, resolve, {once: true})
83+
})
84+
}

0 commit comments

Comments
 (0)