Skip to content
This repository has been archived by the owner on Apr 8, 2024. It is now read-only.

Commit

Permalink
core-input: add limit option (#213)
Browse files Browse the repository at this point in the history
  • Loading branch information
htor authored Mar 29, 2019
1 parent 83b51d3 commit c07ad92
Show file tree
Hide file tree
Showing 6 changed files with 92 additions and 80 deletions.
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@
"scripts": {
"build": "rollup --config",
"install": "node bin/index.js --install",
"lint": "standard",
"publish:patch": "npm run build && node bin/index.js --publish=patch",
"publish:minor": "npm run build && node bin/index.js --publish=minor",
"publish:major": "npm run build && node bin/index.js --publish=major",
"start": "rollup --config --watch",
"static-publish": "npm run build && static-publish --directory=packages --account=nrk-core --latest --major",
"test:js": "npm run build && jest",
"test": "npm run lint && npm run test:js"
"test": "npm run build && jest && standard -v",
"test:watch": "jest --watch"
},
"devDependencies": {
"jest": "24.5.0",
Expand Down
17 changes: 10 additions & 7 deletions packages/core-input/core-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,26 @@ import { IS_IOS, addEvent, escapeHTML, dispatchEvent, requestAnimFrame, queryAll

const UUID = `data-${name}-${version}`.replace(/\W+/g, '-') // Strip invalid attribute characters
const KEYS = { ENTER: 13, ESC: 27, PAGEUP: 33, PAGEDOWN: 34, END: 35, HOME: 36, UP: 38, DOWN: 40 }
const ITEM = '[tabindex="-1"]'
const AJAX_DEBOUNCE = 500

export default function input (elements, content) {
const options = typeof content === 'object' ? content : { content }
const repaint = typeof options.content === 'string'
const limit = Math.max(options.limit, 0) || 0

return queryAll(elements).map((input) => {
const list = input.nextElementSibling
const ajax = typeof options.ajax === 'undefined' ? input.getAttribute(UUID) : options.ajax
const open = typeof options.open === 'undefined' ? input === document.activeElement : options.open

input.setAttribute(UUID, ajax || '')
input.setAttribute(`${UUID}-limit`, limit)
input.setAttribute(IS_IOS ? 'data-role' : 'role', 'combobox') // iOS does not inform user area is editable if combobox
input.setAttribute('aria-autocomplete', 'list')
input.setAttribute('autocomplete', 'off')

if (repaint) list.innerHTML = options.content
queryAll('a,button', list).forEach(setupItem)
queryAll('a,button', list).forEach((...args) => setupItem(limit, ...args))
setupExpand(input, open)

return input
Expand All @@ -43,7 +44,7 @@ function onClickOrFocus (event) {
queryAll(`[${UUID}]`).forEach((input) => {
const list = input.nextElementSibling
const open = input === event.target || list.contains(event.target)
const item = event.type === 'click' && open && queryAll(ITEM, list).filter((item) => item.contains(event.target))[0]
const item = event.type === 'click' && open && queryAll('[tabindex="-1"]', list).filter((item) => item.contains(event.target))[0]

if (item) onSelect(input, { relatedTarget: list, currentTarget: item, value: item.value || item.textContent.trim() })
else setupExpand(input, open)
Expand All @@ -64,7 +65,7 @@ addEvent(UUID, 'keydown', (event) => {

function onKey (input, event) {
const list = input.nextElementSibling
const focusable = [input].concat(queryAll(`${ITEM}:not([hidden])`, list))
const focusable = [input].concat(queryAll(`[tabindex="-1"]:not([hidden])`, list))
const isClosing = event.keyCode === KEYS.ESC && input.getAttribute('aria-expanded') === 'true'
const index = focusable.indexOf(document.activeElement)
let item = false
Expand Down Expand Up @@ -92,15 +93,16 @@ function onSelect (input, detail) {
}

function onFilter (input, detail) {
const limit = Number(input.getAttribute(`${UUID}-limit`)) || Infinity
if (dispatchEvent(input, 'input.filter', detail) && ajax(input) === false) {
queryAll(ITEM, input.nextElementSibling).reduce((acc, item) => {
queryAll('[tabindex="-1"]', input.nextElementSibling).reduce((acc, item, index) => {
const list = item.parentElement.nodeName === 'LI' && item.parentElement
const show = item.textContent.toLowerCase().indexOf(input.value.toLowerCase()) !== -1
const attr = show ? 'removeAttribute' : 'setAttribute'
if (list) list[attr]('hidden', '') // JAWS requires hiding of <li> too (if they exist)
item[attr]('hidden', '')
return show ? acc.concat(item) : acc
}, []).forEach(setupItem)
}, []).forEach((...args) => setupItem(limit, ...args))
}
}

Expand All @@ -111,10 +113,11 @@ function setupExpand (input, open = input.getAttribute('aria-expanded') === 'tru
})
}

function setupItem (item, index, items) {
function setupItem (limit, item, index, items) {
item.setAttribute('aria-label', `${item.textContent.trim()}, ${index + 1} av ${items.length}`)
item.setAttribute('tabindex', '-1')
item.setAttribute('type', 'button')
if (index >= limit) item.parentElement.setAttribute('hidden', '')
}

function ajax (input) {
Expand Down
3 changes: 2 additions & 1 deletion packages/core-input/core-input.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import coreInput from './core-input'
import { exclude } from '../utils'

export default class Input extends React.Component {
static get defaultProps () { return { open: null, ajax: null, onAjax: null, onAjaxBeforeSend: null, onFilter: null, onSelect: null } }
static get defaultProps () { return { open: null, limit: null, ajax: null, onAjax: null, onAjaxBeforeSend: null, onFilter: null, onSelect: null } }
constructor (props) {
super(props)
this.onFilter = (event) => this.props.onFilter && this.props.onFilter(event)
Expand Down Expand Up @@ -47,6 +47,7 @@ Input.propTypes = {
onSelect: PropTypes.func,
onAjax: PropTypes.func,
open: PropTypes.bool,
limit: PropTypes.number,
ajax: PropTypes.oneOfType([
PropTypes.string,
PropTypes.object
Expand Down
97 changes: 41 additions & 56 deletions packages/core-input/core-input.test.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,15 @@
const coreInput = require('./core-input.min')

function expectOpenedState (input, suggestions) {
expect(input.getAttribute('role')).toEqual('combobox')
expect(input.getAttribute('aria-autocomplete')).toEqual('list')
expect(input.getAttribute('autocomplete')).toEqual('off')
expect(input.getAttribute('aria-expanded')).toEqual('true')
expect(suggestions.hasAttribute('hidden')).toBeFalsy()
}

function expectClosedState (input, suggestions) {
expect(input.getAttribute('role')).toEqual('combobox')
expect(input.getAttribute('aria-autocomplete')).toEqual('list')
expect(input.getAttribute('autocomplete')).toEqual('off')
expect(input.getAttribute('aria-expanded')).toEqual('false')
expect(suggestions.hasAttribute('hidden')).toBeTruthy()
}

const standardHTML = `
<input type="text" class="my-input" placeholder="Type something...">
<ul hidden>
<li><button>Chrome</button></li>
<li><button>Firefox</button></li>
<li><button>Opera</button></li>
<li><button>Safari</button></li>
<li><button>Microsoft Edge</button></li>
</ul>
<button id="something-else" type="button"></button>
<input type="text" class="my-input" placeholder="Type something...">
<ul hidden>
<li><button>Chrome</button></li>
<li><button>Firefox</button></li>
<li><button>Opera</button></li>
<li><button>Safari</button></li>
<li><button>Microsoft Edge</button></li>
</ul>
<button id="something-else" type="button"></button>
`

describe('core-input', () => {
Expand All @@ -41,89 +25,90 @@ describe('core-input', () => {
expect(coreInput).toBeInstanceOf(Function)
})

it('should initialize input with props when core-input is called', () => {
it('should initialize input with props', () => {
document.body.innerHTML = standardHTML

const input = document.querySelector('.my-input')

coreInput(input)
expect(input.getAttribute('role')).toEqual('combobox')
expect(input.getAttribute('aria-autocomplete')).toEqual('list')
expect(input.getAttribute('autocomplete')).toEqual('off')
expect(input.getAttribute('aria-expanded')).toEqual('false')
})

it('should expand suggestions when input field is clicked', () => {
it('should expand suggestions when input is clicked', () => {
document.body.innerHTML = standardHTML

const input = document.querySelector('.my-input')
const suggestions = document.querySelector('.my-input + ul')

coreInput(input)

input.click()
expectOpenedState(input, suggestions)
expect(input.getAttribute('role')).toEqual('combobox')
expect(input.getAttribute('aria-autocomplete')).toEqual('list')
expect(input.getAttribute('autocomplete')).toEqual('off')
expect(input.getAttribute('aria-expanded')).toEqual('true')
expect(suggestions.hasAttribute('hidden')).toBeFalsy()
})

it('should set input value to that of clicked suggestion', () => {
it('should set input value to clicked suggestion', () => {
document.body.innerHTML = standardHTML

const input = document.querySelector('.my-input')
const suggestions = document.querySelector('.my-input + ul')
const firefoxBtn = suggestions.querySelector('li:nth-child(2) button')
const callback = jest.fn()

coreInput(input)

input.addEventListener('input.select', callback)
input.click()
firefoxBtn.click()

expect(callback).toHaveBeenCalled()
expect(input.value).toEqual('Firefox')
expectClosedState(input, suggestions)
expect(input.getAttribute('role')).toEqual('combobox')
expect(input.getAttribute('aria-autocomplete')).toEqual('list')
expect(input.getAttribute('autocomplete')).toEqual('off')
expect(input.getAttribute('aria-expanded')).toEqual('false')
expect(suggestions.hasAttribute('hidden')).toBeTruthy()
})

it('should close suggestions if focus is placed outside on elements outside list/input', () => {
it('should close suggestions on focusing outside', () => {
document.body.innerHTML = standardHTML

const input = document.querySelector('.my-input')
const suggestions = document.querySelector('.my-input + ul')
const someOtherBtn = document.querySelector('#something-else')

coreInput(input)

input.click()
someOtherBtn.click()

expectClosedState(input, suggestions)
expect(input.getAttribute('role')).toEqual('combobox')
expect(input.getAttribute('aria-autocomplete')).toEqual('list')
expect(input.getAttribute('autocomplete')).toEqual('off')
expect(input.getAttribute('aria-expanded')).toEqual('false')
expect(suggestions.hasAttribute('hidden')).toBeTruthy()
})

it('should filter suggestion list according to value in input', () => {
it('should filter suggestion from input value', () => {
document.body.innerHTML = standardHTML

const input = document.querySelector('.my-input')
const event = new window.CustomEvent('input', { bubbles: true })

coreInput(input)

input.value = 'Chrome'
input.dispatchEvent(event)

expect(document.querySelectorAll('button[hidden]').length).toEqual(4)
})

it('should set type="button" on all buttons in list', () => {
document.body.innerHTML = standardHTML

coreInput(document.querySelector('.my-input'))
document.querySelectorAll('ul button').forEach((button) => {
expect(button.type).toEqual('button')
})
})
})

module.exports = {
expectOpenedState,
expectClosedState
}
it('should limit length of suggestions from option', () => {
document.body.innerHTML = standardHTML
const input = document.querySelector('.my-input')
const suggestions = document.querySelector('.my-input + ul')
coreInput(input, { limit: 2 })
expect(suggestions.children[0].hasAttribute('hidden')).toBe(false)
expect(suggestions.children[1].hasAttribute('hidden')).toBe(false)
expect(suggestions.children[2].hasAttribute('hidden')).toBe(true)
expect(suggestions.children[3].hasAttribute('hidden')).toBe(true)
expect(suggestions.children[4].hasAttribute('hidden')).toBe(true)
})
})
19 changes: 15 additions & 4 deletions packages/core-input/core-input.test.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
const React = require('react')
const ReactDOM = require('react-dom')
const CoreInput = require('./jsx')
const { expectOpenedState, expectClosedState } = require('./core-input.test.js')

const mount = (props = {}, keepInstance) => {
if (!keepInstance) {
Expand Down Expand Up @@ -53,7 +52,11 @@ describe('core-input/jsx', () => {
const suggestions = document.querySelector('.my-input + ul')

input.click()
expectOpenedState(input, suggestions)
expect(input.getAttribute('role')).toEqual('combobox')
expect(input.getAttribute('aria-autocomplete')).toEqual('list')
expect(input.getAttribute('autocomplete')).toEqual('off')
expect(input.getAttribute('aria-expanded')).toEqual('true')
expect(suggestions.hasAttribute('hidden')).toBeFalsy()
})

it('should set input value to that of clicked suggestion', () => {
Expand All @@ -69,7 +72,11 @@ describe('core-input/jsx', () => {

expect(callback).toHaveBeenCalled()
expect(input.value).toEqual('Firefox')
expectClosedState(input, suggestions)
expect(input.getAttribute('role')).toEqual('combobox')
expect(input.getAttribute('aria-autocomplete')).toEqual('list')
expect(input.getAttribute('autocomplete')).toEqual('off')
expect(input.getAttribute('aria-expanded')).toEqual('false')
expect(suggestions.hasAttribute('hidden')).toBeTruthy()
})

it('should close suggestions if focus is placed outside on elements outside list/input', () => {
Expand All @@ -81,7 +88,11 @@ describe('core-input/jsx', () => {
input.click()
document.body.click()

expectClosedState(input, suggestions)
expect(input.getAttribute('role')).toEqual('combobox')
expect(input.getAttribute('aria-autocomplete')).toEqual('list')
expect(input.getAttribute('autocomplete')).toEqual('off')
expect(input.getAttribute('aria-expanded')).toEqual('false')
expect(suggestions.hasAttribute('hidden')).toBeTruthy()
})

it('should filter suggestion list according to value in input', () => {
Expand Down
31 changes: 22 additions & 9 deletions packages/core-input/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ demo-->

## Usage

Typing toggles the [hidden attribute](https://developer.mozilla.org/en/docs/Web/HTML/Global_attributes/hidden) on items of type `<button>` and `<a>`, based on matching [textContent](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent). Focusing the input unhides the following element. The default filtering behavior can easily be altered through the The default filtering behavior can easily be altered through the `'input.select'`, `'input.filter'`, `'input.ajax'` and `'input.ajax.beforeSend'` [events](#events).
Typing toggles the [hidden attribute](https://developer.mozilla.org/en/docs/Web/HTML/Global_attributes/hidden) on items of type `<button>` and `<a>`, based on matching [textContent](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent). Focusing the input unhides the following element. The default filtering behavior can easily be altered through the The default filtering behavior can easily be altered through the `'input.select'`, `'input.filter'`, `'input.ajax'` and `'input.ajax.beforeSend'` [events](#events).

Results will be rendered in the element directly after the `<input>`.
Always use `coreInput.escapeHTML(String)` to safely render data from API or user.
Expand All @@ -77,15 +77,22 @@ Always use `coreInput.escapeHTML(String)` to safely render data from API or user
```js
import coreInput from '@nrk/core-input'

coreInput(String|Element|Elements, { // Accepts a selector string, NodeList, Element or array of Elements
open: Boolean, // Optional. Defaults to value of aria-expanded. Use to force open state
content: String, // Optional. Set String of HTML content. HTML is used for full flexibility on markup
ajax: String // Optional. Fetch external data, example: "https://search.com?q={{value}}"
coreInput( // Initializes input element
String|Element|Elements, // Accepts a selector string, NodeList, Element or array of Elements
String|Object { // Optional. String sets content HTML, object sets options
open: Boolean, // Use to force open state. Defaults to value of aria-expanded.
content: String, // Sets content HTML. HTML is used for full flexibility on markup
limit: Number, // Sets the maximum number of visible items in list. Doesn't affect actual number of items
ajax: String // Fetches external data. See event 'input.ajax'. Example: 'https://search.com?q={{value}}'
}
})

// Passing a string as second argument sets the 'content' option
coreInput('.my-input', '<li><a href="?q=' + coreInput.escapeHTML(input.value) + '">More results</a></li>') // escape html
coreInput('.my-input', '<li><button>' + coreInput.highlight(item.text, input.value) + '</button></li>') // highlight match
// Example initialize and limit items to 5
coreInput('.my-input', { limit: 5 })
// Example setting HTML content and escaping items
coreInput('.my-input', '<li><a href="?q=' + coreInput.escapeHTML(input.value) + '">More results</a></li>')
// Example setting HTML content and highlighting matched items
coreInput('.my-input', '<li><button>' + coreInput.highlight(item.text, input.value) + '</button></li>')
```

### React / Preact
Expand All @@ -97,7 +104,13 @@ import CoreInput from '@nrk/core-input/jsx'
// Props like className, style, etc. will be applied as actual attributes
// <CoreInput> will handle state itself unless you call event.preventDefault() in onFilter, onSelect or onAjax

<CoreInput open={false} onFilter={(event) => {}} onSelect={(event) => {}} onAjax={(event) => {}} onAjaxBeforeSend={(event) => {}} ajax="https://search.com?q={{value}}">
<CoreInput open={Boolean} // Use to force open state. Defaults to value of aria-expanded.
limit={Number} // Limit the maximum number of results in list.
ajax={String|Object} // Fetches external data. See event 'input.ajax'. Example: 'https://search.com?q={{value}}'
onFilter={Function} // See 'input.filter' event
onSelect={Function} // See 'input.select' event
onAjax={Function} // See 'input.ajax' event
onAjaxBeforeSend={Function}> // See 'input.ajax.beforeSend' event
<input type="text" /> // First element must result in a input-tag. Accepts both elements and components
<ul> // Next element will be used for items. Accepts both elements and components
<li><button>Item 1</button></li> // Interactive items must be <button> or <a>
Expand Down

0 comments on commit c07ad92

Please sign in to comment.