Merge remote-tracking branch 'origin/master' into fix-573

This commit is contained in:
Kazuki Nishikawa 2019-11-15 11:39:09 +09:00
commit 503dc644a7
65 changed files with 3223 additions and 1438 deletions

64
.gitattributes vendored Normal file
View file

@ -0,0 +1,64 @@
## GITATTRIBUTES FOR WEB PROJECTS
#
# These settings are for any web project.
#
# Details per file setting:
# text These files should be normalized (i.e. convert CRLF to LF).
# binary These files are binary and should be left untouched.
#
# Note that binary is a macro for -text -diff.
######################################################################
# Auto detect
## Handle line endings automatically for files detected as
## text and leave all files detected as binary untouched.
## This will handle all files NOT defined below.
* text eol=lf
# Source code
*.css text eol=lf
*.html text diff=html eol=lf
*.js text eol=lf
*.json text eol=lf
*.scss text diff=css eol=lf
*.ts text eol=lf
# Documentation
*.md text eol=lf
*.txt text eol=lf
AUTHORS text eol=lf
CHANGELOG text eol=lf
CHANGES text eol=lf
CONTRIBUTING text eol=lf
COPYING text eol=lf
copyright text eol=lf
*COPYRIGHT* text eol=lf
INSTALL text eol=lf
license text eol=lf
LICENSE text eol=lf
NEWS text eol=lf
readme text eol=lf
*README* text eol=lf
TODO text eol=lf
# Linters
.eslintrc text eol=lf
.stylelintrc text eol=lf
# Configs
.babelrc text eol=lf
.browserslistrc text eol=lf
.editorconfig text eol=lf
.env text eol=lf
.gitattributes text eol=lf
.gitconfig text eol=lf
package-lock.json text -diff eol=lf
*.npmignore text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
browserslist text eol=lf
# Graphics
# SVG treated as an asset (binary) by default.
*.svg text eol=lf
*.png binary

View file

@ -4,12 +4,6 @@
<!--- Why is this change required? What problem does it solve? --> <!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. --> <!--- If it fixes an open issue, please link to the issue here. -->
## How Has This Been Tested?
<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, tests ran to see how -->
<!--- your change affects other areas of the code, etc. -->
## Screenshots (if appropriate) ## Screenshots (if appropriate)
## Types of changes ## Types of changes
@ -17,6 +11,7 @@
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> <!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] Chore (tooling change or documentation change) - [ ] Chore (tooling change or documentation change)
- [ ] Refactor (non-breaking change which maintains existing functionality)
- [ ] Bug fix (non-breaking change which fixes an issue) - [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality) - [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

88
.github/actions-scripts/puppeteer.js vendored Normal file
View file

@ -0,0 +1,88 @@
const { readFileSync, writeFileSync, mkdirSync } = require('fs');
const path = require('path');
const { once } = require('events');
const puppeteer = require('puppeteer');
const pixelmatch = require('pixelmatch');
const { PNG } = require('pngjs');
const server = require('../../server');
async function test() {
const browser = await puppeteer.launch();
const page = await browser.newPage();
let error;
let pixelDifference;
if (!server.listening) await once(server, 'listening');
try {
page.on('console', msg => {
if (msg.type() === 'error') throw new Error(msg.text());
});
page.on('pageerror', err => {
throw err;
});
await page.goto(`http://127.0.0.1:${server.address().port}`, {
waitUntil: 'networkidle2',
});
await page.setViewport({ width: 640, height: 1000 });
await page.click('label[for="choices-single-custom-templates"]');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
const snapshotName = `puppeteer-${process.platform}.png`;
const artifactsPath = 'screenshot';
mkdirSync(artifactsPath, { recursive: true });
const imageBuffer = await page.screenshot({
path: path.join(artifactsPath, snapshotName),
fullPage: true,
});
// compare with snapshot
const screenshot = PNG.sync.read(imageBuffer);
const snapshot = PNG.sync.read(
readFileSync(path.resolve(__dirname, `./__snapshots__/${snapshotName}`)),
);
const { width, height } = screenshot;
const diff = new PNG({ width, height });
pixelDifference = pixelmatch(
screenshot.data,
snapshot.data,
diff.data,
width,
height,
{
threshold: 0.6,
},
);
writeFileSync(path.join(artifactsPath, 'diff.png'), PNG.sync.write(diff));
} catch (err) {
console.error(err);
error = err;
} finally {
await Promise.all([
browser.close(),
new Promise(resolve => server.close(resolve)),
]);
}
if (pixelDifference > 200) {
console.error(
`Snapshot is different from screenshot by ${pixelDifference} pixels`,
);
process.exit(1);
}
if (error) process.exit(1);
}
process.on('unhandledRejection', err => {
console.error(err);
process.exit(1);
});
process.once('uncaughtException', err => {
console.error(err);
process.exit(1);
});
setImmediate(test);

155
.github/actions-scripts/selenium.js vendored Normal file
View file

@ -0,0 +1,155 @@
const path = require('path');
const { readFileSync, writeFileSync, mkdirSync } = require('fs');
const { once } = require('events');
const pixelmatch = require('pixelmatch');
const { PNG } = require('pngjs');
const {
Builder,
By,
Key,
until,
Capabilities,
logging,
} = require('selenium-webdriver');
const server = require('../../server');
async function test() {
let pixelDifference;
let error;
let capabilities;
switch (process.env.BROWSER) {
case 'ie':
capabilities = Capabilities.ie();
capabilities.set('ignoreProtectedModeSettings', true);
capabilities.set('ignoreZoomSetting', true);
capabilities.set('ie.options', {
enableFullPageScreenshot: true,
ensureCleanSession: true,
});
break;
case 'edge':
capabilities = Capabilities.edge();
break;
case 'safari':
capabilities = Capabilities.safari();
capabilities.set('safari.options', { technologyPreview: false });
break;
case 'firefox': {
capabilities = Capabilities.firefox().setLoggingPrefs({ browser: 'ALL' });
break;
}
case 'chrome': {
capabilities = Capabilities.chrome().setLoggingPrefs({ browser: 'ALL' });
capabilities.set('chromeOptions', {
args: ['--headless', '--no-sandbox', '--disable-gpu'],
});
break;
}
}
let driver = await new Builder().withCapabilities(capabilities).build();
if (!server.listening) await once(server, 'listening');
try {
await driver.get(`http://127.0.0.1:${server.address().port}`);
// wait for last choice to init
await driver.wait(
until.elementLocated(By.css('#reset-multiple ~ .choices__list')),
10000,
'waiting for all Choices instances to init',
);
// Resize window
await driver
.manage()
.window()
.maximize();
await driver
.manage()
.window()
// magic numbers here to make sure all demo page are fit inside
.setRect({ x: 0, y: 0, width: 630, height: 4000 });
// and click on press space on it, so it should open choices
await driver
.findElement(By.css('#reset-multiple ~ .choices__list button'))
.sendKeys(Key.SPACE);
await driver.sleep(1000);
// take screenshot
const image = await driver.takeScreenshot();
const imageBuffer = Buffer.from(image, 'base64');
const snapshotName = `${process.env.BROWSER}-${process.platform}.png`;
const artifactsPath = 'screenshot';
mkdirSync(artifactsPath, { recursive: true });
writeFileSync(path.join(artifactsPath, snapshotName), imageBuffer);
// compare with snapshot
const screenshot = PNG.sync.read(imageBuffer);
const snapshot = PNG.sync.read(
readFileSync(path.resolve(__dirname, `./__snapshots__/${snapshotName}`)),
);
const { width, height } = screenshot;
const diff = new PNG({ width, height });
pixelDifference = pixelmatch(
screenshot.data,
snapshot.data,
diff.data,
width,
height,
{
threshold: 1,
},
);
writeFileSync(path.join(artifactsPath, 'diff.png'), PNG.sync.write(diff));
// getting console logs
// ensure no errors in console (only supported in Chrome currently)
if (process.env.BROWSER === 'chrome') {
const entries = await driver
.manage()
.logs()
.get(logging.Type.BROWSER);
if (
Array.isArray(entries) &&
entries.some(entry => entry.level.name_ === 'SEVERE')
)
throw new Error(JSON.stringify(entries));
}
} catch (err) {
console.error(err);
error = err;
} finally {
await Promise.all([
driver.quit(),
new Promise(resolve => server.close(resolve)),
]);
}
if (pixelDifference > 200) {
console.error(
`Snapshot is different from screenshot by ${pixelDifference} pixels`,
);
process.exit(1);
}
if (error) process.exit(1);
}
process.on('unhandledRejection', err => {
console.error(err);
process.exit(1);
});
process.once('uncaughtException', err => {
console.error(err);
process.exit(1);
});
setImmediate(test);

View file

@ -1,6 +1,9 @@
name-template: 'Draft (next release)' name-template: 'Draft (next release)'
tag-template: 'v$NEXT_PATCH_VERSION' tag-template: 'v$NEXT_PATCH_VERSION'
sort-direction: descending sort-direction: descending
exclude-labels:
- 'skip-changelog'
- 'release'
categories: categories:
- title: '🚨 Breaking changes' - title: '🚨 Breaking changes'
labels: labels:

133
.github/workflows/browsers.yml vendored Normal file
View file

@ -0,0 +1,133 @@
name: Browsers
on:
pull_request:
paths:
- 'src/**'
- 'package-lock.json'
- '.browserslistrc'
- '.babelrc'
- 'webpack.config.*'
- 'public/index.html'
- '.github/actions-scripts/__snapshots__/**'
- '.github/workflows/browsers.yml'
jobs:
selenium:
strategy:
fail-fast: false
matrix:
os: [windows-latest, macOS-latest]
browser: [ie, firefox, safari]
exclude:
# On Windows, run tests with only IE and Edge
- os: windows-latest
browser: safari
# On macOS, run tests with only on safari
- os: macOS-latest
browser: ie
- os: macOS-latest
browser: chrome
# Safari workaround is not working in Catalina
- browser: safari
runs-on: ${{ matrix.os }}
steps:
- name: Dump GitHub context
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- uses: actions/checkout@v1
with:
fetch-depth: 1
- uses: actions/setup-node@v1
with:
node-version: '12.x'
- name: Cache node modules
uses: actions/cache@v1
with:
path: ~/.npm
key: ${{ runner.OS }}-build-${{ matrix.browser }}
restore-keys: |
${{ runner.OS }}-build-${{ env.cache-name }}-
${{ runner.OS }}-build-
${{ runner.OS }}-
- run: |
npm ci
npm run build
env:
CYPRESS_INSTALL_BINARY: 0
HUSKY_SKIP_INSTALL: true
# install drivers
- name: Enable Safari Driver
run: |
# Workaround for `sudo safardriver --enable` not working:
# https://github.com/web-platform-tests/wpt/issues/19845
# https://github.com/web-platform-tests/wpt/blob/master/tools/ci/azure/install_safari.yml
mkdir -p ~/Library/WebDriver/
curl https://raw.githubusercontent.com/web-platform-tests/wpt/master/tools/ci/azure/com.apple.Safari.plist -o ~/Library/WebDriver/com.apple.Safari.plist
defaults write com.apple.Safari WebKitJavaScriptCanOpenWindowsAutomatically 1
# sudo safaridriver --enable
if: matrix.browser == 'safari'
- run: |
brew cask install firefox
brew install geckodriver
if: matrix.browser == 'firefox' && matrix.os == 'macOS-latest'
- run: echo "::add-path::$env:GeckoWebDriver"
if: matrix.browser == 'firefox' && matrix.os == 'windows-latest'
- run: echo "::add-path::$env:IEWebDriver"
if: matrix.browser == 'ie' && matrix.os == 'windows-latest'
- run: echo "::add-path::$env:ChromeWebDriver"
if: matrix.browser == 'chrome' && matrix.os == 'windows-latest'
- run: npm i --no-optional --no-audit selenium-webdriver pixelmatch pngjs
- run: node .github/actions-scripts/selenium.js
env:
BROWSER: ${{ matrix.browser }}
PORT: 0
NODE_ENV: production # prevent watching
- uses: actions/upload-artifact@master
if: failure()
with:
name: screenshot-${{ matrix.browser }}-${{ matrix.os }}
path: screenshot
puppeteer:
runs-on: macos-latest
steps:
- uses: actions/checkout@v1
with:
fetch-depth: 1
- name: Cache node modules
uses: actions/cache@v1
with:
path: ~/.npm
key: ${{ runner.OS }}-build-puppeteer
restore-keys: |
${{ runner.OS }}-build-puppeteer
- run: |
npm ci
npm run build
env:
CYPRESS_INSTALL_BINARY: 0
HUSKY_SKIP_INSTALL: true
- run: npm i --no-optional --no-audit puppeteer pixelmatch pngjs
- run: node .github/actions-scripts/puppeteer.js
env:
PORT: 0
NODE_ENV: production # prevent watching
- uses: actions/upload-artifact@master
if: failure()
with:
name: screenshot-puppeteer-darwin
path: screenshot

View file

@ -15,8 +15,8 @@ jobs:
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 10 node-version: 10
# run all tests - name: Build and run all tests
- run: | run: |
npm ci npm ci
npm run build npm run build
npx bundlesize npx bundlesize
@ -32,6 +32,15 @@ jobs:
BUNDLESIZE_GITHUB_TOKEN: ${{secrets.BUNDLESIZE_GITHUB_TOKEN}} BUNDLESIZE_GITHUB_TOKEN: ${{secrets.BUNDLESIZE_GITHUB_TOKEN}}
FORCE_COLOR: 2 FORCE_COLOR: 2
HUSKY_SKIP_INSTALL: true HUSKY_SKIP_INSTALL: true
- name: Commit built files
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git commit -m "Update build files 🏗" -a || echo "No changes to commit" && exit 0
- name: Push changes
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
run: bash <(curl -s https://codecov.io/bash) run: bash <(curl -s https://codecov.io/bash)

View file

@ -14,7 +14,6 @@ jobs:
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 10 node-version: 10
# run all tests
- run: | - run: |
npm ci npm ci
npm run build npm run build
@ -45,7 +44,6 @@ jobs:
CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}} CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}}
publish-npm: publish-npm:
needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
@ -64,7 +62,6 @@ jobs:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
deploy-gh-pages: deploy-gh-pages:
needs: publish-npm
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1

View file

@ -10,11 +10,15 @@ on:
- 'webpack.config.*' - 'webpack.config.*'
- 'public/test/**' - 'public/test/**'
- 'cypress/**' - 'cypress/**'
- '.github/workflows/cypress.yml' - '.github/workflows/e2e-tests.yml'
jobs: jobs:
test-e2e: test-e2e:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
CI: true
TERM: xterm-256color
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
with: with:
@ -22,18 +26,42 @@ jobs:
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 10 node-version: 12.x
- name: Cache node modules
uses: actions/cache@v1
with:
path: ~/.npm
key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.OS }}-build-${{ env.cache-name }}-
${{ runner.OS }}-build-
${{ runner.OS }}-
- name: Get Cypress info
id: cypress-info
run: |
echo ::set-output name=version::$(jq -r .devDependencies.cypress ./package.json)
echo ::set-output name=cache::$(npx cypress cache path)
env:
CYPRESS_INSTALL_BINARY: 0
- name: Cache Cypress cache
uses: actions/cache@v1
with:
path: ${{ steps.cypress-info.outputs.cache }}
key: ${{ runner.OS }}-cypress-${{ steps.cypress-info.outputs.version }}
restore-keys: |
${{ runner.OS }}-cypress-${{ steps.cypress-info.outputs.version }}
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
env: env:
HUSKY_SKIP_INSTALL: true HUSKY_SKIP_INSTALL: true
- name: run Cypress CI - name: run Cypress (with or without recording)
run: npx run-p --race start cypress:ci # if we have ran out of free Cypress recordings, run Cypress with recording switched off
run: npx run-p --race start cypress:ci || npx run-p --race start cypress:run
env: env:
CI: true
TERM: xterm-256color
NODE_ENV: production # prevent watching NODE_ENV: production # prevent watching
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
DEBUG: commit-info,cypress:server:record DEBUG: commit-info,cypress:server:record

View file

@ -1,4 +1,4 @@
name: Release management name: Release drafter
on: on:
push: push:

View file

@ -1,6 +1,7 @@
{ {
"singleQuote": true, "singleQuote": true,
"trailingComma": "all", "trailingComma": "all",
"endOfLine": "lf",
"overrides": [ "overrides": [
{ {
"files": ["*.svg"], "files": ["*.svg"],
@ -8,6 +9,12 @@
"parser": "html", "parser": "html",
"htmlWhitespaceSensitivity": "ignore" "htmlWhitespaceSensitivity": "ignore"
} }
},
{
"files": ["public/*.html"],
"options": {
"trailingComma": "es5"
}
} }
] ]
} }

View file

@ -1,4 +1,4 @@
# Choices.js [![Actions Status](https://github.com/jshjohnson/Choices/workflows/Unit%20Tests/badge.svg)](https://github.com/jshjohnson/Choices/actions) [![npm](https://img.shields.io/npm/v/choices.js.svg)](https://www.npmjs.com/package/choices.js) [![codebeat badge](https://codebeat.co/badges/55120150-5866-42d8-8010-6aaaff5d3fa1)](https://codebeat.co/projects/github-com-jshjohnson-choices-master) # Choices.js [![Actions Status](https://github.com/jshjohnson/Choices/workflows/Build%20and%20test/badge.svg)](https://github.com/jshjohnson/Choices/actions) [![Actions Status](https://github.com/jshjohnson/Choices/workflows/Bundle%20size%20checks/badge.svg)](https://github.com/jshjohnson/Choices/actions) [![npm](https://img.shields.io/npm/v/choices.js.svg)](https://www.npmjs.com/package/choices.js)
A vanilla, lightweight (~19kb gzipped 🎉), configurable select box/text input plugin. Similar to Select2 and Selectize but without the jQuery dependency. A vanilla, lightweight (~19kb gzipped 🎉), configurable select box/text input plugin. Similar to Select2 and Selectize but without the jQuery dependency.
@ -105,7 +105,7 @@ Or include Choices directly:
resetScrollPosition: true, resetScrollPosition: true,
shouldSort: true, shouldSort: true,
shouldSortItems: false, shouldSortItems: false,
sortFn: () => {...}, sorter: () => {...},
placeholder: true, placeholder: true,
placeholderValue: null, placeholderValue: null,
searchPlaceholderValue: null, searchPlaceholderValue: null,
@ -122,8 +122,8 @@ Or include Choices directly:
maxItemText: (maxItemCount) => { maxItemText: (maxItemCount) => {
return `Only ${maxItemCount} values can be added`; return `Only ${maxItemCount} values can be added`;
}, },
itemComparer: (choice, item) => { valueComparer: (value1, value2) => {
return choice === item; return value1 === value2;
}, },
classNames: { classNames: {
containerOuter: 'choices', containerOuter: 'choices',
@ -147,6 +147,7 @@ Or include Choices directly:
openState: 'is-open', openState: 'is-open',
disabledState: 'is-disabled', disabledState: 'is-disabled',
highlightedState: 'is-highlighted', highlightedState: 'is-highlighted',
selectedState: 'is-selected',
flippedState: 'is-flipped', flippedState: 'is-flipped',
loadingState: 'is-loading', loadingState: 'is-loading',
noResults: 'has-no-results', noResults: 'has-no-results',
@ -408,7 +409,7 @@ new Choices(element, {
**Usage:** Whether items should be sorted. If false, items will appear in the order they were selected. **Usage:** Whether items should be sorted. If false, items will appear in the order they were selected.
### sortFn ### sorter
**Type:** `Function` **Default:** sortByAlpha **Type:** `Function` **Default:** sortByAlpha
@ -421,7 +422,7 @@ new Choices(element, {
```js ```js
// Sorting via length of label from largest to smallest // Sorting via length of label from largest to smallest
const example = new Choices(element, { const example = new Choices(element, {
sortFn: function(a, b) { sorter: function(a, b) {
return b.label.length - a.label.length; return b.label.length - a.label.length;
}, },
}; };
@ -431,11 +432,11 @@ const example = new Choices(element, {
**Type:** `Boolean` **Default:** `true` **Type:** `Boolean` **Default:** `true`
**Input types affected:** `text`, `select-multiple` **Input types affected:** `text`
**Usage:** Whether the input should show a placeholder. Used in conjunction with `placeholderValue`. If `placeholder` is set to true and no value is passed to `placeholderValue`, the passed input's placeholder attribute will be used as the placeholder value. **Usage:** Whether the input should show a placeholder. Used in conjunction with `placeholderValue`. If `placeholder` is set to true and no value is passed to `placeholderValue`, the passed input's placeholder attribute will be used as the placeholder value.
**Note:** For single select boxes, the recommended way of adding a placeholder is as follows: **Note:** For select boxes, the recommended way of adding a placeholder is as follows:
```html ```html
<select> <select>
@ -536,13 +537,21 @@ For backward compatibility, `<option placeholder>This is a placeholder</option>`
**Usage:** The text that is shown when a user has focus on the input but has already reached the [max item count](https://github.com/jshjohnson/Choices#maxitemcount). To access the max item count, pass a function with a `maxItemCount` argument (see the [default config](https://github.com/jshjohnson/Choices#setup) for an example), otherwise pass a string. **Usage:** The text that is shown when a user has focus on the input but has already reached the [max item count](https://github.com/jshjohnson/Choices#maxitemcount). To access the max item count, pass a function with a `maxItemCount` argument (see the [default config](https://github.com/jshjohnson/Choices#setup) for an example), otherwise pass a string.
### itemComparer ### valueComparer
**Type:** `Function` **Default:** `strict equality` **Type:** `Function` **Default:** `strict equality`
**Input types affected:** `select-one`, `select-multiple` **Input types affected:** `select-one`, `select-multiple`
**Usage:** Compare choice and value in appropriate way (e.g. deep equality for objects). To compare choice and value, pass a function with a `itemComparer` argument (see the [default config](https://github.com/jshjohnson/Choices#setup) for an example). **Usage:** A custom compare function used when finding choices by value (using `setChoiceByValue`).
**Example:**
```js
const example = new Choices(element, {
valueComparer: (a, b) => value.trim() === b.trim(),
};
```
### classNames ### classNames
@ -570,6 +579,7 @@ classNames: {
openState: 'is-open', openState: 'is-open',
disabledState: 'is-disabled', disabledState: 'is-disabled',
highlightedState: 'is-highlighted', highlightedState: 'is-highlighted',
selectedState: 'is-selected',
flippedState: 'is-flipped', flippedState: 'is-flipped',
selectedState: 'is-highlighted', selectedState: 'is-highlighted',
} }
@ -1053,7 +1063,7 @@ Element.prototype.closest
## Development ## Development
To setup a local environment: clone this repo, navigate into it's directory in a terminal window and run the following command: To setup a local environment: clone this repo, navigate into its directory in a terminal window and run the following command:
`npm install` `npm install`

View file

@ -1,6 +1,6 @@
describe('Choices - select multiple', () => { describe('Choices - select multiple', () => {
beforeEach(() => { beforeEach(() => {
cy.visit('/select-multiple.html'); cy.visit('/select-multiple');
}); });
describe('scenarios', () => { describe('scenarios', () => {
@ -486,20 +486,42 @@ describe('Choices - select multiple', () => {
}); });
}); });
describe('placeholder', () => { describe('placeholder via empty option value', () => {
/*
{
placeholder: true,
placeholderValue: 'I am a placeholder',
}
*/
describe('when no value has been inputted', () => { describe('when no value has been inputted', () => {
it('displays a placeholder', () => { it('displays a placeholder', () => {
cy.get('[data-test-hook=placeholder]') cy.get('[data-test-hook=placeholder-via-option-value]')
.find('.choices__input--cloned') .find('.choices__input--cloned')
.should('have.attr', 'placeholder', 'I am a placeholder'); .should('have.attr', 'placeholder', 'I am a placeholder');
}); });
}); });
describe('when a value has been inputted', () => {
it('does not display a placeholder', () => {
cy.get('[data-test-hook=placeholder-via-option-value]')
.find('.choices__input--cloned')
.type('test')
.should('not.have.value', 'I am a placeholder');
});
});
});
describe('placeholder via option attribute', () => {
describe('when no value has been inputted', () => {
it('displays a placeholder', () => {
cy.get('[data-test-hook=placeholder-via-option-attr]')
.find('.choices__input--cloned')
.should('have.attr', 'placeholder', 'I am a placeholder');
});
});
describe('when a value has been inputted', () => {
it('does not display a placeholder', () => {
cy.get('[data-test-hook=placeholder-via-option-attr]')
.find('.choices__input--cloned')
.type('test')
.should('not.have.value', 'I am a placeholder');
});
});
}); });
describe('remote data', () => { describe('remote data', () => {

View file

@ -1,6 +1,6 @@
describe('Choices - select one', () => { describe('Choices - select one', () => {
beforeEach(() => { beforeEach(() => {
cy.visit('/select-one.html'); cy.visit('/select-one');
}); });
describe('scenarios', () => { describe('scenarios', () => {
@ -12,6 +12,30 @@ describe('Choices - select one', () => {
.click(); .click();
}); });
describe('focusing on container', () => {
describe('pressing enter key', () => {
it('toggles the dropdown', () => {
cy.get('[data-test-hook=basic]')
.find('.choices')
.focus()
.type('{enter}');
cy.get('[data-test-hook=basic]')
.find('.choices__list--dropdown')
.should('not.be.visible');
cy.get('[data-test-hook=basic]')
.find('.choices')
.focus()
.type('{enter}');
cy.get('[data-test-hook=basic]')
.find('.choices__list--dropdown')
.should('be.visible');
});
});
});
describe('focusing on text input', () => { describe('focusing on text input', () => {
it('displays a dropdown of choices', () => { it('displays a dropdown of choices', () => {
cy.get('[data-test-hook=basic]') cy.get('[data-test-hook=basic]')
@ -448,6 +472,102 @@ describe('Choices - select one', () => {
}); });
}); });
describe('placeholder via empty option value', () => {
describe('when no choice has been selected', () => {
it('displays a placeholder', () => {
cy.get('[data-test-hook=placeholder-via-option-value]')
.find('.choices__list--single')
.children()
.first()
.should('have.class', 'choices__placeholder')
.and($placeholder => {
expect($placeholder).to.contain('I am a placeholder');
});
});
});
describe('when a choice has been selected', () => {
it('does not display a placeholder', () => {
cy.get('[data-test-hook=placeholder-via-option-value]')
.find('.choices__input--cloned')
.focus();
cy.get('[data-test-hook=placeholder-via-option-value]')
.find('.choices__list--dropdown .choices__list')
.children()
.first()
.click();
cy.get('[data-test-hook=placeholder-via-option-value]')
.find('.choices__input--cloned')
.should('not.have.value', 'I am a placeholder');
});
});
describe('when choice list is open', () => {
it('displays the placeholder choice first', () => {
cy.get('[data-test-hook=placeholder-via-option-value]')
.find('.choices__input--cloned')
.focus();
cy.get('[data-test-hook=placeholder-via-option-value]')
.find('.choices__list--dropdown .choices__list')
.children()
.first()
.should('have.class', 'choices__placeholder')
.should('have.text', 'I am a placeholder');
});
});
});
describe('placeholder via option attribute', () => {
describe('when no choice has been selected', () => {
it('displays a placeholder', () => {
cy.get('[data-test-hook=placeholder-via-option-attr]')
.find('.choices__list--single')
.children()
.first()
.should('have.class', 'choices__placeholder')
.and($placeholder => {
expect($placeholder).to.contain('I am a placeholder');
});
});
});
describe('when a choice has been selected', () => {
it('does not display a placeholder', () => {
cy.get('[data-test-hook=placeholder-via-option-attr]')
.find('.choices__input--cloned')
.focus();
cy.get('[data-test-hook=placeholder-via-option-attr]')
.find('.choices__list--dropdown .choices__list')
.children()
.first()
.click();
cy.get('[data-test-hook=placeholder-via-option-attr]')
.find('.choices__input--cloned')
.should('not.have.value', 'I am a placeholder');
});
});
describe('when choice list is open', () => {
it('displays the placeholder choice first', () => {
cy.get('[data-test-hook=placeholder-via-option-attr]')
.find('.choices__input--cloned')
.focus();
cy.get('[data-test-hook=placeholder-via-option-attr]')
.find('.choices__list--dropdown .choices__list')
.children()
.first()
.should('have.class', 'choices__placeholder')
.should('have.text', 'I am a placeholder');
});
});
});
describe('remote data', () => { describe('remote data', () => {
beforeEach(() => { beforeEach(() => {
cy.reload(true); cy.reload(true);
@ -458,6 +578,7 @@ describe('Choices - select one', () => {
cy.get('[data-test-hook=remote-data]') cy.get('[data-test-hook=remote-data]')
.find('.choices__list--single') .find('.choices__list--single')
.children() .children()
.should('have.length', 1)
.first() .first()
.should('have.class', 'choices__placeholder') .should('have.class', 'choices__placeholder')
.and($placeholder => { .and($placeholder => {
@ -483,10 +604,14 @@ describe('Choices - select one', () => {
cy.get('[data-test-hook=remote-data]') cy.get('[data-test-hook=remote-data]')
.find('.choices__list--dropdown .choices__list') .find('.choices__list--dropdown .choices__list')
.children() .children()
.should('have.length', 50) .should('have.length', 51) // 50 choices + 1 placeholder choice
.each(($choice, index) => { .each(($choice, index) => {
expect($choice.text().trim()).to.equal(`Label ${index + 1}`); if (index === 0) {
expect($choice.data('value')).to.equal(`Value ${index + 1}`); expect($choice.text().trim()).to.equal('I am a placeholder');
} else {
expect($choice.text().trim()).to.equal(`Label ${index}`);
expect($choice.data('value')).to.equal(`Value ${index}`);
}
}); });
}); });
}); });
@ -862,6 +987,42 @@ describe('Choices - select one', () => {
}); });
}); });
describe('disabling first choice via options', () => {
beforeEach(() => {
cy.get('[data-test-hook=disabled-first-choice-via-options]')
.find('.choices')
.click();
});
let disabledValue;
it('disables the first choice', () => {
cy.get('[data-test-hook=disabled-first-choice-via-options]')
.find('.choices__list--dropdown .choices__list')
.children()
.first()
.should('have.class', 'choices__item--disabled')
.then($choice => {
disabledValue = $choice.val();
});
});
it('selects the first enabled choice', () => {
cy.get('[data-test-hook=disabled-first-choice-via-options]')
.find('.choices__input[hidden]')
.then($option => {
expect($option.text().trim()).to.not.equal(disabledValue);
});
cy.get('[data-test-hook=disabled-first-choice-via-options]')
.find('.choices__item.choices__item--selectable')
.first()
.should($choice => {
expect($choice.text().trim()).to.not.equal(disabledValue);
});
});
});
describe('re-initialising a choices instance', () => { describe('re-initialising a choices instance', () => {
it('preserves the choices list', () => { it('preserves the choices list', () => {
cy.get('[data-test-hook=new-destroy-init]') cy.get('[data-test-hook=new-destroy-init]')

View file

@ -1,6 +1,6 @@
describe('Choices - text element', () => { describe('Choices - text element', () => {
beforeEach(() => { beforeEach(() => {
cy.visit('/text.html'); cy.visit('/text');
}); });
describe('scenarios', () => { describe('scenarios', () => {

View file

@ -11,6 +11,7 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true,
"strictNullChecks": false
} }
} }

74
package-lock.json generated
View file

@ -1,6 +1,6 @@
{ {
"name": "choices.js", "name": "choices.js",
"version": "8.0.0", "version": "9.0.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@ -1944,7 +1944,7 @@
}, },
"browserify-aes": { "browserify-aes": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "http://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
"integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==",
"dev": true, "dev": true,
"requires": { "requires": {
@ -1981,7 +1981,7 @@
}, },
"browserify-rsa": { "browserify-rsa": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "http://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz", "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz",
"integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=", "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=",
"dev": true, "dev": true,
"requires": { "requires": {
@ -2026,7 +2026,7 @@
}, },
"buffer": { "buffer": {
"version": "4.9.1", "version": "4.9.1",
"resolved": "http://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz",
"integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=",
"dev": true, "dev": true,
"requires": { "requires": {
@ -2213,7 +2213,7 @@
}, },
"camelcase-keys": { "camelcase-keys": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "http://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz",
"integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=", "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=",
"dev": true, "dev": true,
"requires": { "requires": {
@ -2807,7 +2807,7 @@
}, },
"create-hash": { "create-hash": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
"integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==",
"dev": true, "dev": true,
"requires": { "requires": {
@ -2820,7 +2820,7 @@
}, },
"create-hmac": { "create-hmac": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "http://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
"integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==",
"dev": true, "dev": true,
"requires": { "requires": {
@ -2956,9 +2956,9 @@
"dev": true "dev": true
}, },
"cypress": { "cypress": {
"version": "3.5.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-3.5.0.tgz", "resolved": "https://registry.npmjs.org/cypress/-/cypress-3.6.0.tgz",
"integrity": "sha512-I1iSReD2C8CTP6s4BvQky4gEqHBnKLmhBIqFyCUZdj6BQ6ZDxGnmIbQPM5g79E2iP60KTIbTK99ZPSDVtsNUUg==", "integrity": "sha512-ODhbOrH1XZx0DUoYmJSvOSbEQjycNOpFYe7jOnHkT1+sdsn2+uqwAjZ1x982q3H4R/5iZjpSd50gd/iw2bofzg==",
"dev": true, "dev": true,
"requires": { "requires": {
"@cypress/listr-verbose-renderer": "0.4.1", "@cypress/listr-verbose-renderer": "0.4.1",
@ -3350,7 +3350,7 @@
}, },
"diffie-hellman": { "diffie-hellman": {
"version": "5.0.3", "version": "5.0.3",
"resolved": "http://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
"integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==",
"dev": true, "dev": true,
"requires": { "requires": {
@ -3420,7 +3420,7 @@
}, },
"duplexer": { "duplexer": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "http://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz",
"integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E=", "integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E=",
"dev": true "dev": true
}, },
@ -4092,7 +4092,7 @@
"dependencies": { "dependencies": {
"pify": { "pify": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
"dev": true "dev": true
} }
@ -5873,7 +5873,7 @@
}, },
"get-stream": { "get-stream": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz",
"integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=",
"dev": true "dev": true
}, },
@ -6038,7 +6038,7 @@
}, },
"got": { "got": {
"version": "6.7.1", "version": "6.7.1",
"resolved": "http://registry.npmjs.org/got/-/got-6.7.1.tgz", "resolved": "https://registry.npmjs.org/got/-/got-6.7.1.tgz",
"integrity": "sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=", "integrity": "sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=",
"dev": true, "dev": true,
"requires": { "requires": {
@ -6903,7 +6903,7 @@
}, },
"is-obj": { "is-obj": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "http://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz",
"integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=",
"dev": true "dev": true
}, },
@ -7866,7 +7866,7 @@
}, },
"load-json-file": { "load-json-file": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",
"integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=",
"dev": true, "dev": true,
"requires": { "requires": {
@ -7887,7 +7887,7 @@
}, },
"pify": { "pify": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
"dev": true "dev": true
} }
@ -8145,7 +8145,7 @@
}, },
"media-typer": { "media-typer": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "http://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=",
"dev": true "dev": true
}, },
@ -8186,7 +8186,7 @@
}, },
"meow": { "meow": {
"version": "3.7.0", "version": "3.7.0",
"resolved": "http://registry.npmjs.org/meow/-/meow-3.7.0.tgz", "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz",
"integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=", "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=",
"dev": true, "dev": true,
"requires": { "requires": {
@ -8214,7 +8214,7 @@
}, },
"load-json-file": { "load-json-file": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz",
"integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=",
"dev": true, "dev": true,
"requires": { "requires": {
@ -8256,7 +8256,7 @@
}, },
"pify": { "pify": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
"dev": true "dev": true
}, },
@ -8454,7 +8454,7 @@
}, },
"mkdirp": { "mkdirp": {
"version": "0.5.1", "version": "0.5.1",
"resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
"dev": true, "dev": true,
"requires": { "requires": {
@ -8724,7 +8724,7 @@
"dependencies": { "dependencies": {
"semver": { "semver": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "http://registry.npmjs.org/semver/-/semver-5.3.0.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz",
"integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=", "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=",
"dev": true "dev": true
} }
@ -9603,7 +9603,7 @@
}, },
"onetime": { "onetime": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "http://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz",
"integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=", "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=",
"dev": true "dev": true
}, },
@ -9653,7 +9653,7 @@
}, },
"ora": { "ora": {
"version": "0.2.3", "version": "0.2.3",
"resolved": "http://registry.npmjs.org/ora/-/ora-0.2.3.tgz", "resolved": "https://registry.npmjs.org/ora/-/ora-0.2.3.tgz",
"integrity": "sha1-N1J9Igrc1Tw5tzVx11QVbV22V6Q=", "integrity": "sha1-N1J9Igrc1Tw5tzVx11QVbV22V6Q=",
"dev": true, "dev": true,
"requires": { "requires": {
@ -9704,7 +9704,7 @@
}, },
"os-locale": { "os-locale": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "http://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz",
"integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=",
"dev": true, "dev": true,
"requires": { "requires": {
@ -9940,7 +9940,7 @@
"dependencies": { "dependencies": {
"pify": { "pify": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
"dev": true "dev": true
} }
@ -11770,7 +11770,7 @@
}, },
"safe-regex": { "safe-regex": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "http://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
"integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=",
"dev": true, "dev": true,
"requires": { "requires": {
@ -12134,7 +12134,7 @@
}, },
"sha.js": { "sha.js": {
"version": "2.4.11", "version": "2.4.11",
"resolved": "http://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
"integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
"dev": true, "dev": true,
"requires": { "requires": {
@ -12201,6 +12201,12 @@
"supports-color": "^5.5.0" "supports-color": "^5.5.0"
} }
}, },
"sinon-chai": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/sinon-chai/-/sinon-chai-3.3.0.tgz",
"integrity": "sha512-r2JhDY7gbbmh5z3Q62pNbrjxZdOAjpsqW/8yxAZRSqLZqowmfGZPGUZPFf3UX36NLis0cv8VEM5IJh9HgkSOAA==",
"dev": true
},
"slash": { "slash": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@ -12209,7 +12215,7 @@
}, },
"slice-ansi": { "slice-ansi": {
"version": "0.0.4", "version": "0.0.4",
"resolved": "http://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz",
"integrity": "sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=", "integrity": "sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=",
"dev": true "dev": true
}, },
@ -12629,7 +12635,7 @@
}, },
"strip-ansi": { "strip-ansi": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
"integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
"dev": true, "dev": true,
"requires": { "requires": {
@ -12644,7 +12650,7 @@
}, },
"strip-eof": { "strip-eof": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "http://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
"integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=",
"dev": true "dev": true
}, },
@ -12977,7 +12983,7 @@
}, },
"through": { "through": {
"version": "2.3.8", "version": "2.3.8",
"resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=",
"dev": true "dev": true
}, },

View file

@ -1,6 +1,6 @@
{ {
"name": "choices.js", "name": "choices.js",
"version": "8.0.0", "version": "9.0.0",
"description": "A vanilla JS customisable text input/select box plugin", "description": "A vanilla JS customisable text input/select box plugin",
"main": "./public/assets/scripts/choices.js", "main": "./public/assets/scripts/choices.js",
"types": "./types/index.d.ts", "types": "./types/index.d.ts",
@ -9,8 +9,8 @@
"build": "run-p js:build css:build", "build": "run-p js:build css:build",
"lint": "eslint src/scripts", "lint": "eslint src/scripts",
"bundlesize": "bundlesize", "bundlesize": "bundlesize",
"cypress:run": "$(npm bin)/cypress run", "cypress:run": "cypress run",
"cypress:open": "$(npm bin)/cypress open", "cypress:open": "cypress open",
"cypress:ci": "cypress run --record --group $GITHUB_REF --ci-build-id $GITHUB_SHA", "cypress:ci": "cypress run --record --group $GITHUB_REF --ci-build-id $GITHUB_SHA",
"test": "run-s test:unit test:e2e", "test": "run-s test:unit test:e2e",
"test:unit": "NODE_ENV=test mocha", "test:unit": "NODE_ENV=test mocha",
@ -62,7 +62,7 @@
"bundlesize": "^0.18.0", "bundlesize": "^0.18.0",
"chai": "^4.2.0", "chai": "^4.2.0",
"csso-cli": "^3.0.0", "csso-cli": "^3.0.0",
"cypress": "3.5.0", "cypress": "3.6.0",
"eslint": "^6.6.0", "eslint": "^6.6.0",
"eslint-config-airbnb-base": "^14.0.0", "eslint-config-airbnb-base": "^14.0.0",
"eslint-config-prettier": "^6.5.0", "eslint-config-prettier": "^6.5.0",
@ -84,6 +84,7 @@
"postcss-cli": "^6.1.3", "postcss-cli": "^6.1.3",
"prettier": "^1.18.2", "prettier": "^1.18.2",
"sinon": "^7.5.0", "sinon": "^7.5.0",
"sinon-chai": "^3.3.0",
"webpack": "^4.41.2", "webpack": "^4.41.2",
"webpack-cli": "^3.3.9", "webpack-cli": "^3.3.9",
"webpack-dev-middleware": "^3.7.2", "webpack-dev-middleware": "^3.7.2",

View file

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<browserconfig> <browserconfig>
<msapplication> <msapplication>
<tile> <tile>
<square150x150logo src="/assets/images/mstile-150x150.png"/> <square150x150logo src="/assets/images/mstile-150x150.png"/>
<TileColor>#ffffff</TileColor> <TileColor>#ffffff</TileColor>
</tile> </tile>
</msapplication> </msapplication>
</browserconfig> </browserconfig>

View file

@ -1,4 +1,3 @@
// get polyfill settings from top level config // get polyfill settings from top level config
// @ts-ignore // @ts-ignore
const { settings } = require('../../../.eslintrc.json'); const { settings } = require('../../../.eslintrc.json');
@ -6,19 +5,23 @@ const { settings } = require('../../../.eslintrc.json');
// Adding non-polyfilable Symbol-related functions as they are most probably // Adding non-polyfilable Symbol-related functions as they are most probably
// behind the flag // behind the flag
settings.polyfills.push('Symbol.toStringTag', 'Symbol.for', 'Object.getOwnPropertySymbols', 'Object.getOwnPropertyDescriptors') settings.polyfills.push(
'Symbol.toStringTag',
'Symbol.for',
'Object.getOwnPropertySymbols',
'Object.getOwnPropertyDescriptors',
'Promise', // Promise is gate checked
);
module.exports = /** @type {import('eslint').Linter.Config} */({ module.exports = /** @type {import('eslint').Linter.Config} */ ({
root: true, root: true,
extends: [ extends: ['plugin:compat/recommended'],
"plugin:compat/recommended"
],
parserOptions: { parserOptions: {
// ensure that it's compatible with ES5 browsers, so, no `const`, etc // ensure that it's compatible with ES5 browsers, so, no `const`, etc
ecmaVersion: 5 ecmaVersion: 5,
}, },
env: { env: {
browser: true browser: true,
}, },
settings settings,
}) });

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -72,6 +72,10 @@
box-shadow: 0px 0px 0px 2px #00bcd4; box-shadow: 0px 0px 0px 2px #00bcd4;
} }
.choices[data-type*='select-one'] .choices__item[data-value=''] .choices__button {
display: none;
}
.choices[data-type*='select-one']:after { .choices[data-type*='select-one']:after {
content: ''; content: '';
height: 0; height: 0;
@ -196,6 +200,7 @@
border: 1px solid #00a5bb; border: 1px solid #00a5bb;
color: #ffffff; color: #ffffff;
word-break: break-all; word-break: break-all;
box-sizing: border-box;
} }
.choices__list--multiple .choices__item[data-deletable] { .choices__list--multiple .choices__item[data-deletable] {
@ -218,7 +223,7 @@
} }
.choices__list--dropdown { .choices__list--dropdown {
display: none; visibility: hidden;
z-index: 1; z-index: 1;
position: absolute; position: absolute;
width: 100%; width: 100%;
@ -230,10 +235,11 @@
border-bottom-right-radius: 2.5px; border-bottom-right-radius: 2.5px;
overflow: hidden; overflow: hidden;
word-break: break-all; word-break: break-all;
will-change: visibility;
} }
.choices__list--dropdown.is-active { .choices__list--dropdown.is-active {
display: block; visibility: visible;
} }
.is-open .choices__list--dropdown { .is-open .choices__list--dropdown {

File diff suppressed because one or more lines are too long

View file

@ -214,6 +214,7 @@
placeholder="This is a placeholder" placeholder="This is a placeholder"
multiple multiple
> >
<option value="">Choose a city</option>
<optgroup label="UK"> <optgroup label="UK">
<option value="London">London</option> <option value="London">London</option>
<option value="Manchester">Manchester</option> <option value="Manchester">Manchester</option>
@ -336,8 +337,8 @@
data-trigger data-trigger
name="choices-single-groups" name="choices-single-groups"
id="choices-single-groups" id="choices-single-groups"
placeholder="This is a placeholder"
> >
<option value="">Choose a city</option>
<optgroup label="UK"> <optgroup label="UK">
<option value="London">London</option> <option value="London">London</option>
<option value="Manchester">Manchester</option> <option value="Manchester">Manchester</option>
@ -376,7 +377,6 @@
data-trigger data-trigger
name="choices-single-rtl" name="choices-single-rtl"
id="choices-single-rtl" id="choices-single-rtl"
placeholder="This is a placeholder"
dir="rtl" dir="rtl"
> >
<option value="Choice 1">Choice 1</option> <option value="Choice 1">Choice 1</option>
@ -402,7 +402,6 @@
class="form-control" class="form-control"
name="choices-single-preset-options" name="choices-single-preset-options"
id="choices-single-preset-options" id="choices-single-preset-options"
placeholder="This is a placeholder"
></select> ></select>
<label for="choices-single-selected-option" <label for="choices-single-selected-option"
@ -415,7 +414,6 @@
class="form-control" class="form-control"
name="choices-single-selected-option" name="choices-single-selected-option"
id="choices-single-selected-option" id="choices-single-selected-option"
placeholder="This is a placeholder"
></select> ></select>
<label for="choices-with-custom-props-via-html" <label for="choices-with-custom-props-via-html"
@ -440,7 +438,6 @@
class="form-control" class="form-control"
name="choices-single-no-sorting" name="choices-single-no-sorting"
id="choices-single-no-sorting" id="choices-single-no-sorting"
placeholder="This is a placeholder"
> >
<option value="Madrid">Madrid</option> <option value="Madrid">Madrid</option>
<option value="Toronto">Toronto</option> <option value="Toronto">Toronto</option>
@ -467,7 +464,6 @@
class="form-control" class="form-control"
name="choices-single-custom-templates" name="choices-single-custom-templates"
id="choices-single-custom-templates" id="choices-single-custom-templates"
placeholder="This is a placeholder"
> >
<option value="React">React</option> <option value="React">React</option>
<option value="Angular">Angular</option> <option value="Angular">Angular</option>
@ -481,12 +477,8 @@
'Cities' is 'London' 'Cities' is 'London'
</p> </p>
<label for="cities">Cities</label> <label for="cities">Cities</label>
<select <select class="form-control" name="cities" id="cities">
class="form-control" <option value="">Choose a city</option>
name="cities"
id="cities"
placeholder="Choose a city"
>
<option value="Leeds">Leeds</option> <option value="Leeds">Leeds</option>
<option value="Manchester">Manchester</option> <option value="Manchester">Manchester</option>
<option value="London">London</option> <option value="London">London</option>
@ -495,12 +487,8 @@
</select> </select>
<label for="tube-stations">Tube stations</label> <label for="tube-stations">Tube stations</label>
<select <select class="form-control" name="tube-stations" id="tube-stations">
class="form-control" <option value="">Choose a tube station</option>
name="tube-stations"
id="tube-stations"
placeholder="Choose a tube station"
>
<option value="Moorgate">Moorgate</option> <option value="Moorgate">Moorgate</option>
<option value="St Pauls">St Pauls</option> <option value="St Pauls">St Pauls</option>
<option value="Old Street">Old Street</option> <option value="Old Street">Old Street</option>
@ -515,12 +503,7 @@
<p>Change the values and press reset to restore to initial state.</p> <p>Change the values and press reset to restore to initial state.</p>
<form> <form>
<label for="reset-simple">Change me!</label> <label for="reset-simple">Change me!</label>
<select <select class="form-control" name="reset-simple" id="reset-simple">
class="form-control"
name="reset-simple"
id="reset-simple"
placeholder="Choose an option"
>
<option value="Option 1">Option 1</option> <option value="Option 1">Option 1</option>
<option value="Option 2" selected>Option 2</option> <option value="Option 2" selected>Option 2</option>
<option value="Option 3">Option 3</option> <option value="Option 3">Option 3</option>
@ -533,7 +516,6 @@
class="form-control" class="form-control"
name="reset-multiple" name="reset-multiple"
id="reset-multiple" id="reset-multiple"
placeholder="This is a placeholder"
multiple multiple
> >
<option value="Choice 1" selected>Choice 1</option> <option value="Choice 1" selected>Choice 1</option>
@ -564,7 +546,7 @@
editItems: true, editItems: true,
maxItemCount: 5, maxItemCount: 5,
removeItemButton: true, removeItemButton: true,
}, }
); );
var textUniqueVals = new Choices('#choices-text-unique-values', { var textUniqueVals = new Choices('#choices-text-unique-values', {
@ -612,7 +594,7 @@
{ {
prependValue: 'item-', prependValue: 'item-',
appendValue: '-' + Date.now(), appendValue: '-' + Date.now(),
}, }
).removeActiveItems(); ).removeActiveItems();
var textPresetVal = new Choices('#choices-text-preset-values', { var textPresetVal = new Choices('#choices-text-preset-values', {
@ -629,7 +611,7 @@
}); });
var multipleDefault = new Choices( var multipleDefault = new Choices(
document.getElementById('choices-multiple-groups'), document.getElementById('choices-multiple-groups')
); );
var multipleFetch = new Choices('#choices-multiple-remote-fetch', { var multipleFetch = new Choices('#choices-multiple-remote-fetch', {
@ -638,7 +620,7 @@
maxItemCount: 5, maxItemCount: 5,
}).setChoices(function() { }).setChoices(function() {
return fetch( return fetch(
'https://api.discogs.com/artists/55980/releases?token=QBRmstCkwXEvCjTclCpumbtNwvVkEzGAdELXyRyW', 'https://api.discogs.com/artists/55980/releases?token=QBRmstCkwXEvCjTclCpumbtNwvVkEzGAdELXyRyW'
) )
.then(function(response) { .then(function(response) {
return response.json(); return response.json();
@ -654,7 +636,7 @@
'#choices-multiple-remove-button', '#choices-multiple-remove-button',
{ {
removeItemButton: true, removeItemButton: true,
}, }
); );
/* Use label on event */ /* Use label on event */
@ -673,7 +655,7 @@
], ],
'value', 'value',
'label', 'label',
false, false
); );
choicesSelect.passedElement.element.addEventListener( choicesSelect.passedElement.element.addEventListener(
@ -681,7 +663,7 @@
function(event) { function(event) {
document.getElementById('message').innerHTML = document.getElementById('message').innerHTML =
'You just added "' + event.detail.label + '"'; 'You just added "' + event.detail.label + '"';
}, }
); );
choicesSelect.passedElement.element.addEventListener( choicesSelect.passedElement.element.addEventListener(
@ -689,7 +671,7 @@
function(event) { function(event) {
document.getElementById('message').innerHTML = document.getElementById('message').innerHTML =
'You just removed "' + event.detail.label + '"'; 'You just removed "' + event.detail.label + '"';
}, }
); );
var singleFetch = new Choices('#choices-single-remote-fetch', { var singleFetch = new Choices('#choices-single-remote-fetch', {
@ -697,7 +679,7 @@
}) })
.setChoices(function() { .setChoices(function() {
return fetch( return fetch(
'https://api.discogs.com/artists/391170/releases?token=QBRmstCkwXEvCjTclCpumbtNwvVkEzGAdELXyRyW', 'https://api.discogs.com/artists/391170/releases?token=QBRmstCkwXEvCjTclCpumbtNwvVkEzGAdELXyRyW'
) )
.then(function(response) { .then(function(response) {
return response.json(); return response.json();
@ -717,7 +699,7 @@
searchPlaceholderValue: "Search for a Smiths' record", searchPlaceholderValue: "Search for a Smiths' record",
}).setChoices(function(callback) { }).setChoices(function(callback) {
return fetch( return fetch(
'https://api.discogs.com/artists/83080/releases?token=QBRmstCkwXEvCjTclCpumbtNwvVkEzGAdELXyRyW', 'https://api.discogs.com/artists/83080/releases?token=QBRmstCkwXEvCjTclCpumbtNwvVkEzGAdELXyRyW'
) )
.then(function(res) { .then(function(res) {
return res.json(); return res.json();
@ -750,7 +732,7 @@
], ],
'value', 'value',
'label', 'label',
false, false
); );
var singlePresetOpts = new Choices('#choices-single-preset-options', { var singlePresetOpts = new Choices('#choices-single-preset-options', {
@ -779,7 +761,7 @@
}, },
], ],
'value', 'value',
'label', 'label'
); );
var singleSelectedOpt = new Choices('#choices-single-selected-option', { var singleSelectedOpt = new Choices('#choices-single-selected-option', {
@ -801,7 +783,7 @@
'#choices-with-custom-props-via-html', '#choices-with-custom-props-via-html',
{ {
searchFields: ['label', 'value', 'customProperties'], searchFields: ['label', 'value', 'customProperties'],
}, }
); );
var singleNoSorting = new Choices('#choices-single-no-sorting', { var singleNoSorting = new Choices('#choices-single-no-sorting', {
@ -810,7 +792,7 @@
var cities = new Choices(document.getElementById('cities')); var cities = new Choices(document.getElementById('cities'));
var tubeStations = new Choices( var tubeStations = new Choices(
document.getElementById('tube-stations'), document.getElementById('tube-stations')
).disable(); ).disable();
cities.passedElement.element.addEventListener('change', function(e) { cities.passedElement.element.addEventListener('change', function(e) {
@ -838,7 +820,7 @@
String( String(
data.highlighted data.highlighted
? classNames.highlightedState ? classNames.highlightedState
: classNames.itemSelectable, : classNames.itemSelectable
) + ) +
'"\ '"\
data-item\ data-item\
@ -859,7 +841,7 @@
String(data.label) + String(data.label) +
'\ '\
</div>\ </div>\
', '
); );
}, },
choice: function(classNames, data) { choice: function(classNames, data) {
@ -874,7 +856,7 @@
String( String(
data.disabled data.disabled
? classNames.itemDisabled ? classNames.itemDisabled
: classNames.itemSelectable, : classNames.itemSelectable
) + ) +
'"\ '"\
data-select-text="' + data-select-text="' +
@ -885,7 +867,7 @@
String( String(
data.disabled data.disabled
? 'data-choice-disabled aria-disabled="true"' ? 'data-choice-disabled aria-disabled="true"'
: 'data-choice-selectable', : 'data-choice-selectable'
) + ) +
'\ '\
data-id="' + data-id="' +
@ -896,7 +878,7 @@
'"\ '"\
' + ' +
String( String(
data.groupId > 0 ? 'role="treeitem"' : 'role="option"', data.groupId > 0 ? 'role="treeitem"' : 'role="option"'
) + ) +
'\ '\
>\ >\
@ -904,12 +886,12 @@
String(data.label) + String(data.label) +
'\ '\
</div>\ </div>\
', '
); );
}, },
}; };
}, },
}, }
); );
var resetSimple = new Choices(document.getElementById('reset-simple')); var resetSimple = new Choices(document.getElementById('reset-simple'));

View file

@ -15,43 +15,46 @@
<link <link
rel="apple-touch-icon" rel="apple-touch-icon"
sizes="180x180" sizes="180x180"
href="../assets/images/apple-touch-icon.png" href="../../assets/images/apple-touch-icon.png"
/> />
<link <link
rel="icon" rel="icon"
type="image/png" type="image/png"
href="../assets/images/favicon-32x32.png" href="../../assets/images/favicon-32x32.png"
sizes="32x32" sizes="32x32"
/> />
<link <link
rel="icon" rel="icon"
type="image/png" type="image/png"
href="../assets/images/favicon-16x16.png" href="../../assets/images/favicon-16x16.png"
sizes="16x16" sizes="16x16"
/> />
<link rel="manifest" href="../assets/images/manifest.json" /> <link rel="manifest" href="../../assets/images/manifest.json" />
<link <link
rel="mask-icon" rel="mask-icon"
href="../assets/images/safari-pinned-tab.svg" href="../../assets/images/safari-pinned-tab.svg"
color="#00bcd4" color="#00bcd4"
/> />
<link rel="shortcut icon" href="../assets/images/favicon.ico" /> <link rel="shortcut icon" href="../../assets/images/favicon.ico" />
<meta <meta
name="msapplication-config" name="msapplication-config"
content="../assets/images/browserconfig.xml" content="../../assets/images/browserconfig.xml"
/> />
<meta name="theme-color" content="#ffffff" /> <meta name="theme-color" content="#ffffff" />
<!-- Ignore these --> <!-- Ignore these -->
<link rel="stylesheet" href="../assets/styles/base.min.css?version=6.0.3" /> <link
rel="stylesheet"
href="../../assets/styles/base.min.css?version=6.0.3"
/>
<!-- End ignore these --> <!-- End ignore these -->
<!-- Choices includes --> <!-- Choices includes -->
<link <link
rel="stylesheet" rel="stylesheet"
href="../assets/styles/choices.min.css?version=6.0.3" href="../../assets/styles/choices.min.css?version=6.0.3"
/> />
<script src="../assets/scripts/choices.min.js?version=6.0.3"></script> <script src="../../assets/scripts/choices.min.js?version=6.0.3"></script>
<!-- End Choices includes --> <!-- End Choices includes -->
</head> </head>
@ -194,14 +197,34 @@
</select> </select>
</div> </div>
<div data-test-hook="placeholder"> <div data-test-hook="placeholder-via-option-value">
<label for="choices-placeholder">Placeholder</label> <label for="choices-placeholder-via-option-value"
>Placeholder via empty option value</label
>
<select <select
class="form-control" class="form-control"
name="choices-placeholder" name="choices-placeholder-via-option-value"
id="choices-placeholder" id="choices-placeholder-via-option-value"
multiple multiple
> >
<option value="">I am a placeholder</option>
<option value="Choice 1">Choice 1</option>
<option value="Choice 2">Choice 2</option>
<option value="Choice 3">Choice 3</option>
</select>
</div>
<div data-test-hook="placeholder-via-option-attr">
<label for="choices-placeholder-via-option-attr"
>Placeholder via option attribute</label
>
<select
class="form-control"
name="choices-placeholder-via-option-attr"
id="choices-placeholder-via-option-attr"
multiple
>
<option placeholder>I am a placeholder</option>
<option value="Choice 1">Choice 1</option> <option value="Choice 1">Choice 1</option>
<option value="Choice 2">Choice 2</option> <option value="Choice 2">Choice 2</option>
<option value="Choice 3">Choice 3</option> <option value="Choice 3">Choice 3</option>
@ -215,7 +238,9 @@
name="choices-remote-data" name="choices-remote-data"
id="choices-remote-data" id="choices-remote-data"
multiple multiple
></select> >
<option value="">I am a placeholder</option>
</select>
</div> </div>
<div data-test-hook="scrolling-dropdown"> <div data-test-hook="scrolling-dropdown">
@ -373,10 +398,9 @@
searchFloor: 5, searchFloor: 5,
}); });
new Choices('#choices-placeholder', { new Choices('#choices-placeholder-via-option-value');
placeholder: true,
placeholderValue: 'I am a placeholder', new Choices('#choices-placeholder-via-option-attr');
});
new Choices('#choices-remote-data', { new Choices('#choices-remote-data', {
shouldSort: false, shouldSort: false,

View file

@ -15,43 +15,46 @@
<link <link
rel="apple-touch-icon" rel="apple-touch-icon"
sizes="180x180" sizes="180x180"
href="../assets/images/apple-touch-icon.png" href="../../assets/images/apple-touch-icon.png"
/> />
<link <link
rel="icon" rel="icon"
type="image/png" type="image/png"
href="../assets/images/favicon-32x32.png" href="../../assets/images/favicon-32x32.png"
sizes="32x32" sizes="32x32"
/> />
<link <link
rel="icon" rel="icon"
type="image/png" type="image/png"
href="../assets/images/favicon-16x16.png" href="../../assets/images/favicon-16x16.png"
sizes="16x16" sizes="16x16"
/> />
<link rel="manifest" href="../assets/images/manifest.json" /> <link rel="manifest" href="../../assets/images/manifest.json" />
<link <link
rel="mask-icon" rel="mask-icon"
href="../assets/images/safari-pinned-tab.svg" href="../../assets/images/safari-pinned-tab.svg"
color="#00bcd4" color="#00bcd4"
/> />
<link rel="shortcut icon" href="../assets/images/favicon.ico" /> <link rel="shortcut icon" href="../../assets/images/favicon.ico" />
<meta <meta
name="msapplication-config" name="msapplication-config"
content="../assets/images/browserconfig.xml" content="../../assets/images/browserconfig.xml"
/> />
<meta name="theme-color" content="#ffffff" /> <meta name="theme-color" content="#ffffff" />
<!-- Ignore these --> <!-- Ignore these -->
<link rel="stylesheet" href="../assets/styles/base.min.css?version=6.0.3" /> <link
rel="stylesheet"
href="../../assets/styles/base.min.css?version=6.0.3"
/>
<!-- End ignore these --> <!-- End ignore these -->
<!-- Choices includes --> <!-- Choices includes -->
<link <link
rel="stylesheet" rel="stylesheet"
href="../assets/styles/choices.min.css?version=6.0.3" href="../../assets/styles/choices.min.css?version=6.0.3"
/> />
<script src="../assets/scripts/choices.min.js?version=6.0.3"></script> <script src="../../assets/scripts/choices.min.js?version=6.0.3"></script>
<!-- End Choices includes --> <!-- End Choices includes -->
</head> </head>
@ -99,6 +102,18 @@
</select> </select>
</div> </div>
<div data-test-hook="disabled-first-choice-via-options">
<label for="choices-disabled-choice-via-options"
>Disabled first choice by options</label
>
<select
class="form-control"
name="choices-disabled-choice-via-options"
id="choices-disabled-choice-via-options"
>
</select>
</div>
<div data-test-hook="add-items-disabled"> <div data-test-hook="add-items-disabled">
<label for="choices-add-items-disabled">Add items disabled</label> <label for="choices-add-items-disabled">Add items disabled</label>
<select <select
@ -178,13 +193,47 @@
</select> </select>
</div> </div>
<div data-test-hook="placeholder-via-option-value">
<label for="choices-placeholder-via-option-value"
>Placeholder via empty option value</label
>
<select
class="form-control"
name="choices-placeholder-via-option-value"
id="choices-placeholder-via-option-value"
>
<option value="">I am a placeholder</option>
<option value="Choice 1">Choice 1</option>
<option value="Choice 2">Choice 2</option>
<option value="Choice 3">Choice 3</option>
</select>
</div>
<div data-test-hook="placeholder-via-option-attr">
<label for="choices-placeholder-via-option-attr"
>Placeholder via option attribute</label
>
<select
class="form-control"
name="choices-placeholder-via-option-attr"
id="choices-placeholder-via-option-attr"
>
<option placeholder>I am a placeholder</option>
<option value="Choice 1">Choice 1</option>
<option value="Choice 2">Choice 2</option>
<option value="Choice 3">Choice 3</option>
</select>
</div>
<div data-test-hook="remote-data"> <div data-test-hook="remote-data">
<label for="choices-remote-data">Remote data</label> <label for="choices-remote-data">Remote data</label>
<select <select
class="form-control" class="form-control"
name="choices-remote-data" name="choices-remote-data"
id="choices-remote-data" id="choices-remote-data"
></select> >
<option value="">I am a placeholder</option>
</select>
</div> </div>
<div data-test-hook="scrolling-dropdown"> <div data-test-hook="scrolling-dropdown">
@ -313,7 +362,11 @@
<div data-test-hook="new-destroy-init"> <div data-test-hook="new-destroy-init">
<label for="choices-new-destroy-init">New, Destroy, Init</label> <label for="choices-new-destroy-init">New, Destroy, Init</label>
<select class="form-control" name="choices-new-destroy-init" id="choices-new-destroy-init"> <select
class="form-control"
name="choices-new-destroy-init"
id="choices-new-destroy-init"
>
<option value="Choice 1">Choice 1</option> <option value="Choice 1">Choice 1</option>
<option value="Choice 2">Choice 2</option> <option value="Choice 2">Choice 2</option>
<option value="Choice 3">Choice 3</option> <option value="Choice 3">Choice 3</option>
@ -347,6 +400,29 @@
removeItemButton: true, removeItemButton: true,
}); });
new Choices('#choices-disabled-choice-via-options', {
removeItemButton: true,
choices: [
{
value: 'Choice 1',
label: 'Choice 1',
disabled: true,
},
{
value: 'Choice 2',
label: 'Choice 2',
},
{
value: 'Choice 3',
label: 'Choice 3',
},
{
value: 'Choice 4',
label: 'Choice 4',
},
],
});
new Choices('#choices-add-items-disabled', { new Choices('#choices-add-items-disabled', {
addItems: false, addItems: false,
}); });
@ -370,6 +446,10 @@
searchFloor: 5, searchFloor: 5,
}); });
new Choices('#choices-placeholder-via-option-value');
new Choices('#choices-placeholder-via-option-attr');
new Choices('#choices-remote-data', { new Choices('#choices-remote-data', {
shouldSort: false, shouldSort: false,
}).setChoices(async () => { }).setChoices(async () => {
@ -460,9 +540,11 @@
new Choices('#choices-search-by-label', { searchFields: ['label'] }); new Choices('#choices-search-by-label', { searchFields: ['label'] });
const newDestroyInitChoices = new Choices('#choices-new-destroy-init'); const newDestroyInitChoices = new Choices('#choices-new-destroy-init');
document.querySelector('button.destroy').addEventListener('click', () => { document
newDestroyInitChoices.destroy(); .querySelector('button.destroy')
}); .addEventListener('click', () => {
newDestroyInitChoices.destroy();
});
document.querySelector('button.init').addEventListener('click', () => { document.querySelector('button.init').addEventListener('click', () => {
newDestroyInitChoices.init(); newDestroyInitChoices.init();
}); });

View file

@ -15,43 +15,46 @@
<link <link
rel="apple-touch-icon" rel="apple-touch-icon"
sizes="180x180" sizes="180x180"
href="../assets/images/apple-touch-icon.png" href="../../assets/images/apple-touch-icon.png"
/> />
<link <link
rel="icon" rel="icon"
type="image/png" type="image/png"
href="../assets/images/favicon-32x32.png" href="../../assets/images/favicon-32x32.png"
sizes="32x32" sizes="32x32"
/> />
<link <link
rel="icon" rel="icon"
type="image/png" type="image/png"
href="../assets/images/favicon-16x16.png" href="../../assets/images/favicon-16x16.png"
sizes="16x16" sizes="16x16"
/> />
<link rel="manifest" href="../assets/images/manifest.json" /> <link rel="manifest" href="../../assets/images/manifest.json" />
<link <link
rel="mask-icon" rel="mask-icon"
href="../assets/images/safari-pinned-tab.svg" href="../../assets/images/safari-pinned-tab.svg"
color="#00bcd4" color="#00bcd4"
/> />
<link rel="shortcut icon" href="../assets/images/favicon.ico" /> <link rel="shortcut icon" href="../../assets/images/favicon.ico" />
<meta <meta
name="msapplication-config" name="msapplication-config"
content="../assets/images/browserconfig.xml" content="../../assets/images/browserconfig.xml"
/> />
<meta name="theme-color" content="#ffffff" /> <meta name="theme-color" content="#ffffff" />
<!-- Ignore these --> <!-- Ignore these -->
<link rel="stylesheet" href="../assets/styles/base.min.css?version=6.0.3" /> <link
rel="stylesheet"
href="../../assets/styles/base.min.css?version=6.0.3"
/>
<!-- End ignore these --> <!-- End ignore these -->
<!-- Choices includes --> <!-- Choices includes -->
<link <link
rel="stylesheet" rel="stylesheet"
href="../assets/styles/choices.min.css?version=6.0.3" href="../../assets/styles/choices.min.css?version=6.0.3"
/> />
<script src="../assets/scripts/choices.min.js?version=6.0.3"></script> <script src="../../assets/scripts/choices.min.js?version=6.0.3"></script>
<!-- End Choices includes --> <!-- End Choices includes -->
</head> </head>

View file

@ -2,8 +2,8 @@
const express = require('express'); const express = require('express');
const path = require('path'); const path = require('path');
const PORT = 3001; const PORT = process.env.PORT || 3001;
const DIST_DIR = path.join(__dirname, 'public'); const DIST_DIR = path.resolve(__dirname, 'public');
const app = express(); const app = express();
@ -51,7 +51,7 @@ const server = app.listen(PORT, err => {
console.log(err); console.log(err);
} }
console.log(`Listening at http://localhost:${PORT} 👂`); console.log(`Listening at http://localhost:${server.address().port} 👂`);
}); });
process.on('SIGTERM', () => { process.on('SIGTERM', () => {
@ -70,3 +70,5 @@ process.on('SIGTERM', () => {
process.exit(0); process.exit(0);
} }
}); });
module.exports = server;

View file

@ -1,5 +1,14 @@
/**
* @typedef {import('redux').Action} Action
* @typedef {import('../../../types/index').Choices.Choice} Choice
*/
import { ACTION_TYPES } from '../constants'; import { ACTION_TYPES } from '../constants';
/**
* @argument {Choice} choice
* @returns {Action & Choice}
*/
export const addChoice = ({ export const addChoice = ({
value, value,
label, label,
@ -23,16 +32,27 @@ export const addChoice = ({
keyCode, keyCode,
}); });
/**
* @argument {Choice[]} results
* @returns {Action & { results: Choice[] }}
*/
export const filterChoices = results => ({ export const filterChoices = results => ({
type: ACTION_TYPES.FILTER_CHOICES, type: ACTION_TYPES.FILTER_CHOICES,
results, results,
}); });
/**
* @argument {boolean} active
* @returns {Action & { active: boolean }}
*/
export const activateChoices = (active = true) => ({ export const activateChoices = (active = true) => ({
type: ACTION_TYPES.ACTIVATE_CHOICES, type: ACTION_TYPES.ACTIVATE_CHOICES,
active, active,
}); });
/**
* @returns {Action}
*/
export const clearChoices = () => ({ export const clearChoices = () => ({
type: ACTION_TYPES.CLEAR_CHOICES, type: ACTION_TYPES.CLEAR_CHOICES,
}); });

View file

@ -1,4 +0,0 @@
export const setIsLoading = isLoading => ({
type: 'SET_IS_LOADING',
isLoading,
});

View file

@ -1,28 +0,0 @@
import { expect } from 'chai';
import * as actions from './general';
describe('actions/general', () => {
describe('setIsLoading action', () => {
describe('setting loading state to true', () => {
it('returns expected action', () => {
const expectedAction = {
type: 'SET_IS_LOADING',
isLoading: true,
};
expect(actions.setIsLoading(true)).to.eql(expectedAction);
});
});
describe('setting loading state to false', () => {
it('returns expected action', () => {
const expectedAction = {
type: 'SET_IS_LOADING',
isLoading: false,
};
expect(actions.setIsLoading(false)).to.eql(expectedAction);
});
});
});
});

View file

@ -1,6 +1,15 @@
import { ACTION_TYPES } from '../constants'; import { ACTION_TYPES } from '../constants';
export const addGroup = (value, id, active, disabled) => ({ /**
* @typedef {import('redux').Action} Action
* @typedef {import('../../../types/index').Choices.Group} Group
*/
/**
* @param {Group} group
* @returns {Action & Group}
*/
export const addGroup = ({ value, id, active, disabled }) => ({
type: ACTION_TYPES.ADD_GROUP, type: ACTION_TYPES.ADD_GROUP,
value, value,
id, id,

View file

@ -16,7 +16,7 @@ describe('actions/groups', () => {
disabled, disabled,
}; };
expect(actions.addGroup(value, id, active, disabled)).to.eql( expect(actions.addGroup({ value, id, active, disabled })).to.eql(
expectedAction, expectedAction,
); );
}); });

View file

@ -1,5 +1,14 @@
import { ACTION_TYPES } from '../constants'; import { ACTION_TYPES } from '../constants';
/**
* @typedef {import('redux').Action} Action
* @typedef {import('../../../types/index').Choices.Item} Item
*/
/**
* @param {Item} item
* @returns {Action & Item}
*/
export const addItem = ({ export const addItem = ({
value, value,
label, label,
@ -21,12 +30,22 @@ export const addItem = ({
keyCode, keyCode,
}); });
/**
* @param {string} id
* @param {string} choiceId
* @returns {Action & { id: string, choiceId: string }}
*/
export const removeItem = (id, choiceId) => ({ export const removeItem = (id, choiceId) => ({
type: ACTION_TYPES.REMOVE_ITEM, type: ACTION_TYPES.REMOVE_ITEM,
id, id,
choiceId, choiceId,
}); });
/**
* @param {string} id
* @param {boolean} highlighted
* @returns {Action & { id: string, highlighted: boolean }}
*/
export const highlightItem = (id, highlighted) => ({ export const highlightItem = (id, highlighted) => ({
type: ACTION_TYPES.HIGHLIGHT_ITEM, type: ACTION_TYPES.HIGHLIGHT_ITEM,
id, id,

View file

@ -9,8 +9,8 @@ describe('actions/items', () => {
const id = '1234'; const id = '1234';
const choiceId = '1234'; const choiceId = '1234';
const groupId = 'test'; const groupId = 'test';
const customProperties = 'test'; const customProperties = { test: true };
const placeholder = 'test'; const placeholder = true;
const keyCode = 10; const keyCode = 10;
const expectedAction = { const expectedAction = {

View file

@ -1,8 +1,28 @@
/**
* @typedef {import('redux').Action} Action
*/
/**
* @returns {Action}
*/
export const clearAll = () => ({ export const clearAll = () => ({
type: 'CLEAR_ALL', type: 'CLEAR_ALL',
}); });
/**
* @param {any} state
* @returns {Action & { state: object }}
*/
export const resetTo = state => ({ export const resetTo = state => ({
type: 'RESET_TO', type: 'RESET_TO',
state, state,
}); });
/**
* @param {boolean} isLoading
* @returns {Action & { isLoading: boolean }}
*/
export const setIsLoading = isLoading => ({
type: 'SET_IS_LOADING',
isLoading,
});

View file

@ -11,4 +11,40 @@ describe('actions/misc', () => {
expect(actions.clearAll()).to.eql(expectedAction); expect(actions.clearAll()).to.eql(expectedAction);
}); });
}); });
describe('resetTo action', () => {
it('returns RESET_TO action', () => {
const state = { test: true };
const expectedAction = {
type: 'RESET_TO',
state,
};
expect(actions.resetTo(state)).to.eql(expectedAction);
});
});
describe('setIsLoading action', () => {
describe('setting loading state to true', () => {
it('returns expected action', () => {
const expectedAction = {
type: 'SET_IS_LOADING',
isLoading: true,
};
expect(actions.setIsLoading(true)).to.eql(expectedAction);
});
});
describe('setting loading state to false', () => {
it('returns expected action', () => {
const expectedAction = {
type: 'SET_IS_LOADING',
isLoading: false,
};
expect(actions.setIsLoading(false)).to.eql(expectedAction);
});
});
});
}); });

View file

@ -10,7 +10,14 @@ import {
WrappedInput, WrappedInput,
WrappedSelect, WrappedSelect,
} from './components'; } from './components';
import { DEFAULT_CONFIG, EVENTS, KEY_CODES } from './constants'; import {
DEFAULT_CONFIG,
EVENTS,
KEY_CODES,
TEXT_TYPE,
SELECT_ONE_TYPE,
SELECT_MULTIPLE_TYPE,
} from './constants';
import { TEMPLATES } from './templates'; import { TEMPLATES } from './templates';
import { import {
addChoice, addChoice,
@ -20,8 +27,7 @@ import {
} from './actions/choices'; } from './actions/choices';
import { addItem, removeItem, highlightItem } from './actions/items'; import { addItem, removeItem, highlightItem } from './actions/items';
import { addGroup } from './actions/groups'; import { addGroup } from './actions/groups';
import { clearAll, resetTo } from './actions/misc'; import { clearAll, resetTo, setIsLoading } from './actions/misc';
import { setIsLoading } from './actions/general';
import { import {
isScrolledIntoView, isScrolledIntoView,
getAdjacentEl, getAdjacentEl,
@ -30,23 +36,30 @@ import {
strToEl, strToEl,
sortByScore, sortByScore,
generateId, generateId,
findAncestorByAttrName,
isIE11,
existsInArray, existsInArray,
cloneObject, cloneObject,
diff, diff,
} from './lib/utils'; } from './lib/utils';
const USER_DEFAULTS = /** @type {Partial<import('../../types/index').Choices.Options>} */ ({}); /** @see {@link http://browserhacks.com/#hack-acea075d0ac6954f275a70023906050c} */
const IS_IE11 =
'-ms-scroll-limit' in document.documentElement.style &&
'-ms-ime-align' in document.documentElement.style;
/**
* @typedef {import('../../types/index').Choices.Choice} Choice
* @typedef {import('../../types/index').Choices.Item} Item
* @typedef {import('../../types/index').Choices.Group} Group
* @typedef {import('../../types/index').Choices.Options} Options
*/
/** @type {Partial<Options>} */
const USER_DEFAULTS = {};
/** /**
* Choices * Choices
* @author Josh Johnson<josh@joshuajohnson.co.uk> * @author Josh Johnson<josh@joshuajohnson.co.uk>
*/ */
/**
* @typedef {import('../../types/index').Choices.Choice} Choice
*/
class Choices { class Choices {
static get defaults() { static get defaults() {
return Object.preventExtensions({ return Object.preventExtensions({
@ -61,29 +74,17 @@ class Choices {
/** /**
* @param {string | HTMLInputElement | HTMLSelectElement} element * @param {string | HTMLInputElement | HTMLSelectElement} element
* @param {Partial<import('../../types/index').Choices.Options>} userConfig * @param {Partial<Options>} userConfig
*/ */
constructor(element = '[data-choice]', userConfig = {}) { constructor(element = '[data-choice]', userConfig = {}) {
/** @type {Partial<Options>} */
this.config = merge.all( this.config = merge.all(
[DEFAULT_CONFIG, Choices.defaults.options, userConfig], [DEFAULT_CONFIG, Choices.defaults.options, userConfig],
// When merging array configs, replace with a copy of the userConfig array, // When merging array configs, replace with a copy of the userConfig array,
// instead of concatenating with the default array // instead of concatenating with the default array
{ arrayMerge: (destinationArray, sourceArray) => [...sourceArray] }, { arrayMerge: (_, sourceArray) => [...sourceArray] },
); );
// Convert addItemFilter to function
if (
userConfig.addItemFilter &&
typeof userConfig.addItemFilter !== 'function'
) {
const re =
userConfig.addItemFilter instanceof RegExp
? userConfig.addItemFilter
: new RegExp(userConfig.addItemFilter);
this.config.addItemFilter = re.test.bind(re);
}
const invalidConfigOptions = diff(this.config, DEFAULT_CONFIG); const invalidConfigOptions = diff(this.config, DEFAULT_CONFIG);
if (invalidConfigOptions.length) { if (invalidConfigOptions.length) {
console.warn( console.warn(
@ -92,10 +93,6 @@ class Choices {
); );
} }
if (!['auto', 'always'].includes(this.config.renderSelectedChoices)) {
this.config.renderSelectedChoices = 'auto';
}
const passedElement = const passedElement =
typeof element === 'string' ? document.querySelector(element) : element; typeof element === 'string' ? document.querySelector(element) : element;
@ -110,12 +107,31 @@ class Choices {
); );
} }
this._isTextElement = passedElement.type === 'text'; this._isTextElement = passedElement.type === TEXT_TYPE;
this._isSelectOneElement = passedElement.type === 'select-one'; this._isSelectOneElement = passedElement.type === SELECT_ONE_TYPE;
this._isSelectMultipleElement = passedElement.type === 'select-multiple'; this._isSelectMultipleElement = passedElement.type === SELECT_MULTIPLE_TYPE;
this._isSelectElement = this._isSelectElement =
this._isSelectOneElement || this._isSelectMultipleElement; this._isSelectOneElement || this._isSelectMultipleElement;
this.config.searchEnabled =
this._isSelectMultipleElement || this.config.searchEnabled;
if (!['auto', 'always'].includes(this.config.renderSelectedChoices)) {
this.config.renderSelectedChoices = 'auto';
}
if (
userConfig.addItemFilter &&
typeof userConfig.addItemFilter !== 'function'
) {
const re =
userConfig.addItemFilter instanceof RegExp
? userConfig.addItemFilter
: new RegExp(userConfig.addItemFilter);
this.config.addItemFilter = re.test.bind(re);
}
if (this._isTextElement) { if (this._isTextElement) {
this.passedElement = new WrappedInput({ this.passedElement = new WrappedInput({
element: passedElement, element: passedElement,
@ -149,7 +165,8 @@ class Choices {
* or when calculated direction is different from the document * or when calculated direction is different from the document
* @type {HTMLElement['dir']} * @type {HTMLElement['dir']}
*/ */
this._direction = this.passedElement.element.dir; this._direction = this.passedElement.dir;
if (!this._direction) { if (!this._direction) {
const { direction: elementDirection } = window.getComputedStyle( const { direction: elementDirection } = window.getComputedStyle(
this.passedElement.element, this.passedElement.element,
@ -161,17 +178,35 @@ class Choices {
this._direction = elementDirection; this._direction = elementDirection;
} }
} }
this._idNames = { this._idNames = {
itemChoice: 'item-choice', itemChoice: 'item-choice',
}; };
// Assign preset groups from passed element
this._presetGroups = this.passedElement.optionGroups;
// Assign preset choices from passed object
this._presetChoices = this.config.choices;
// Assign preset items from passed object first // Assign preset items from passed object first
this._presetItems = this.config.items; this._presetItems = this.config.items;
// Then add any values passed from attribute // Add any values passed from attribute
if (this.passedElement.value) { if (this.passedElement.value) {
this._presetItems = this._presetItems.concat( this._presetItems = this._presetItems.concat(
this.passedElement.value.split(this.config.delimiter), this.passedElement.value.split(this.config.delimiter),
); );
} }
// Create array of choices from option elements
if (this.passedElement.options) {
this.passedElement.options.forEach(o => {
this._presetChoices.push({
value: o.value,
label: o.innerHTML,
selected: o.selected,
disabled: o.disabled || o.parentNode.disabled,
placeholder: o.value === '' || o.hasAttribute('placeholder'),
customProperties: o.getAttribute('data-custom-properties'),
});
});
}
this._render = this._render.bind(this); this._render = this._render.bind(this);
this._onFocus = this._onFocus.bind(this); this._onFocus = this._onFocus.bind(this);
@ -190,16 +225,8 @@ class Choices {
this._onDirectionKey = this._onDirectionKey.bind(this); this._onDirectionKey = this._onDirectionKey.bind(this);
this._onDeleteKey = this._onDeleteKey.bind(this); this._onDeleteKey = this._onDeleteKey.bind(this);
if (this.config.shouldSortItems === true && this._isSelectOneElement) {
if (!this.config.silent) {
console.warn(
"shouldSortElements: Type of passed element is 'select-one', falling back to false.",
);
}
}
// If element has already been initialised with Choices, fail silently // If element has already been initialised with Choices, fail silently
if (this.passedElement.element.getAttribute('data-choice') === 'active') { if (this.passedElement.isActive) {
if (!this.config.silent) { if (!this.config.silent) {
console.warn( console.warn(
'Trying to initialise Choices on element already initialised', 'Trying to initialise Choices on element already initialised',
@ -463,7 +490,7 @@ class Choices {
* *
* **Input types affected:** select-one, select-multiple * **Input types affected:** select-one, select-multiple
* *
* @template {object[] | ((instance: Choices) => object[] | Promise<object[]>)} T * @template {Choice[] | ((instance: Choices) => object[] | Promise<object[]>)} T
* @param {T} [choicesArrayOrFetcher] * @param {T} [choicesArrayOrFetcher]
* @param {string} [value = 'value'] - name of `value` field * @param {string} [value = 'value'] - name of `value` field
* @param {string} [label = 'label'] - name of 'label' field * @param {string} [label = 'label'] - name of 'label' field
@ -550,19 +577,16 @@ class Choices {
this.clearChoices(); this.clearChoices();
} }
if (!Array.isArray(choicesArrayOrFetcher)) { if (typeof choicesArrayOrFetcher === 'function') {
if (typeof choicesArrayOrFetcher !== 'function') { // it's a choices fetcher function
throw new TypeError(
`.setChoices must be called either with array of choices with a function resulting into Promise of array of choices`,
);
}
// it's a choices fetcher
requestAnimationFrame(() => this._handleLoadingState(true));
const fetcher = choicesArrayOrFetcher(this); const fetcher = choicesArrayOrFetcher(this);
if (typeof fetcher === 'object' && typeof fetcher.then === 'function') {
if (typeof Promise === 'function' && fetcher instanceof Promise) {
// that's a promise // that's a promise
return fetcher // eslint-disable-next-line compat/compat
return new Promise(resolve => requestAnimationFrame(resolve))
.then(() => this._handleLoadingState(true))
.then(() => fetcher)
.then(data => this.setChoices(data, value, label, replaceChoices)) .then(data => this.setChoices(data, value, label, replaceChoices))
.catch(err => { .catch(err => {
if (!this.config.silent) { if (!this.config.silent) {
@ -572,6 +596,7 @@ class Choices {
.then(() => this._handleLoadingState(false)) .then(() => this._handleLoadingState(false))
.then(() => this); .then(() => this);
} }
// function returned something else than promise, let's check if it's an array of choices // function returned something else than promise, let's check if it's an array of choices
if (!Array.isArray(fetcher)) { if (!Array.isArray(fetcher)) {
throw new TypeError( throw new TypeError(
@ -583,13 +608,21 @@ class Choices {
return this.setChoices(fetcher, value, label, false); return this.setChoices(fetcher, value, label, false);
} }
if (!Array.isArray(choicesArrayOrFetcher)) {
throw new TypeError(
`.setChoices must be called either with array of choices with a function resulting into Promise of array of choices`,
);
}
this.containerOuter.removeLoadingState(); this.containerOuter.removeLoadingState();
const addGroupsAndChoices = groupOrChoice => { this._startLoading();
choicesArrayOrFetcher.forEach(groupOrChoice => {
if (groupOrChoice.choices) { if (groupOrChoice.choices) {
this._addGroup({ this._addGroup({
id: parseInt(groupOrChoice.id, 10) || null,
group: groupOrChoice, group: groupOrChoice,
id: groupOrChoice.id || null,
valueKey: value, valueKey: value,
labelKey: label, labelKey: label,
}); });
@ -603,11 +636,9 @@ class Choices {
placeholder: groupOrChoice.placeholder, placeholder: groupOrChoice.placeholder,
}); });
} }
}; });
this._setLoading(true); this._stopLoading();
choicesArrayOrFetcher.forEach(addGroupsAndChoices);
this._setLoading(false);
return this; return this;
} }
@ -776,7 +807,7 @@ class Choices {
// If sorting is enabled, filter groups // If sorting is enabled, filter groups
if (this.config.shouldSort) { if (this.config.shouldSort) {
groups.sort(this.config.sortFn); groups.sort(this.config.sorter);
} }
groups.forEach(group => { groups.forEach(group => {
@ -802,7 +833,7 @@ class Choices {
searchResultLimit, searchResultLimit,
renderChoiceLimit, renderChoiceLimit,
} = this.config; } = this.config;
const filter = this._isSearching ? sortByScore : this.config.sortFn; const filter = this._isSearching ? sortByScore : this.config.sorter;
const appendChoice = choice => { const appendChoice = choice => {
const shouldRender = const shouldRender =
renderSelectedChoices === 'auto' renderSelectedChoices === 'auto'
@ -846,11 +877,13 @@ class Choices {
let choiceLimit = rendererableChoices.length; let choiceLimit = rendererableChoices.length;
// Prepend placeholeder // Prepend placeholeder
const sortedChoices = [...placeholderChoices, ...normalChoices]; const sortedChoices = this._isSelectOneElement
? [...placeholderChoices, ...normalChoices]
: normalChoices;
if (this._isSearching) { if (this._isSearching) {
choiceLimit = searchResultLimit; choiceLimit = searchResultLimit;
} else if (renderChoiceLimit > 0 && !withinGroup) { } else if (renderChoiceLimit && renderChoiceLimit > 0 && !withinGroup) {
choiceLimit = renderChoiceLimit; choiceLimit = renderChoiceLimit;
} }
@ -866,11 +899,11 @@ class Choices {
_createItemsFragment(items, fragment = document.createDocumentFragment()) { _createItemsFragment(items, fragment = document.createDocumentFragment()) {
// Create fragment to add elements to // Create fragment to add elements to
const { shouldSortItems, sortFn, removeItemButton } = this.config; const { shouldSortItems, sorter, removeItemButton } = this.config;
// If sorting is enabled, filter items // If sorting is enabled, filter items
if (shouldSortItems && !this._isSelectOneElement) { if (shouldSortItems && !this._isSelectOneElement) {
items.sort(sortFn); items.sort(sorter);
} }
if (this._isTextElement) { if (this._isTextElement) {
@ -889,7 +922,7 @@ class Choices {
}; };
// Add each list item to list // Add each list item to list
items.forEach(item => addItemToFragment(item)); items.forEach(addItemToFragment);
return fragment; return fragment;
} }
@ -1045,8 +1078,12 @@ class Choices {
} }
} }
_setLoading(isLoading) { _startLoading() {
this._store.dispatch(setIsLoading(isLoading)); this._store.dispatch(setIsLoading(true));
}
_stopLoading() {
this._store.dispatch(setIsLoading(false));
} }
_handleLoadingState(setLoading = true) { _handleLoadingState(setLoading = true) {
@ -1194,16 +1231,24 @@ class Choices {
const { documentElement } = document; const { documentElement } = document;
// capture events - can cancel event processing or propagation // capture events - can cancel event processing or propagation
documentElement.addEventListener('keydown', this._onKeyDown, true);
documentElement.addEventListener('touchend', this._onTouchEnd, true); documentElement.addEventListener('touchend', this._onTouchEnd, true);
documentElement.addEventListener('mousedown', this._onMouseDown, true); this.containerOuter.element.addEventListener(
'keydown',
this._onKeyDown,
true,
);
this.containerOuter.element.addEventListener(
'mousedown',
this._onMouseDown,
true,
);
// passive events - doesn't call `preventDefault` or `stopPropagation` // passive events - doesn't call `preventDefault` or `stopPropagation`
documentElement.addEventListener('click', this._onClick, { passive: true }); documentElement.addEventListener('click', this._onClick, { passive: true });
documentElement.addEventListener('touchmove', this._onTouchMove, { documentElement.addEventListener('touchmove', this._onTouchMove, {
passive: true, passive: true,
}); });
documentElement.addEventListener('mouseover', this._onMouseOver, { this.dropdown.element.addEventListener('mouseover', this._onMouseOver, {
passive: true, passive: true,
}); });
@ -1239,58 +1284,43 @@ class Choices {
_removeEventListeners() { _removeEventListeners() {
const { documentElement } = document; const { documentElement } = document;
documentElement.removeEventListener('keydown', this._onKeyDown, true);
documentElement.removeEventListener('touchend', this._onTouchEnd, true); documentElement.removeEventListener('touchend', this._onTouchEnd, true);
documentElement.removeEventListener('mousedown', this._onMouseDown, true); this.containerOuter.element.removeEventListener(
'keydown',
this._onKeyDown,
true,
);
this.containerOuter.element.removeEventListener(
'mousedown',
this._onMouseDown,
true,
);
documentElement.removeEventListener('keyup', this._onKeyUp, { documentElement.removeEventListener('click', this._onClick);
passive: true, documentElement.removeEventListener('touchmove', this._onTouchMove);
}); this.dropdown.element.removeEventListener('mouseover', this._onMouseOver);
documentElement.removeEventListener('click', this._onClick, {
passive: true,
});
documentElement.removeEventListener('touchmove', this._onTouchMove, {
passive: true,
});
documentElement.removeEventListener('mouseover', this._onMouseOver, {
passive: true,
});
if (this._isSelectOneElement) { if (this._isSelectOneElement) {
this.containerOuter.element.removeEventListener('focus', this._onFocus, { this.containerOuter.element.removeEventListener('focus', this._onFocus);
passive: true, this.containerOuter.element.removeEventListener('blur', this._onBlur);
});
this.containerOuter.element.removeEventListener('blur', this._onBlur, {
passive: true,
});
} }
this.input.element.removeEventListener('focus', this._onFocus, { this.input.element.removeEventListener('keyup', this._onKeyUp);
passive: true, this.input.element.removeEventListener('focus', this._onFocus);
}); this.input.element.removeEventListener('blur', this._onBlur);
this.input.element.removeEventListener('blur', this._onBlur, {
passive: true,
});
if (this.input.element.form) { if (this.input.element.form) {
this.input.element.form.removeEventListener('reset', this._onFormReset, { this.input.element.form.removeEventListener('reset', this._onFormReset);
passive: true,
});
} }
this.input.removeEventListeners(); this.input.removeEventListeners();
} }
/**
* @param {KeyboardEvent} event
*/
_onKeyDown(event) { _onKeyDown(event) {
const { target, keyCode, ctrlKey, metaKey } = event; const { target, keyCode, ctrlKey, metaKey } = event;
if (
target !== this.input.element &&
!this.containerOuter.element.contains(target)
) {
return;
}
const { activeItems } = this._store; const { activeItems } = this._store;
const hasFocusedInput = this.input.isFocussed; const hasFocusedInput = this.input.isFocussed;
const hasActiveDropdown = this.dropdown.isActive; const hasActiveDropdown = this.dropdown.isActive;
@ -1494,7 +1524,7 @@ class Choices {
if ( if (
!isScrolledIntoView(nextEl, this.choiceList.element, directionInt) !isScrolledIntoView(nextEl, this.choiceList.element, directionInt)
) { ) {
this.choiceList.scrollToChoice(nextEl, directionInt); this.choiceList.scrollToChildElement(nextEl, directionInt);
} }
this._highlightChoice(nextEl); this._highlightChoice(nextEl);
} }
@ -1544,47 +1574,55 @@ class Choices {
this._wasTap = true; this._wasTap = true;
} }
/**
* Handles mousedown event in capture mode for containetOuter.element
* @param {MouseEvent} event
*/
_onMouseDown(event) { _onMouseDown(event) {
const { target, shiftKey } = event; const { target } = event;
// If we have our mouse down on the scrollbar and are on IE11... if (!(target instanceof HTMLElement)) {
if (
this.choiceList.element.contains(target) &&
isIE11(navigator.userAgent)
) {
this._isScrollingOnIe = true;
}
if (
!this.containerOuter.element.contains(target) ||
target === this.input.element
) {
return; return;
} }
const { activeItems } = this._store; // If we have our mouse down on the scrollbar and are on IE11...
const hasShiftKey = shiftKey; if (IS_IE11 && this.choiceList.element.contains(target)) {
const buttonTarget = findAncestorByAttrName(target, 'data-button'); // check if click was on a scrollbar area
const itemTarget = findAncestorByAttrName(target, 'data-item'); const firstChoice = /** @type {HTMLElement} */ (this.choiceList.element
const choiceTarget = findAncestorByAttrName(target, 'data-choice'); .firstElementChild);
const isOnScrollbar =
if (buttonTarget) { this._direction === 'ltr'
this._handleButtonAction(activeItems, buttonTarget); ? event.offsetX >= firstChoice.offsetWidth
} else if (itemTarget) { : event.offsetX < firstChoice.offsetLeft;
this._handleItemAction(activeItems, itemTarget, hasShiftKey); this._isScrollingOnIe = isOnScrollbar;
} else if (choiceTarget) {
this._handleChoiceAction(activeItems, choiceTarget);
} }
if (target === this.input.element) {
return;
}
const item = target.closest('[data-button],[data-item],[data-choice]');
if (item instanceof HTMLElement) {
const hasShiftKey = event.shiftKey;
const { activeItems } = this._store;
const { dataset } = item;
if ('button' in dataset) {
this._handleButtonAction(activeItems, item);
} else if ('item' in dataset) {
this._handleItemAction(activeItems, item, hasShiftKey);
} else if ('choice' in dataset) {
this._handleChoiceAction(activeItems, item);
}
}
event.preventDefault(); event.preventDefault();
} }
/**
* Handles mouseover event over this.dropdown
* @param {MouseEvent} event
*/
_onMouseOver({ target }) { _onMouseOver({ target }) {
const targetWithinDropdown = if (target instanceof HTMLElement && 'choice' in target.dataset) {
target === this.dropdown || this.dropdown.element.contains(target);
const shouldHighlightChoice =
targetWithinDropdown && target.hasAttribute('data-choice');
if (shouldHighlightChoice) {
this._highlightChoice(target); this._highlightChoice(target);
} }
} }
@ -1633,18 +1671,18 @@ class Choices {
} }
const focusActions = { const focusActions = {
text: () => { [TEXT_TYPE]: () => {
if (target === this.input.element) { if (target === this.input.element) {
this.containerOuter.addFocusState(); this.containerOuter.addFocusState();
} }
}, },
'select-one': () => { [SELECT_ONE_TYPE]: () => {
this.containerOuter.addFocusState(); this.containerOuter.addFocusState();
if (target === this.input.element) { if (target === this.input.element) {
this.showDropdown(true); this.showDropdown(true);
} }
}, },
'select-multiple': () => { [SELECT_MULTIPLE_TYPE]: () => {
if (target === this.input.element) { if (target === this.input.element) {
this.showDropdown(true); this.showDropdown(true);
// If element is a select box, the focused element is the container and the dropdown // If element is a select box, the focused element is the container and the dropdown
@ -1664,7 +1702,7 @@ class Choices {
const { activeItems } = this._store; const { activeItems } = this._store;
const hasHighlightedItems = activeItems.some(item => item.highlighted); const hasHighlightedItems = activeItems.some(item => item.highlighted);
const blurActions = { const blurActions = {
text: () => { [TEXT_TYPE]: () => {
if (target === this.input.element) { if (target === this.input.element) {
this.containerOuter.removeFocusState(); this.containerOuter.removeFocusState();
if (hasHighlightedItems) { if (hasHighlightedItems) {
@ -1673,7 +1711,7 @@ class Choices {
this.hideDropdown(true); this.hideDropdown(true);
} }
}, },
'select-one': () => { [SELECT_ONE_TYPE]: () => {
this.containerOuter.removeFocusState(); this.containerOuter.removeFocusState();
if ( if (
target === this.input.element || target === this.input.element ||
@ -1682,7 +1720,7 @@ class Choices {
this.hideDropdown(true); this.hideDropdown(true);
} }
}, },
'select-multiple': () => { [SELECT_MULTIPLE_TYPE]: () => {
if (target === this.input.element) { if (target === this.input.element) {
this.containerOuter.removeFocusState(); this.containerOuter.removeFocusState();
this.hideDropdown(true); this.hideDropdown(true);
@ -1773,7 +1811,7 @@ class Choices {
const passedCustomProperties = customProperties; const passedCustomProperties = customProperties;
const { items } = this._store; const { items } = this._store;
const passedLabel = label || passedValue; const passedLabel = label || passedValue;
const passedOptionId = parseInt(choiceId, 10) || -1; const passedOptionId = choiceId || -1;
const group = groupId >= 0 ? this._store.getGroupById(groupId) : null; const group = groupId >= 0 ? this._store.getGroupById(groupId) : null;
const id = items ? items.length + 1 : 1; const id = items ? items.length + 1 : 1;
@ -1867,12 +1905,12 @@ class Choices {
this._store.dispatch( this._store.dispatch(
addChoice({ addChoice({
value,
label: choiceLabel,
id: choiceId, id: choiceId,
groupId, groupId,
disabled: isDisabled,
elementId: choiceElementId, elementId: choiceElementId,
value,
label: choiceLabel,
disabled: isDisabled,
customProperties, customProperties,
placeholder, placeholder,
keyCode, keyCode,
@ -1899,7 +1937,14 @@ class Choices {
const isDisabled = group.disabled ? group.disabled : false; const isDisabled = group.disabled ? group.disabled : false;
if (groupChoices) { if (groupChoices) {
this._store.dispatch(addGroup(group.label, groupId, true, isDisabled)); this._store.dispatch(
addGroup({
value: group.label,
id: groupId,
active: true,
disabled: isDisabled,
}),
);
const addGroupChoices = choice => { const addGroupChoices = choice => {
const isOptDisabled = const isOptDisabled =
@ -1919,7 +1964,12 @@ class Choices {
groupChoices.forEach(addGroupChoices); groupChoices.forEach(addGroupChoices);
} else { } else {
this._store.dispatch( this._store.dispatch(
addGroup(group.label, group.id, false, group.disabled), addGroup({
value: group.label,
id: group.id,
active: false,
disabled: group.disabled,
}),
); );
} }
} }
@ -2004,7 +2054,7 @@ class Choices {
this.input.placeholder = this.config.searchPlaceholderValue || ''; this.input.placeholder = this.config.searchPlaceholderValue || '';
} else if (this._placeholderValue) { } else if (this._placeholderValue) {
this.input.placeholder = this._placeholderValue; this.input.placeholder = this._placeholderValue;
this.input.setWidth(true); this.input.setWidth();
} }
this.containerOuter.element.appendChild(this.containerInner.element); this.containerOuter.element.appendChild(this.containerInner.element);
@ -2025,116 +2075,112 @@ class Choices {
} }
if (this._isSelectElement) { if (this._isSelectElement) {
this._addPredefinedChoices(); this._highlightPosition = 0;
} else if (this._isTextElement) { this._isSearching = false;
this._addPredefinedItems(); this._startLoading();
if (this._presetGroups.length) {
this._addPredefinedGroups(this._presetGroups);
} else {
this._addPredefinedChoices(this._presetChoices);
}
this._stopLoading();
}
if (this._isTextElement) {
this._addPredefinedItems(this._presetItems);
} }
} }
_addPredefinedChoices() { _addPredefinedGroups(groups) {
const passedGroups = this.passedElement.optionGroups; // If we have a placeholder option
const placeholderChoice = this.passedElement.placeholderOption;
this._highlightPosition = 0; if (
this._isSearching = false; placeholderChoice &&
this._setLoading(true); placeholderChoice.parentNode.tagName === 'SELECT'
) {
if (passedGroups && passedGroups.length) { this._addChoice({
// If we have a placeholder option value: placeholderChoice.value,
const placeholderChoice = this.passedElement.placeholderOption; label: placeholderChoice.innerHTML,
if ( isSelected: placeholderChoice.selected,
placeholderChoice && isDisabled: placeholderChoice.disabled,
placeholderChoice.parentNode.tagName === 'SELECT' placeholder: true,
) {
this._addChoice({
value: placeholderChoice.value,
label: placeholderChoice.innerHTML,
isSelected: placeholderChoice.selected,
isDisabled: placeholderChoice.disabled,
placeholder: true,
});
}
passedGroups.forEach(group =>
this._addGroup({
group,
id: group.id || null,
}),
);
} else {
const passedOptions = this.passedElement.options;
const filter = this.config.sortFn;
const allChoices = [...this.config.choices];
// Create array of options from option elements
passedOptions.forEach(o => {
allChoices.push({
value: o.value,
label: o.innerHTML,
selected: o.selected,
disabled: o.disabled || o.parentNode.disabled,
placeholder: o.hasAttribute('placeholder'),
customProperties: o.getAttribute('data-custom-properties'),
});
}); });
}
// If sorting is enabled or the user is searching, filter choices groups.forEach(group =>
if (this.config.shouldSort) { this._addGroup({
allChoices.sort(filter); group,
} id: group.id || null,
}),
);
}
// Determine whether there is a selected choice _addPredefinedChoices(choices) {
const hasSelectedChoice = allChoices.some(choice => choice.selected); // If sorting is enabled or the user is searching, filter choices
const handleChoice = (choice, index) => { if (this.config.shouldSort) {
const { value, label, customProperties, placeholder } = choice; choices.sort(this.config.sorter);
}
if (this._isSelectElement) { const hasSelectedChoice = choices.some(choice => choice.selected);
// If the choice is actually a group const firstEnabledChoiceIndex = choices.findIndex(
if (choice.choices) { choice => choice.disabled === undefined || !choice.disabled,
this._addGroup({ );
group: choice,
id: choice.id || null,
});
} else {
// If there is a selected choice already or the choice is not
// the first in the array, add each choice normally
// Otherwise pre-select the first choice in the array if it's a single select
const shouldPreselect =
this._isSelectOneElement && !hasSelectedChoice && index === 0;
const isSelected = shouldPreselect ? true : choice.selected;
const isDisabled = shouldPreselect ? false : choice.disabled;
this._addChoice({ choices.forEach((choice, index) => {
value, const { value, label, customProperties, placeholder } = choice;
label,
isSelected, if (this._isSelectElement) {
isDisabled, // If the choice is actually a group
customProperties, if (choice.choices) {
placeholder, this._addGroup({
}); group: choice,
} id: choice.id || null,
});
} else { } else {
/**
* If there is a selected choice already or the choice is not the first in
* the array, add each choice normally.
*
* Otherwise we pre-select the first enabled choice in the array ("select-one" only)
*/
const shouldPreselect =
this._isSelectOneElement &&
!hasSelectedChoice &&
index === firstEnabledChoiceIndex;
const isSelected = shouldPreselect ? true : choice.selected;
const isDisabled = choice.disabled;
this._addChoice({ this._addChoice({
value, value,
label, label,
isSelected: choice.selected, isSelected,
isDisabled: choice.disabled, isDisabled,
customProperties, customProperties,
placeholder, placeholder,
}); });
} }
}; } else {
this._addChoice({
// Add each choice value,
allChoices.forEach((choice, index) => handleChoice(choice, index)); label,
} isSelected: choice.selected,
isDisabled: choice.disabled,
this._setLoading(false); customProperties,
placeholder,
});
}
});
} }
_addPredefinedItems() { /**
const handlePresetItem = item => { * @param {Item[]} items
const itemType = getType(item); */
if (itemType === 'Object' && item.value) { _addPredefinedItems(items) {
items.forEach(item => {
if (typeof item === 'object' && item.value) {
this._addItem({ this._addItem({
value: item.value, value: item.value,
label: item.label, label: item.label,
@ -2142,14 +2188,14 @@ class Choices {
customProperties: item.customProperties, customProperties: item.customProperties,
placeholder: item.placeholder, placeholder: item.placeholder,
}); });
} else if (itemType === 'String') { }
if (typeof item === 'string') {
this._addItem({ this._addItem({
value: item, value: item,
}); });
} }
}; });
this._presetItems.forEach(item => handlePresetItem(item));
} }
_setChoiceOrItem(item) { _setChoiceOrItem(item) {
@ -2204,7 +2250,7 @@ class Choices {
const { choices } = this._store; const { choices } = this._store;
// Check 'value' property exists and the choice isn't already selected // Check 'value' property exists and the choice isn't already selected
const foundChoice = choices.find(choice => const foundChoice = choices.find(choice =>
this.config.itemComparer(choice.value, val), this.config.valueComparer(choice.value, val),
); );
if (foundChoice && !foundChoice.selected) { if (foundChoice && !foundChoice.selected) {
@ -2221,17 +2267,29 @@ class Choices {
} }
_generatePlaceholderValue() { _generatePlaceholderValue() {
if (this._isSelectOneElement) { if (this._isSelectElement) {
return false; const { placeholderOption } = this.passedElement;
return placeholderOption ? placeholderOption.text : false;
} }
return this.config.placeholder const { placeholder, placeholderValue } = this.config;
? this.config.placeholderValue || const {
this.passedElement.element.getAttribute('placeholder') element: { dataset },
: false; } = this.passedElement;
}
/* ===== End of Private functions ====== */ if (placeholder) {
if (placeholderValue) {
return placeholderValue;
}
if (dataset.placeholder) {
return dataset.placeholder;
}
}
return false;
}
} }
export default Choices; export default Choices;

View file

@ -1,10 +1,13 @@
import { expect } from 'chai'; import chai, { expect } from 'chai';
import { spy, stub } from 'sinon'; import { spy, stub } from 'sinon';
import sinonChai from 'sinon-chai';
import Choices from './choices'; import Choices from './choices';
import { EVENTS, ACTION_TYPES, DEFAULT_CONFIG } from './constants'; import { EVENTS, ACTION_TYPES, DEFAULT_CONFIG } from './constants';
import { WrappedSelect, WrappedInput } from './components/index'; import { WrappedSelect, WrappedInput } from './components/index';
chai.use(sinonChai);
describe('choices', () => { describe('choices', () => {
let instance; let instance;
let output; let output;
@ -60,6 +63,36 @@ describe('choices', () => {
...config, ...config,
}); });
}); });
describe('passing the searchEnabled config option with a value of false', () => {
describe('passing a select-multiple element', () => {
it('sets searchEnabled to true', () => {
document.body.innerHTML = `
<select data-choice multiple></select>
`;
instance = new Choices('[data-choice]', {
searchEnabled: false,
});
expect(instance.config.searchEnabled).to.equal(true);
});
});
});
describe('passing the renderSelectedChoices config option with an unexpected value', () => {
it('sets renderSelectedChoices to "auto"', () => {
document.body.innerHTML = `
<select data-choice multiple></select>
`;
instance = new Choices('[data-choice]', {
renderSelectedChoices: 'test',
});
expect(instance.config.renderSelectedChoices).to.equal('auto');
});
});
}); });
}); });
@ -1133,7 +1166,7 @@ describe('choices', () => {
const fetcher = async inst => { const fetcher = async inst => {
expect(inst).to.eq(choice); expect(inst).to.eq(choice);
fetcherCalled = true; fetcherCalled = true;
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 800));
return [ return [
{ label: 'l1', value: 'v1', customProperties: 'prop1' }, { label: 'l1', value: 'v1', customProperties: 'prop1' },
@ -1142,15 +1175,10 @@ describe('choices', () => {
}; };
expect(choice._store.choices.length).to.equal(0); expect(choice._store.choices.length).to.equal(0);
const promise = choice.setChoices(fetcher); const promise = choice.setChoices(fetcher);
await new Promise(resolve =>
requestAnimationFrame(() => {
expect(handleLoadingStateSpy.callCount).to.equal(1);
resolve();
}),
);
expect(fetcherCalled).to.be.true; expect(fetcherCalled).to.be.true;
const res = await promise; const res = await promise;
expect(res).to.equal(choice); expect(res).to.equal(choice);
expect(handleLoadingStateSpy.callCount).to.equal(2);
expect(choice._store.choices[1].value).to.equal('v2'); expect(choice._store.choices[1].value).to.equal('v2');
expect(choice._store.choices[1].label).to.equal('l2'); expect(choice._store.choices[1].label).to.equal('l2');
expect(choice._store.choices[1].customProperties).to.equal('prop2'); expect(choice._store.choices[1].customProperties).to.equal('prop2');
@ -1511,12 +1539,12 @@ describe('choices', () => {
const label = 'label'; const label = 'label';
const choices = [ const choices = [
{ {
id: '1', id: 1,
value: '1', value: '1',
label: 'Test 1', label: 'Test 1',
}, },
{ {
id: '2', id: 2,
value: '2', value: '2',
label: 'Test 2', label: 'Test 2',
}, },
@ -1735,15 +1763,15 @@ describe('choices', () => {
beforeEach(() => { beforeEach(() => {
sortFnStub = stub(); sortFnStub = stub();
instance.config.sortFn = sortFnStub; instance.config.sorter = sortFnStub;
instance.config.shouldSort = true; instance.config.shouldSort = true;
}); });
afterEach(() => { afterEach(() => {
instance.config.sortFn.reset(); instance.config.sorter.reset();
}); });
it('sorts groups by config.sortFn', () => { it('sorts groups by config.sorter', () => {
expect(sortFnStub.called).to.equal(false); expect(sortFnStub.called).to.equal(false);
instance._createGroupsFragment(groups, choices); instance._createGroupsFragment(groups, choices);
expect(sortFnStub.called).to.equal(true); expect(sortFnStub.called).to.equal(true);
@ -1755,12 +1783,12 @@ describe('choices', () => {
beforeEach(() => { beforeEach(() => {
sortFnStub = stub(); sortFnStub = stub();
instance.config.sortFn = sortFnStub; instance.config.sorter = sortFnStub;
instance.config.shouldSort = false; instance.config.shouldSort = false;
}); });
afterEach(() => { afterEach(() => {
instance.config.sortFn.reset(); instance.config.sorter.reset();
}); });
it('does not sort groups', () => { it('does not sort groups', () => {
@ -1878,5 +1906,124 @@ describe('choices', () => {
}); });
}); });
}); });
describe('_generatePlaceholderValue', () => {
describe('select element', () => {
describe('when a placeholder option is defined', () => {
it('returns the text value of the placeholder option', () => {
const placeholderValue = 'I am a placeholder';
instance._isSelectElement = true;
instance.passedElement.placeholderOption = {
text: placeholderValue,
};
const value = instance._generatePlaceholderValue();
expect(value).to.equal(placeholderValue);
});
});
describe('when a placeholder option is not defined', () => {
it('returns false', () => {
instance._isSelectElement = true;
instance.passedElement.placeholderOption = undefined;
const value = instance._generatePlaceholderValue();
expect(value).to.equal(false);
});
});
});
describe('text input', () => {
describe('when the placeholder config option is set to true', () => {
describe('when the placeholderValue config option is defined', () => {
it('returns placeholderValue', () => {
const placeholderValue = 'I am a placeholder';
instance._isSelectElement = false;
instance.config.placeholder = true;
instance.config.placeholderValue = placeholderValue;
const value = instance._generatePlaceholderValue();
expect(value).to.equal(placeholderValue);
});
});
describe('when the placeholderValue config option is not defined', () => {
describe('when the placeholder attribute is defined on the passed element', () => {
it('returns the value of the placeholder attribute', () => {
const placeholderValue = 'I am a placeholder';
instance._isSelectElement = false;
instance.config.placeholder = true;
instance.config.placeholderValue = undefined;
instance.passedElement.element = {
dataset: {
placeholder: placeholderValue,
},
};
const value = instance._generatePlaceholderValue();
expect(value).to.equal(placeholderValue);
});
});
describe('when the placeholder attribute is not defined on the passed element', () => {
it('returns false', () => {
instance._isSelectElement = false;
instance.config.placeholder = true;
instance.config.placeholderValue = undefined;
instance.passedElement.element = {
dataset: {
placeholder: undefined,
},
};
const value = instance._generatePlaceholderValue();
expect(value).to.equal(false);
});
});
});
});
describe('when the placeholder config option is set to false', () => {
it('returns false', () => {
instance._isSelectElement = false;
instance.config.placeholder = false;
const value = instance._generatePlaceholderValue();
expect(value).to.equal(false);
});
});
});
});
describe('_getTemplate', () => {
describe('when not passing a template key', () => {
it('returns null', () => {
output = instance._getTemplate();
expect(output).to.equal(null);
});
});
describe('when passing a template key', () => {
it('returns the generated template for the given template key', () => {
const templateKey = 'test';
const element = document.createElement('div');
const customArg = { test: true };
instance._templates = {
[templateKey]: stub().returns(element),
};
output = instance._getTemplate(templateKey, customArg);
expect(output).to.deep.equal(element);
expect(instance._templates[templateKey]).to.have.been.calledOnceWith(
instance.config.classNames,
customArg,
);
});
});
});
}); });
}); });

View file

@ -1,42 +1,48 @@
import { wrap } from '../lib/utils'; import { wrap } from '../lib/utils';
import { SELECT_ONE_TYPE } from '../constants';
/**
* @typedef {import('../../../types/index').Choices.passedElement} passedElement
* @typedef {import('../../../types/index').Choices.ClassNames} ClassNames
*/
export default class Container { export default class Container {
/**
* @param {{
* element: HTMLElement,
* type: passedElement['type'],
* classNames: ClassNames,
* position
* }} args
*/
constructor({ element, type, classNames, position }) { constructor({ element, type, classNames, position }) {
Object.assign(this, { element, classNames, type, position }); this.element = element;
this.classNames = classNames;
this.type = type;
this.position = position;
this.isOpen = false; this.isOpen = false;
this.isFlipped = false; this.isFlipped = false;
this.isFocussed = false; this.isFocussed = false;
this.isDisabled = false; this.isDisabled = false;
this.isLoading = false; this.isLoading = false;
this._onFocus = this._onFocus.bind(this); this._onFocus = this._onFocus.bind(this);
this._onBlur = this._onBlur.bind(this); this._onBlur = this._onBlur.bind(this);
} }
/**
* Add event listeners
*/
addEventListeners() { addEventListeners() {
this.element.addEventListener('focus', this._onFocus); this.element.addEventListener('focus', this._onFocus);
this.element.addEventListener('blur', this._onBlur); this.element.addEventListener('blur', this._onBlur);
} }
/**
* Remove event listeners
*/
/** */
removeEventListeners() { removeEventListeners() {
this.element.removeEventListener('focus', this._onFocus); this.element.removeEventListener('focus', this._onFocus);
this.element.removeEventListener('blur', this._onBlur); this.element.removeEventListener('blur', this._onBlur);
} }
/** /**
* Determine whether container should be flipped * Determine whether container should be flipped based on passed
* based on passed dropdown position * dropdown position
* @param {Number} dropdownPos * @param {number} dropdownPos
* @returns * @returns {boolean}
*/ */
shouldFlip(dropdownPos) { shouldFlip(dropdownPos) {
if (typeof dropdownPos !== 'number') { if (typeof dropdownPos !== 'number') {
@ -57,20 +63,19 @@ export default class Container {
} }
/** /**
* Set active descendant attribute * @param {string} activeDescendantID
* @param {Number} activeDescendant ID of active descendant
*/ */
setActiveDescendant(activeDescendantID) { setActiveDescendant(activeDescendantID) {
this.element.setAttribute('aria-activedescendant', activeDescendantID); this.element.setAttribute('aria-activedescendant', activeDescendantID);
} }
/**
* Remove active descendant attribute
*/
removeActiveDescendant() { removeActiveDescendant() {
this.element.removeAttribute('aria-activedescendant'); this.element.removeAttribute('aria-activedescendant');
} }
/**
* @param {number} dropdownPos
*/
open(dropdownPos) { open(dropdownPos) {
this.element.classList.add(this.classNames.openState); this.element.classList.add(this.classNames.openState);
this.element.setAttribute('aria-expanded', 'true'); this.element.setAttribute('aria-expanded', 'true');
@ -109,34 +114,34 @@ export default class Container {
this.element.classList.remove(this.classNames.focusState); this.element.classList.remove(this.classNames.focusState);
} }
/**
* Remove disabled state
*/
enable() { enable() {
this.element.classList.remove(this.classNames.disabledState); this.element.classList.remove(this.classNames.disabledState);
this.element.removeAttribute('aria-disabled'); this.element.removeAttribute('aria-disabled');
if (this.type === 'select-one') { if (this.type === SELECT_ONE_TYPE) {
this.element.setAttribute('tabindex', '0'); this.element.setAttribute('tabindex', '0');
} }
this.isDisabled = false; this.isDisabled = false;
} }
/**
* Set disabled state
*/
disable() { disable() {
this.element.classList.add(this.classNames.disabledState); this.element.classList.add(this.classNames.disabledState);
this.element.setAttribute('aria-disabled', 'true'); this.element.setAttribute('aria-disabled', 'true');
if (this.type === 'select-one') { if (this.type === SELECT_ONE_TYPE) {
this.element.setAttribute('tabindex', '-1'); this.element.setAttribute('tabindex', '-1');
} }
this.isDisabled = true; this.isDisabled = true;
} }
/**
* @param {HTMLElement} element
*/
wrap(element) { wrap(element) {
wrap(element, this.element); wrap(element, this.element);
} }
/**
* @param {Element} element
*/
unwrap(element) { unwrap(element) {
// Move passed element outside this element // Move passed element outside this element
this.element.parentNode.insertBefore(element, this.element); this.element.parentNode.insertBefore(element, this.element);
@ -144,34 +149,22 @@ export default class Container {
this.element.parentNode.removeChild(this.element); this.element.parentNode.removeChild(this.element);
} }
/**
* Add loading state to element
*/
addLoadingState() { addLoadingState() {
this.element.classList.add(this.classNames.loadingState); this.element.classList.add(this.classNames.loadingState);
this.element.setAttribute('aria-busy', 'true'); this.element.setAttribute('aria-busy', 'true');
this.isLoading = true; this.isLoading = true;
} }
/**
* Remove loading state from element
*/
removeLoadingState() { removeLoadingState() {
this.element.classList.remove(this.classNames.loadingState); this.element.classList.remove(this.classNames.loadingState);
this.element.removeAttribute('aria-busy'); this.element.removeAttribute('aria-busy');
this.isLoading = false; this.isLoading = false;
} }
/**
* Set focussed state
*/
_onFocus() { _onFocus() {
this.isFocussed = true; this.isFocussed = true;
} }
/**
* Remove blurred state
*/
_onBlur() { _onBlur() {
this.isFocussed = false; this.isFocussed = false;
} }

View file

@ -1,13 +1,26 @@
export default class Dropdown { /**
constructor({ element, type, classNames }) { * @typedef {import('../../../types/index').Choices.passedElement} passedElement
Object.assign(this, { element, type, classNames }); * @typedef {import('../../../types/index').Choices.ClassNames} ClassNames
*/
export default class Dropdown {
/**
* @param {{
* element: HTMLElement,
* type: passedElement['type'],
* classNames: ClassNames,
* }} args
*/
constructor({ element, type, classNames }) {
this.element = element;
this.classNames = classNames;
this.type = type;
this.isActive = false; this.isActive = false;
} }
/** /**
* Bottom position of dropdown in viewport coordinates * Bottom position of dropdown in viewport coordinates
* @type {number} Vertical position * @returns {number} Vertical position
*/ */
get distanceFromTopWindow() { get distanceFromTopWindow() {
return this.element.getBoundingClientRect().bottom; return this.element.getBoundingClientRect().bottom;
@ -15,7 +28,8 @@ export default class Dropdown {
/** /**
* Find element that matches passed selector * Find element that matches passed selector
* @return {HTMLElement} * @param {string} selector
* @returns {HTMLElement | null}
*/ */
getChild(selector) { getChild(selector) {
return this.element.querySelector(selector); return this.element.querySelector(selector);
@ -23,8 +37,7 @@ export default class Dropdown {
/** /**
* Show dropdown to user by adding active state class * Show dropdown to user by adding active state class
* @return {Object} Class instance * @returns {this}
* @public
*/ */
show() { show() {
this.element.classList.add(this.classNames.activeState); this.element.classList.add(this.classNames.activeState);
@ -36,8 +49,7 @@ export default class Dropdown {
/** /**
* Hide dropdown from user * Hide dropdown from user
* @return {Object} Class instance * @returns {this}
* @public
*/ */
hide() { hide() {
this.element.classList.remove(this.classNames.activeState); this.element.classList.remove(this.classNames.activeState);

View file

@ -1,11 +1,19 @@
import { sanitise } from '../lib/utils'; import { sanitise } from '../lib/utils';
import { SELECT_ONE_TYPE } from '../constants';
/**
* @typedef {import('../../../types/index').Choices.passedElement} passedElement
* @typedef {import('../../../types/index').Choices.ClassNames} ClassNames
*/
export default class Input { export default class Input {
/** /**
* * @param {{
* @typedef {import('../../../types/index').Choices.passedElement} passedElement * element: HTMLInputElement,
* @typedef {import('../../../types/index').Choices.ClassNames} ClassNames * type: passedElement['type'],
* @param {{element: HTMLInputElement, type: passedElement['type'], classNames: ClassNames, preventPaste: boolean }} p * classNames: ClassNames,
* preventPaste: boolean
* }} args
*/ */
constructor({ element, type, classNames, preventPaste }) { constructor({ element, type, classNames, preventPaste }) {
this.element = element; this.element = element;
@ -21,14 +29,23 @@ export default class Input {
this._onBlur = this._onBlur.bind(this); this._onBlur = this._onBlur.bind(this);
} }
/**
* @param {string} placeholder
*/
set placeholder(placeholder) { set placeholder(placeholder) {
this.element.placeholder = placeholder; this.element.placeholder = placeholder;
} }
/**
* @returns {string}
*/
get value() { get value() {
return sanitise(this.element.value); return sanitise(this.element.value);
} }
/**
* @param {string} value
*/
set value(value) { set value(value) {
this.element.value = value; this.element.value = value;
} }
@ -83,8 +100,8 @@ export default class Input {
/** /**
* Set value of input to blank * Set value of input to blank
* @return {Object} Class instance * @param {boolean} setWidth
* @public * @returns {this}
*/ */
clear(setWidth = true) { clear(setWidth = true) {
if (this.element.value) { if (this.element.value) {
@ -109,6 +126,9 @@ export default class Input {
style.width = `${value.length + 1}ch`; style.width = `${value.length + 1}ch`;
} }
/**
* @param {string} activeDescendantID
*/
setActiveDescendant(activeDescendantID) { setActiveDescendant(activeDescendantID) {
this.element.setAttribute('aria-activedescendant', activeDescendantID); this.element.setAttribute('aria-activedescendant', activeDescendantID);
} }
@ -118,11 +138,14 @@ export default class Input {
} }
_onInput() { _onInput() {
if (this.type !== 'select-one') { if (this.type !== SELECT_ONE_TYPE) {
this.setWidth(); this.setWidth();
} }
} }
/**
* @param {Event} event
*/
_onPaste(event) { _onPaste(event) {
if (this.preventPaste) { if (this.preventPaste) {
event.preventDefault(); event.preventDefault();

View file

@ -1,9 +1,14 @@
import { SCROLLING_SPEED } from '../constants'; import { SCROLLING_SPEED } from '../constants';
/**
* @typedef {import('../../../types/index').Choices.Choice} Choice
*/
export default class List { export default class List {
/**
* @param {{ element: HTMLElement }} args
*/
constructor({ element }) { constructor({ element }) {
Object.assign(this, { element }); this.element = element;
this.scrollPos = this.element.scrollTop; this.scrollPos = this.element.scrollTop;
this.height = this.element.offsetHeight; this.height = this.element.offsetHeight;
} }
@ -12,14 +17,24 @@ export default class List {
this.element.innerHTML = ''; this.element.innerHTML = '';
} }
/**
* @param {Element | DocumentFragment} node
*/
append(node) { append(node) {
this.element.appendChild(node); this.element.appendChild(node);
} }
/**
* @param {string} selector
* @returns {Element | null}
*/
getChild(selector) { getChild(selector) {
return this.element.querySelector(selector); return this.element.querySelector(selector);
} }
/**
* @returns {boolean}
*/
hasChildren() { hasChildren() {
return this.element.hasChildNodes(); return this.element.hasChildNodes();
} }
@ -28,28 +43,39 @@ export default class List {
this.element.scrollTop = 0; this.element.scrollTop = 0;
} }
scrollToChoice(choice, direction) { /**
if (!choice) { * @param {Element} element
* @param {1 | -1} direction
*/
scrollToChildElement(element, direction) {
if (!element) {
return; return;
} }
const dropdownHeight = this.element.offsetHeight; const listHeight = this.element.offsetHeight;
const choiceHeight = choice.offsetHeight;
// Distance from bottom of element to top of parent
const choicePos = choice.offsetTop + choiceHeight;
// Scroll position of dropdown // Scroll position of dropdown
const containerScrollPos = this.element.scrollTop + dropdownHeight; const listScrollPosition = this.element.scrollTop + listHeight;
// Difference between the choice and scroll position
const elementHeight = element.offsetHeight;
// Distance from bottom of element to top of parent
const elementPos = element.offsetTop + elementHeight;
// Difference between the element and scroll position
const destination = const destination =
direction > 0 direction > 0
? this.element.scrollTop + choicePos - containerScrollPos ? this.element.scrollTop + elementPos - listScrollPosition
: choice.offsetTop; : element.offsetTop;
requestAnimationFrame(time => { requestAnimationFrame(() => {
this._animateScroll(time, destination, direction); this._animateScroll(destination, direction);
}); });
} }
/**
* @param {number} scrollPos
* @param {number} strength
* @param {number} destination
*/
_scrollDown(scrollPos, strength, destination) { _scrollDown(scrollPos, strength, destination) {
const easing = (destination - scrollPos) / strength; const easing = (destination - scrollPos) / strength;
const distance = easing > 1 ? easing : 1; const distance = easing > 1 ? easing : 1;
@ -57,6 +83,11 @@ export default class List {
this.element.scrollTop = scrollPos + distance; this.element.scrollTop = scrollPos + distance;
} }
/**
* @param {number} scrollPos
* @param {number} strength
* @param {number} destination
*/
_scrollUp(scrollPos, strength, destination) { _scrollUp(scrollPos, strength, destination) {
const easing = (scrollPos - destination) / strength; const easing = (scrollPos - destination) / strength;
const distance = easing > 1 ? easing : 1; const distance = easing > 1 ? easing : 1;
@ -64,7 +95,11 @@ export default class List {
this.element.scrollTop = scrollPos - distance; this.element.scrollTop = scrollPos - distance;
} }
_animateScroll(time, destination, direction) { /**
* @param {*} destination
* @param {*} direction
*/
_animateScroll(destination, direction) {
const strength = SCROLLING_SPEED; const strength = SCROLLING_SPEED;
const choiceListScrollTop = this.element.scrollTop; const choiceListScrollTop = this.element.scrollTop;
let continueAnimation = false; let continueAnimation = false;
@ -85,7 +120,7 @@ export default class List {
if (continueAnimation) { if (continueAnimation) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
this._animateScroll(time, destination, direction); this._animateScroll(destination, direction);
}); });
} }
} }

View file

@ -1,16 +1,39 @@
import { dispatchEvent } from '../lib/utils'; import { dispatchEvent } from '../lib/utils';
export default class WrappedElement { /**
constructor({ element, classNames }) { * @typedef {import('../../../types/index').Choices.passedElement} passedElement
Object.assign(this, { element, classNames }); * @typedef {import('../../../types/index').Choices.ClassNames} ClassNames
*/
if (!(element instanceof Element)) { export default class WrappedElement {
/**
* @param {{
* element: HTMLInputElement | HTMLSelectElement,
* classNames: ClassNames,
* }} args
*/
constructor({ element, classNames }) {
this.element = element;
this.classNames = classNames;
if (
!(element instanceof HTMLInputElement) &&
!(element instanceof HTMLSelectElement)
) {
throw new TypeError('Invalid element passed'); throw new TypeError('Invalid element passed');
} }
this.isDisabled = false; this.isDisabled = false;
} }
get isActive() {
return this.element.dataset.choice === 'active';
}
get dir() {
return this.element.dir;
}
get value() { get value() {
return this.element.value; return this.element.value;
} }
@ -26,7 +49,7 @@ export default class WrappedElement {
this.element.hidden = true; this.element.hidden = true;
// Remove element from tab index // Remove element from tab index
this.element.tabIndex = '-1'; this.element.tabIndex = -1;
// Backup original styles if any // Backup original styles if any
const origStyle = this.element.getAttribute('style'); const origStyle = this.element.getAttribute('style');

View file

@ -31,6 +31,19 @@ describe('components/wrappedElement', () => {
it('sets isDisabled flag to false', () => { it('sets isDisabled flag to false', () => {
expect(instance.isDisabled).to.eql(false); expect(instance.isDisabled).to.eql(false);
}); });
describe('passing an element that is not an instance of HTMLInputElement or HTMLSelectElement', () => {
it('throws a TypeError', () => {
element = document.createElement('div');
expect(
() =>
new WrappedElement({
element,
classNames: DEFAULT_CLASSNAMES,
}),
).to.throw(TypeError, 'Invalid element passed');
});
});
}); });
describe('value getter', () => { describe('value getter', () => {
@ -39,6 +52,22 @@ describe('components/wrappedElement', () => {
}); });
}); });
describe('isActive getter', () => {
it('returns whether the "data-choice" attribute is set to "active"', () => {
instance.element.dataset.choice = 'active';
expect(instance.isActive).to.equal(true);
instance.element.dataset.choice = 'inactive';
expect(instance.isActive).to.equal(false);
});
});
describe('dir getter', () => {
it('returns the direction of the element', () => {
expect(instance.dir).to.equal(instance.element.dir);
});
});
describe('conceal', () => { describe('conceal', () => {
let originalStyling; let originalStyling;

View file

@ -1,15 +1,33 @@
import WrappedElement from './wrapped-element'; import WrappedElement from './wrapped-element';
/**
* @typedef {import('../../../types/index').Choices.ClassNames} ClassNames
* @typedef {import('../../../types/index').Choices.Item} Item
*/
export default class WrappedInput extends WrappedElement { export default class WrappedInput extends WrappedElement {
/**
* @param {{
* element: HTMLInputElement,
* classNames: ClassNames,
* delimiter: string
* }} args
*/
constructor({ element, classNames, delimiter }) { constructor({ element, classNames, delimiter }) {
super({ element, classNames }); super({ element, classNames });
this.delimiter = delimiter; this.delimiter = delimiter;
} }
/**
* @returns {string}
*/
get value() { get value() {
return this.element.value; return this.element.value;
} }
/**
* @param {Item[]} items
*/
set value(items) { set value(items) {
const itemValues = items.map(({ value }) => value); const itemValues = items.map(({ value }) => value);
const joinedValues = itemValues.join(this.delimiter); const joinedValues = itemValues.join(this.delimiter);

View file

@ -1,6 +1,20 @@
import WrappedElement from './wrapped-element'; import WrappedElement from './wrapped-element';
/**
* @typedef {import('../../../types/index').Choices.ClassNames} ClassNames
* @typedef {import('../../../types/index').Choices.Item} Item
* @typedef {import('../../../types/index').Choices.Choice} Choice
*/
export default class WrappedSelect extends WrappedElement { export default class WrappedSelect extends WrappedElement {
/**
* @param {{
* element: HTMLSelectElement,
* classNames: ClassNames,
* delimiter: string
* template: function
* }} args
*/
constructor({ element, classNames, template }) { constructor({ element, classNames, template }) {
super({ element, classNames }); super({ element, classNames });
this.template = template; this.template = template;
@ -14,14 +28,23 @@ export default class WrappedSelect extends WrappedElement {
); );
} }
/**
* @returns {Element[]}
*/
get optionGroups() { get optionGroups() {
return Array.from(this.element.getElementsByTagName('OPTGROUP')); return Array.from(this.element.getElementsByTagName('OPTGROUP'));
} }
/**
* @returns {Item[] | Choice[]}
*/
get options() { get options() {
return Array.from(this.element.options); return Array.from(this.element.options);
} }
/**
* @param {Item[] | Choice[]} options
*/
set options(options) { set options(options) {
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
const addOptionToFragment = data => { const addOptionToFragment = data => {
@ -37,6 +60,9 @@ export default class WrappedSelect extends WrappedElement {
this.appendDocFragment(fragment); this.appendDocFragment(fragment);
} }
/**
* @param {DocumentFragment} fragment
*/
appendDocFragment(fragment) { appendDocFragment(fragment) {
this.element.innerHTML = ''; this.element.innerHTML = '';
this.element.appendChild(fragment); this.element.appendChild(fragment);

View file

@ -1,5 +1,11 @@
import { sanitise, sortByAlpha } from './lib/utils'; import { sanitise, sortByAlpha } from './lib/utils';
/**
* @typedef {import('../../types/index').Choices.ClassNames} ClassNames
* @typedef {import('../../types/index').Choices.Options} Options
*/
/** @type {ClassNames} */
export const DEFAULT_CLASSNAMES = { export const DEFAULT_CLASSNAMES = {
containerOuter: 'choices', containerOuter: 'choices',
containerInner: 'choices__inner', containerInner: 'choices__inner',
@ -22,12 +28,14 @@ export const DEFAULT_CLASSNAMES = {
openState: 'is-open', openState: 'is-open',
disabledState: 'is-disabled', disabledState: 'is-disabled',
highlightedState: 'is-highlighted', highlightedState: 'is-highlighted',
selectedState: 'is-selected',
flippedState: 'is-flipped', flippedState: 'is-flipped',
loadingState: 'is-loading', loadingState: 'is-loading',
noResults: 'has-no-results', noResults: 'has-no-results',
noChoices: 'has-no-choices', noChoices: 'has-no-choices',
}; };
/** @type {Options} */
export const DEFAULT_CONFIG = { export const DEFAULT_CONFIG = {
items: [], items: [],
choices: [], choices: [],
@ -51,7 +59,7 @@ export const DEFAULT_CONFIG = {
resetScrollPosition: true, resetScrollPosition: true,
shouldSort: true, shouldSort: true,
shouldSortItems: false, shouldSortItems: false,
sortFn: sortByAlpha, sorter: sortByAlpha,
placeholder: true, placeholder: true,
placeholderValue: null, placeholderValue: null,
searchPlaceholderValue: null, searchPlaceholderValue: null,
@ -66,7 +74,7 @@ export const DEFAULT_CONFIG = {
customAddItemText: 'Only values matching specific conditions can be added', customAddItemText: 'Only values matching specific conditions can be added',
addItemText: value => `Press Enter to add <b>"${sanitise(value)}"</b>`, addItemText: value => `Press Enter to add <b>"${sanitise(value)}"</b>`,
maxItemText: maxItemCount => `Only ${maxItemCount} values can be added`, maxItemText: maxItemCount => `Only ${maxItemCount} values can be added`,
itemComparer: (choice, item) => choice === item, valueComparer: (value1, value2) => value1 === value2,
fuseOptions: { fuseOptions: {
includeScore: true, includeScore: true,
}, },
@ -111,4 +119,8 @@ export const KEY_CODES = {
PAGE_DOWN_KEY: 34, PAGE_DOWN_KEY: 34,
}; };
export const TEXT_TYPE = 'text';
export const SELECT_ONE_TYPE = 'select-one';
export const SELECT_MULTIPLE_TYPE = 'select-multiple';
export const SCROLLING_SPEED = 4; export const SCROLLING_SPEED = 4;

View file

@ -35,6 +35,7 @@ describe('constants', () => {
'openState', 'openState',
'disabledState', 'disabledState',
'highlightedState', 'highlightedState',
'selectedState',
'flippedState', 'flippedState',
'loadingState', 'loadingState',
'noResults', 'noResults',

View file

@ -1,45 +0,0 @@
window.delegateEvent = (function delegateEvent() {
let events;
let addedListenerTypes;
if (typeof events === 'undefined') {
events = new Map();
}
if (typeof addedListenerTypes === 'undefined') {
addedListenerTypes = [];
}
function _callback(event) {
const type = events.get(event.type);
if (!type) {
return;
}
type.forEach(fn => fn(event));
}
return {
add: function add(type, fn) {
// Cache list of events.
if (events.has(type)) {
events.get(type).push(fn);
} else {
events.set(type, [fn]);
}
// Setup events.
if (addedListenerTypes.indexOf(type) === -1) {
document.documentElement.addEventListener(type, _callback, true);
addedListenerTypes.push(type);
}
},
remove: function remove(type, fn) {
if (!events.get(type)) {
return;
}
events.set(type, events.get(type).filter(item => item !== fn));
if (!events.get(type).length) {
addedListenerTypes.splice(addedListenerTypes.indexOf(type), 1);
}
},
};
})();

View file

@ -1,9 +1,23 @@
/**
* @param {number} min
* @param {number} max
* @returns {number}
*/
export const getRandomNumber = (min, max) => export const getRandomNumber = (min, max) =>
Math.floor(Math.random() * (max - min) + min); Math.floor(Math.random() * (max - min) + min);
/**
* @param {number} length
* @returns {string}
*/
export const generateChars = length => export const generateChars = length =>
Array.from({ length }, () => getRandomNumber(0, 36).toString(36)).join(''); Array.from({ length }, () => getRandomNumber(0, 36).toString(36)).join('');
/**
* @param {HTMLInputElement | HTMLSelectElement} element
* @param {string} prefix
* @returns {string}
*/
export const generateId = (element, prefix) => { export const generateId = (element, prefix) => {
let id = let id =
element.id || element.id ||
@ -15,11 +29,25 @@ export const generateId = (element, prefix) => {
return id; return id;
}; };
/**
* @param {any} obj
* @returns {string}
*/
export const getType = obj => Object.prototype.toString.call(obj).slice(8, -1); export const getType = obj => Object.prototype.toString.call(obj).slice(8, -1);
/**
* @param {string} type
* @param {any} obj
* @returns {boolean}
*/
export const isType = (type, obj) => export const isType = (type, obj) =>
obj !== undefined && obj !== null && getType(obj) === type; obj !== undefined && obj !== null && getType(obj) === type;
/**
* @param {HTMLElement} element
* @param {HTMLElement} [wrapper={HTMLDivElement}]
* @returns {HTMLElement}
*/
export const wrap = (element, wrapper = document.createElement('div')) => { export const wrap = (element, wrapper = document.createElement('div')) => {
if (element.nextSibling) { if (element.nextSibling) {
element.parentNode.insertBefore(wrapper, element.nextSibling); element.parentNode.insertBefore(wrapper, element.nextSibling);
@ -31,39 +59,38 @@ export const wrap = (element, wrapper = document.createElement('div')) => {
}; };
/** /**
* @param {HTMLElement} el * @param {Element} startEl
* @param {string} attr * @param {string} selector
* @param {1 | -1} direction
* @returns {Element | undefined}
*/ */
export const findAncestorByAttrName = (el, attr) => el.closest(`[${attr}]`); export const getAdjacentEl = (startEl, selector, direction = 1) => {
if (!(startEl instanceof Element) || typeof selector !== 'string') {
return undefined;
}
export const getAdjacentEl = const prop = `${direction > 0 ? 'next' : 'previous'}ElementSibling`;
/**
* @param {Element} startEl let sibling = startEl[prop];
* @param {string} selector while (sibling) {
* @param {1 | -1} direction if (sibling.matches(selector)) {
* @returns {Element | undefined} return sibling;
*/
(startEl, selector, direction = 1) => {
if (!(startEl instanceof Element) || typeof selector !== 'string') {
return undefined;
} }
sibling = sibling[prop];
}
const prop = `${direction > 0 ? 'next' : 'previous'}ElementSibling`; return sibling;
};
let sibling = startEl[prop]; /**
while (sibling) { * @param {Element} element
if (sibling.matches(selector)) { * @param {Element} parent
return sibling; * @param {-1 | 1} direction
} * @returns {boolean}
sibling = sibling[prop]; */
} export const isScrolledIntoView = (element, parent, direction = 1) => {
if (!element) {
return sibling; return false;
};
export const isScrolledIntoView = (el, parent, direction = 1) => {
if (!el) {
return;
} }
let isVisible; let isVisible;
@ -71,15 +98,20 @@ export const isScrolledIntoView = (el, parent, direction = 1) => {
if (direction > 0) { if (direction > 0) {
// In view from bottom // In view from bottom
isVisible = isVisible =
parent.scrollTop + parent.offsetHeight >= el.offsetTop + el.offsetHeight; parent.scrollTop + parent.offsetHeight >=
element.offsetTop + element.offsetHeight;
} else { } else {
// In view from top // In view from top
isVisible = el.offsetTop >= parent.scrollTop; isVisible = element.offsetTop >= parent.scrollTop;
} }
return isVisible; return isVisible;
}; };
/**
* @param {any} value
* @returns {any}
*/
export const sanitise = value => { export const sanitise = value => {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return value; return value;
@ -92,6 +124,9 @@ export const sanitise = value => {
.replace(/"/g, '&quot;'); .replace(/"/g, '&quot;');
}; };
/**
* @returns {() => (str: string) => Element}
*/
export const strToEl = (() => { export const strToEl = (() => {
const tmpEl = document.createElement('div'); const tmpEl = document.createElement('div');
@ -108,21 +143,32 @@ export const strToEl = (() => {
}; };
})(); })();
export const sortByAlpha = /**
/** * @param {{ label?: string, value: string }} a
* @param {{ label?: string, value: string }} a * @param {{ label?: string, value: string }} b
* @param {{ label?: string, value: string }} b * @returns {number}
* @returns {number} */
*/ export const sortByAlpha = (
({ value, label = value }, { value: value2, label: label2 = value2 }) => { value, label = value },
label.localeCompare(label2, [], { { value: value2, label: label2 = value2 },
sensitivity: 'base', ) =>
ignorePunctuation: true, label.localeCompare(label2, [], {
numeric: true, sensitivity: 'base',
}); ignorePunctuation: true,
numeric: true,
});
/**
* @param {{ score: number }} a
* @param {{ score: number }} b
*/
export const sortByScore = (a, b) => a.score - b.score; export const sortByScore = (a, b) => a.score - b.score;
/**
* @param {HTMLElement} element
* @param {string} type
* @param {object} customArgs
*/
export const dispatchEvent = (element, type, customArgs = null) => { export const dispatchEvent = (element, type, customArgs = null) => {
const event = new CustomEvent(type, { const event = new CustomEvent(type, {
detail: customArgs, detail: customArgs,
@ -133,9 +179,12 @@ export const dispatchEvent = (element, type, customArgs = null) => {
return element.dispatchEvent(event); return element.dispatchEvent(event);
}; };
export const isIE11 = userAgent => /**
!!(userAgent.match(/Trident/) && userAgent.match(/rv[ :]11/)); * @param {array} array
* @param {any} value
* @param {string} [key="value"]
* @returns {boolean}
*/
export const existsInArray = (array, value, key = 'value') => export const existsInArray = (array, value, key = 'value') =>
array.some(item => { array.some(item => {
if (typeof value === 'string') { if (typeof value === 'string') {
@ -145,8 +194,18 @@ export const existsInArray = (array, value, key = 'value') =>
return item[key] === value; return item[key] === value;
}); });
/**
* @param {any} obj
* @returns {any}
*/
export const cloneObject = obj => JSON.parse(JSON.stringify(obj)); export const cloneObject = obj => JSON.parse(JSON.stringify(obj));
/**
* Returns an array of keys present on the first but missing on the second object
* @param {object} a
* @param {object} b
* @returns {string[]}
*/
export const diff = (a, b) => { export const diff = (a, b) => {
const aKeys = Object.keys(a).sort(); const aKeys = Object.keys(a).sort();
const bKeys = Object.keys(b).sort(); const bKeys = Object.keys(b).sort();

View file

@ -9,10 +9,10 @@ import {
sanitise, sanitise,
sortByAlpha, sortByAlpha,
sortByScore, sortByScore,
isIE11,
existsInArray, existsInArray,
cloneObject, cloneObject,
dispatchEvent, dispatchEvent,
diff,
} from './utils'; } from './utils';
describe('utils', () => { describe('utils', () => {
@ -37,7 +37,7 @@ describe('utils', () => {
describe('generateId', () => { describe('generateId', () => {
describe('when given element has id value', () => { describe('when given element has id value', () => {
it('generates a unique prefixed id based on given elements id', () => { it('generates a unique prefixed id based on given elements id', () => {
const element = document.createElement('div'); const element = document.createElement('select');
element.id = 'test-id'; element.id = 'test-id';
const prefix = 'test-prefix'; const prefix = 'test-prefix';
@ -49,7 +49,7 @@ describe('utils', () => {
describe('when given element has no id value but name value', () => { describe('when given element has no id value but name value', () => {
it('generates a unique prefixed id based on given elements name plus 2 random characters', () => { it('generates a unique prefixed id based on given elements name plus 2 random characters', () => {
const element = document.createElement('div'); const element = document.createElement('select');
element.name = 'test-name'; element.name = 'test-name';
const prefix = 'test-prefix'; const prefix = 'test-prefix';
@ -63,7 +63,7 @@ describe('utils', () => {
describe('when given element has no id value and no name value', () => { describe('when given element has no id value and no name value', () => {
it('generates a unique prefixed id based on 4 random characters', () => { it('generates a unique prefixed id based on 4 random characters', () => {
const element = document.createElement('div'); const element = document.createElement('select');
const prefix = 'test-prefix'; const prefix = 'test-prefix';
const output = generateId(element, prefix); const output = generateId(element, prefix);
@ -83,7 +83,7 @@ describe('utils', () => {
expect(getType([])).to.equal('Array'); expect(getType([])).to.equal('Array');
expect(getType(() => {})).to.equal('Function'); expect(getType(() => {})).to.equal('Function');
expect(getType(new Error())).to.equal('Error'); expect(getType(new Error())).to.equal('Error');
expect(getType(new RegExp())).to.equal('RegExp'); expect(getType(new RegExp(/''/g))).to.equal('RegExp');
expect(getType(new String())).to.equal('String'); // eslint-disable-line expect(getType(new String())).to.equal('String'); // eslint-disable-line
expect(getType('')).to.equal('String'); expect(getType('')).to.equal('String');
}); });
@ -97,12 +97,24 @@ describe('utils', () => {
}); });
describe('sanitise', () => { describe('sanitise', () => {
it('strips HTML from value', () => { describe('when passing a parameter that is not a string', () => {
const value = '<script>somethingMalicious();</script>'; it('returns the passed argument', () => {
const output = sanitise(value); const value = {
expect(output).to.equal( test: true,
'&lt;script&rt;somethingMalicious();&lt;/script&rt;', };
); const output = sanitise(value);
expect(output).to.equal(value);
});
});
describe('when passing a string', () => {
it('strips HTML from value', () => {
const value = '<script>somethingMalicious();</script>';
const output = sanitise(value);
expect(output).to.equal(
'&lt;script&rt;somethingMalicious();&lt;/script&rt;',
);
});
}); });
}); });
@ -189,18 +201,6 @@ describe('utils', () => {
}); });
}); });
describe('isIE11', () => {
it('returns whether the given user agent string matches an IE11 user agent string', () => {
const IE11UserAgent =
'Mozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko';
const firefoxUserAgent =
'Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0';
expect(isIE11(IE11UserAgent)).to.equal(true);
expect(isIE11(firefoxUserAgent)).to.equal(false);
});
});
describe('existsInArray', () => { describe('existsInArray', () => {
it('determines whether a value exists within given array', () => { it('determines whether a value exists within given array', () => {
const values = [ const values = [
@ -238,4 +238,20 @@ describe('utils', () => {
expect(output).to.eql(object); expect(output).to.eql(object);
}); });
}); });
describe('diff', () => {
it('returns an array of keys present on the first but missing on the second object', () => {
const obj1 = {
foo: 'bar',
baz: 'foo',
};
const obj2 = {
foo: 'bar',
};
const output = diff(obj1, obj2);
expect(output).to.deep.equal(['baz']);
});
});
}); });

View file

@ -1,6 +1,12 @@
import { createStore } from 'redux'; import { createStore } from 'redux';
import rootReducer from '../reducers/index'; import rootReducer from '../reducers/index';
/**
* @typedef {import('../../../types/index').Choices.Choice} Choice
* @typedef {import('../../../types/index').Choices.Group} Group
* @typedef {import('../../../types/index').Choices.Item} Item
*/
export default class Store { export default class Store {
constructor() { constructor() {
this._store = createStore( this._store = createStore(
@ -21,7 +27,7 @@ export default class Store {
/** /**
* Dispatch event to store (wrapped Redux method) * Dispatch event to store (wrapped Redux method)
* @param {Function} action Action function to trigger * @param {{ type: string, [x: string]: any }} action Action to trigger
* @return * @return
*/ */
dispatch(action) { dispatch(action) {
@ -30,7 +36,7 @@ export default class Store {
/** /**
* Get store object (wrapping Redux method) * Get store object (wrapping Redux method)
* @return {Object} State * @returns {object} State
*/ */
get state() { get state() {
return this._store.getState(); return this._store.getState();
@ -38,7 +44,7 @@ export default class Store {
/** /**
* Get items from store * Get items from store
* @return {Array} Item objects * @returns {Item[]} Item objects
*/ */
get items() { get items() {
return this.state.items; return this.state.items;
@ -46,7 +52,7 @@ export default class Store {
/** /**
* Get active items from store * Get active items from store
* @return {Array} Item objects * @returns {Item[]} Item objects
*/ */
get activeItems() { get activeItems() {
return this.items.filter(item => item.active === true); return this.items.filter(item => item.active === true);
@ -54,7 +60,7 @@ export default class Store {
/** /**
* Get highlighted items from store * Get highlighted items from store
* @return {Array} Item objects * @returns {Item[]} Item objects
*/ */
get highlightedActiveItems() { get highlightedActiveItems() {
return this.items.filter(item => item.active && item.highlighted); return this.items.filter(item => item.active && item.highlighted);
@ -62,7 +68,7 @@ export default class Store {
/** /**
* Get choices from store * Get choices from store
* @return {Array} Option objects * @returns {Choice[]} Option objects
*/ */
get choices() { get choices() {
return this.state.choices; return this.state.choices;
@ -70,18 +76,15 @@ export default class Store {
/** /**
* Get active choices from store * Get active choices from store
* @return {Array} Option objects * @returns {Choice[]} Option objects
*/ */
get activeChoices() { get activeChoices() {
const { choices } = this; return this.choices.filter(choice => choice.active === true);
const values = choices.filter(choice => choice.active === true);
return values;
} }
/** /**
* Get selectable choices from store * Get selectable choices from store
* @return {Array} Option objects * @returns {Choice[]} Option objects
*/ */
get selectableChoices() { get selectableChoices() {
return this.choices.filter(choice => choice.disabled !== true); return this.choices.filter(choice => choice.disabled !== true);
@ -89,7 +92,7 @@ export default class Store {
/** /**
* Get choices that can be searched (excluding placeholders) * Get choices that can be searched (excluding placeholders)
* @return {Array} Option objects * @returns {Choice[]} Option objects
*/ */
get searchableChoices() { get searchableChoices() {
return this.selectableChoices.filter(choice => choice.placeholder !== true); return this.selectableChoices.filter(choice => choice.placeholder !== true);
@ -97,7 +100,7 @@ export default class Store {
/** /**
* Get placeholder choice from store * Get placeholder choice from store
* @return {Object} Found placeholder * @returns {Choice | undefined} Found placeholder
*/ */
get placeholderChoice() { get placeholderChoice() {
return [...this.choices] return [...this.choices]
@ -107,7 +110,7 @@ export default class Store {
/** /**
* Get groups from store * Get groups from store
* @return {Array} Group objects * @returns {Group[]} Group objects
*/ */
get groups() { get groups() {
return this.state.groups; return this.state.groups;
@ -115,7 +118,7 @@ export default class Store {
/** /**
* Get active groups from store * Get active groups from store
* @return {Array} Group objects * @returns {Group[]} Group objects
*/ */
get activeGroups() { get activeGroups() {
const { groups, choices } = this; const { groups, choices } = this;
@ -132,7 +135,7 @@ export default class Store {
/** /**
* Get loading state from store * Get loading state from store
* @return {Boolean} Loading State * @returns {boolean} Loading State
*/ */
isLoading() { isLoading() {
return this.state.general.loading; return this.state.general.loading;
@ -140,25 +143,19 @@ export default class Store {
/** /**
* Get single choice by it's ID * Get single choice by it's ID
* @param {id} string * @param {string} id
* @return {import('../../../types/index').Choices.Choice | false} Found choice * @returns {Choice | undefined} Found choice
*/ */
getChoiceById(id) { getChoiceById(id) {
if (id) { return this.activeChoices.find(choice => choice.id === parseInt(id, 10));
const n = parseInt(id, 10);
return this.activeChoices.find(choice => choice.id === n);
}
return false;
} }
/** /**
* Get group by group id * Get group by group id
* @param {Number} id Group ID * @param {number} id Group ID
* @return {Object} Group data * @returns {Group | undefined} Group data
*/ */
getGroupById(id) { getGroupById(id) {
return this.groups.find(group => group.id === parseInt(id, 10)); return this.groups.find(group => group.id === id);
} }
} }

View file

@ -218,13 +218,6 @@ describe('reducers/store', () => {
expect(actualResponse).to.eql(expectedResponse); expect(actualResponse).to.eql(expectedResponse);
}); });
}); });
describe('passing no id', () => {
it('returns false', () => {
const actualResponse = instance.getChoiceById();
expect(actualResponse).to.equal(false);
});
});
}); });
describe('placeholderChoice getter', () => { describe('placeholderChoice getter', () => {
@ -252,10 +245,8 @@ describe('reducers/store', () => {
describe('getGroupById', () => { describe('getGroupById', () => {
it('returns group by id', () => { it('returns group by id', () => {
const id = '1'; const id = 1;
const expectedResponse = state.groups.find( const expectedResponse = state.groups.find(group => group.id === id);
group => group.id === parseInt(id, 10),
);
const actualResponse = instance.getGroupById(id); const actualResponse = instance.getGroupById(id);
expect(actualResponse).to.eql(expectedResponse); expect(actualResponse).to.eql(expectedResponse);
}); });

View file

@ -2,9 +2,22 @@
* Helpers to create HTML elements used by Choices * Helpers to create HTML elements used by Choices
* Can be overridden by providing `callbackOnCreateTemplates` option * Can be overridden by providing `callbackOnCreateTemplates` option
* @typedef {import('../../types/index').Choices.Templates} Templates * @typedef {import('../../types/index').Choices.Templates} Templates
* @typedef {import('../../types/index').Choices.ClassNames} ClassNames
* @typedef {import('../../types/index').Choices.Options} Options
* @typedef {import('../../types/index').Choices.Item} Item
* @typedef {import('../../types/index').Choices.Choice} Choice
* @typedef {import('../../types/index').Choices.Group} Group
*/ */
export const TEMPLATES = /** @type {Templates} */ ({ export const TEMPLATES = /** @type {Templates} */ ({
/**
* @param {Partial<ClassNames>} classNames
* @param {"ltr" | "rtl" | "auto"} dir
* @param {boolean} isSelectElement
* @param {boolean} isSelectOneElement
* @param {boolean} searchEnabled
* @param {"select-one" | "select-multiple" | "text"} passedElementType
*/
containerOuter( containerOuter(
{ containerOuter }, { containerOuter },
dir, dir,
@ -39,16 +52,30 @@ export const TEMPLATES = /** @type {Templates} */ ({
return div; return div;
}, },
/**
* @param {Partial<ClassNames>} classNames
*/
containerInner({ containerInner }) { containerInner({ containerInner }) {
return Object.assign(document.createElement('div'), { return Object.assign(document.createElement('div'), {
className: containerInner, className: containerInner,
}); });
}, },
/**
* @param {Partial<ClassNames>} classNames
* @param {boolean} isSelectOneElement
*/
itemList({ list, listSingle, listItems }, isSelectOneElement) { itemList({ list, listSingle, listItems }, isSelectOneElement) {
return Object.assign(document.createElement('div'), { return Object.assign(document.createElement('div'), {
className: `${list} ${isSelectOneElement ? listSingle : listItems}`, className: `${list} ${isSelectOneElement ? listSingle : listItems}`,
}); });
}, },
/**
* @param {Partial<ClassNames>} classNames
* @param {string} value
*/
placeholder({ placeholder }, value) { placeholder({ placeholder }, value) {
return Object.assign(document.createElement('div'), { return Object.assign(document.createElement('div'), {
className: placeholder, className: placeholder,
@ -56,6 +83,11 @@ export const TEMPLATES = /** @type {Templates} */ ({
}); });
}, },
/**
* @param {Partial<ClassNames>} classNames
* @param {Item} item
* @param {boolean} removeItemButton
*/
item( item(
{ item, button, highlightedState, itemSelectable, placeholder }, { item, button, highlightedState, itemSelectable, placeholder },
{ {
@ -93,6 +125,7 @@ export const TEMPLATES = /** @type {Templates} */ ({
if (isPlaceholder) { if (isPlaceholder) {
div.classList.add(placeholder); div.classList.add(placeholder);
} }
div.classList.add(highlighted ? highlightedState : itemSelectable); div.classList.add(highlighted ? highlightedState : itemSelectable);
if (removeItemButton) { if (removeItemButton) {
@ -117,6 +150,11 @@ export const TEMPLATES = /** @type {Templates} */ ({
return div; return div;
}, },
/**
* @param {Partial<ClassNames>} classNames
* @param {boolean} isSelectOneElement
*/
choiceList({ list }, isSelectOneElement) { choiceList({ list }, isSelectOneElement) {
const div = Object.assign(document.createElement('div'), { const div = Object.assign(document.createElement('div'), {
className: list, className: list,
@ -130,6 +168,10 @@ export const TEMPLATES = /** @type {Templates} */ ({
return div; return div;
}, },
/**
* @param {Partial<ClassNames>} classNames
* @param {Group} group
*/
choiceGroup({ group, groupHeading, itemDisabled }, { id, value, disabled }) { choiceGroup({ group, groupHeading, itemDisabled }, { id, value, disabled }) {
const div = Object.assign(document.createElement('div'), { const div = Object.assign(document.createElement('div'), {
className: `${group} ${disabled ? itemDisabled : ''}`, className: `${group} ${disabled ? itemDisabled : ''}`,
@ -157,15 +199,28 @@ export const TEMPLATES = /** @type {Templates} */ ({
return div; return div;
}, },
/**
* @param {Partial<ClassNames>} classNames
* @param {Choice} choice
* @param {Options['itemSelectText']} selectText
*/
choice( choice(
{ item, itemChoice, itemSelectable, itemDisabled, placeholder }, {
item,
itemChoice,
itemSelectable,
selectedState,
itemDisabled,
placeholder,
},
{ {
id, id,
value, value,
label, label,
groupId, groupId,
elementId, elementId,
disabled, disabled: isDisabled,
selected: isSelected,
placeholder: isPlaceholder, placeholder: isPlaceholder,
}, },
selectText, selectText,
@ -173,11 +228,17 @@ export const TEMPLATES = /** @type {Templates} */ ({
const div = Object.assign(document.createElement('div'), { const div = Object.assign(document.createElement('div'), {
id: elementId, id: elementId,
innerHTML: label, innerHTML: label,
className: `${item} ${itemChoice} ${ className: `${item} ${itemChoice}`,
disabled ? itemDisabled : itemSelectable
} ${isPlaceholder ? placeholder : ''}`,
}); });
if (isSelected) {
div.classList.add(selectedState);
}
if (isPlaceholder) {
div.classList.add(placeholder);
}
div.setAttribute('role', groupId > 0 ? 'treeitem' : 'option'); div.setAttribute('role', groupId > 0 ? 'treeitem' : 'option');
Object.assign(div.dataset, { Object.assign(div.dataset, {
@ -187,15 +248,22 @@ export const TEMPLATES = /** @type {Templates} */ ({
selectText, selectText,
}); });
if (disabled) { if (isDisabled) {
div.classList.add(itemDisabled);
div.dataset.choiceDisabled = ''; div.dataset.choiceDisabled = '';
div.setAttribute('aria-disabled', 'true'); div.setAttribute('aria-disabled', 'true');
} else { } else {
div.classList.add(itemSelectable);
div.dataset.choiceSelectable = ''; div.dataset.choiceSelectable = '';
} }
return div; return div;
}, },
/**
* @param {Partial<ClassNames>} classNames
* @param {string} placeholderValue
*/
input({ input, inputCloned }, placeholderValue) { input({ input, inputCloned }, placeholderValue) {
const inp = Object.assign(document.createElement('input'), { const inp = Object.assign(document.createElement('input'), {
type: 'text', type: 'text',
@ -211,6 +279,10 @@ export const TEMPLATES = /** @type {Templates} */ ({
return inp; return inp;
}, },
/**
* @param {Partial<ClassNames>} classNames
*/
dropdown({ list, listDropdown }) { dropdown({ list, listDropdown }) {
const div = document.createElement('div'); const div = document.createElement('div');
@ -219,6 +291,13 @@ export const TEMPLATES = /** @type {Templates} */ ({
return div; return div;
}, },
/**
*
* @param {Partial<ClassNames>} classNames
* @param {string} innerHTML
* @param {"no-choices" | "no-results" | ""} type
*/
notice({ item, itemChoice, noResults, noChoices }, innerHTML, type = '') { notice({ item, itemChoice, noResults, noChoices }, innerHTML, type = '') {
const classes = [item, itemChoice]; const classes = [item, itemChoice];
@ -233,6 +312,10 @@ export const TEMPLATES = /** @type {Templates} */ ({
className: classes.join(' '), className: classes.join(' '),
}); });
}, },
/**
* @param {Item} option
*/
option({ label, value, customProperties, active, disabled }) { option({ label, value, customProperties, active, disabled }) {
const opt = new Option(label, value, false, active); const opt = new Option(label, value, false, active);

View file

@ -13,6 +13,7 @@ function expectEqualElements(element1, element2) {
expect(Object.keys(element1.dataset)).to.have.members( expect(Object.keys(element1.dataset)).to.have.members(
Object.keys(element2.dataset), Object.keys(element2.dataset),
); );
expect(element1.classList).to.include(element2.classList);
// compare attributes values // compare attributes values
for (const attribute of Object.values(element1.attributes)) { for (const attribute of Object.values(element1.attributes)) {
expect(element1.getAttribute(attribute)).to.equal( expect(element1.getAttribute(attribute)).to.equal(
@ -24,7 +25,7 @@ function expectEqualElements(element1, element2) {
describe('templates', () => { describe('templates', () => {
describe('containerOuter', () => { describe('containerOuter', () => {
const classes = { const classes = {
containerOuter: 'test', containerOuter: 'class-1',
}; };
const direction = 'rtl'; const direction = 'rtl';
@ -158,7 +159,7 @@ describe('templates', () => {
describe('containerInner', () => { describe('containerInner', () => {
it('returns expected html', () => { it('returns expected html', () => {
const classes = { const classes = {
containerInner: 'test', containerInner: 'class-1',
}; };
const expectedOutput = strToEl( const expectedOutput = strToEl(
`<div class="${classes.containerInner}"></div>`, `<div class="${classes.containerInner}"></div>`,
@ -171,9 +172,9 @@ describe('templates', () => {
describe('itemList', () => { describe('itemList', () => {
const classes = { const classes = {
list: 'test 1', list: 'class-1',
listSingle: 'test 2', listSingle: 'class-2',
listItems: 'test 3', listItems: 'class-3',
}; };
describe('select one element', () => { describe('select one element', () => {
@ -202,7 +203,7 @@ describe('templates', () => {
describe('placeholder', () => { describe('placeholder', () => {
it('returns expected html', () => { it('returns expected html', () => {
const classes = { const classes = {
placeholder: 'test', placeholder: 'class-1',
}; };
const value = 'test'; const value = 'test';
const expectedOutput = strToEl(` const expectedOutput = strToEl(`
@ -215,7 +216,7 @@ describe('templates', () => {
describe('choiceList', () => { describe('choiceList', () => {
const classes = { const classes = {
list: 'test', list: 'class-1',
}; };
describe('select one element', () => { describe('select one element', () => {
@ -252,9 +253,9 @@ describe('templates', () => {
describe('choiceGroup', () => { describe('choiceGroup', () => {
const classes = { const classes = {
group: 'test 1', group: 'class-1',
groupHeading: 'test 2', groupHeading: 'class-2',
itemDisabled: 'test 3', itemDisabled: 'class-3',
}; };
let data; let data;
@ -316,11 +317,12 @@ describe('templates', () => {
describe('choice', () => { describe('choice', () => {
const classes = { const classes = {
item: 'test 1', item: 'class-1',
itemChoice: 'test 2', itemChoice: 'class-2',
itemDisabled: 'test 3', itemDisabled: 'class-3',
itemSelectable: 'test 4', itemSelectable: 'class-4',
placeholder: 'test 5', placeholder: 'class-5',
selectedState: 'class-6',
}; };
const itemSelectText = 'test 6'; const itemSelectText = 'test 6';
@ -335,6 +337,7 @@ describe('templates', () => {
elementId: 'test', elementId: 'test',
label: 'test', label: 'test',
value: 'test', value: 'test',
selected: false,
}; };
}); });
@ -390,6 +393,35 @@ describe('templates', () => {
}); });
}); });
describe('selected state', () => {
beforeEach(() => {
data = {
...data,
selected: true,
};
});
it('returns expected html', () => {
const expectedOutput = strToEl(`
<div
class="${classes.item} ${classes.itemChoice} ${classes.selectedState} ${classes.itemSelectable}"
data-select-text="${itemSelectText}"
data-choice
data-id="${data.id}"
data-value="${data.value}"
data-choice-selectable
id="${data.elementId}"
role="option"
>
${data.label}
</div>
`);
const actualOutput = templates.choice(classes, data, itemSelectText);
expectEqualElements(actualOutput, expectedOutput);
});
});
describe('placeholder', () => { describe('placeholder', () => {
beforeEach(() => { beforeEach(() => {
data = { data = {
@ -401,7 +433,7 @@ describe('templates', () => {
it('returns expected html', () => { it('returns expected html', () => {
const expectedOutput = strToEl(` const expectedOutput = strToEl(`
<div <div
class="${classes.item} ${classes.itemChoice} ${classes.itemSelectable} ${classes.placeholder}" class="${classes.item} ${classes.itemChoice} ${classes.placeholder} ${classes.itemSelectable}"
data-select-text="${itemSelectText}" data-select-text="${itemSelectText}"
data-choice data-choice
data-id="${data.id}" data-id="${data.id}"
@ -451,8 +483,8 @@ describe('templates', () => {
describe('input', () => { describe('input', () => {
const classes = { const classes = {
input: 'test 1', input: 'class-1',
inputCloned: 'test 2', inputCloned: 'class-2',
}; };
it('returns expected html', () => { it('returns expected html', () => {
@ -479,9 +511,10 @@ describe('templates', () => {
describe('dropdown', () => { describe('dropdown', () => {
const classes = { const classes = {
list: 'test-1', list: 'class-1',
listDropdown: 'test-2', listDropdown: 'class-2',
}; };
it('returns expected html', () => { it('returns expected html', () => {
const value = 'test'; const value = 'test';
const expectedOutput = strToEl( const expectedOutput = strToEl(
@ -495,10 +528,10 @@ describe('templates', () => {
describe('notice', () => { describe('notice', () => {
const classes = { const classes = {
item: 'test-1', item: 'class-1',
itemChoice: 'test-2', itemChoice: 'class-2',
noResults: 'test-3', noResults: 'class-3',
noChoices: 'test-4', noChoices: 'class-4',
}; };
const label = 'test'; const label = 'test';

View file

@ -83,6 +83,9 @@ $choices-icon-cross-inverse: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEiI
box-shadow: 0px 0px 0px 2px $choices-highlight-color; box-shadow: 0px 0px 0px 2px $choices-highlight-color;
} }
} }
.#{$choices-selector}__item[data-value=''] .#{$choices-selector}__button {
display: none;
}
&:after { &:after {
content: ''; content: '';
height: 0; height: 0;
@ -198,6 +201,7 @@ $choices-icon-cross-inverse: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEiI
border: 1px solid darken($choices-primary-color, 5%); border: 1px solid darken($choices-primary-color, 5%);
color: #ffffff; color: #ffffff;
word-break: break-all; word-break: break-all;
box-sizing: border-box;
&[data-deletable] { &[data-deletable] {
padding-right: 5px; padding-right: 5px;
} }

92
types/index.d.ts vendored
View file

@ -1,4 +1,4 @@
// Type definitions for Choices.js 7.1.x // Type definitions for Choices.js
// Project: https://github.com/jshjohnson/Choices // Project: https://github.com/jshjohnson/Choices
// Definitions by: // Definitions by:
// Arthur vasconcelos <https://github.com/arthurvasconcelos>, // Arthur vasconcelos <https://github.com/arthurvasconcelos>,
@ -19,14 +19,16 @@ declare namespace Choices {
type noticeStringFunction = (value: string) => string; type noticeStringFunction = (value: string) => string;
type noticeLimitFunction = (maxItemCount: number) => string; type noticeLimitFunction = (maxItemCount: number) => string;
type filterFunction = (value: string) => boolean; type filterFunction = (value: string) => boolean;
type valueCompareFunction = (value1: string, value2: string) => boolean;
} }
interface Choice { interface Choice {
id?: number;
customProperties?: Record<string, any>; customProperties?: Record<string, any>;
disabled?: boolean; disabled?: boolean;
active?: boolean;
elementId?: string; elementId?: string;
groupId?: string; groupId?: number;
id?: string;
keyCode?: number; keyCode?: number;
label: string; label: string;
placeholder?: boolean; placeholder?: boolean;
@ -34,6 +36,19 @@ declare namespace Choices {
value: string; value: string;
} }
interface Group {
id?: number;
active?: boolean;
disabled?: boolean;
value: any;
}
interface Item extends Choice {
choiceId?: number;
keyCode?: number;
highlighted?: boolean;
}
/** /**
* Events fired by Choices behave the same as standard events. Each event is triggered on the element passed to Choices (accessible via `this.passedElement`. Arguments are accessible within the `event.detail` object. * Events fired by Choices behave the same as standard events. Each event is triggered on the element passed to Choices (accessible via `this.passedElement`. Arguments are accessible within the `event.detail` object.
*/ */
@ -46,11 +61,11 @@ declare namespace Choices {
* Arguments: id, value, label, groupValue, keyCode * Arguments: id, value, label, groupValue, keyCode
*/ */
addItem: CustomEvent<{ addItem: CustomEvent<{
id: string; id: number;
value: string; value: string;
label: string; label: string;
groupValue: string; groupValue: string;
keyCode: string; keyCode: number;
}>; }>;
/** /**
@ -61,7 +76,7 @@ declare namespace Choices {
* Arguments: id, value, label, groupValue * Arguments: id, value, label, groupValue
*/ */
removeItem: CustomEvent<{ removeItem: CustomEvent<{
id: string; id: number;
value: string; value: string;
label: string; label: string;
groupValue: string; groupValue: string;
@ -75,7 +90,7 @@ declare namespace Choices {
* Arguments: id, value, label, groupValue * Arguments: id, value, label, groupValue
*/ */
highlightItem: CustomEvent<{ highlightItem: CustomEvent<{
id: string; id: number;
value: string; value: string;
label: string; label: string;
groupValue: string; groupValue: string;
@ -89,7 +104,7 @@ declare namespace Choices {
* Arguments: id, value, label, groupValue * Arguments: id, value, label, groupValue
*/ */
unhighlightItem: CustomEvent<{ unhighlightItem: CustomEvent<{
id: string; id: number;
value: string; value: string;
label: string; label: string;
groupValue: string; groupValue: string;
@ -149,18 +164,6 @@ declare namespace Choices {
highlightChoice: CustomEvent<{ el: Choices.passedElement }>; highlightChoice: CustomEvent<{ el: Choices.passedElement }>;
} }
interface Group {
active?: boolean;
disabled?: boolean;
id?: string;
value: any;
}
interface Item extends Choice {
choiceId?: string;
keyCode?: number;
}
interface Templates { interface Templates {
containerOuter: ( containerOuter: (
this: Choices, this: Choices,
@ -263,6 +266,8 @@ declare namespace Choices {
disabledState: string; disabledState: string;
/** @default 'is-highlighted' */ /** @default 'is-highlighted' */
highlightedState: string; highlightedState: string;
/** @default 'is-selected' */
selectedState: string;
/** @default 'is-flipped' */ /** @default 'is-flipped' */
flippedState: string; flippedState: string;
/** @default 'is-loading' */ /** @default 'is-loading' */
@ -404,7 +409,7 @@ declare namespace Choices {
* *
* @default null * @default null
*/ */
addItemFilter: string | RegExp | Choices.Types.filterFunction; addItemFilter: string | RegExp | Choices.Types.filterFunction | null;
/** /**
* The text that is shown when a user has inputted a new item but has not pressed the enter key. To access the current input value, pass a function with a `value` argument (see the **default config** [https://github.com/jshjohnson/Choices#setup] for an example), otherwise pass a string. * The text that is shown when a user has inputted a new item but has not pressed the enter key. To access the current input value, pass a function with a `value` argument (see the **default config** [https://github.com/jshjohnson/Choices#setup] for an example), otherwise pass a string.
@ -564,7 +569,7 @@ declare namespace Choices {
* ``` * ```
* // Sorting via length of label from largest to smallest * // Sorting via length of label from largest to smallest
* const example = new Choices(element, { * const example = new Choices(element, {
* sortFilter: function(a, b) { * sorter: function(a, b) {
* return b.label.length - a.label.length; * return b.label.length - a.label.length;
* }, * },
* }; * };
@ -572,7 +577,7 @@ declare namespace Choices {
* *
* @default sortByAlpha * @default sortByAlpha
*/ */
sortFilter: (current: Choice, next: Choice) => number; sorter: (current: Choice, next: Choice) => number;
/** /**
* Whether the input should show a placeholder. Used in conjunction with `placeholderValue`. If `placeholder` is set to true and no value is passed to `placeholderValue`, the passed input's placeholder attribute will be used as the placeholder value. * Whether the input should show a placeholder. Used in conjunction with `placeholderValue`. If `placeholder` is set to true and no value is passed to `placeholderValue`, the passed input's placeholder attribute will be used as the placeholder value.
@ -600,7 +605,7 @@ declare namespace Choices {
* *
* @default null * @default null
*/ */
placeholderValue: string; placeholderValue: string | null;
/** /**
* The value of the search inputs placeholder. * The value of the search inputs placeholder.
@ -609,7 +614,7 @@ declare namespace Choices {
* *
* @default null * @default null
*/ */
searchPlaceholderValue: string; searchPlaceholderValue: string | null;
/** /**
* Prepend a value to each item added/selected. * Prepend a value to each item added/selected.
@ -618,7 +623,7 @@ declare namespace Choices {
* *
* @default null * @default null
*/ */
prependValue: string; prependValue: string | null;
/** /**
* Append a value to each item added/selected. * Append a value to each item added/selected.
@ -627,7 +632,7 @@ declare namespace Choices {
* *
* @default null * @default null
*/ */
appendValue: string; appendValue: string | null;
/** /**
* Whether selected choices should be removed from the list. By default choices are removed when they are selected in multiple select box. To always render choices pass `always`. * Whether selected choices should be removed from the list. By default choices are removed when they are selected in multiple select box. To always render choices pass `always`.
@ -689,16 +694,37 @@ declare namespace Choices {
/** /**
* If no duplicates are allowed, and the value already exists in the array. * If no duplicates are allowed, and the value already exists in the array.
* *
* @default 'Only unique values can be added.' * @default 'Only unique values can be added'
*/ */
uniqueItemText: string | Choices.Types.noticeStringFunction; uniqueItemText: string | Choices.Types.noticeStringFunction;
/**
* The text that is shown when addItemFilter is passed and it returns false
*
* **Input types affected:** text
*
* @default 'Only values matching specific conditions can be added'
*/
customAddItemText: string | Choices.Types.noticeStringFunction;
/**
* Compare choice and value in appropriate way (e.g. deep equality for objects). To compare choice and value, pass a function with a `valueComparer` argument (see the [default config](https://github.com/jshjohnson/Choices#setup) for an example).
*
* **Input types affected:** select-one, select-multiple
*
* @default
* ```
* (choice, item) => choice === item;
* ```
*/
valueComparer: Choices.Types.valueCompareFunction;
/** /**
* Classes added to HTML generated by Choices. By default classnames follow the BEM notation. * Classes added to HTML generated by Choices. By default classnames follow the BEM notation.
* *
* **Input types affected:** text, select-one, select-multiple * **Input types affected:** text, select-one, select-multiple
*/ */
classNames: Partial<Choices.ClassNames>; classNames: Choices.ClassNames;
/** /**
* Choices uses the great Fuse library for searching. You can find more options here: https://github.com/krisk/Fuse#options * Choices uses the great Fuse library for searching. You can find more options here: https://github.com/krisk/Fuse#options
@ -714,7 +740,7 @@ declare namespace Choices {
* *
* @default null * @default null
*/ */
callbackOnInit: (this: Choices) => void; callbackOnInit: ((this: Choices) => void) | null;
/** /**
* Function to run on template creation. Through this callback it is possible to provide custom templates for the various components of Choices (see terminology). For Choices to work with custom templates, it is important you maintain the various data attributes defined here [https://github.com/jshjohnson/Choices/blob/67f29c286aa21d88847adfcd6304dc7d068dc01f/assets/scripts/src/choices.js#L1993-L2067]. * Function to run on template creation. Through this callback it is possible to provide custom templates for the various components of Choices (see terminology). For Choices to work with custom templates, it is important you maintain the various data attributes defined here [https://github.com/jshjohnson/Choices/blob/67f29c286aa21d88847adfcd6304dc7d068dc01f/assets/scripts/src/choices.js#L1993-L2067].
@ -750,9 +776,9 @@ declare namespace Choices {
* *
* @default null * @default null
*/ */
callbackOnCreateTemplates: ( callbackOnCreateTemplates:
template: Choices.Types.strToEl, | ((template: Choices.Types.strToEl) => Partial<Choices.Templates>)
) => Partial<Choices.Templates>; | null;
} }
} }