Skip to content

Commit

Permalink
fix API filter method + adjust screen reader announcements to be limi…
Browse files Browse the repository at this point in the history
…ted by max results + update version number to 1.0.1
  • Loading branch information
mynamesleon committed Jan 22, 2020
1 parent e346904 commit 1b2d75d
Show file tree
Hide file tree
Showing 8 changed files with 111 additions and 164 deletions.
42 changes: 18 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,45 +38,39 @@ new AriaAutocomplete(document.getElementById('some-element'), {
});
```

### Plain JavaScript module
At its core, the autocomplete requires only an element and a `source`. When the element is an input, its value will be set using the user's selection(s). If a `source` option isn't provided (is falsy, or an empty Array), and the element is either a `<select>`, or has child checkboxes, those will be used to build up the `source`.

You can copy the [dist/aria-autocomplete.min.js](/mynamesleon/aria-autocomplete/blob/master/dist/aria-autocomplete.min.js) file into your project and import it into the browser:
```javascript
new AriaAutocomplete(document.getElementById('some-input'), {
source: ['Afghanistan', 'Albania', 'Algeria', ...more]
});

```html
<script type="text/javascript" src="js/aria-autocomplete.min.js"></script>
const select = document.getElementById('some-select');
new AriaAutocomplete(select);

const div = document.getElementById('some-div-with-child-checkboxes');
new AriaAutocomplete(div);
```

### Styling Aria Autocomplete
### Plain JavaScript module

I would encourage you to style it yourself to match your own site or application's design. An example stylesheet is included in the repository however at [dist/aria-autocomplete.min.css](/mynamesleon/aria-autocomplete/blob/master/dist/aria-autocomplete.css) which you can copy into your project and import into the browser:
You can grab the minified JS from the `dist` directory, or straight from unpkg:

```html
<link rel="stylesheet" src="css/aria-autocomplete.css" />
<script src="https://unpkg.com/aria-autocomplete" type="text/javascript"></script>
```

### Styling Aria Autocomplete

**I would encourage you to style it yourself** to match your own site or application's design. An example stylesheet is included in the `dist` directory however which you can copy into your project and import into the browser.

## Performance

I wrote this from the ground up largely because I needed an autocomplete with better performance than others I'd tried. I've optimised the JavaScript where I can, but in some browsers the list _rendering_ will still be a hit to performance. In my testing, modern browsers can render huge lists (1000+ items) just fine (on my laptop, averaging 40ms in Chrome, and under 20ms in Firefox).

As we all know however, Internet Explorer _sucks_. If you need to support Internet Explorer, I suggest using a sensible combination for the `delay`, `maxResults`, and possibly `minLength` options, to prevent the browser from freezing as your users type, and to reduce the rendering impact. Testing on my laptop, the list rendering in IE11 would take on average: 55ms for 250 items, 300ms for 650 items, and over 600ms for 1000 items.

## API Documentation

At its core, the autocomplete requires only an element, and a `source`. When the element is an input, its value will be set using the user's selection(s). If a `source` option isn't provided however (is falsy, or an empty Array), and the element is either a `<select>`, or has child checkboxes, those will be used to build up the `source`.

```javascript
new AriaAutocomplete(document.getElementById('some-input'), {
source: ['Afghanistan', 'Albania', 'Algeria', ...more]
});

const select = document.getElementById('some-select');
new AriaAutocomplete(select);

const div = document.getElementById('some-div-with-child-checkboxes');
new AriaAutocomplete(div);
```

### Options
## Options

The full list of options, and their defaults:

Expand Down
18 changes: 13 additions & 5 deletions dist/aria-autocomplete.min.js

Large diffs are not rendered by default.

20 changes: 15 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
{
"name": "aria-autocomplete",
"version": "1.0.0",
"version": "1.0.1",
"description": "Accessible, extensible, JavaScript autocomplete with multi-select",
"main": "dist/aria-autocomplete.min.js",
"style": "dist/aria-autocomplete.min.css",
"browser": "dist/aria-autocomplete.min.js",
"scripts": {
"build": "parcel build src/aria-autocomplete.js --out-file aria-autocomplete.min.js --no-source-maps",
"dev": "NODE_ENV=development parcel build src/aria-autocomplete.js --out-file aria-autocomplete.js --no-minify",
"css": "NODE_ENV=development parcel build src/aria-autocomplete.less --out-file aria-autocomplete.min.css --no-source-maps"
"css": "parcel build src/aria-autocomplete.less --out-file aria-autocomplete.min.css --no-source-maps"
},
"repository": {
"type": "git",
Expand Down Expand Up @@ -44,7 +44,9 @@
"parcel-bundler": "^1.12.4"
},
"dependencies": {
"element-closest": "^3.0.2",
"element-addclass": "^1.0.1",
"element-hasclass": "^1.0.0",
"element-removeclass": "^1.0.0",
"input-autowidth": "^1.0.2",
"is-printable-keycode": "^1.0.1"
}
Expand Down
106 changes: 28 additions & 78 deletions src/aria-autocomplete.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import isPrintableKey from 'is-printable-keycode';
import InputAutoWidth from 'input-autowidth';
import removeClass from 'element-removeclass';
import addClass from 'element-addclass';
import hasClass from 'element-hasclass';

import {
CLEANED_LABEL_PROP,
SELECTED_OPTION_PROP,
trimString,
hasClass,
addClass,
removeClass,
cleanString,
mergeObjects,
dispatchEvent,
Expand All @@ -16,18 +16,9 @@ import {
searchVarPropsFor,
removeDuplicatesAndLabel
} from './autocomplete-helpers';
import AutocompleteIds from './autocomplete-ids';
import DEFAULT_OPTIONS from './default-options';

/**
* @description polyfill element.closest for IE use
*/
import elementClosest from 'element-closest';
elementClosest(window);

/**
* @description incremental index used for element ID generation
*/
let appIndex = 0;
import './closest-polyfill';

/**
* @param {Element} element
Expand Down Expand Up @@ -490,9 +481,7 @@ class AriaAutocomplete {

// set original input value
if (this.elementIsInput) {
const valToSetString = valToSet.join(
this.options.multipleSeparator
);
const valToSetString = valToSet.join(this.options.multipleSeparator);
if (valToSetString !== this.element.value) {
this.element.value = valToSetString;
dispatchEvent(this.element, 'change');
Expand Down Expand Up @@ -538,8 +527,9 @@ class AriaAutocomplete {
this.setInputValue(this.multiple ? '' : option.label, true);

// reset selected array in single select mode
// use splice so that selected Array in API is also correctly updated
if (!alreadySelected && !this.multiple) {
this.selected = [];
this.selected.splice(0);
}

// (re)set values of any DOM elements based on selected array
Expand Down Expand Up @@ -598,26 +588,25 @@ class AriaAutocomplete {
const updated = this.removeSelectedFromResults(results);
// allow callback to alter the response before rendering
const callback = this.triggerOptionCallback('onResponse', updated);
this.filteredSource = callback
? processSourceArray(callback, mapping)
: updated;
this.filteredSource = callback ? processSourceArray(callback, mapping) : updated;

// build up the list html
const optionId = this.ids.OPTION;
const cssName = this.cssNameSpace;
const length = this.filteredSource.length;
const checkCallback = typeof this.options.onItemRender === 'function';
const maxResults = this.forceShowAll ? 9999 : this.options.maxResults;
for (let i = 0; i < length && i < maxResults; i += 1) {
const lengthToUse = maxResults < length ? maxResults : length;

for (let i = 0; i < lengthToUse; i += 1) {
const thisSource = this.filteredSource[i];
const callbackResponse =
checkCallback &&
this.triggerOptionCallback('onItemRender', [thisSource]);
checkCallback && this.triggerOptionCallback('onItemRender', [thisSource]);
const itemContent = callbackResponse || thisSource.label;
toShow.push(
`<li tabindex="-1" aria-selected="false" role="option" class="${cssName}__option" ` +
`id="${optionId}--${i}" aria-posinset="${i + 1}" ` +
`aria-setsize="${length}">${itemContent}</li>`
`aria-setsize="${lengthToUse}">${itemContent}</li>`
);
}

Expand All @@ -636,17 +625,15 @@ class AriaAutocomplete {
if (!toShow.length && typeof noText === 'string' && noText.length) {
announce = noText;
let optionClass = `${cssName}__option`;
toShow.push(
`<li class="${optionClass} ${optionClass}--no-results">${noText}</li>`
);
toShow.push(`<li class="${optionClass} ${optionClass}--no-results">${noText}</li>`);
}

// remove loading class(es) and reset variables
this.cancelFilterPrep();

// announce to screen reader
if (!announce) {
announce = this.triggerOptionCallback('srResultsText', [length]);
announce = this.triggerOptionCallback('srResultsText', [lengthToUse]);
}
this.announce(announce);

Expand Down Expand Up @@ -689,10 +676,11 @@ class AriaAutocomplete {
const xhr = new XMLHttpRequest();
const encode = encodeURIComponent;
const isShowAll = this.forceShowAll;
const unlimited = isShowAll || isFirstCall;
const context = isFirstCall ? null : this.api;
const baseAmount = this.multiple ? this.selected.length : 0;
const ampersandOrQuestionMark = /\?/.test(this.source) ? '&' : '?';
const unlimited =
isShowAll || isFirstCall || this.options.maxResults === DEFAULT_OPTIONS.maxResults;
let url =
this.source +
ampersandOrQuestionMark +
Expand Down Expand Up @@ -1005,10 +993,7 @@ class AriaAutocomplete {
// if closed, and text is long enough, run search
if (!this.menuOpen) {
this.forceShowAll = this.options.minLength < 1;
if (
this.forceShowAll ||
this.input.value.length >= this.options.minLength
) {
if (this.forceShowAll || this.input.value.length >= this.options.minLength) {
this.filterPrep(event);
}
}
Expand Down Expand Up @@ -1218,9 +1203,7 @@ class AriaAutocomplete {
this.multiple = true; // force multiple in this case
// reset source and use checkboxes
this.source = [];
const elements = this.element.querySelectorAll(
'input[type="checkbox"]'
);
const elements = this.element.querySelectorAll('input[type="checkbox"]');
for (let i = 0, l = elements.length; i < l; i += 1) {
const checkbox = elements[i];
// must have a value other than empty string
Expand Down Expand Up @@ -1330,9 +1313,7 @@ class AriaAutocomplete {
prepListSourceFunction() {
if (this.elementIsInput && this.element.value) {
this.source.call(undefined, this.element.value, response => {
this.prepSelectedFromArray(
processSourceArray(response, this.options.sourceMapping)
);
this.prepSelectedFromArray(processSourceArray(response, this.options.sourceMapping));
this.setInputStartingStates(false);
});
}
Expand Down Expand Up @@ -1376,9 +1357,7 @@ class AriaAutocomplete {
if (setAriaAttrs) {
// update corresponding label to now focus on the new input
if (this.ids.ELEMENT) {
const label = document.querySelector(
'[for="' + this.ids.ELEMENT + '"]'
);
const label = document.querySelector('[for="' + this.ids.ELEMENT + '"]');
if (label) {
label.ariaAutocompleteOriginalFor = this.ids.ELEMENT;
label.setAttribute('for', this.ids.INPUT);
Expand Down Expand Up @@ -1424,9 +1403,7 @@ class AriaAutocomplete {
const o = this.options;
const cssName = this.cssNameSpace;
const wrapperClass = o.wrapperClassName ? ` ${o.wrapperClassName}` : '';
const newHtml = [
`<div id="${this.ids.WRAPPER}" class="${cssName}__wrapper${wrapperClass}">`
];
const newHtml = [`<div id="${this.ids.WRAPPER}" class="${cssName}__wrapper${wrapperClass}">`];

// add input
const name = o.name ? ` ${o.name}` : ``;
Expand Down Expand Up @@ -1478,25 +1455,13 @@ class AriaAutocomplete {
this.api = {
open: () => this.show.call(this),
close: () => this.hide.call(this),
filter: val => this.filter.call(val)
filter: val => this.filter.call(this, val)
};

const a = [
'options',
'destroy',
'enable',
'disable',
'input',
'wrapper',
'list',
'selected'
];
const a = ['options', 'destroy', 'enable', 'disable', 'input', 'wrapper', 'list', 'selected'];

for (let i = 0, l = a.length; i < l; i += 1) {
this.api[a[i]] =
typeof this[a[i]] === 'function'
? () => this[a[i]].call(this)
: this[a[i]];
this.api[a[i]] = typeof this[a[i]] === 'function' ? () => this[a[i]].call(this) : this[a[i]];
}

// store api on original element
Expand Down Expand Up @@ -1540,22 +1505,9 @@ class AriaAutocomplete {
* @param {Object=} options
*/
init(element, options) {
// ids used for DOM queries and accessibility attributes e.g. aria-controls
appIndex += 1;
this.ids = {};
this.ids.ELEMENT = element.id;
this.ids.PREFIX = `${element.id || ''}aria-autocomplete-${appIndex}`;
this.ids.LIST = `${this.ids.PREFIX}-list`;
this.ids.INPUT = `${this.ids.PREFIX}-input`;
this.ids.BUTTON = `${this.ids.PREFIX}-button`;
this.ids.OPTION = `${this.ids.PREFIX}-option`;
this.ids.WRAPPER = `${this.ids.PREFIX}-wrapper`;
this.ids.OPTION_SELECTED = `${this.ids.OPTION}-selected`;
this.ids.SR_ASSISTANCE = `${this.ids.PREFIX}-sr-assistance`;
this.ids.SR_ANNOUNCEMENTS = `${this.ids.PREFIX}-sr-announcements`;

this.selected = [];
this.element = element;
this.ids = new AutocompleteIds(element.id);
this.elementIsInput = element.nodeName === 'INPUT';
this.elementIsSelect = element.nodeName === 'SELECT';
this.options = mergeObjects(DEFAULT_OPTIONS, options);
Expand All @@ -1575,9 +1527,7 @@ class AriaAutocomplete {
this.input = document.getElementById(this.ids.INPUT);
this.wrapper = document.getElementById(this.ids.WRAPPER);
this.showAll = document.getElementById(this.ids.BUTTON);
this.srAnnouncements = document.getElementById(
this.ids.SR_ANNOUNCEMENTS
);
this.srAnnouncements = document.getElementById(this.ids.SR_ANNOUNCEMENTS);

// set internal source array, from static elements if necessary
this.prepListSource();
Expand Down
Loading

0 comments on commit 1b2d75d

Please sign in to comment.