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? -->
<!--- 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)
## Types of changes
@ -17,6 +11,7 @@
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] Chore (tooling change or documentation change)
- [ ] Refactor (non-breaking change which maintains existing functionality)
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] 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)'
tag-template: 'v$NEXT_PATCH_VERSION'
sort-direction: descending
exclude-labels:
- 'skip-changelog'
- 'release'
categories:
- title: '🚨 Breaking changes'
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
with:
node-version: 10
# run all tests
- run: |
- name: Build and run all tests
run: |
npm ci
npm run build
npx bundlesize
@ -32,6 +32,15 @@ jobs:
BUNDLESIZE_GITHUB_TOKEN: ${{secrets.BUNDLESIZE_GITHUB_TOKEN}}
FORCE_COLOR: 2
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
run: bash <(curl -s https://codecov.io/bash)

View file

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

View file

@ -10,11 +10,15 @@ on:
- 'webpack.config.*'
- 'public/test/**'
- 'cypress/**'
- '.github/workflows/cypress.yml'
- '.github/workflows/e2e-tests.yml'
jobs:
test-e2e:
runs-on: ubuntu-latest
env:
CI: true
TERM: xterm-256color
steps:
- uses: actions/checkout@v1
with:
@ -22,18 +26,42 @@ jobs:
- uses: actions/setup-node@v1
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
run: npm ci
env:
HUSKY_SKIP_INSTALL: true
- name: run Cypress CI
run: npx run-p --race start cypress:ci
- name: run Cypress (with or without recording)
# 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:
CI: true
TERM: xterm-256color
NODE_ENV: production # prevent watching
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
DEBUG: commit-info,cypress:server:record

View file

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

View file

@ -1,6 +1,7 @@
{
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "lf",
"overrides": [
{
"files": ["*.svg"],
@ -8,6 +9,12 @@
"parser": "html",
"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.
@ -105,7 +105,7 @@ Or include Choices directly:
resetScrollPosition: true,
shouldSort: true,
shouldSortItems: false,
sortFn: () => {...},
sorter: () => {...},
placeholder: true,
placeholderValue: null,
searchPlaceholderValue: null,
@ -122,8 +122,8 @@ Or include Choices directly:
maxItemText: (maxItemCount) => {
return `Only ${maxItemCount} values can be added`;
},
itemComparer: (choice, item) => {
return choice === item;
valueComparer: (value1, value2) => {
return value1 === value2;
},
classNames: {
containerOuter: 'choices',
@ -147,6 +147,7 @@ Or include Choices directly:
openState: 'is-open',
disabledState: 'is-disabled',
highlightedState: 'is-highlighted',
selectedState: 'is-selected',
flippedState: 'is-flipped',
loadingState: 'is-loading',
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.
### sortFn
### sorter
**Type:** `Function` **Default:** sortByAlpha
@ -421,7 +422,7 @@ new Choices(element, {
```js
// Sorting via length of label from largest to smallest
const example = new Choices(element, {
sortFn: function(a, b) {
sorter: function(a, b) {
return b.label.length - a.label.length;
},
};
@ -431,11 +432,11 @@ const example = new Choices(element, {
**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.
**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
<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.
### itemComparer
### valueComparer
**Type:** `Function` **Default:** `strict equality`
**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
@ -570,6 +579,7 @@ classNames: {
openState: 'is-open',
disabledState: 'is-disabled',
highlightedState: 'is-highlighted',
selectedState: 'is-selected',
flippedState: 'is-flipped',
selectedState: 'is-highlighted',
}
@ -1053,7 +1063,7 @@ Element.prototype.closest
## 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`

View file

@ -1,6 +1,6 @@
describe('Choices - select multiple', () => {
beforeEach(() => {
cy.visit('/select-multiple.html');
cy.visit('/select-multiple');
});
describe('scenarios', () => {
@ -486,20 +486,42 @@ describe('Choices - select multiple', () => {
});
});
describe('placeholder', () => {
/*
{
placeholder: true,
placeholderValue: 'I am a placeholder',
}
*/
describe('placeholder via empty option value', () => {
describe('when no value has been inputted', () => {
it('displays a placeholder', () => {
cy.get('[data-test-hook=placeholder]')
cy.get('[data-test-hook=placeholder-via-option-value]')
.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-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', () => {

View file

@ -1,6 +1,6 @@
describe('Choices - select one', () => {
beforeEach(() => {
cy.visit('/select-one.html');
cy.visit('/select-one');
});
describe('scenarios', () => {
@ -12,6 +12,30 @@ describe('Choices - select one', () => {
.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', () => {
it('displays a dropdown of choices', () => {
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', () => {
beforeEach(() => {
cy.reload(true);
@ -458,6 +578,7 @@ describe('Choices - select one', () => {
cy.get('[data-test-hook=remote-data]')
.find('.choices__list--single')
.children()
.should('have.length', 1)
.first()
.should('have.class', 'choices__placeholder')
.and($placeholder => {
@ -483,10 +604,14 @@ describe('Choices - select one', () => {
cy.get('[data-test-hook=remote-data]')
.find('.choices__list--dropdown .choices__list')
.children()
.should('have.length', 50)
.should('have.length', 51) // 50 choices + 1 placeholder choice
.each(($choice, index) => {
expect($choice.text().trim()).to.equal(`Label ${index + 1}`);
expect($choice.data('value')).to.equal(`Value ${index + 1}`);
if (index === 0) {
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', () => {
it('preserves the choices list', () => {
cy.get('[data-test-hook=new-destroy-init]')

View file

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

View file

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

74
package-lock.json generated
View file

@ -1,6 +1,6 @@
{
"name": "choices.js",
"version": "8.0.0",
"version": "9.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -1944,7 +1944,7 @@
},
"browserify-aes": {
"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==",
"dev": true,
"requires": {
@ -1981,7 +1981,7 @@
},
"browserify-rsa": {
"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=",
"dev": true,
"requires": {
@ -2026,7 +2026,7 @@
},
"buffer": {
"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=",
"dev": true,
"requires": {
@ -2213,7 +2213,7 @@
},
"camelcase-keys": {
"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=",
"dev": true,
"requires": {
@ -2807,7 +2807,7 @@
},
"create-hash": {
"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==",
"dev": true,
"requires": {
@ -2820,7 +2820,7 @@
},
"create-hmac": {
"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==",
"dev": true,
"requires": {
@ -2956,9 +2956,9 @@
"dev": true
},
"cypress": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-3.5.0.tgz",
"integrity": "sha512-I1iSReD2C8CTP6s4BvQky4gEqHBnKLmhBIqFyCUZdj6BQ6ZDxGnmIbQPM5g79E2iP60KTIbTK99ZPSDVtsNUUg==",
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-3.6.0.tgz",
"integrity": "sha512-ODhbOrH1XZx0DUoYmJSvOSbEQjycNOpFYe7jOnHkT1+sdsn2+uqwAjZ1x982q3H4R/5iZjpSd50gd/iw2bofzg==",
"dev": true,
"requires": {
"@cypress/listr-verbose-renderer": "0.4.1",
@ -3350,7 +3350,7 @@
},
"diffie-hellman": {
"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==",
"dev": true,
"requires": {
@ -3420,7 +3420,7 @@
},
"duplexer": {
"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=",
"dev": true
},
@ -4092,7 +4092,7 @@
"dependencies": {
"pify": {
"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=",
"dev": true
}
@ -5873,7 +5873,7 @@
},
"get-stream": {
"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=",
"dev": true
},
@ -6038,7 +6038,7 @@
},
"got": {
"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=",
"dev": true,
"requires": {
@ -6903,7 +6903,7 @@
},
"is-obj": {
"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=",
"dev": true
},
@ -7866,7 +7866,7 @@
},
"load-json-file": {
"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=",
"dev": true,
"requires": {
@ -7887,7 +7887,7 @@
},
"pify": {
"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=",
"dev": true
}
@ -8145,7 +8145,7 @@
},
"media-typer": {
"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=",
"dev": true
},
@ -8186,7 +8186,7 @@
},
"meow": {
"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=",
"dev": true,
"requires": {
@ -8214,7 +8214,7 @@
},
"load-json-file": {
"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=",
"dev": true,
"requires": {
@ -8256,7 +8256,7 @@
},
"pify": {
"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=",
"dev": true
},
@ -8454,7 +8454,7 @@
},
"mkdirp": {
"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=",
"dev": true,
"requires": {
@ -8724,7 +8724,7 @@
"dependencies": {
"semver": {
"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=",
"dev": true
}
@ -9603,7 +9603,7 @@
},
"onetime": {
"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=",
"dev": true
},
@ -9653,7 +9653,7 @@
},
"ora": {
"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=",
"dev": true,
"requires": {
@ -9704,7 +9704,7 @@
},
"os-locale": {
"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=",
"dev": true,
"requires": {
@ -9940,7 +9940,7 @@
"dependencies": {
"pify": {
"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=",
"dev": true
}
@ -11770,7 +11770,7 @@
},
"safe-regex": {
"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=",
"dev": true,
"requires": {
@ -12134,7 +12134,7 @@
},
"sha.js": {
"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==",
"dev": true,
"requires": {
@ -12201,6 +12201,12 @@
"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": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@ -12209,7 +12215,7 @@
},
"slice-ansi": {
"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=",
"dev": true
},
@ -12629,7 +12635,7 @@
},
"strip-ansi": {
"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=",
"dev": true,
"requires": {
@ -12644,7 +12650,7 @@
},
"strip-eof": {
"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=",
"dev": true
},
@ -12977,7 +12983,7 @@
},
"through": {
"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=",
"dev": true
},

View file

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

View file

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

View file

@ -1,4 +1,3 @@
// get polyfill settings from top level config
// @ts-ignore
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
// 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,
extends: [
"plugin:compat/recommended"
],
extends: ['plugin:compat/recommended'],
parserOptions: {
// ensure that it's compatible with ES5 browsers, so, no `const`, etc
ecmaVersion: 5
ecmaVersion: 5,
},
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;
}
.choices[data-type*='select-one'] .choices__item[data-value=''] .choices__button {
display: none;
}
.choices[data-type*='select-one']:after {
content: '';
height: 0;
@ -196,6 +200,7 @@
border: 1px solid #00a5bb;
color: #ffffff;
word-break: break-all;
box-sizing: border-box;
}
.choices__list--multiple .choices__item[data-deletable] {
@ -218,7 +223,7 @@
}
.choices__list--dropdown {
display: none;
visibility: hidden;
z-index: 1;
position: absolute;
width: 100%;
@ -230,10 +235,11 @@
border-bottom-right-radius: 2.5px;
overflow: hidden;
word-break: break-all;
will-change: visibility;
}
.choices__list--dropdown.is-active {
display: block;
visibility: visible;
}
.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"
multiple
>
<option value="">Choose a city</option>
<optgroup label="UK">
<option value="London">London</option>
<option value="Manchester">Manchester</option>
@ -336,8 +337,8 @@
data-trigger
name="choices-single-groups"
id="choices-single-groups"
placeholder="This is a placeholder"
>
<option value="">Choose a city</option>
<optgroup label="UK">
<option value="London">London</option>
<option value="Manchester">Manchester</option>
@ -376,7 +377,6 @@
data-trigger
name="choices-single-rtl"
id="choices-single-rtl"
placeholder="This is a placeholder"
dir="rtl"
>
<option value="Choice 1">Choice 1</option>
@ -402,7 +402,6 @@
class="form-control"
name="choices-single-preset-options"
id="choices-single-preset-options"
placeholder="This is a placeholder"
></select>
<label for="choices-single-selected-option"
@ -415,7 +414,6 @@
class="form-control"
name="choices-single-selected-option"
id="choices-single-selected-option"
placeholder="This is a placeholder"
></select>
<label for="choices-with-custom-props-via-html"
@ -440,7 +438,6 @@
class="form-control"
name="choices-single-no-sorting"
id="choices-single-no-sorting"
placeholder="This is a placeholder"
>
<option value="Madrid">Madrid</option>
<option value="Toronto">Toronto</option>
@ -467,7 +464,6 @@
class="form-control"
name="choices-single-custom-templates"
id="choices-single-custom-templates"
placeholder="This is a placeholder"
>
<option value="React">React</option>
<option value="Angular">Angular</option>
@ -481,12 +477,8 @@
'Cities' is 'London'
</p>
<label for="cities">Cities</label>
<select
class="form-control"
name="cities"
id="cities"
placeholder="Choose a city"
>
<select class="form-control" name="cities" id="cities">
<option value="">Choose a city</option>
<option value="Leeds">Leeds</option>
<option value="Manchester">Manchester</option>
<option value="London">London</option>
@ -495,12 +487,8 @@
</select>
<label for="tube-stations">Tube stations</label>
<select
class="form-control"
name="tube-stations"
id="tube-stations"
placeholder="Choose a tube station"
>
<select class="form-control" name="tube-stations" id="tube-stations">
<option value="">Choose a tube station</option>
<option value="Moorgate">Moorgate</option>
<option value="St Pauls">St Pauls</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>
<form>
<label for="reset-simple">Change me!</label>
<select
class="form-control"
name="reset-simple"
id="reset-simple"
placeholder="Choose an option"
>
<select class="form-control" name="reset-simple" id="reset-simple">
<option value="Option 1">Option 1</option>
<option value="Option 2" selected>Option 2</option>
<option value="Option 3">Option 3</option>
@ -533,7 +516,6 @@
class="form-control"
name="reset-multiple"
id="reset-multiple"
placeholder="This is a placeholder"
multiple
>
<option value="Choice 1" selected>Choice 1</option>
@ -564,7 +546,7 @@
editItems: true,
maxItemCount: 5,
removeItemButton: true,
},
}
);
var textUniqueVals = new Choices('#choices-text-unique-values', {
@ -612,7 +594,7 @@
{
prependValue: 'item-',
appendValue: '-' + Date.now(),
},
}
).removeActiveItems();
var textPresetVal = new Choices('#choices-text-preset-values', {
@ -629,7 +611,7 @@
});
var multipleDefault = new Choices(
document.getElementById('choices-multiple-groups'),
document.getElementById('choices-multiple-groups')
);
var multipleFetch = new Choices('#choices-multiple-remote-fetch', {
@ -638,7 +620,7 @@
maxItemCount: 5,
}).setChoices(function() {
return fetch(
'https://api.discogs.com/artists/55980/releases?token=QBRmstCkwXEvCjTclCpumbtNwvVkEzGAdELXyRyW',
'https://api.discogs.com/artists/55980/releases?token=QBRmstCkwXEvCjTclCpumbtNwvVkEzGAdELXyRyW'
)
.then(function(response) {
return response.json();
@ -654,7 +636,7 @@
'#choices-multiple-remove-button',
{
removeItemButton: true,
},
}
);
/* Use label on event */
@ -673,7 +655,7 @@
],
'value',
'label',
false,
false
);
choicesSelect.passedElement.element.addEventListener(
@ -681,7 +663,7 @@
function(event) {
document.getElementById('message').innerHTML =
'You just added "' + event.detail.label + '"';
},
}
);
choicesSelect.passedElement.element.addEventListener(
@ -689,7 +671,7 @@
function(event) {
document.getElementById('message').innerHTML =
'You just removed "' + event.detail.label + '"';
},
}
);
var singleFetch = new Choices('#choices-single-remote-fetch', {
@ -697,7 +679,7 @@
})
.setChoices(function() {
return fetch(
'https://api.discogs.com/artists/391170/releases?token=QBRmstCkwXEvCjTclCpumbtNwvVkEzGAdELXyRyW',
'https://api.discogs.com/artists/391170/releases?token=QBRmstCkwXEvCjTclCpumbtNwvVkEzGAdELXyRyW'
)
.then(function(response) {
return response.json();
@ -717,7 +699,7 @@
searchPlaceholderValue: "Search for a Smiths' record",
}).setChoices(function(callback) {
return fetch(
'https://api.discogs.com/artists/83080/releases?token=QBRmstCkwXEvCjTclCpumbtNwvVkEzGAdELXyRyW',
'https://api.discogs.com/artists/83080/releases?token=QBRmstCkwXEvCjTclCpumbtNwvVkEzGAdELXyRyW'
)
.then(function(res) {
return res.json();
@ -750,7 +732,7 @@
],
'value',
'label',
false,
false
);
var singlePresetOpts = new Choices('#choices-single-preset-options', {
@ -779,7 +761,7 @@
},
],
'value',
'label',
'label'
);
var singleSelectedOpt = new Choices('#choices-single-selected-option', {
@ -801,7 +783,7 @@
'#choices-with-custom-props-via-html',
{
searchFields: ['label', 'value', 'customProperties'],
},
}
);
var singleNoSorting = new Choices('#choices-single-no-sorting', {
@ -810,7 +792,7 @@
var cities = new Choices(document.getElementById('cities'));
var tubeStations = new Choices(
document.getElementById('tube-stations'),
document.getElementById('tube-stations')
).disable();
cities.passedElement.element.addEventListener('change', function(e) {
@ -838,7 +820,7 @@
String(
data.highlighted
? classNames.highlightedState
: classNames.itemSelectable,
: classNames.itemSelectable
) +
'"\
data-item\
@ -859,7 +841,7 @@
String(data.label) +
'\
</div>\
',
'
);
},
choice: function(classNames, data) {
@ -874,7 +856,7 @@
String(
data.disabled
? classNames.itemDisabled
: classNames.itemSelectable,
: classNames.itemSelectable
) +
'"\
data-select-text="' +
@ -885,7 +867,7 @@
String(
data.disabled
? 'data-choice-disabled aria-disabled="true"'
: 'data-choice-selectable',
: 'data-choice-selectable'
) +
'\
data-id="' +
@ -896,7 +878,7 @@
'"\
' +
String(
data.groupId > 0 ? 'role="treeitem"' : 'role="option"',
data.groupId > 0 ? 'role="treeitem"' : 'role="option"'
) +
'\
>\
@ -904,12 +886,12 @@
String(data.label) +
'\
</div>\
',
'
);
},
};
},
},
}
);
var resetSimple = new Choices(document.getElementById('reset-simple'));

View file

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

View file

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

View file

@ -15,43 +15,46 @@
<link
rel="apple-touch-icon"
sizes="180x180"
href="../assets/images/apple-touch-icon.png"
href="../../assets/images/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
href="../assets/images/favicon-32x32.png"
href="../../assets/images/favicon-32x32.png"
sizes="32x32"
/>
<link
rel="icon"
type="image/png"
href="../assets/images/favicon-16x16.png"
href="../../assets/images/favicon-16x16.png"
sizes="16x16"
/>
<link rel="manifest" href="../assets/images/manifest.json" />
<link rel="manifest" href="../../assets/images/manifest.json" />
<link
rel="mask-icon"
href="../assets/images/safari-pinned-tab.svg"
href="../../assets/images/safari-pinned-tab.svg"
color="#00bcd4"
/>
<link rel="shortcut icon" href="../assets/images/favicon.ico" />
<link rel="shortcut icon" href="../../assets/images/favicon.ico" />
<meta
name="msapplication-config"
content="../assets/images/browserconfig.xml"
content="../../assets/images/browserconfig.xml"
/>
<meta name="theme-color" content="#ffffff" />
<!-- 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 -->
<!-- Choices includes -->
<link
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 -->
</head>

View file

@ -2,8 +2,8 @@
const express = require('express');
const path = require('path');
const PORT = 3001;
const DIST_DIR = path.join(__dirname, 'public');
const PORT = process.env.PORT || 3001;
const DIST_DIR = path.resolve(__dirname, 'public');
const app = express();
@ -51,7 +51,7 @@ const server = app.listen(PORT, err => {
console.log(err);
}
console.log(`Listening at http://localhost:${PORT} 👂`);
console.log(`Listening at http://localhost:${server.address().port} 👂`);
});
process.on('SIGTERM', () => {
@ -70,3 +70,5 @@ process.on('SIGTERM', () => {
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';
/**
* @argument {Choice} choice
* @returns {Action & Choice}
*/
export const addChoice = ({
value,
label,
@ -23,16 +32,27 @@ export const addChoice = ({
keyCode,
});
/**
* @argument {Choice[]} results
* @returns {Action & { results: Choice[] }}
*/
export const filterChoices = results => ({
type: ACTION_TYPES.FILTER_CHOICES,
results,
});
/**
* @argument {boolean} active
* @returns {Action & { active: boolean }}
*/
export const activateChoices = (active = true) => ({
type: ACTION_TYPES.ACTIVATE_CHOICES,
active,
});
/**
* @returns {Action}
*/
export const clearChoices = () => ({
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';
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,
value,
id,

View file

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

View file

@ -1,5 +1,14 @@
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 = ({
value,
label,
@ -21,12 +30,22 @@ export const addItem = ({
keyCode,
});
/**
* @param {string} id
* @param {string} choiceId
* @returns {Action & { id: string, choiceId: string }}
*/
export const removeItem = (id, choiceId) => ({
type: ACTION_TYPES.REMOVE_ITEM,
id,
choiceId,
});
/**
* @param {string} id
* @param {boolean} highlighted
* @returns {Action & { id: string, highlighted: boolean }}
*/
export const highlightItem = (id, highlighted) => ({
type: ACTION_TYPES.HIGHLIGHT_ITEM,
id,

View file

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

View file

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

View file

@ -1,10 +1,13 @@
import { expect } from 'chai';
import chai, { expect } from 'chai';
import { spy, stub } from 'sinon';
import sinonChai from 'sinon-chai';
import Choices from './choices';
import { EVENTS, ACTION_TYPES, DEFAULT_CONFIG } from './constants';
import { WrappedSelect, WrappedInput } from './components/index';
chai.use(sinonChai);
describe('choices', () => {
let instance;
let output;
@ -60,6 +63,36 @@ describe('choices', () => {
...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 => {
expect(inst).to.eq(choice);
fetcherCalled = true;
await new Promise(resolve => setTimeout(resolve, 1000));
await new Promise(resolve => setTimeout(resolve, 800));
return [
{ label: 'l1', value: 'v1', customProperties: 'prop1' },
@ -1142,15 +1175,10 @@ describe('choices', () => {
};
expect(choice._store.choices.length).to.equal(0);
const promise = choice.setChoices(fetcher);
await new Promise(resolve =>
requestAnimationFrame(() => {
expect(handleLoadingStateSpy.callCount).to.equal(1);
resolve();
}),
);
expect(fetcherCalled).to.be.true;
const res = await promise;
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].label).to.equal('l2');
expect(choice._store.choices[1].customProperties).to.equal('prop2');
@ -1511,12 +1539,12 @@ describe('choices', () => {
const label = 'label';
const choices = [
{
id: '1',
id: 1,
value: '1',
label: 'Test 1',
},
{
id: '2',
id: 2,
value: '2',
label: 'Test 2',
},
@ -1735,15 +1763,15 @@ describe('choices', () => {
beforeEach(() => {
sortFnStub = stub();
instance.config.sortFn = sortFnStub;
instance.config.sorter = sortFnStub;
instance.config.shouldSort = true;
});
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);
instance._createGroupsFragment(groups, choices);
expect(sortFnStub.called).to.equal(true);
@ -1755,12 +1783,12 @@ describe('choices', () => {
beforeEach(() => {
sortFnStub = stub();
instance.config.sortFn = sortFnStub;
instance.config.sorter = sortFnStub;
instance.config.shouldSort = false;
});
afterEach(() => {
instance.config.sortFn.reset();
instance.config.sorter.reset();
});
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 { SELECT_ONE_TYPE } from '../constants';
/**
* @typedef {import('../../../types/index').Choices.passedElement} passedElement
* @typedef {import('../../../types/index').Choices.ClassNames} ClassNames
*/
export default class Container {
/**
* @param {{
* element: HTMLElement,
* type: passedElement['type'],
* classNames: ClassNames,
* position
* }} args
*/
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.isFlipped = false;
this.isFocussed = false;
this.isDisabled = false;
this.isLoading = false;
this._onFocus = this._onFocus.bind(this);
this._onBlur = this._onBlur.bind(this);
}
/**
* Add event listeners
*/
addEventListeners() {
this.element.addEventListener('focus', this._onFocus);
this.element.addEventListener('blur', this._onBlur);
}
/**
* Remove event listeners
*/
/** */
removeEventListeners() {
this.element.removeEventListener('focus', this._onFocus);
this.element.removeEventListener('blur', this._onBlur);
}
/**
* Determine whether container should be flipped
* based on passed dropdown position
* @param {Number} dropdownPos
* @returns
* Determine whether container should be flipped based on passed
* dropdown position
* @param {number} dropdownPos
* @returns {boolean}
*/
shouldFlip(dropdownPos) {
if (typeof dropdownPos !== 'number') {
@ -57,20 +63,19 @@ export default class Container {
}
/**
* Set active descendant attribute
* @param {Number} activeDescendant ID of active descendant
* @param {string} activeDescendantID
*/
setActiveDescendant(activeDescendantID) {
this.element.setAttribute('aria-activedescendant', activeDescendantID);
}
/**
* Remove active descendant attribute
*/
removeActiveDescendant() {
this.element.removeAttribute('aria-activedescendant');
}
/**
* @param {number} dropdownPos
*/
open(dropdownPos) {
this.element.classList.add(this.classNames.openState);
this.element.setAttribute('aria-expanded', 'true');
@ -109,34 +114,34 @@ export default class Container {
this.element.classList.remove(this.classNames.focusState);
}
/**
* Remove disabled state
*/
enable() {
this.element.classList.remove(this.classNames.disabledState);
this.element.removeAttribute('aria-disabled');
if (this.type === 'select-one') {
if (this.type === SELECT_ONE_TYPE) {
this.element.setAttribute('tabindex', '0');
}
this.isDisabled = false;
}
/**
* Set disabled state
*/
disable() {
this.element.classList.add(this.classNames.disabledState);
this.element.setAttribute('aria-disabled', 'true');
if (this.type === 'select-one') {
if (this.type === SELECT_ONE_TYPE) {
this.element.setAttribute('tabindex', '-1');
}
this.isDisabled = true;
}
/**
* @param {HTMLElement} element
*/
wrap(element) {
wrap(element, this.element);
}
/**
* @param {Element} element
*/
unwrap(element) {
// Move passed element outside this element
this.element.parentNode.insertBefore(element, this.element);
@ -144,34 +149,22 @@ export default class Container {
this.element.parentNode.removeChild(this.element);
}
/**
* Add loading state to element
*/
addLoadingState() {
this.element.classList.add(this.classNames.loadingState);
this.element.setAttribute('aria-busy', 'true');
this.isLoading = true;
}
/**
* Remove loading state from element
*/
removeLoadingState() {
this.element.classList.remove(this.classNames.loadingState);
this.element.removeAttribute('aria-busy');
this.isLoading = false;
}
/**
* Set focussed state
*/
_onFocus() {
this.isFocussed = true;
}
/**
* Remove blurred state
*/
_onBlur() {
this.isFocussed = false;
}

View file

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

View file

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

View file

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

View file

@ -1,16 +1,39 @@
import { dispatchEvent } from '../lib/utils';
export default class WrappedElement {
constructor({ element, classNames }) {
Object.assign(this, { element, classNames });
/**
* @typedef {import('../../../types/index').Choices.passedElement} passedElement
* @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');
}
this.isDisabled = false;
}
get isActive() {
return this.element.dataset.choice === 'active';
}
get dir() {
return this.element.dir;
}
get value() {
return this.element.value;
}
@ -26,7 +49,7 @@ export default class WrappedElement {
this.element.hidden = true;
// Remove element from tab index
this.element.tabIndex = '-1';
this.element.tabIndex = -1;
// Backup original styles if any
const origStyle = this.element.getAttribute('style');

View file

@ -31,6 +31,19 @@ describe('components/wrappedElement', () => {
it('sets isDisabled flag to 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', () => {
@ -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', () => {
let originalStyling;

View file

@ -1,15 +1,33 @@
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 {
/**
* @param {{
* element: HTMLInputElement,
* classNames: ClassNames,
* delimiter: string
* }} args
*/
constructor({ element, classNames, delimiter }) {
super({ element, classNames });
this.delimiter = delimiter;
}
/**
* @returns {string}
*/
get value() {
return this.element.value;
}
/**
* @param {Item[]} items
*/
set value(items) {
const itemValues = items.map(({ value }) => value);
const joinedValues = itemValues.join(this.delimiter);

View file

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

View file

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

View file

@ -35,6 +35,7 @@ describe('constants', () => {
'openState',
'disabledState',
'highlightedState',
'selectedState',
'flippedState',
'loadingState',
'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) =>
Math.floor(Math.random() * (max - min) + min);
/**
* @param {number} length
* @returns {string}
*/
export const generateChars = length =>
Array.from({ length }, () => getRandomNumber(0, 36).toString(36)).join('');
/**
* @param {HTMLInputElement | HTMLSelectElement} element
* @param {string} prefix
* @returns {string}
*/
export const generateId = (element, prefix) => {
let id =
element.id ||
@ -15,11 +29,25 @@ export const generateId = (element, prefix) => {
return id;
};
/**
* @param {any} obj
* @returns {string}
*/
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) =>
obj !== undefined && obj !== null && getType(obj) === type;
/**
* @param {HTMLElement} element
* @param {HTMLElement} [wrapper={HTMLDivElement}]
* @returns {HTMLElement}
*/
export const wrap = (element, wrapper = document.createElement('div')) => {
if (element.nextSibling) {
element.parentNode.insertBefore(wrapper, element.nextSibling);
@ -31,39 +59,38 @@ export const wrap = (element, wrapper = document.createElement('div')) => {
};
/**
* @param {HTMLElement} el
* @param {string} attr
* @param {Element} startEl
* @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 =
/**
* @param {Element} startEl
* @param {string} selector
* @param {1 | -1} direction
* @returns {Element | undefined}
*/
(startEl, selector, direction = 1) => {
if (!(startEl instanceof Element) || typeof selector !== 'string') {
return undefined;
const prop = `${direction > 0 ? 'next' : 'previous'}ElementSibling`;
let sibling = startEl[prop];
while (sibling) {
if (sibling.matches(selector)) {
return sibling;
}
sibling = sibling[prop];
}
const prop = `${direction > 0 ? 'next' : 'previous'}ElementSibling`;
return sibling;
};
let sibling = startEl[prop];
while (sibling) {
if (sibling.matches(selector)) {
return sibling;
}
sibling = sibling[prop];
}
return sibling;
};
export const isScrolledIntoView = (el, parent, direction = 1) => {
if (!el) {
return;
/**
* @param {Element} element
* @param {Element} parent
* @param {-1 | 1} direction
* @returns {boolean}
*/
export const isScrolledIntoView = (element, parent, direction = 1) => {
if (!element) {
return false;
}
let isVisible;
@ -71,15 +98,20 @@ export const isScrolledIntoView = (el, parent, direction = 1) => {
if (direction > 0) {
// In view from bottom
isVisible =
parent.scrollTop + parent.offsetHeight >= el.offsetTop + el.offsetHeight;
parent.scrollTop + parent.offsetHeight >=
element.offsetTop + element.offsetHeight;
} else {
// In view from top
isVisible = el.offsetTop >= parent.scrollTop;
isVisible = element.offsetTop >= parent.scrollTop;
}
return isVisible;
};
/**
* @param {any} value
* @returns {any}
*/
export const sanitise = value => {
if (typeof value !== 'string') {
return value;
@ -92,6 +124,9 @@ export const sanitise = value => {
.replace(/"/g, '&quot;');
};
/**
* @returns {() => (str: string) => Element}
*/
export const strToEl = (() => {
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 }} b
* @returns {number}
*/
({ value, label = value }, { value: value2, label: label2 = value2 }) =>
label.localeCompare(label2, [], {
sensitivity: 'base',
ignorePunctuation: true,
numeric: true,
});
/**
* @param {{ label?: string, value: string }} a
* @param {{ label?: string, value: string }} b
* @returns {number}
*/
export const sortByAlpha = (
{ value, label = value },
{ value: value2, label: label2 = value2 },
) =>
label.localeCompare(label2, [], {
sensitivity: 'base',
ignorePunctuation: true,
numeric: true,
});
/**
* @param {{ score: number }} a
* @param {{ score: number }} b
*/
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) => {
const event = new CustomEvent(type, {
detail: customArgs,
@ -133,9 +179,12 @@ export const dispatchEvent = (element, type, customArgs = null) => {
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') =>
array.some(item => {
if (typeof value === 'string') {
@ -145,8 +194,18 @@ export const existsInArray = (array, value, key = 'value') =>
return item[key] === value;
});
/**
* @param {any} obj
* @returns {any}
*/
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) => {
const aKeys = Object.keys(a).sort();
const bKeys = Object.keys(b).sort();

View file

@ -9,10 +9,10 @@ import {
sanitise,
sortByAlpha,
sortByScore,
isIE11,
existsInArray,
cloneObject,
dispatchEvent,
diff,
} from './utils';
describe('utils', () => {
@ -37,7 +37,7 @@ describe('utils', () => {
describe('generateId', () => {
describe('when given element has id value', () => {
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';
const prefix = 'test-prefix';
@ -49,7 +49,7 @@ describe('utils', () => {
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', () => {
const element = document.createElement('div');
const element = document.createElement('select');
element.name = 'test-name';
const prefix = 'test-prefix';
@ -63,7 +63,7 @@ describe('utils', () => {
describe('when given element has no id value and no name value', () => {
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 output = generateId(element, prefix);
@ -83,7 +83,7 @@ describe('utils', () => {
expect(getType([])).to.equal('Array');
expect(getType(() => {})).to.equal('Function');
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('')).to.equal('String');
});
@ -97,12 +97,24 @@ describe('utils', () => {
});
describe('sanitise', () => {
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;',
);
describe('when passing a parameter that is not a string', () => {
it('returns the passed argument', () => {
const value = {
test: true,
};
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', () => {
it('determines whether a value exists within given array', () => {
const values = [
@ -238,4 +238,20 @@ describe('utils', () => {
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 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 {
constructor() {
this._store = createStore(
@ -21,7 +27,7 @@ export default class Store {
/**
* 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
*/
dispatch(action) {
@ -30,7 +36,7 @@ export default class Store {
/**
* Get store object (wrapping Redux method)
* @return {Object} State
* @returns {object} State
*/
get state() {
return this._store.getState();
@ -38,7 +44,7 @@ export default class Store {
/**
* Get items from store
* @return {Array} Item objects
* @returns {Item[]} Item objects
*/
get items() {
return this.state.items;
@ -46,7 +52,7 @@ export default class Store {
/**
* Get active items from store
* @return {Array} Item objects
* @returns {Item[]} Item objects
*/
get activeItems() {
return this.items.filter(item => item.active === true);
@ -54,7 +60,7 @@ export default class Store {
/**
* Get highlighted items from store
* @return {Array} Item objects
* @returns {Item[]} Item objects
*/
get highlightedActiveItems() {
return this.items.filter(item => item.active && item.highlighted);
@ -62,7 +68,7 @@ export default class Store {
/**
* Get choices from store
* @return {Array} Option objects
* @returns {Choice[]} Option objects
*/
get choices() {
return this.state.choices;
@ -70,18 +76,15 @@ export default class Store {
/**
* Get active choices from store
* @return {Array} Option objects
* @returns {Choice[]} Option objects
*/
get activeChoices() {
const { choices } = this;
const values = choices.filter(choice => choice.active === true);
return values;
return this.choices.filter(choice => choice.active === true);
}
/**
* Get selectable choices from store
* @return {Array} Option objects
* @returns {Choice[]} Option objects
*/
get selectableChoices() {
return this.choices.filter(choice => choice.disabled !== true);
@ -89,7 +92,7 @@ export default class Store {
/**
* Get choices that can be searched (excluding placeholders)
* @return {Array} Option objects
* @returns {Choice[]} Option objects
*/
get searchableChoices() {
return this.selectableChoices.filter(choice => choice.placeholder !== true);
@ -97,7 +100,7 @@ export default class Store {
/**
* Get placeholder choice from store
* @return {Object} Found placeholder
* @returns {Choice | undefined} Found placeholder
*/
get placeholderChoice() {
return [...this.choices]
@ -107,7 +110,7 @@ export default class Store {
/**
* Get groups from store
* @return {Array} Group objects
* @returns {Group[]} Group objects
*/
get groups() {
return this.state.groups;
@ -115,7 +118,7 @@ export default class Store {
/**
* Get active groups from store
* @return {Array} Group objects
* @returns {Group[]} Group objects
*/
get activeGroups() {
const { groups, choices } = this;
@ -132,7 +135,7 @@ export default class Store {
/**
* Get loading state from store
* @return {Boolean} Loading State
* @returns {boolean} Loading State
*/
isLoading() {
return this.state.general.loading;
@ -140,25 +143,19 @@ export default class Store {
/**
* Get single choice by it's ID
* @param {id} string
* @return {import('../../../types/index').Choices.Choice | false} Found choice
* @param {string} id
* @returns {Choice | undefined} Found choice
*/
getChoiceById(id) {
if (id) {
const n = parseInt(id, 10);
return this.activeChoices.find(choice => choice.id === n);
}
return false;
return this.activeChoices.find(choice => choice.id === parseInt(id, 10));
}
/**
* Get group by group id
* @param {Number} id Group ID
* @return {Object} Group data
* @param {number} id Group ID
* @returns {Group | undefined} Group data
*/
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);
});
});
describe('passing no id', () => {
it('returns false', () => {
const actualResponse = instance.getChoiceById();
expect(actualResponse).to.equal(false);
});
});
});
describe('placeholderChoice getter', () => {
@ -252,10 +245,8 @@ describe('reducers/store', () => {
describe('getGroupById', () => {
it('returns group by id', () => {
const id = '1';
const expectedResponse = state.groups.find(
group => group.id === parseInt(id, 10),
);
const id = 1;
const expectedResponse = state.groups.find(group => group.id === id);
const actualResponse = instance.getGroupById(id);
expect(actualResponse).to.eql(expectedResponse);
});

View file

@ -2,9 +2,22 @@
* Helpers to create HTML elements used by Choices
* Can be overridden by providing `callbackOnCreateTemplates` option
* @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} */ ({
/**
* @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 },
dir,
@ -39,16 +52,30 @@ export const TEMPLATES = /** @type {Templates} */ ({
return div;
},
/**
* @param {Partial<ClassNames>} classNames
*/
containerInner({ containerInner }) {
return Object.assign(document.createElement('div'), {
className: containerInner,
});
},
/**
* @param {Partial<ClassNames>} classNames
* @param {boolean} isSelectOneElement
*/
itemList({ list, listSingle, listItems }, isSelectOneElement) {
return Object.assign(document.createElement('div'), {
className: `${list} ${isSelectOneElement ? listSingle : listItems}`,
});
},
/**
* @param {Partial<ClassNames>} classNames
* @param {string} value
*/
placeholder({ placeholder }, value) {
return Object.assign(document.createElement('div'), {
className: placeholder,
@ -56,6 +83,11 @@ export const TEMPLATES = /** @type {Templates} */ ({
});
},
/**
* @param {Partial<ClassNames>} classNames
* @param {Item} item
* @param {boolean} removeItemButton
*/
item(
{ item, button, highlightedState, itemSelectable, placeholder },
{
@ -93,6 +125,7 @@ export const TEMPLATES = /** @type {Templates} */ ({
if (isPlaceholder) {
div.classList.add(placeholder);
}
div.classList.add(highlighted ? highlightedState : itemSelectable);
if (removeItemButton) {
@ -117,6 +150,11 @@ export const TEMPLATES = /** @type {Templates} */ ({
return div;
},
/**
* @param {Partial<ClassNames>} classNames
* @param {boolean} isSelectOneElement
*/
choiceList({ list }, isSelectOneElement) {
const div = Object.assign(document.createElement('div'), {
className: list,
@ -130,6 +168,10 @@ export const TEMPLATES = /** @type {Templates} */ ({
return div;
},
/**
* @param {Partial<ClassNames>} classNames
* @param {Group} group
*/
choiceGroup({ group, groupHeading, itemDisabled }, { id, value, disabled }) {
const div = Object.assign(document.createElement('div'), {
className: `${group} ${disabled ? itemDisabled : ''}`,
@ -157,15 +199,28 @@ export const TEMPLATES = /** @type {Templates} */ ({
return div;
},
/**
* @param {Partial<ClassNames>} classNames
* @param {Choice} choice
* @param {Options['itemSelectText']} selectText
*/
choice(
{ item, itemChoice, itemSelectable, itemDisabled, placeholder },
{
item,
itemChoice,
itemSelectable,
selectedState,
itemDisabled,
placeholder,
},
{
id,
value,
label,
groupId,
elementId,
disabled,
disabled: isDisabled,
selected: isSelected,
placeholder: isPlaceholder,
},
selectText,
@ -173,11 +228,17 @@ export const TEMPLATES = /** @type {Templates} */ ({
const div = Object.assign(document.createElement('div'), {
id: elementId,
innerHTML: label,
className: `${item} ${itemChoice} ${
disabled ? itemDisabled : itemSelectable
} ${isPlaceholder ? placeholder : ''}`,
className: `${item} ${itemChoice}`,
});
if (isSelected) {
div.classList.add(selectedState);
}
if (isPlaceholder) {
div.classList.add(placeholder);
}
div.setAttribute('role', groupId > 0 ? 'treeitem' : 'option');
Object.assign(div.dataset, {
@ -187,15 +248,22 @@ export const TEMPLATES = /** @type {Templates} */ ({
selectText,
});
if (disabled) {
if (isDisabled) {
div.classList.add(itemDisabled);
div.dataset.choiceDisabled = '';
div.setAttribute('aria-disabled', 'true');
} else {
div.classList.add(itemSelectable);
div.dataset.choiceSelectable = '';
}
return div;
},
/**
* @param {Partial<ClassNames>} classNames
* @param {string} placeholderValue
*/
input({ input, inputCloned }, placeholderValue) {
const inp = Object.assign(document.createElement('input'), {
type: 'text',
@ -211,6 +279,10 @@ export const TEMPLATES = /** @type {Templates} */ ({
return inp;
},
/**
* @param {Partial<ClassNames>} classNames
*/
dropdown({ list, listDropdown }) {
const div = document.createElement('div');
@ -219,6 +291,13 @@ export const TEMPLATES = /** @type {Templates} */ ({
return div;
},
/**
*
* @param {Partial<ClassNames>} classNames
* @param {string} innerHTML
* @param {"no-choices" | "no-results" | ""} type
*/
notice({ item, itemChoice, noResults, noChoices }, innerHTML, type = '') {
const classes = [item, itemChoice];
@ -233,6 +312,10 @@ export const TEMPLATES = /** @type {Templates} */ ({
className: classes.join(' '),
});
},
/**
* @param {Item} option
*/
option({ label, value, customProperties, active, disabled }) {
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(
Object.keys(element2.dataset),
);
expect(element1.classList).to.include(element2.classList);
// compare attributes values
for (const attribute of Object.values(element1.attributes)) {
expect(element1.getAttribute(attribute)).to.equal(
@ -24,7 +25,7 @@ function expectEqualElements(element1, element2) {
describe('templates', () => {
describe('containerOuter', () => {
const classes = {
containerOuter: 'test',
containerOuter: 'class-1',
};
const direction = 'rtl';
@ -158,7 +159,7 @@ describe('templates', () => {
describe('containerInner', () => {
it('returns expected html', () => {
const classes = {
containerInner: 'test',
containerInner: 'class-1',
};
const expectedOutput = strToEl(
`<div class="${classes.containerInner}"></div>`,
@ -171,9 +172,9 @@ describe('templates', () => {
describe('itemList', () => {
const classes = {
list: 'test 1',
listSingle: 'test 2',
listItems: 'test 3',
list: 'class-1',
listSingle: 'class-2',
listItems: 'class-3',
};
describe('select one element', () => {
@ -202,7 +203,7 @@ describe('templates', () => {
describe('placeholder', () => {
it('returns expected html', () => {
const classes = {
placeholder: 'test',
placeholder: 'class-1',
};
const value = 'test';
const expectedOutput = strToEl(`
@ -215,7 +216,7 @@ describe('templates', () => {
describe('choiceList', () => {
const classes = {
list: 'test',
list: 'class-1',
};
describe('select one element', () => {
@ -252,9 +253,9 @@ describe('templates', () => {
describe('choiceGroup', () => {
const classes = {
group: 'test 1',
groupHeading: 'test 2',
itemDisabled: 'test 3',
group: 'class-1',
groupHeading: 'class-2',
itemDisabled: 'class-3',
};
let data;
@ -316,11 +317,12 @@ describe('templates', () => {
describe('choice', () => {
const classes = {
item: 'test 1',
itemChoice: 'test 2',
itemDisabled: 'test 3',
itemSelectable: 'test 4',
placeholder: 'test 5',
item: 'class-1',
itemChoice: 'class-2',
itemDisabled: 'class-3',
itemSelectable: 'class-4',
placeholder: 'class-5',
selectedState: 'class-6',
};
const itemSelectText = 'test 6';
@ -335,6 +337,7 @@ describe('templates', () => {
elementId: 'test',
label: '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', () => {
beforeEach(() => {
data = {
@ -401,7 +433,7 @@ describe('templates', () => {
it('returns expected html', () => {
const expectedOutput = strToEl(`
<div
class="${classes.item} ${classes.itemChoice} ${classes.itemSelectable} ${classes.placeholder}"
class="${classes.item} ${classes.itemChoice} ${classes.placeholder} ${classes.itemSelectable}"
data-select-text="${itemSelectText}"
data-choice
data-id="${data.id}"
@ -451,8 +483,8 @@ describe('templates', () => {
describe('input', () => {
const classes = {
input: 'test 1',
inputCloned: 'test 2',
input: 'class-1',
inputCloned: 'class-2',
};
it('returns expected html', () => {
@ -479,9 +511,10 @@ describe('templates', () => {
describe('dropdown', () => {
const classes = {
list: 'test-1',
listDropdown: 'test-2',
list: 'class-1',
listDropdown: 'class-2',
};
it('returns expected html', () => {
const value = 'test';
const expectedOutput = strToEl(
@ -495,10 +528,10 @@ describe('templates', () => {
describe('notice', () => {
const classes = {
item: 'test-1',
itemChoice: 'test-2',
noResults: 'test-3',
noChoices: 'test-4',
item: 'class-1',
itemChoice: 'class-2',
noResults: 'class-3',
noChoices: 'class-4',
};
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;
}
}
.#{$choices-selector}__item[data-value=''] .#{$choices-selector}__button {
display: none;
}
&:after {
content: '';
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%);
color: #ffffff;
word-break: break-all;
box-sizing: border-box;
&[data-deletable] {
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
// Definitions by:
// Arthur vasconcelos <https://github.com/arthurvasconcelos>,
@ -19,14 +19,16 @@ declare namespace Choices {
type noticeStringFunction = (value: string) => string;
type noticeLimitFunction = (maxItemCount: number) => string;
type filterFunction = (value: string) => boolean;
type valueCompareFunction = (value1: string, value2: string) => boolean;
}
interface Choice {
id?: number;
customProperties?: Record<string, any>;
disabled?: boolean;
active?: boolean;
elementId?: string;
groupId?: string;
id?: string;
groupId?: number;
keyCode?: number;
label: string;
placeholder?: boolean;
@ -34,6 +36,19 @@ declare namespace Choices {
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.
*/
@ -46,11 +61,11 @@ declare namespace Choices {
* Arguments: id, value, label, groupValue, keyCode
*/
addItem: CustomEvent<{
id: string;
id: number;
value: string;
label: string;
groupValue: string;
keyCode: string;
keyCode: number;
}>;
/**
@ -61,7 +76,7 @@ declare namespace Choices {
* Arguments: id, value, label, groupValue
*/
removeItem: CustomEvent<{
id: string;
id: number;
value: string;
label: string;
groupValue: string;
@ -75,7 +90,7 @@ declare namespace Choices {
* Arguments: id, value, label, groupValue
*/
highlightItem: CustomEvent<{
id: string;
id: number;
value: string;
label: string;
groupValue: string;
@ -89,7 +104,7 @@ declare namespace Choices {
* Arguments: id, value, label, groupValue
*/
unhighlightItem: CustomEvent<{
id: string;
id: number;
value: string;
label: string;
groupValue: string;
@ -149,18 +164,6 @@ declare namespace Choices {
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 {
containerOuter: (
this: Choices,
@ -263,6 +266,8 @@ declare namespace Choices {
disabledState: string;
/** @default 'is-highlighted' */
highlightedState: string;
/** @default 'is-selected' */
selectedState: string;
/** @default 'is-flipped' */
flippedState: string;
/** @default 'is-loading' */
@ -404,7 +409,7 @@ declare namespace Choices {
*
* @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.
@ -564,7 +569,7 @@ declare namespace Choices {
* ```
* // Sorting via length of label from largest to smallest
* const example = new Choices(element, {
* sortFilter: function(a, b) {
* sorter: function(a, b) {
* return b.label.length - a.label.length;
* },
* };
@ -572,7 +577,7 @@ declare namespace Choices {
*
* @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.
@ -600,7 +605,7 @@ declare namespace Choices {
*
* @default null
*/
placeholderValue: string;
placeholderValue: string | null;
/**
* The value of the search inputs placeholder.
@ -609,7 +614,7 @@ declare namespace Choices {
*
* @default null
*/
searchPlaceholderValue: string;
searchPlaceholderValue: string | null;
/**
* Prepend a value to each item added/selected.
@ -618,7 +623,7 @@ declare namespace Choices {
*
* @default null
*/
prependValue: string;
prependValue: string | null;
/**
* Append a value to each item added/selected.
@ -627,7 +632,7 @@ declare namespace Choices {
*
* @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`.
@ -689,16 +694,37 @@ declare namespace Choices {
/**
* 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;
/**
* 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.
*
* **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
@ -714,7 +740,7 @@ declare namespace Choices {
*
* @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].
@ -750,9 +776,9 @@ declare namespace Choices {
*
* @default null
*/
callbackOnCreateTemplates: (
template: Choices.Types.strToEl,
) => Partial<Choices.Templates>;
callbackOnCreateTemplates:
| ((template: Choices.Types.strToEl) => Partial<Choices.Templates>)
| null;
}
}