From c07ad92936905de9378af23c45bc1c3092b4b0c2 Mon Sep 17 00:00:00 2001 From: Herman Torjussen Date: Fri, 29 Mar 2019 12:51:54 +0100 Subject: [PATCH] core-input: add limit option (#213) --- package.json | 5 +- packages/core-input/core-input.js | 17 +++-- packages/core-input/core-input.jsx | 3 +- packages/core-input/core-input.test.js | 97 +++++++++++-------------- packages/core-input/core-input.test.jsx | 19 ++++- packages/core-input/readme.md | 31 +++++--- 6 files changed, 92 insertions(+), 80 deletions(-) diff --git a/package.json b/package.json index 965b56c2..6073659b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/core-input/core-input.js b/packages/core-input/core-input.js index 6950967a..8438f169 100644 --- a/packages/core-input/core-input.js +++ b/packages/core-input/core-input.js @@ -3,12 +3,12 @@ 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 @@ -16,12 +16,13 @@ export default function input (elements, content) { 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 @@ -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) @@ -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 @@ -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
  • too (if they exist) item[attr]('hidden', '') return show ? acc.concat(item) : acc - }, []).forEach(setupItem) + }, []).forEach((...args) => setupItem(limit, ...args)) } } @@ -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) { diff --git a/packages/core-input/core-input.jsx b/packages/core-input/core-input.jsx index 17e073f3..a0e415da 100644 --- a/packages/core-input/core-input.jsx +++ b/packages/core-input/core-input.jsx @@ -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) @@ -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 diff --git a/packages/core-input/core-input.test.js b/packages/core-input/core-input.test.js index f309efb7..192ecb2c 100644 --- a/packages/core-input/core-input.test.js +++ b/packages/core-input/core-input.test.js @@ -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 = ` - - - + + + ` describe('core-input', () => { @@ -41,11 +25,9 @@ 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') @@ -53,77 +35,80 @@ describe('core-input', () => { 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) + }) +}) diff --git a/packages/core-input/core-input.test.jsx b/packages/core-input/core-input.test.jsx index 27c9331e..e6fa9ad2 100644 --- a/packages/core-input/core-input.test.jsx +++ b/packages/core-input/core-input.test.jsx @@ -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) { @@ -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', () => { @@ -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', () => { @@ -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', () => { diff --git a/packages/core-input/readme.md b/packages/core-input/readme.md index 67e7081f..27ce348d 100644 --- a/packages/core-input/readme.md +++ b/packages/core-input/readme.md @@ -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 `
  • ') // highlight match +// Example initialize and limit items to 5 +coreInput('.my-input', { limit: 5 }) +// Example setting HTML content and escaping items +coreInput('.my-input', '
  • More results
  • ') +// Example setting HTML content and highlighting matched items +coreInput('.my-input', '
  • ') ``` ### React / Preact @@ -97,7 +104,13 @@ import CoreInput from '@nrk/core-input/jsx' // Props like className, style, etc. will be applied as actual attributes // will handle state itself unless you call event.preventDefault() in onFilter, onSelect or onAjax - {}} onSelect={(event) => {}} onAjax={(event) => {}} onAjaxBeforeSend={(event) => {}} ajax="https://search.com?q={{value}}"> + // See 'input.ajax.beforeSend' event // First element must result in a input-tag. Accepts both elements and components
      // Next element will be used for items. Accepts both elements and components
    • // Interactive items must be