├── .browserslistrc ├── .codebeatignore ├── .codecov.yml ├── .editorconfig ├── .eslintrc.json ├── .git-blame-ignore-revs ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── actions-scripts │ └── polyfills-sync.cjs ├── release-drafter.yml └── workflows │ ├── browsers.yml │ ├── bundlesize.yml │ ├── deploy-pages.yml │ ├── deployment.yml │ ├── lint.yml │ ├── polyfills-sync.yml │ ├── release-drafter.yml │ └── unit-tests.yml ├── .gitignore ├── .huskyrc ├── .nvmrc ├── .prettierrc.json ├── .stylelintrc.json ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.json ├── jsconfig.json ├── package-lock.json ├── package.json ├── playwright.config.ts ├── public ├── assets │ ├── images │ │ ├── android-chrome-192x192.png │ │ ├── apple-touch-icon.png │ │ ├── browserconfig.xml │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── favicon.png │ │ ├── logo.svg │ │ ├── manifest.json │ │ ├── mstile-150x150.png │ │ └── safari-pinned-tab.svg │ ├── scripts │ │ ├── choices.js │ │ ├── choices.min.js │ │ ├── choices.mjs │ │ ├── choices.search-basic.js │ │ ├── choices.search-basic.min.js │ │ ├── choices.search-basic.mjs │ │ ├── choices.search-kmp.js │ │ ├── choices.search-kmp.min.js │ │ ├── choices.search-kmp.mjs │ │ ├── choices.search-prefix.js │ │ ├── choices.search-prefix.min.js │ │ └── choices.search-prefix.mjs │ └── styles │ │ ├── base.css │ │ ├── base.css.map │ │ ├── base.min.css │ │ ├── choices.css │ │ ├── choices.css.map │ │ └── choices.min.css ├── index.html ├── robots.txt ├── test │ ├── data.json │ ├── disabled-data.json │ ├── select-multiple │ │ ├── index-performance.html │ │ └── index.html │ ├── select-one │ │ └── index.html │ └── text │ │ └── index.html └── types │ └── src │ ├── index.d.ts │ └── scripts │ ├── actions │ ├── choices.d.ts │ ├── groups.d.ts │ └── items.d.ts │ ├── choices.d.ts │ ├── components │ ├── container.d.ts │ ├── dropdown.d.ts │ ├── index.d.ts │ ├── input.d.ts │ ├── list.d.ts │ ├── wrapped-element.d.ts │ ├── wrapped-input.d.ts │ └── wrapped-select.d.ts │ ├── constants.d.ts │ ├── defaults.d.ts │ ├── interfaces │ ├── action-type.d.ts │ ├── build-flags.d.ts │ ├── choice-full.d.ts │ ├── class-names.d.ts │ ├── event-choice.d.ts │ ├── event-type.d.ts │ ├── group-full.d.ts │ ├── index.d.ts │ ├── input-choice.d.ts │ ├── input-group.d.ts │ ├── item.d.ts │ ├── keycode-map.d.ts │ ├── options.d.ts │ ├── passed-element-type.d.ts │ ├── passed-element.d.ts │ ├── position-options-type.d.ts │ ├── search.d.ts │ ├── state.d.ts │ ├── store.d.ts │ ├── string-pre-escaped.d.ts │ ├── string-untrusted.d.ts │ ├── templates.d.ts │ └── types.d.ts │ ├── lib │ ├── choice-input.d.ts │ ├── html-guard-statements.d.ts │ └── utils.d.ts │ ├── reducers │ ├── choices.d.ts │ ├── groups.d.ts │ └── items.d.ts │ ├── search │ ├── fuse.d.ts │ ├── index.d.ts │ ├── kmp.d.ts │ └── prefix-filter.d.ts │ ├── store │ └── store.d.ts │ └── templates.d.ts ├── scripts ├── lint-staged.config.js ├── rollup.config.mjs └── server.mjs ├── src ├── entry.js ├── icons │ ├── cross-inverse.svg │ └── cross.svg ├── index.ts ├── scripts │ ├── actions │ │ ├── choices.ts │ │ ├── groups.ts │ │ └── items.ts │ ├── choices.ts │ ├── components │ │ ├── container.ts │ │ ├── dropdown.ts │ │ ├── index.ts │ │ ├── input.ts │ │ ├── list.ts │ │ ├── wrapped-element.ts │ │ ├── wrapped-input.ts │ │ └── wrapped-select.ts │ ├── constants.ts │ ├── defaults.ts │ ├── interfaces │ │ ├── action-type.ts │ │ ├── build-flags.ts │ │ ├── choice-full.ts │ │ ├── class-names.ts │ │ ├── event-choice.ts │ │ ├── event-type.ts │ │ ├── group-full.ts │ │ ├── index.ts │ │ ├── input-choice.ts │ │ ├── input-group.ts │ │ ├── item.ts │ │ ├── keycode-map.ts │ │ ├── options.ts │ │ ├── passed-element-type.ts │ │ ├── passed-element.ts │ │ ├── position-options-type.ts │ │ ├── search.ts │ │ ├── state.ts │ │ ├── store.ts │ │ ├── string-pre-escaped.ts │ │ ├── string-untrusted.ts │ │ ├── templates.ts │ │ └── types.ts │ ├── lib │ │ ├── choice-input.ts │ │ ├── html-guard-statements.ts │ │ └── utils.ts │ ├── reducers │ │ ├── choices.ts │ │ ├── groups.ts │ │ └── items.ts │ ├── search │ │ ├── fuse.ts │ │ ├── index.ts │ │ ├── kmp.ts │ │ └── prefix-filter.ts │ ├── store │ │ └── store.ts │ └── templates.ts ├── styles │ ├── base.scss │ └── choices.scss └── tsconfig.json ├── test-e2e ├── __screenshots__ │ ├── chromium-darwin.png │ ├── chromium-linux.png │ ├── chromium-win32.png │ ├── firefox-linux.png │ ├── webkit-darwin.png │ └── webkit-linux.png ├── bundle-test.ts ├── hars │ ├── 0432285dab6a62ab5e6efaf4cb1272720e0c3f1b.json │ ├── 69c6136c671f1173ee6d6596d125e821dea44a4f.json │ ├── a8763b95c9f6c98156a9100e8bed82cb17b93ecd.json │ └── discogs.har ├── merge.config.ts ├── select-test-suit.ts ├── test-suit.ts ├── tests │ ├── demo-page.spec.ts │ ├── select-multiple-performance.spec.ts │ ├── select-multiple.spec.ts │ ├── select-one.spec.ts │ └── text.spec.ts ├── text-test-suit.ts └── tsconfig.json ├── test ├── scripts │ ├── actions │ │ ├── choices.test.ts │ │ ├── groups.test.ts │ │ └── items.test.ts │ ├── choices.test.ts │ ├── components │ │ ├── container.test.ts │ │ ├── dropdown.test.ts │ │ ├── input.test.ts │ │ ├── list.test.ts │ │ ├── wrapped-element.test.ts │ │ ├── wrapped-input.test.ts │ │ └── wrapped-select.test.ts │ ├── lib │ │ └── utils.test.ts │ ├── reducers │ │ ├── choices.test.ts │ │ ├── groups.test.ts │ │ └── items.test.ts │ ├── search │ │ └── index.test.ts │ ├── store │ │ └── store.test.ts │ └── templates.test.ts ├── setupFiles │ └── window-matchMedia.ts └── tsconfig.json └── vitest.config.ts /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | -------------------------------------------------------------------------------- /.codebeatignore: -------------------------------------------------------------------------------- 1 | public/** 2 | webpack.config.*.js 3 | *.js -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | parsers: 3 | javascript: 4 | enable_partials: yes 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint", "prettier", "sort-class-members"], 4 | "extends": [ 5 | "airbnb-base", 6 | "airbnb-typescript", 7 | "plugin:prettier/recommended", 8 | "plugin:compat/recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "env": { 12 | "es6": true, 13 | "node": true, 14 | "browser": true 15 | }, 16 | "parserOptions": { 17 | "sourceType": "module", 18 | "project": true 19 | }, 20 | "rules": { 21 | "no-param-reassign": ["error", { "props": false }], 22 | "@typescript-eslint/explicit-function-return-type": "error", 23 | "import/no-named-as-default": "off", 24 | "import/prefer-default-export": "off", 25 | "import/no-extraneous-dependencies": [ 26 | "error", 27 | { 28 | "devDependencies": true 29 | } 30 | ], 31 | "no-console": [ 32 | "warn", 33 | { 34 | "allow": ["warn", "error"] 35 | } 36 | ], 37 | "no-plusplus": "off", 38 | "no-unused-expressions": "off", 39 | "no-underscore-dangle": "off", 40 | "consistent-return": "off", 41 | "import/no-useless-path-segments": "warn", 42 | "prefer-destructuring": [ 43 | "warn", 44 | { 45 | "array": false, 46 | "object": true 47 | } 48 | ], 49 | "curly": ["error", "all"], 50 | "newline-before-return": "error", 51 | "sort-class-members/sort-class-members": [ 52 | 2, 53 | { 54 | "order": [ 55 | "[static-properties]", 56 | "[static-methods]", 57 | "[properties]", 58 | "[conventional-private-properties]", 59 | "constructor", 60 | "[methods]", 61 | "[conventional-private-methods]" 62 | ], 63 | "accessorPairPositioning": "getThenSet" 64 | } 65 | ], 66 | "lines-between-class-members": "off", 67 | "@typescript-eslint/no-floating-promises": "error", 68 | "@typescript-eslint/no-namespace": "off", 69 | "react/jsx-filename-extension": [0], 70 | "import/extensions": [ 71 | "error", 72 | "ignorePackages", 73 | { 74 | "js": "never", 75 | "mjs": "never", 76 | "jsx": "never", 77 | "ts": "never", 78 | "tsx": "never" 79 | } 80 | ] 81 | }, 82 | "overrides": [ 83 | { 84 | "files": ["*.test.ts", "*.spec.ts"], 85 | "rules": { 86 | "no-await-in-loop": "off", 87 | "@typescript-eslint/explicit-function-return-type": "off", 88 | "no-restricted-syntax": "off", 89 | "compat/compat": "off", 90 | "no-new": "off", 91 | "@typescript-eslint/no-empty-function": "off", 92 | "@typescript-eslint/no-explicit-any": "off", 93 | "@typescript-eslint/no-non-null-assertion": "off", 94 | "@typescript-eslint/no-unused-expressions": "off", 95 | "@typescript-eslint/naming-convention": [ 96 | "error", 97 | { 98 | "selector": "default", 99 | "format": ["camelCase", "PascalCase", "UPPER_CASE"], 100 | "leadingUnderscore": "allow" 101 | } 102 | ] 103 | } 104 | } 105 | ], 106 | "settings": { 107 | "polyfills": [ 108 | "Array.from", 109 | "Array.prototype.find", 110 | "Array.prototype.includes", 111 | "Symbol", 112 | "Symbol.iterator", 113 | "DOMTokenList", 114 | "Object.assign", 115 | "CustomEvent", 116 | "Element.prototype.classList", 117 | "Element.prototype.closest", 118 | "Element.prototype.dataset", 119 | "Element.prototype.replaceChildren" 120 | ], 121 | "import/resolver": { 122 | "node": { 123 | "extensions": [".js", ".ts"] 124 | } 125 | } 126 | }, 127 | "ignorePatterns": ["node_modules/*", "public/*"] 128 | } 129 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # byte shaving (hoist semi-commonly variables, remove some low level low-usage functions) 2 | 157a47a44a01e3ce4b54ad211b1756ff59985bef 3 | 5bee41d7ff08e05442b232e3e552dcb6c703568d 4 | e9382df0ae63edfc7540f82f74cf969342c759c0 5 | 6 | # prettier config change 7 | 00433d200d8cccc8b544fbc8f05d5e96bf8ccff7 8 | 9 | # misc linting cleanup 10 | 00009d2effa8b41a6ce27ef8b06a35a04215aea6 11 | 62b786d1f13d0934137a62909d3a37db0a3e927e 12 | 5ad61841143508c9f91f0edd57f81f8b11066e0a 13 | 84a61cad1ddab1e851c98efa619a2cd35af434c1 14 | 33f573247e8badc9ee10defe326f13985342e09b 15 | b0199538a82d49de429f35546e412d14fc8bfeb9 -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ## GITATTRIBUTES FOR WEB PROJECTS 2 | # 3 | # These settings are for any web project. 4 | # 5 | # Details per file setting: 6 | # text These files should be normalized (i.e. convert CRLF to LF). 7 | # binary These files are binary and should be left untouched. 8 | # 9 | # Note that binary is a macro for -text -diff. 10 | ###################################################################### 11 | 12 | # Auto detect 13 | ## Handle line endings automatically for files detected as 14 | ## text and leave all files detected as binary untouched. 15 | ## This will handle all files NOT defined below. 16 | * text eol=lf 17 | 18 | # Source code 19 | *.css text eol=lf 20 | *.html text diff=html eol=lf 21 | *.js text eol=lf 22 | *.json text eol=lf 23 | *.scss text diff=css eol=lf 24 | *.ts text eol=lf 25 | 26 | # Documentation 27 | *.md text eol=lf 28 | *.txt text eol=lf 29 | AUTHORS text eol=lf 30 | CHANGELOG text eol=lf 31 | CHANGES text eol=lf 32 | CONTRIBUTING text eol=lf 33 | COPYING text eol=lf 34 | copyright text eol=lf 35 | *COPYRIGHT* text eol=lf 36 | INSTALL text eol=lf 37 | license text eol=lf 38 | LICENSE text eol=lf 39 | NEWS text eol=lf 40 | readme text eol=lf 41 | *README* text eol=lf 42 | TODO text eol=lf 43 | 44 | # Linters 45 | .eslintrc text eol=lf 46 | .stylelintrc text eol=lf 47 | 48 | # Configs 49 | .babelrc text eol=lf 50 | .browserslistrc text eol=lf 51 | .editorconfig text eol=lf 52 | .env text eol=lf 53 | .gitattributes text eol=lf 54 | .gitconfig text eol=lf 55 | package-lock.json text -diff eol=lf 56 | *.npmignore text eol=lf 57 | *.yaml text eol=lf 58 | *.yml text eol=lf 59 | browserslist text eol=lf 60 | 61 | # Graphics 62 | # SVG treated as an asset (binary) by default. 63 | *.svg text eol=lf 64 | *.png binary 65 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Using https://jsfiddle.net/ to create a minimal reproducible example. 15 | 16 | Otherwise 17 | 18 | Steps to reproduce the behavior: 19 | 1. Go to '...' 20 | 2. Click on '....' 21 | 3. Scroll down to '....' 22 | 4. See error 23 | 24 | **Expected behavior** 25 | A clear and concise description of what you expected to happen. 26 | 27 | **Screenshots** 28 | If applicable, add screenshots to help explain your problem. 29 | 30 | **Choices version and bundle** 31 | - Version: [e.g. v11.0.0 choices.min.js] 32 | 33 | **Desktop (please complete the following information):** 34 | - OS: [e.g. iOS] 35 | - Browser [e.g. chrome, safari] 36 | - Version [e.g. 22] 37 | 38 | **Smartphone (please complete the following information):** 39 | - Device: [e.g. iPhone6] 40 | - OS: [e.g. iOS8.1] 41 | - Browser [e.g. stock browser, safari] 42 | - Version [e.g. 22] 43 | 44 | **Additional context** 45 | Add any other context about the problem here. 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature request 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | 6 | 7 | ## Screenshots (if appropriate) 8 | 9 | ## Types of changes 10 | 11 | 12 | 13 | - [ ] Chore (tooling change or documentation change) 14 | - [ ] Refactor (non-breaking change which maintains existing functionality) 15 | - [ ] Bug fix (non-breaking change which fixes an issue) 16 | - [ ] New feature (non-breaking change which adds functionality) 17 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 18 | 19 | ## Checklist 20 | 21 | 22 | 23 | 24 | - [ ] My code follows the code style of this project. 25 | - [ ] I have added new tests for the bug I fixed/the new feature I added. 26 | - [ ] I have modified existing tests for the bug I fixed/the new feature I added. 27 | - [ ] My change requires a change to the documentation. 28 | - [ ] I have updated the documentation accordingly. 29 | -------------------------------------------------------------------------------- /.github/actions-scripts/polyfills-sync.cjs: -------------------------------------------------------------------------------- 1 | const { readFileSync } = require('fs'); 2 | const path = require('path'); 3 | const assert = require('assert'); 4 | 5 | const readme = readFileSync(path.resolve(__dirname, '../../README.md'), 'utf8'); 6 | 7 | const polyfillsFromDocs = /^```polyfills\s*\n([^`]+)\n^```/m 8 | .exec(readme)[1] 9 | .split('\n') 10 | .map(v => v.trim()) 11 | .sort(); 12 | // @ts-ignore 13 | const polyfillsFromSettings = require('../../.eslintrc.json').settings.polyfills.sort(); 14 | assert.deepStrictEqual(polyfillsFromDocs, polyfillsFromSettings); 15 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'Draft (next release)' 2 | tag-template: 'v$NEXT_PATCH_VERSION' 3 | sort-direction: descending 4 | exclude-labels: 5 | - 'skip-changelog' 6 | - 'release' 7 | categories: 8 | - title: '🚨 Breaking changes' 9 | labels: 10 | - 'breaking change' 11 | - title: '🚀 Features' 12 | labels: 13 | - 'feature' 14 | - 'enhancement' 15 | - title: '🐛 Bug Fixes' 16 | labels: 17 | - 'bugfix' 18 | - title: '🔧 Maintenance' 19 | labels: 20 | - 'chore' 21 | - 'housekeeping' 22 | - 'refactor' 23 | - 'documentation' 24 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 25 | template: | 26 | # Changes 27 | $CHANGES 28 | # Contributors 29 | $CONTRIBUTORS 30 | -------------------------------------------------------------------------------- /.github/workflows/browsers.yml: -------------------------------------------------------------------------------- 1 | name: End-to-end tests (playwright) 2 | on: 3 | push: 4 | branches: [ main ] 5 | paths: 6 | - 'src/**' 7 | - 'test-e2e/**' 8 | - 'package-lock.json' 9 | - '.browserslistrc' 10 | - 'babel.config.json' 11 | - 'public/index.html' 12 | - 'public/**/index.html' 13 | - '.github/workflows/browsers.yml' 14 | - 'playwright.config.ts' 15 | pull_request: 16 | paths: 17 | - 'src/**' 18 | - 'test-e2e/**' 19 | - 'package-lock.json' 20 | - '.browserslistrc' 21 | - 'babel.config.json' 22 | - 'public/index.html' 23 | - 'public/**/index.html' 24 | - '.github/workflows/browsers.yml' 25 | - 'playwright.config.ts' 26 | jobs: 27 | test-e2e-playwright: 28 | timeout-minutes: 60 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | os: [windows-latest, macos-latest, ubuntu-latest] 33 | browser: [chromium, firefox, webkit] 34 | exclude: 35 | - os: windows-latest 36 | browser: webkit 37 | - os: windows-latest 38 | browser: firefox 39 | - os: macos-latest 40 | browser: firefox 41 | runs-on: ${{ matrix.os }} 42 | steps: 43 | - uses: actions/checkout@v4 44 | with: 45 | fetch-depth: 1 46 | 47 | - uses: actions/setup-node@v4 48 | with: 49 | node-version: 20 50 | cache: 'npm' 51 | 52 | - name: Install dependencies 53 | run: npm ci --no-audit 54 | env: 55 | HUSKY_SKIP_INSTALL: true 56 | 57 | - name: Install Playwright Browsers 58 | run: npx playwright install --with-deps 59 | - run: npx playwright install-deps 60 | 61 | - name: Run Playwright tests 62 | run: npx playwright test --project=${{ matrix.browser }} 63 | - uses: actions/upload-artifact@v4 64 | if: failure() 65 | with: 66 | name: screenshot-${{ matrix.os }}-${{ matrix.browser }} 67 | path: test-results/**/*.png 68 | - uses: actions/upload-artifact@v4 69 | if: '!cancelled()' 70 | with: 71 | name: blob-report-${{ matrix.os }}-${{ matrix.browser }} 72 | path: blob-report/ 73 | retention-days: 1 74 | 75 | merge-reports: 76 | # Merge reports after playwright-tests, even if some shards have failed 77 | if: ${{ !cancelled() }} 78 | needs: [test-e2e-playwright] 79 | 80 | runs-on: ubuntu-latest 81 | steps: 82 | - uses: actions/checkout@v4 83 | with: 84 | fetch-depth: 1 85 | 86 | - uses: actions/setup-node@v4 87 | with: 88 | node-version: 20 89 | - name: Install dependencies 90 | run: npm ci 91 | 92 | - name: Download blob reports from GitHub Actions Artifacts 93 | uses: actions/download-artifact@v4 94 | with: 95 | path: all-blob-reports 96 | pattern: blob-report-* 97 | merge-multiple: true 98 | 99 | - name: Merge into HTML Report 100 | run: npx playwright merge-reports -c test-e2e/merge.config.ts ./all-blob-reports 101 | 102 | - name: Upload HTML report 103 | uses: actions/upload-artifact@v4 104 | with: 105 | name: html-report--attempt-${{ github.run_attempt }} 106 | path: playwright-report 107 | retention-days: 30 -------------------------------------------------------------------------------- /.github/workflows/bundlesize.yml: -------------------------------------------------------------------------------- 1 | name: Bundle size checks 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - '.github/workflows/bundlesize.yml' 8 | - 'src/scripts/**' 9 | - 'src/styles/**' 10 | - 'package-lock.json' 11 | - '.browserslistrc' 12 | pull_request: 13 | paths: 14 | - '.github/workflows/bundlesize.yml' 15 | - 'src/scripts/**' 16 | - 'src/styles/**' 17 | - 'package-lock.json' 18 | - '.browserslistrc' 19 | 20 | jobs: 21 | measure: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 1 27 | 28 | - uses: actions/setup-node@v4 29 | with: 30 | node-version: 20 31 | cache: 'npm' 32 | 33 | - name: Install dependencies 34 | run: npm ci --no-audit 35 | env: 36 | HUSKY_SKIP_INSTALL: true 37 | 38 | - run: npm run build 39 | 40 | # we don't need to build here, as even minized assets expected to be commited 41 | 42 | - run: npm run bundlesize 43 | env: 44 | # token has expired, don't block the test 45 | #CI: true 46 | #BUNDLESIZE_GITHUB_TOKEN: ${{secrets.BUNDLESIZE_GITHUB_TOKEN}} 47 | CI_REPO_NAME: ${{ github.event.repository.name }} 48 | CI_REPO_OWNER: ${{ github.event.organization.login }} 49 | CI_COMMIT_SHA: ${{ github.event.after }} 50 | GIT_COMMIT: ${{ github.event.after }} 51 | CI_BRANCH: ${{ github.head_ref }} 52 | FORCE_COLOR: 2 53 | -------------------------------------------------------------------------------- /.github/workflows/deploy-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Pages 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | deploy-gh-pages: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 1 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 20 18 | registry-url: https://registry.npmjs.org/ 19 | - name: Build 20 | run: | 21 | npm ci 22 | npm run build 23 | rm -rf public/test 24 | env: 25 | HUSKY_SKIP_INSTALL: true 26 | - name: Deploy 27 | uses: peaceiris/actions-gh-pages@v4 28 | with: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | PUBLISH_BRANCH: gh-pages 31 | PUBLISH_DIR: ./public 32 | -------------------------------------------------------------------------------- /.github/workflows/deployment.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish-npm: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 1 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 20 17 | registry-url: https://registry.npmjs.org/ 18 | - run: npm ci 19 | env: 20 | HUSKY_SKIP_INSTALL: true 21 | - run: npm publish 22 | env: 23 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 24 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Code linting 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - '.github/workflows/lint.yml' 8 | - 'src/scripts/**' 9 | - 'src/*.ts' 10 | - 'src/styles/**' 11 | - 'test/**' 12 | - 'test-e2e/**' 13 | - 'package-lock.json' 14 | - '.browserslistrc' 15 | - '.eslintrc.json' 16 | - '.editorconfig' 17 | - '.prettierrc.json' 18 | - '.stylelintrc.json' 19 | pull_request: 20 | paths: 21 | - '.github/workflows/lint.yml' 22 | - 'src/scripts/**' 23 | - 'src/*.ts' 24 | - 'src/styles/**' 25 | - 'test/**' 26 | - 'test-e2e/**' 27 | - 'package-lock.json' 28 | - '.browserslistrc' 29 | - '.eslintrc.json' 30 | - '.editorconfig' 31 | - '.prettierrc.json' 32 | - '.stylelintrc.json' 33 | 34 | jobs: 35 | lint: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | with: 40 | fetch-depth: 1 41 | 42 | - uses: actions/setup-node@v4 43 | with: 44 | node-version: 20 45 | cache: 'npm' 46 | 47 | - name: Install dependencies 48 | run: npm ci --no-audit 49 | env: 50 | HUSKY_SKIP_INSTALL: true 51 | 52 | - name: run eslint 53 | run: npm run lint:js 54 | 55 | ## Can't use same eslint config for TypeScript and JavaScript 56 | ## TypeScript rules cause rule definition not found errors 57 | ## Can be re-enabled if this is resolved: https://github.com/eslint/eslint/issues/14851 58 | # - name: Lint JS bundle 59 | # run: | 60 | # npm run js:build 61 | # npx eslint --no-ignore ./public/assets/scripts/*.js 62 | 63 | - name: run stylelint 64 | run: npm run lint:scss -------------------------------------------------------------------------------- /.github/workflows/polyfills-sync.yml: -------------------------------------------------------------------------------- 1 | name: Polyfills documentation 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - 'README.md' 7 | - '.browserslistrc' 8 | - '.eslintrc.json' 9 | 10 | jobs: 11 | sync: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 1 17 | 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | 22 | - name: Check Polyfills documentation and settings sync 23 | run: node .github/actions-scripts/polyfills-sync.cjs 24 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release drafter 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | jobs: 8 | update-draft-release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: release-drafter/release-drafter@v5 12 | env: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - '.github/workflows/unit-tests.yml' 8 | - 'src/scripts/**' 9 | - 'src/*.ts' 10 | - 'test/**' 11 | - 'package-lock.json' 12 | - '.browserslistrc' 13 | - 'babel.config.json' 14 | - 'vitest.config.ts' 15 | pull_request: 16 | paths: 17 | - '.github/workflows/unit-tests.yml' 18 | - 'src/scripts/**' 19 | - 'src/*.ts' 20 | - 'test/**' 21 | - 'package-lock.json' 22 | - '.browserslistrc' 23 | - 'babel.config.json' 24 | - 'vitest.config.ts' 25 | 26 | jobs: 27 | test-unit: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v4 31 | with: 32 | fetch-depth: 1 33 | 34 | - uses: actions/setup-node@v4 35 | with: 36 | node-version: 20 37 | cache: 'npm' 38 | 39 | - name: Install dependencies 40 | run: npm ci --no-audit 41 | env: 42 | HUSKY_SKIP_INSTALL: true 43 | 44 | - run: npm run build 45 | 46 | - run: npm run test:unit:coverage 47 | env: 48 | FORCE_COLOR: 2 49 | 50 | - name: Upload coverage to Codecov 51 | run: bash <(curl -s https://codecov.io/bash) 52 | -f ./coverage/lcov.info 53 | -B ${{ github.head_ref }} 54 | -C ${{ github.sha }} 55 | -Z || echo 'Codecov upload failed' 56 | env: 57 | CI: true 58 | GITLAB_CI: true # pretend we are GitLab CI, while Codecov adding support for Github Actions 59 | CODECOV_ENV: github-action 60 | CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}} 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .DS_Store 4 | .idea 5 | .rollup.cache 6 | tsconfig.tsbuildinfo 7 | .npmrc 8 | .run 9 | 10 | # Test 11 | tests/reports 12 | tests/results 13 | .nyc_output 14 | coverage 15 | /test-results/ 16 | /playwright-report/ 17 | /blob-report/ 18 | /playwright/.cache/ 19 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "skipCI": true, 3 | "hooks": { 4 | "pre-commit": "lint-staged" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.16.0 -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "endOfLine": "lf", 6 | "overrides": [ 7 | { 8 | "files": ["*.svg"], 9 | "options": { 10 | "parser": "html", 11 | "htmlWhitespaceSensitivity": "ignore" 12 | } 13 | }, 14 | { 15 | "files": ["public/*.html"], 16 | "options": { 17 | "trailingComma": "es5" 18 | } 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard-scss", 3 | "rules": { 4 | "media-feature-range-notation": null, 5 | "declaration-block-no-redundant-longhand-properties": null 6 | } 7 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | // we enforce ESLint rules, so, recommend extension 6 | "dbaeumer.vscode-eslint", 7 | // we use prettier, so, recommend extension 8 | "esbenp.prettier-vscode", 9 | // we are on GitHub, so, recommend extension 10 | "github.vscode-pull-request-github", 11 | // needed for our configured debug configuration with Chrome 12 | "msjsdiag.debugger-for-chrome" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "chrome", 6 | "request": "launch", 7 | "name": "Launch Chrome", 8 | "preLaunchTask": "buildAndWatch", 9 | "url": "http://localhost:3001", 10 | "webRoot": "${workspaceFolder}", 11 | "sourceMapPathOverrides": { 12 | "webpack://Choices/*": "${workspaceFolder}/*" 13 | } 14 | }, 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.enable": true, 3 | // prevent watch task failures on lint errors 4 | "eslint.autoFixOnSave": true, 5 | // switch off default VSCode formatting rules 6 | "javascript.format.enable": false, 7 | // Javascript prettier runs via ESLint 8 | "prettier.disableLanguages": ["javascript"], 9 | "[json]": { 10 | "editor.defaultFormatter": "esbenp.prettier-vscode" 11 | }, 12 | "[html]": { 13 | "editor.defaultFormatter": "esbenp.prettier-vscode" 14 | }, 15 | "[scss]": { 16 | "editor.defaultFormatter": "esbenp.prettier-vscode" 17 | }, 18 | "[javascript]": { 19 | "editor.formatOnSave": false 20 | }, 21 | "search.exclude": { 22 | "**/node_modules": true, 23 | "public/assets": true, 24 | "**/coverage": true 25 | }, 26 | // for Windows collaborators 27 | "files.eol": "\n", 28 | "files.encoding": "utf8", 29 | // associations for some files this project is using 30 | "files.associations": { 31 | ".browserslistrc": "gitignore", 32 | ".huskyrc": "jsonc", 33 | ".npmrc": "ini" 34 | }, 35 | // We use NPM as package manager 36 | "npm.packageManager": "npm", 37 | "npm.autoDetect": "on", 38 | "npm.fetchOnlinePackageInfo": true, 39 | "eslint.packageManager": "npm", 40 | "json.schemas": [ 41 | // Husky config file 42 | { 43 | "fileMatch": [".huskyrc"], 44 | "url": "http://json.schemastore.org/huskyrc" 45 | }, 46 | // Prettier config 47 | { 48 | "fileMatch": [".prettierrc.json"], 49 | "url": "http://json.schemastore.org/prettierrc" 50 | } 51 | ], 52 | "editor.codeActionsOnSave": { 53 | "source.fixAll.eslint": true 54 | }, 55 | "stylelint.validate": [ 56 | "css", 57 | "less", 58 | "postcss", 59 | "scss" 60 | ] 61 | } 62 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "label": "buildAndWatch", 9 | "script": "js:watch", 10 | "group": { 11 | "kind": "build", 12 | "isDefault": true 13 | }, 14 | "isBackground": true, 15 | "presentation": { 16 | "echo": true, 17 | "reveal": "always", 18 | "focus": false, 19 | "panel": "dedicated", 20 | "showReuseMessage": true, 21 | "clear": false 22 | }, 23 | "problemMatcher": [ 24 | "$eslint-stylish", 25 | { 26 | "owner": "webpack", 27 | "fileLocation": "absolute", 28 | "pattern": [ 29 | { 30 | "regexp": "^Module build failed \\(from (\\.+)\\)", 31 | "file": 1, 32 | "line": 2, 33 | "column": 3 34 | }, 35 | { 36 | "regexp": "\\s*TS\\d+:\\s*(.*)", 37 | "message": 1 38 | } 39 | ], 40 | "severity": "error", 41 | "source": "webpack", 42 | "background": { 43 | "activeOnStart": true, 44 | "beginsPattern": "^Listening at", 45 | "endsPattern": "Compiled successfully\\." 46 | } 47 | } 48 | ] 49 | }, 50 | { 51 | "type": "npm", 52 | "script": "css:build", 53 | "group": "build", 54 | "problemMatcher": ["$node-sass"] 55 | }, 56 | { 57 | "type": "npm", 58 | "script": "lint", 59 | "problemMatcher": ["$eslint-stylish"] 60 | }, 61 | { 62 | "type": "npm", 63 | "script": "build", 64 | "group": "build" 65 | }, 66 | { 67 | "type": "npm", 68 | "script": "test", 69 | "group": "test" 70 | }, 71 | { 72 | "type": "npm", 73 | "script": "test:e2e", 74 | "group": "test" 75 | }, 76 | { 77 | "type": "npm", 78 | "script": "test:unit", 79 | "group": "test" 80 | }, 81 | ] 82 | } 83 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at josh@joshuajohnson.co.uk. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributions 2 | In lieu of a formal styleguide, take care to maintain the existing coding style ensuring there are no linting errors. Add unit tests for any new or changed functionality. Lint and test your code using the npm scripts below: 3 | 4 | ### Minified code 5 | For compatibility, `new` and `get` must be pure (side effect free). 6 | 7 | ### NPM tasks 8 | | Task | Usage | 9 | |---------------------------|--------------------------------------------------------------| 10 | | `npm run start` | Fire up local server for development | 11 | | `npm run test:unit` | Run sequence of tests once | 12 | | `npm run test:unit:watch` | Fire up test server and re-test on file change | 13 | | `npm run test:e2e` | Run sequence of e2e tests (with local server) | 14 | | `npm run test` | Run both unit and e2e tests | 15 | | `npm run playwright:gui` | Run Playwright e2e tests (GUI) | 16 | | `npm run playwright:cli` | Run Playwright e2e tests (CLI) | 17 | | `npm run js:build` | Compile Choices to an uglified JavaScript file | 18 | | `npm run css:watch` | Watch SCSS files for changes. On a change, run build process | 19 | | `npm run css:build` | Compile, minify and prefix SCSS files to CSS | 20 | 21 | ## Passing environmental arguments to rollup 22 | 23 | Use `--` followed by normal rollup `--environment` arguments. The last one overrides any previous ones with the same name 24 | 25 | An example of changing what js:watch will bind to: 26 | ``` 27 | npm run js:watch -- --environment WATCH_HOST:0.0.0.0 28 | ``` 29 | 30 | ## Build flags 31 | 32 | The following build flags are supported via environment variables: 33 | 34 | ### CHOICES_SEARCH_FUSE 35 | **Values:**: **"full" / "basic" / "null" ** 36 | **Usage:** The level of integration with fuse. `full` is the entire fuse.js build, `basic` is fuse.js with just standard fuzzy searching. `null` is a basic prefix string search with no fuse.js 37 | **Example**: 38 | ``` 39 | npm run js:watch -- --environment CHOICES_SEARCH_FUSE:basic 40 | ``` 41 | 42 | ### CHOICES_SEARCH_KMP 43 | **Values:**: **"1" / "0" ** 44 | **Usage:** High performance `indexOf`-like search algorithm. 45 | **Example**: 46 | ``` 47 | npm run js:watch -- --environment CHOICES_SEARCH_KMP:1 48 | ``` 49 | 50 | ### CHOICES_CAN_USE_DOM 51 | **Values:**: **"1" / "0" ** 52 | **Usage:** Indicates if DOM methods are supported in the global namespace. Useful if importing into DOM or the e2e tests without a DOM implementation available. 53 | **Example**: 54 | ``` 55 | npm run js:watch -- --environment CHOICES_CAN_USE_DOM:1 56 | ``` 57 | 58 | ## Pull requests 59 | When submitting a pull request that resolves a bug, feel free to use the following template: 60 | 61 | ```md 62 | ## This is the problem: 63 | 64 | ## Steps to reproduce: 65 | 66 | ## This is my solution: 67 | ``` 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Josh Johnson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-typescript"], 3 | "plugins": [ 4 | "@babel/plugin-transform-object-rest-spread" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": true, 4 | "target": "es2020", 5 | "lib": ["esnext", "dom"], 6 | "types": [], 7 | "strict": true, 8 | "moduleResolution": "node", 9 | /* Additional Checks */ 10 | "noImplicitAny": false, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "strictNullChecks": false 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | import { PlaywrightTestConfig } from 'playwright/types/test'; 3 | import { BundleTest } from './test-e2e/bundle-test'; 4 | 5 | /** 6 | * Read environment variables from file. 7 | * https://github.com/motdotla/dotenv 8 | */ 9 | // import dotenv from 'dotenv'; 10 | // dotenv.config({ path: path.resolve(__dirname, '.env') }); 11 | 12 | /** 13 | * See https://playwright.dev/docs/test-configuration. 14 | */ 15 | const config: PlaywrightTestConfig = { 16 | testDir: './test-e2e', 17 | snapshotPathTemplate: '{testDir}/__screenshots__/{projectName}-{platform}{ext}', 18 | /* Run tests in files in parallel */ 19 | fullyParallel: true, 20 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 21 | forbidOnly: !!process.env.CI, 22 | /* Retry on CI only */ 23 | retries: 0, 24 | /* Opt out of parallel tests on CI. */ 25 | workers: process.env.CI ? 1 : undefined, 26 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 27 | reporter: process.env.CI ? [['dot'], ['blob']] : 'line', 28 | timeout: process.env.CI ? 5000 : 1000, 29 | expect : { 30 | timeout: process.env.CI ? 1000 : 500, 31 | }, 32 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 33 | use: { 34 | /* Base URL to use in actions like `await page.goto('/')`. */ 35 | baseURL: 'http://localhost:3001/', 36 | 37 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 38 | trace: 'on-first-retry', 39 | 40 | testIdAttribute: 'data-test-hook', 41 | }, 42 | 43 | /* Configure projects for major browsers */ 44 | projects: [ 45 | { 46 | name: 'chromium', 47 | use: { 48 | ...devices['Desktop Chrome'], 49 | contextOptions: { 50 | // chromium-specific permissions 51 | permissions: ['clipboard-read', 'clipboard-write'], 52 | }, 53 | }, 54 | }, 55 | { 56 | name: 'firefox', 57 | use: { ...devices['Desktop Firefox'] }, 58 | }, 59 | { 60 | name: 'webkit', 61 | use: { ...devices['Desktop Safari'] }, 62 | }, 63 | 64 | /* Test against mobile viewports. */ 65 | // { 66 | // name: 'Mobile Chrome', 67 | // use: { ...devices['Pixel 5'] }, 68 | // }, 69 | // { 70 | // name: 'Mobile Safari', 71 | // use: { ...devices['iPhone 12'] }, 72 | // }, 73 | 74 | /* Test against branded browsers. */ 75 | // { 76 | // name: 'Microsoft Edge', 77 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 78 | // }, 79 | // { 80 | // name: 'Google Chrome', 81 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 82 | // }, 83 | ], 84 | 85 | /* Run your local dev server before starting the tests */ 86 | webServer: { 87 | command: 'npm run start', 88 | url: 'http://localhost:3001', 89 | reuseExistingServer: !process.env.CI, 90 | }, 91 | }; 92 | 93 | const bundles = [ 94 | { 95 | name: '', 96 | bundle: process.env.CI ? '/assets/scripts/choices.min.js' : '/assets/scripts/choices.js', 97 | enabled: true, 98 | }, 99 | ]; 100 | const projects = config.projects; 101 | if (config.use.baseURL) { 102 | config.projects = []; 103 | 104 | projects.forEach((project) => { 105 | bundles.forEach(({ name, bundle, enabled }) => { 106 | if (!enabled) { 107 | return; 108 | } 109 | const projectBundle = { 110 | ...project, 111 | name: project.name + name, 112 | use: { 113 | ...project.use, 114 | bundle: config.use.baseURL + bundle, 115 | } 116 | }; 117 | config.projects.push(projectBundle); 118 | }); 119 | }); 120 | } 121 | 122 | export default defineConfig(config); -------------------------------------------------------------------------------- /public/assets/images/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Choices-js/Choices/8c0c11e26f2d1446263c20f85e0c437c6e37f74e/public/assets/images/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/assets/images/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Choices-js/Choices/8c0c11e26f2d1446263c20f85e0c437c6e37f74e/public/assets/images/apple-touch-icon.png -------------------------------------------------------------------------------- /public/assets/images/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #ffffff 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/assets/images/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Choices-js/Choices/8c0c11e26f2d1446263c20f85e0c437c6e37f74e/public/assets/images/favicon-16x16.png -------------------------------------------------------------------------------- /public/assets/images/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Choices-js/Choices/8c0c11e26f2d1446263c20f85e0c437c6e37f74e/public/assets/images/favicon-32x32.png -------------------------------------------------------------------------------- /public/assets/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Choices-js/Choices/8c0c11e26f2d1446263c20f85e0c437c6e37f74e/public/assets/images/favicon.ico -------------------------------------------------------------------------------- /public/assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Choices-js/Choices/8c0c11e26f2d1446263c20f85e0c437c6e37f74e/public/assets/images/favicon.png -------------------------------------------------------------------------------- /public/assets/images/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Choices.js", 3 | "icons": [ 4 | { 5 | "src": "\/assets\/images\/android-chrome-192x192.png", 6 | "sizes": "192x192", 7 | "type": "image\/png" 8 | } 9 | ], 10 | "theme_color": "#ffffff", 11 | "display": "standalone" 12 | } 13 | -------------------------------------------------------------------------------- /public/assets/images/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Choices-js/Choices/8c0c11e26f2d1446263c20f85e0c437c6e37f74e/public/assets/images/mstile-150x150.png -------------------------------------------------------------------------------- /public/assets/images/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /public/assets/styles/base.css: -------------------------------------------------------------------------------- 1 | /* ============================================= 2 | = Generic styling = 3 | ============================================= */ 4 | * { 5 | -webkit-font-smoothing: antialiased; 6 | -moz-osx-font-smoothing: grayscale; 7 | } 8 | 9 | *, 10 | *::before, 11 | *::after { 12 | box-sizing: border-box; 13 | } 14 | 15 | html, 16 | body { 17 | position: relative; 18 | margin: 0; 19 | width: 100%; 20 | height: 100%; 21 | } 22 | 23 | body { 24 | font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; 25 | font-size: 16px; 26 | line-height: 1.4; 27 | color: #fff; 28 | background-color: #333; 29 | overflow-x: hidden; 30 | } 31 | 32 | label { 33 | display: block; 34 | margin-bottom: 8px; 35 | font-size: 14px; 36 | font-weight: 500; 37 | cursor: pointer; 38 | } 39 | 40 | p { 41 | margin-top: 0; 42 | margin-bottom: 8px; 43 | } 44 | 45 | hr { 46 | display: block; 47 | margin: 30px 0; 48 | border: 0; 49 | border-bottom: 1px solid #eaeaea; 50 | height: 1px; 51 | } 52 | 53 | h1, 54 | h2, 55 | h3, 56 | h4, 57 | h5, 58 | h6 { 59 | margin-top: 0; 60 | margin-bottom: 12px; 61 | font-weight: 400; 62 | line-height: 1.2; 63 | } 64 | 65 | a, 66 | a:visited, 67 | a:focus { 68 | color: #fff; 69 | text-decoration: none; 70 | font-weight: 600; 71 | } 72 | 73 | .form-control { 74 | display: block; 75 | width: 100%; 76 | background-color: #f9f9f9; 77 | padding: 12px; 78 | border: 1px solid #ddd; 79 | border-radius: 2.5px; 80 | font-size: 14px; 81 | appearance: none; 82 | margin-bottom: 24px; 83 | } 84 | 85 | h1, 86 | .h1 { 87 | font-size: 32px; 88 | } 89 | 90 | h2, 91 | .h2 { 92 | font-size: 24px; 93 | } 94 | 95 | h3, 96 | .h3 { 97 | font-size: 20px; 98 | } 99 | 100 | h4, 101 | .h4 { 102 | font-size: 18px; 103 | } 104 | 105 | h5, 106 | .h5 { 107 | font-size: 16px; 108 | } 109 | 110 | h6, 111 | .h6 { 112 | font-size: 14px; 113 | } 114 | 115 | label + p { 116 | margin-top: -4px; 117 | } 118 | 119 | .container { 120 | display: block; 121 | margin: auto; 122 | max-width: 40em; 123 | padding: 48px; 124 | } 125 | @media (max-width: 620px) { 126 | .container { 127 | padding: 0; 128 | } 129 | } 130 | 131 | .section { 132 | background-color: #fff; 133 | padding: 24px; 134 | color: #333; 135 | } 136 | .section a, 137 | .section a:visited, 138 | .section a:focus { 139 | color: #005F75; 140 | } 141 | 142 | .logo { 143 | display: block; 144 | margin-bottom: 12px; 145 | } 146 | 147 | .logo-img { 148 | width: 100%; 149 | height: auto; 150 | display: inline-block; 151 | max-width: 100%; 152 | vertical-align: top; 153 | padding: 6px 0; 154 | } 155 | 156 | .visible-ie { 157 | display: none; 158 | } 159 | 160 | .push-bottom { 161 | margin-bottom: 24px; 162 | } 163 | 164 | .zero-bottom { 165 | margin-bottom: 0; 166 | } 167 | 168 | .zero-top { 169 | margin-top: 0; 170 | } 171 | 172 | .text-center { 173 | text-align: center; 174 | } 175 | 176 | [data-test-hook] { 177 | margin-bottom: 24px; 178 | } 179 | 180 | /* ===== End of Section comment block ====== */ 181 | -------------------------------------------------------------------------------- /public/assets/styles/base.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sourceRoot":"","sources":["../../../src/styles/base.scss"],"names":[],"mappings":"AAAA;AAAA;AAAA;AAYA;EACE;EACA;;;AAGF;AAAA;AAAA;EAGE;;;AAGF;AAAA;EAEE;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;;;AAGF;AAAA;AAAA;AAAA;AAAA;AAAA;EAME;EACA;EACA;EACA;;;AAGF;AAAA;AAAA;EAGE;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,eAtFiB;;;AAyFnB;AAAA;EAEE,WA1FoB;;;AA6FtB;AAAA;EAEE,WA9FoB;;;AAiGtB;AAAA;EAEE,WAlGoB;;;AAqGtB;AAAA;EAEE,WAtGoB;;;AAyGtB;AAAA;EAEE,WA1GoB;;;AA6GtB;AAAA;EAEE,WA9GoB;;;AAiHtB;EACE;;;AAGF;EACE;EACA;EACA;EACA;;AAEA;EANF;IAOI;;;;AAIJ;EACE;EACA,SAxIiB;EAyIjB;;AAEA;AAAA;AAAA;EAGE;;;AAIJ;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE,eArKiB;;;AAwKnB;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE,eArLiB;;;AAwLnB","file":"base.css"} -------------------------------------------------------------------------------- /public/assets/styles/base.min.css: -------------------------------------------------------------------------------- 1 | *{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}*,::after,::before{box-sizing:border-box}body,html{position:relative;margin:0;width:100%;height:100%}body{font-family:"Helvetica Neue",Helvetica,Arial,"Lucida Grande",sans-serif;font-size:16px;line-height:1.4;color:#fff;background-color:#333;overflow-x:hidden}hr,label{display:block}label,p{margin-bottom:8px}label{font-size:14px;font-weight:500;cursor:pointer}p{margin-top:0}hr{margin:30px 0;border:0;border-bottom:1px solid #eaeaea;height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:12px;font-weight:400;line-height:1.2}a,a:focus,a:visited{color:#fff;text-decoration:none;font-weight:600}.form-control{display:block;width:100%;background-color:#f9f9f9;padding:12px;border:1px solid #ddd;border-radius:2.5px;font-size:14px;appearance:none;margin-bottom:24px}.h1,h1{font-size:32px}.h2,h2{font-size:24px}.h3,h3{font-size:20px}.h4,h4{font-size:18px}.h5,h5{font-size:16px}.h6,h6{font-size:14px}label+p{margin-top:-4px}.container{display:block;margin:auto;max-width:40em;padding:48px}@media (max-width:620px){.container{padding:0}}.section{background-color:#fff;padding:24px;color:#333}.section a,.section a:focus,.section a:visited{color:#005f75}.logo{display:block;margin-bottom:12px}.logo-img{width:100%;height:auto;display:inline-block;max-width:100%;vertical-align:top;padding:6px 0}.visible-ie{display:none}.push-bottom{margin-bottom:24px}.zero-bottom{margin-bottom:0}.zero-top{margin-top:0}.text-center{text-align:center}[data-test-hook]{margin-bottom:24px} -------------------------------------------------------------------------------- /public/assets/styles/choices.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sourceRoot":"","sources":["../../../src/styles/choices.scss"],"names":[],"mappings":"AAAA;AAAA;AAAA;AA2BA;EACE;EACA;EACA,eApBkB;EAqBlB,WAxBqB;;AA0BrB;EACE;;AAGF;EACE;;AAGF;EACE;;AAIA;AAAA;EAEE,kBAlCsB;EAmCtB;EACA;;AAEF;EACE;;AAIJ;EACE;;;AAIJ;EACE;;AACA;EACE;;AAEF;EACE;EACA;EACA;EACA;EACA;EACA;;AAEF;EACE,kBApDyB;EAqDzB;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EAEE;;AAGF;EACE;;AAGJ;EACE;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;;AAIA;EACE;EACA;;AAEF;EACE;EACA;EACA;EACA;;;AAOJ;AAAA;EACE;;AAEF;AAAA;EACE;EACA;EACA;EACA;EACA;EACA,aA5HoB;EA6HpB;EACA;EACA,kBA9HiB;EA+HjB,iBAjIuB;EAkIvB,OAlIuB;EAmIvB;EACA;EACA;;AAEA;AAAA;AAAA;EAEE;;;AAKN;EACE;EACA;EACA;EACA,kBA1JiB;EA2JjB;EACA;EACA,eA/JsB;EAgKtB,WAnKqB;EAoKrB;EACA;;AAEA;EAEE;;AAGF;EACE;;AAGF;EACE;;;AAIJ;EACE;EACA;EACA;;AAOF;EACE;EACA;EACA;;AAEA;EACE;EACA;;AAEF;EACE;;;AAIJ;EACE;;AACA;EACE;EACA;EACA,eA9MyB;EA+MzB;EACA,WAnNmB;EAoNnB;EACA;EACA;EACA,kBA9MoB;EA+MpB;EACA;EACA;EACA;;AAEA;EACE;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;;AAKN;EACE;EACA,SApOgB;EAqOhB;EACA;EACA,kBAjP0B;EAkP1B;EACA;EACA;EACA,2BAzPsB;EA0PtB,4BA1PsB;EA2PtB;EACA;;AAEA;EACE;;AAGF;EACE;;AAGF;EACE;EACA;EACA;EACA;EACA;;AAEF;EACE;EACA;EACA;EACA;EACA;;AAEF;EACE;EACA;EACA,WA1RmB;;AA4RnB;EACE;;AAKA;EADF;IAEI;;EAEA;IACE;IACA,WAtSa;IAuSb;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;;EAEA;IACE;IACA;;;AAMR;EACE;;AAEA;EACE;;;AAUR;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;EACA,WAzVqB;EA0VrB;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;;;AAIJ;EACE;EACA;EACA,kBA5WiB;EA6WjB,WAlXqB;EAmXrB;EACA;EACA;EACA;EACA;;AAEA;EACE;;AAGF;EAIE;;AAGF;EAEE;EACA;EACA;;AAGF;EACE;EACA;;;AAIJ;EACE;;;AAGF","file":"choices.css"} -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | Disallow: /test/* -------------------------------------------------------------------------------- /public/test/data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "label": "Label 1", 4 | "value": "Value 1" 5 | }, 6 | { 7 | "label": "Label 2", 8 | "value": "Value 2" 9 | }, 10 | { 11 | "label": "Label 3", 12 | "value": "Value 3" 13 | }, 14 | { 15 | "label": "Label 4", 16 | "value": "Value 4" 17 | }, 18 | { 19 | "label": "Label 5", 20 | "value": "Value 5" 21 | }, 22 | { 23 | "label": "Label 6", 24 | "value": "Value 6" 25 | }, 26 | { 27 | "label": "Label 7", 28 | "value": "Value 7" 29 | }, 30 | { 31 | "label": "Label 8", 32 | "value": "Value 8" 33 | }, 34 | { 35 | "label": "Label 9", 36 | "value": "Value 9" 37 | }, 38 | { 39 | "label": "Label 10", 40 | "value": "Value 10" 41 | } 42 | ] -------------------------------------------------------------------------------- /public/test/disabled-data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "label": "Disabled Label 1", 4 | "value": "Disabled Value 1", 5 | "disabled": true 6 | }, 7 | { 8 | "label": "Disabled Label 2", 9 | "value": "Disabled Value 2", 10 | "disabled": true 11 | }, 12 | { 13 | "label": "Disabled Label 3", 14 | "value": "Disabled Value 3", 15 | "disabled": true 16 | }, 17 | { 18 | "label": "Disabled Label 4", 19 | "value": "Disabled Value 4", 20 | "disabled": true 21 | }, 22 | { 23 | "label": "Disabled Label 5", 24 | "value": "Disabled Value 5", 25 | "disabled": true 26 | }, 27 | { 28 | "label": "Disabled Label 6", 29 | "value": "Disabled Value 6", 30 | "disabled": true 31 | }, 32 | { 33 | "label": "Disabled Label 7", 34 | "value": "Disabled Value 7", 35 | "disabled": true 36 | }, 37 | { 38 | "label": "Disabled Label 8", 39 | "value": "Disabled Value 8", 40 | "disabled": true 41 | }, 42 | { 43 | "label": "Disabled Label 9", 44 | "value": "Disabled Value 9", 45 | "disabled": true 46 | }, 47 | { 48 | "label": "Disabled Label 10", 49 | "value": "Disabled Value 10", 50 | "disabled": true 51 | } 52 | ] -------------------------------------------------------------------------------- /public/test/select-multiple/index-performance.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Choices 10 | 15 | 20 | 26 | 32 | 33 | 38 | 39 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 |
57 |
58 |

Select multiple inputs

59 |
60 | 61 | 62 | 63 | 64 | 71 | 110 |
111 |
112 |
113 | 114 | 115 | -------------------------------------------------------------------------------- /public/types/src/index.d.ts: -------------------------------------------------------------------------------- 1 | import Choices from './scripts/choices'; 2 | export * from './scripts/interfaces'; 3 | export * from './scripts/constants'; 4 | export * from './scripts/defaults'; 5 | export { default as templates } from './scripts/templates'; 6 | export default Choices; 7 | -------------------------------------------------------------------------------- /public/types/src/scripts/actions/choices.d.ts: -------------------------------------------------------------------------------- 1 | import { ChoiceFull } from '../interfaces/choice-full'; 2 | import { ActionType } from '../interfaces'; 3 | import { SearchResult } from '../interfaces/search'; 4 | import { AnyAction } from '../interfaces/store'; 5 | export type ChoiceActions = AddChoiceAction | RemoveChoiceAction | FilterChoicesAction | ActivateChoicesAction | ClearChoicesAction; 6 | export interface AddChoiceAction extends AnyAction { 7 | choice: ChoiceFull; 8 | } 9 | export interface RemoveChoiceAction extends AnyAction { 10 | choice: ChoiceFull; 11 | } 12 | export interface FilterChoicesAction extends AnyAction { 13 | results: SearchResult[]; 14 | } 15 | export interface ActivateChoicesAction extends AnyAction { 16 | active: boolean; 17 | } 18 | /** 19 | * @deprecated use clearStore() or clearChoices() instead. 20 | */ 21 | export interface ClearChoicesAction extends AnyAction { 22 | } 23 | export declare const addChoice: (choice: ChoiceFull) => AddChoiceAction; 24 | export declare const removeChoice: (choice: ChoiceFull) => RemoveChoiceAction; 25 | export declare const filterChoices: (results: SearchResult[]) => FilterChoicesAction; 26 | export declare const activateChoices: (active?: boolean) => ActivateChoicesAction; 27 | /** 28 | * @deprecated use clearStore() or clearChoices() instead. 29 | */ 30 | export declare const clearChoices: () => ClearChoicesAction; 31 | -------------------------------------------------------------------------------- /public/types/src/scripts/actions/groups.d.ts: -------------------------------------------------------------------------------- 1 | import { GroupFull } from '../interfaces/group-full'; 2 | import { ActionType } from '../interfaces'; 3 | import { AnyAction } from '../interfaces/store'; 4 | export type GroupActions = AddGroupAction; 5 | export interface AddGroupAction extends AnyAction { 6 | group: GroupFull; 7 | } 8 | export declare const addGroup: (group: GroupFull) => AddGroupAction; 9 | -------------------------------------------------------------------------------- /public/types/src/scripts/actions/items.d.ts: -------------------------------------------------------------------------------- 1 | import { ChoiceFull } from '../interfaces/choice-full'; 2 | import { ActionType } from '../interfaces'; 3 | import { AnyAction } from '../interfaces/store'; 4 | export type ItemActions = AddItemAction | RemoveItemAction | HighlightItemAction; 5 | export interface AddItemAction extends AnyAction { 6 | item: ChoiceFull; 7 | } 8 | export interface RemoveItemAction extends AnyAction { 9 | item: ChoiceFull; 10 | } 11 | export interface HighlightItemAction extends AnyAction { 12 | item: ChoiceFull; 13 | highlighted: boolean; 14 | } 15 | export declare const addItem: (item: ChoiceFull) => AddItemAction; 16 | export declare const removeItem: (item: ChoiceFull) => RemoveItemAction; 17 | export declare const highlightItem: (item: ChoiceFull, highlighted: boolean) => HighlightItemAction; 18 | -------------------------------------------------------------------------------- /public/types/src/scripts/components/container.d.ts: -------------------------------------------------------------------------------- 1 | import { ClassNames } from '../interfaces/class-names'; 2 | import { PositionOptionsType } from '../interfaces/position-options-type'; 3 | import { PassedElementType } from '../interfaces/passed-element-type'; 4 | export default class Container { 5 | element: HTMLElement; 6 | type: PassedElementType; 7 | classNames: ClassNames; 8 | position: PositionOptionsType; 9 | isOpen: boolean; 10 | isFlipped: boolean; 11 | isDisabled: boolean; 12 | isLoading: boolean; 13 | constructor({ element, type, classNames, position, }: { 14 | element: HTMLElement; 15 | type: PassedElementType; 16 | classNames: ClassNames; 17 | position: PositionOptionsType; 18 | }); 19 | /** 20 | * Determine whether container should be flipped based on passed 21 | * dropdown position 22 | */ 23 | shouldFlip(dropdownPos: number, dropdownHeight: number): boolean; 24 | setActiveDescendant(activeDescendantID: string): void; 25 | removeActiveDescendant(): void; 26 | open(dropdownPos: number, dropdownHeight: number): void; 27 | close(): void; 28 | addFocusState(): void; 29 | removeFocusState(): void; 30 | enable(): void; 31 | disable(): void; 32 | wrap(element: HTMLElement): void; 33 | unwrap(element: HTMLElement): void; 34 | addLoadingState(): void; 35 | removeLoadingState(): void; 36 | } 37 | -------------------------------------------------------------------------------- /public/types/src/scripts/components/dropdown.d.ts: -------------------------------------------------------------------------------- 1 | import { ClassNames } from '../interfaces/class-names'; 2 | import { PassedElementType } from '../interfaces/passed-element-type'; 3 | export default class Dropdown { 4 | element: HTMLElement; 5 | type: PassedElementType; 6 | classNames: ClassNames; 7 | isActive: boolean; 8 | constructor({ element, type, classNames, }: { 9 | element: HTMLElement; 10 | type: PassedElementType; 11 | classNames: ClassNames; 12 | }); 13 | /** 14 | * Show dropdown to user by adding active state class 15 | */ 16 | show(): this; 17 | /** 18 | * Hide dropdown from user 19 | */ 20 | hide(): this; 21 | } 22 | -------------------------------------------------------------------------------- /public/types/src/scripts/components/index.d.ts: -------------------------------------------------------------------------------- 1 | import Dropdown from './dropdown'; 2 | import Container from './container'; 3 | import Input from './input'; 4 | import List from './list'; 5 | import WrappedInput from './wrapped-input'; 6 | import WrappedSelect from './wrapped-select'; 7 | export { Dropdown, Container, Input, List, WrappedInput, WrappedSelect }; 8 | -------------------------------------------------------------------------------- /public/types/src/scripts/components/input.d.ts: -------------------------------------------------------------------------------- 1 | import { ClassNames } from '../interfaces/class-names'; 2 | import { PassedElementType } from '../interfaces/passed-element-type'; 3 | export default class Input { 4 | element: HTMLInputElement; 5 | type: PassedElementType; 6 | classNames: ClassNames; 7 | preventPaste: boolean; 8 | isFocussed: boolean; 9 | isDisabled: boolean; 10 | constructor({ element, type, classNames, preventPaste, }: { 11 | element: HTMLInputElement; 12 | type: PassedElementType; 13 | classNames: ClassNames; 14 | preventPaste: boolean; 15 | }); 16 | set placeholder(placeholder: string); 17 | get value(): string; 18 | set value(value: string); 19 | addEventListeners(): void; 20 | removeEventListeners(): void; 21 | enable(): void; 22 | disable(): void; 23 | focus(): void; 24 | blur(): void; 25 | clear(setWidth?: boolean): this; 26 | /** 27 | * Set the correct input width based on placeholder 28 | * value or input value 29 | */ 30 | setWidth(): void; 31 | setActiveDescendant(activeDescendantID: string): void; 32 | removeActiveDescendant(): void; 33 | _onInput(): void; 34 | _onPaste(event: ClipboardEvent): void; 35 | _onFocus(): void; 36 | _onBlur(): void; 37 | } 38 | -------------------------------------------------------------------------------- /public/types/src/scripts/components/list.d.ts: -------------------------------------------------------------------------------- 1 | export default class List { 2 | element: HTMLElement; 3 | scrollPos: number; 4 | height: number; 5 | constructor({ element }: { 6 | element: HTMLElement; 7 | }); 8 | prepend(node: Element | DocumentFragment): void; 9 | scrollToTop(): void; 10 | scrollToChildElement(element: HTMLElement, direction: 1 | -1): void; 11 | _scrollDown(scrollPos: number, strength: number, destination: number): void; 12 | _scrollUp(scrollPos: number, strength: number, destination: number): void; 13 | _animateScroll(destination: number, direction: number): void; 14 | } 15 | -------------------------------------------------------------------------------- /public/types/src/scripts/components/wrapped-element.d.ts: -------------------------------------------------------------------------------- 1 | import { ClassNames } from '../interfaces/class-names'; 2 | import { EventTypes } from '../interfaces/event-type'; 3 | import { EventMap } from '../interfaces'; 4 | export default class WrappedElement { 5 | element: T; 6 | classNames: ClassNames; 7 | isDisabled: boolean; 8 | constructor({ element, classNames }: { 9 | element: any; 10 | classNames: any; 11 | }); 12 | get isActive(): boolean; 13 | get dir(): string; 14 | get value(): string; 15 | set value(value: string); 16 | conceal(): void; 17 | reveal(): void; 18 | enable(): void; 19 | disable(): void; 20 | triggerEvent(eventType: EventTypes, data?: EventMap[K]['detail']): void; 21 | } 22 | -------------------------------------------------------------------------------- /public/types/src/scripts/components/wrapped-input.d.ts: -------------------------------------------------------------------------------- 1 | import WrappedElement from './wrapped-element'; 2 | export default class WrappedInput extends WrappedElement { 3 | } 4 | -------------------------------------------------------------------------------- /public/types/src/scripts/components/wrapped-select.d.ts: -------------------------------------------------------------------------------- 1 | import { ClassNames } from '../interfaces/class-names'; 2 | import WrappedElement from './wrapped-element'; 3 | import { GroupFull } from '../interfaces/group-full'; 4 | import { ChoiceFull } from '../interfaces/choice-full'; 5 | export default class WrappedSelect extends WrappedElement { 6 | classNames: ClassNames; 7 | template: (data: object) => HTMLOptionElement; 8 | extractPlaceholder: boolean; 9 | constructor({ element, classNames, template, extractPlaceholder, }: { 10 | element: HTMLSelectElement; 11 | classNames: ClassNames; 12 | template: (data: object) => HTMLOptionElement; 13 | extractPlaceholder: boolean; 14 | }); 15 | get placeholderOption(): HTMLOptionElement | null; 16 | addOptions(choices: ChoiceFull[]): void; 17 | optionsAsChoices(): (ChoiceFull | GroupFull)[]; 18 | _optionToChoice(option: HTMLOptionElement): ChoiceFull; 19 | _optgroupToChoice(optgroup: HTMLOptGroupElement): GroupFull; 20 | } 21 | -------------------------------------------------------------------------------- /public/types/src/scripts/constants.d.ts: -------------------------------------------------------------------------------- 1 | export declare const SCROLLING_SPEED: number; 2 | -------------------------------------------------------------------------------- /public/types/src/scripts/defaults.d.ts: -------------------------------------------------------------------------------- 1 | import { ClassNames } from './interfaces/class-names'; 2 | import { Options } from './interfaces/options'; 3 | export declare const DEFAULT_CLASSNAMES: ClassNames; 4 | export declare const DEFAULT_CONFIG: Options; 5 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/action-type.d.ts: -------------------------------------------------------------------------------- 1 | import { Types } from './types'; 2 | export declare const ActionType: { 3 | readonly ADD_CHOICE: "ADD_CHOICE"; 4 | readonly REMOVE_CHOICE: "REMOVE_CHOICE"; 5 | readonly FILTER_CHOICES: "FILTER_CHOICES"; 6 | readonly ACTIVATE_CHOICES: "ACTIVATE_CHOICES"; 7 | readonly CLEAR_CHOICES: "CLEAR_CHOICES"; 8 | readonly ADD_GROUP: "ADD_GROUP"; 9 | readonly ADD_ITEM: "ADD_ITEM"; 10 | readonly REMOVE_ITEM: "REMOVE_ITEM"; 11 | readonly HIGHLIGHT_ITEM: "HIGHLIGHT_ITEM"; 12 | }; 13 | export type ActionTypes = Types.ValueOf; 14 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/build-flags.d.ts: -------------------------------------------------------------------------------- 1 | export declare const canUseDom: boolean; 2 | export declare const searchFuse: string | undefined; 3 | export declare const searchKMP: boolean; 4 | /** 5 | * These are not directly used, as an exported object (even as const) will prevent tree-shake away code paths 6 | */ 7 | export declare const BuildFlags: { 8 | readonly searchFuse: string | undefined; 9 | readonly searchKMP: boolean; 10 | readonly canUseDom: boolean; 11 | }; 12 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/choice-full.d.ts: -------------------------------------------------------------------------------- 1 | import { StringUntrusted } from './string-untrusted'; 2 | import { Types } from './types'; 3 | import { GroupFull } from './group-full'; 4 | export interface ChoiceFull { 5 | id: number; 6 | highlighted: boolean; 7 | element?: HTMLOptionElement | HTMLOptGroupElement; 8 | itemEl?: HTMLElement; 9 | choiceEl?: HTMLElement; 10 | labelClass?: Array; 11 | labelDescription?: string; 12 | customProperties?: Types.CustomProperties; 13 | disabled: boolean; 14 | active: boolean; 15 | elementId?: string; 16 | group: GroupFull | null; 17 | label: StringUntrusted | string; 18 | placeholder: boolean; 19 | selected: boolean; 20 | value: string; 21 | score: number; 22 | rank: number; 23 | } 24 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/class-names.d.ts: -------------------------------------------------------------------------------- 1 | /** Classes added to HTML generated by By default classnames follow the BEM notation. */ 2 | export interface ClassNames { 3 | /** @default ['choices'] */ 4 | containerOuter: string | Array; 5 | /** @default ['choices__inner'] */ 6 | containerInner: string | Array; 7 | /** @default ['choices__input'] */ 8 | input: string | Array; 9 | /** @default ['choices__input--cloned'] */ 10 | inputCloned: string | Array; 11 | /** @default ['choices__list'] */ 12 | list: string | Array; 13 | /** @default ['choices__list--multiple'] */ 14 | listItems: string | Array; 15 | /** @default ['choices__list--single'] */ 16 | listSingle: string | Array; 17 | /** @default ['choices__list--dropdown'] */ 18 | listDropdown: string | Array; 19 | /** @default ['choices__item'] */ 20 | item: string | Array; 21 | /** @default ['choices__item--selectable'] */ 22 | itemSelectable: string | Array; 23 | /** @default ['choices__item--disabled'] */ 24 | itemDisabled: string | Array; 25 | /** @default ['choices__item--choice'] */ 26 | itemChoice: string | Array; 27 | /** @default ['choices__description'] */ 28 | description: string | Array; 29 | /** @default ['choices__placeholder'] */ 30 | placeholder: string | Array; 31 | /** @default ['choices__group'] */ 32 | group: string | Array; 33 | /** @default ['choices__heading'] */ 34 | groupHeading: string | Array; 35 | /** @default ['choices__button'] */ 36 | button: string | Array; 37 | /** @default ['is-active'] */ 38 | activeState: string | Array; 39 | /** @default ['is-focused'] */ 40 | focusState: string | Array; 41 | /** @default ['is-open'] */ 42 | openState: string | Array; 43 | /** @default ['is-disabled'] */ 44 | disabledState: string | Array; 45 | /** @default ['is-highlighted'] */ 46 | highlightedState: string | Array; 47 | /** @default ['is-selected'] */ 48 | selectedState: string | Array; 49 | /** @default ['is-flipped'] */ 50 | flippedState: string | Array; 51 | /** @default ['is-loading'] */ 52 | loadingState: string | Array; 53 | /** @default ['choices__notice'] */ 54 | notice: string | Array; 55 | /** @default ['choices__item--selectable', 'add-choice'] */ 56 | addChoice: string | Array; 57 | /** @default ['has-no-results'] */ 58 | noResults: string | Array; 59 | /** @default ['has-no-choices'] */ 60 | noChoices: string | Array; 61 | } 62 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/event-choice.d.ts: -------------------------------------------------------------------------------- 1 | import { InputChoice } from './input-choice'; 2 | export type EventChoiceValueType = B extends true ? string : EventChoice; 3 | export interface EventChoice extends InputChoice { 4 | element?: HTMLOptionElement | HTMLOptGroupElement; 5 | groupValue?: string; 6 | keyCode?: number; 7 | } 8 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/event-type.d.ts: -------------------------------------------------------------------------------- 1 | import { Types } from './types'; 2 | export declare const EventType: { 3 | readonly showDropdown: "showDropdown"; 4 | readonly hideDropdown: "hideDropdown"; 5 | readonly change: "change"; 6 | readonly choice: "choice"; 7 | readonly search: "search"; 8 | readonly addItem: "addItem"; 9 | readonly removeItem: "removeItem"; 10 | readonly highlightItem: "highlightItem"; 11 | readonly highlightChoice: "highlightChoice"; 12 | readonly unhighlightItem: "unhighlightItem"; 13 | }; 14 | export type EventTypes = Types.ValueOf; 15 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/group-full.d.ts: -------------------------------------------------------------------------------- 1 | import { ChoiceFull } from './choice-full'; 2 | export interface GroupFull { 3 | id: number; 4 | active: boolean; 5 | disabled: boolean; 6 | label?: string; 7 | element?: HTMLOptGroupElement; 8 | groupEl?: HTMLElement; 9 | choices: ChoiceFull[]; 10 | } 11 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './action-type'; 2 | export * from './input-choice'; 3 | export * from './input-group'; 4 | export * from './event-choice'; 5 | export * from './class-names'; 6 | export * from './event-type'; 7 | export * from './item'; 8 | export * from './keycode-map'; 9 | export * from './options'; 10 | export * from './passed-element'; 11 | export * from './passed-element-type'; 12 | export * from './position-options-type'; 13 | export * from './state'; 14 | export * from './types'; 15 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/input-choice.d.ts: -------------------------------------------------------------------------------- 1 | import { StringUntrusted } from './string-untrusted'; 2 | import { Types } from './types'; 3 | export interface InputChoice { 4 | id?: number; 5 | highlighted?: boolean; 6 | labelClass?: string | Array; 7 | labelDescription?: string; 8 | customProperties?: Types.CustomProperties; 9 | disabled?: boolean; 10 | active?: boolean; 11 | label: StringUntrusted | string; 12 | placeholder?: boolean; 13 | selected?: boolean; 14 | value: any; 15 | } 16 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/input-group.d.ts: -------------------------------------------------------------------------------- 1 | import { InputChoice } from './input-choice'; 2 | import { StringUntrusted } from './string-untrusted'; 3 | export interface InputGroup { 4 | id?: number; 5 | active?: boolean; 6 | disabled?: boolean; 7 | label?: StringUntrusted | string; 8 | value: string; 9 | choices: InputChoice[]; 10 | } 11 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/item.d.ts: -------------------------------------------------------------------------------- 1 | import { InputChoice } from './input-choice'; 2 | import { InputGroup } from './input-group'; 3 | /** 4 | * @deprecated Use InputChoice instead 5 | */ 6 | export interface Item extends InputChoice { 7 | } 8 | /** 9 | * @deprecated Use InputChoice instead 10 | */ 11 | export interface Choice extends InputChoice { 12 | } 13 | /** 14 | * @deprecated Use InputGroup instead 15 | */ 16 | export interface Group extends InputGroup { 17 | } 18 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/keycode-map.d.ts: -------------------------------------------------------------------------------- 1 | export declare const KeyCodeMap: { 2 | readonly TAB_KEY: 9; 3 | readonly SHIFT_KEY: 16; 4 | readonly BACK_KEY: 46; 5 | readonly DELETE_KEY: 8; 6 | readonly ENTER_KEY: 13; 7 | readonly A_KEY: 65; 8 | readonly ESC_KEY: 27; 9 | readonly UP_KEY: 38; 10 | readonly DOWN_KEY: 40; 11 | readonly PAGE_UP_KEY: 33; 12 | readonly PAGE_DOWN_KEY: 34; 13 | }; 14 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/passed-element-type.d.ts: -------------------------------------------------------------------------------- 1 | import { Types } from './types'; 2 | export declare const PassedElementTypes: { 3 | readonly Text: "text"; 4 | readonly SelectOne: "select-one"; 5 | readonly SelectMultiple: "select-multiple"; 6 | }; 7 | export type PassedElementType = Types.ValueOf; 8 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/passed-element.d.ts: -------------------------------------------------------------------------------- 1 | import { InputChoice } from './input-choice'; 2 | import { EventChoice } from './event-choice'; 3 | /** 4 | * 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. 5 | */ 6 | export interface EventMap { 7 | /** 8 | * Triggered each time an item is added (programmatically or by the user). 9 | * 10 | * **Input types affected:** text, select-one, select-multiple 11 | * 12 | * Arguments: id, value, label, groupValue 13 | */ 14 | addItem: CustomEvent; 15 | /** 16 | * Triggered each time an item is removed (programmatically or by the user). 17 | * 18 | * **Input types affected:** text, select-one, select-multiple 19 | * 20 | * Arguments: id, value, label, groupValue 21 | */ 22 | removeItem: CustomEvent; 23 | /** 24 | * Triggered each time an item is highlighted. 25 | * 26 | * **Input types affected:** text, select-multiple 27 | * 28 | * Arguments: id, value, label, groupValue 29 | */ 30 | highlightItem: CustomEvent; 31 | /** 32 | * Triggered each time an item is unhighlighted. 33 | * 34 | * **Input types affected:** text, select-multiple 35 | * 36 | * Arguments: id, value, label, groupValue 37 | */ 38 | unhighlightItem: CustomEvent; 39 | /** 40 | * Triggered each time a choice is selected **by a user**, regardless if it changes the value of the input. 41 | * 42 | * **Input types affected:** select-one, select-multiple 43 | * 44 | * Arguments: choice: Choice 45 | */ 46 | choice: CustomEvent<{ 47 | choice: InputChoice; 48 | }>; 49 | /** 50 | * Triggered each time an item is added/removed **by a user**. 51 | * 52 | * **Input types affected:** text, select-one, select-multiple 53 | * 54 | * Arguments: value 55 | */ 56 | change: CustomEvent<{ 57 | value: string; 58 | }>; 59 | /** 60 | * Triggered when a user types into an input to search choices. When a search is ended, a search event with an empty value with no resultCount is triggered. 61 | * 62 | * **Input types affected:** select-one, select-multiple 63 | * 64 | * Arguments: value, resultCount 65 | */ 66 | search: CustomEvent<{ 67 | value: string; 68 | resultCount: number; 69 | }>; 70 | /** 71 | * Triggered when the dropdown is shown. 72 | * 73 | * **Input types affected:** select-one, select-multiple 74 | * 75 | * Arguments: - 76 | */ 77 | showDropdown: CustomEvent; 78 | /** 79 | * Triggered when the dropdown is hidden. 80 | * 81 | * **Input types affected:** select-one, select-multiple 82 | * 83 | * Arguments: - 84 | */ 85 | hideDropdown: CustomEvent; 86 | /** 87 | * Triggered when a choice from the dropdown is highlighted. 88 | * 89 | * Input types affected: select-one, select-multiple 90 | * Arguments: el is the choice.passedElement that was affected. 91 | */ 92 | highlightChoice: CustomEvent<{ 93 | el: HTMLElement; 94 | }>; 95 | } 96 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/position-options-type.d.ts: -------------------------------------------------------------------------------- 1 | export type PositionOptionsType = 'auto' | 'top' | 'bottom'; 2 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/search.d.ts: -------------------------------------------------------------------------------- 1 | export interface SearchResult { 2 | item: T; 3 | score: number; 4 | rank: number; 5 | } 6 | export interface Searcher { 7 | reset(): void; 8 | isEmptyIndex(): boolean; 9 | index(data: T[]): void; 10 | search(needle: string): SearchResult[]; 11 | } 12 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/state.d.ts: -------------------------------------------------------------------------------- 1 | import { ChoiceFull } from './choice-full'; 2 | import { GroupFull } from './group-full'; 3 | export interface State { 4 | choices: ChoiceFull[]; 5 | groups: GroupFull[]; 6 | items: ChoiceFull[]; 7 | } 8 | export type StateChangeSet = { 9 | [K in keyof State]: boolean; 10 | }; 11 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/store.d.ts: -------------------------------------------------------------------------------- 1 | import { StateChangeSet, State } from './state'; 2 | import { ChoiceFull } from './choice-full'; 3 | import { GroupFull } from './group-full'; 4 | import { ActionTypes } from './action-type'; 5 | export interface AnyAction { 6 | type: A; 7 | } 8 | export interface StateUpdate { 9 | update: boolean; 10 | state: T; 11 | } 12 | export type Reducer = (state: T, action: AnyAction, context?: unknown) => StateUpdate; 13 | export type StoreListener = (changes: StateChangeSet) => void; 14 | export interface Store { 15 | dispatch(action: AnyAction): void; 16 | subscribe(onChange: StoreListener): void; 17 | withTxn(func: () => void): void; 18 | reset(): void; 19 | get defaultState(): State; 20 | /** 21 | * Get store object 22 | */ 23 | get state(): State; 24 | /** 25 | * Get items from store 26 | */ 27 | get items(): ChoiceFull[]; 28 | /** 29 | * Get highlighted items from store 30 | */ 31 | get highlightedActiveItems(): ChoiceFull[]; 32 | /** 33 | * Get choices from store 34 | */ 35 | get choices(): ChoiceFull[]; 36 | /** 37 | * Get active choices from store 38 | */ 39 | get activeChoices(): ChoiceFull[]; 40 | /** 41 | * Get choices that can be searched (excluding placeholders) 42 | */ 43 | get searchableChoices(): ChoiceFull[]; 44 | /** 45 | * Get groups from store 46 | */ 47 | get groups(): GroupFull[]; 48 | /** 49 | * Get active groups from store 50 | */ 51 | get activeGroups(): GroupFull[]; 52 | /** 53 | * Get loading state from store 54 | */ 55 | inTxn(): boolean; 56 | /** 57 | * Get single choice by it's ID 58 | */ 59 | getChoiceById(id: number): ChoiceFull | undefined; 60 | /** 61 | * Get group by group id 62 | */ 63 | getGroupById(id: number): GroupFull | undefined; 64 | } 65 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/string-pre-escaped.d.ts: -------------------------------------------------------------------------------- 1 | export interface StringPreEscaped { 2 | readonly trusted: string; 3 | } 4 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/string-untrusted.d.ts: -------------------------------------------------------------------------------- 1 | export interface StringUntrusted { 2 | readonly escaped: string; 3 | readonly raw: string; 4 | } 5 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/templates.d.ts: -------------------------------------------------------------------------------- 1 | import { PassedElementType } from './passed-element-type'; 2 | import { StringPreEscaped } from './string-pre-escaped'; 3 | import { ChoiceFull } from './choice-full'; 4 | import { GroupFull } from './group-full'; 5 | import { Options } from './options'; 6 | import { Types } from './types'; 7 | export type TemplateOptions = Pick; 8 | export declare const NoticeTypes: { 9 | readonly noChoices: "no-choices"; 10 | readonly noResults: "no-results"; 11 | readonly addChoice: "add-choice"; 12 | readonly generic: ""; 13 | }; 14 | export type NoticeType = Types.ValueOf; 15 | export type CallbackOnCreateTemplatesFn = (template: Types.StrToEl, escapeForTemplate: Types.EscapeForTemplateFn, getClassNames: Types.GetClassNamesFn) => Partial; 16 | export interface Templates { 17 | containerOuter(options: TemplateOptions, dir: HTMLElement['dir'], isSelectElement: boolean, isSelectOneElement: boolean, searchEnabled: boolean, passedElementType: PassedElementType, labelId: string): HTMLDivElement; 18 | containerInner({ classNames: { containerInner } }: TemplateOptions): HTMLDivElement; 19 | itemList(options: TemplateOptions, isSelectOneElement: boolean): HTMLDivElement; 20 | placeholder(options: TemplateOptions, value: StringPreEscaped | string): HTMLDivElement; 21 | item(options: TemplateOptions, choice: ChoiceFull, removeItemButton: boolean): HTMLDivElement; 22 | choiceList(options: TemplateOptions, isSelectOneElement: boolean): HTMLDivElement; 23 | choiceGroup(options: TemplateOptions, group: GroupFull): HTMLDivElement; 24 | choice(options: TemplateOptions, choice: ChoiceFull, selectText: string, groupText?: string): HTMLDivElement; 25 | input(options: TemplateOptions, placeholderValue: string | null): HTMLInputElement; 26 | dropdown(options: TemplateOptions): HTMLDivElement; 27 | notice(options: TemplateOptions, innerText: string, type: NoticeType): HTMLDivElement; 28 | option(choice: ChoiceFull): HTMLOptionElement; 29 | } 30 | -------------------------------------------------------------------------------- /public/types/src/scripts/interfaces/types.d.ts: -------------------------------------------------------------------------------- 1 | import { StringUntrusted } from './string-untrusted'; 2 | import { StringPreEscaped } from './string-pre-escaped'; 3 | export declare namespace Types { 4 | type StrToEl = (str: string) => HTMLElement | HTMLInputElement | HTMLOptionElement; 5 | type EscapeForTemplateFn = (allowHTML: boolean, s: StringUntrusted | StringPreEscaped | string) => string; 6 | type GetClassNamesFn = (s: string | Array) => string; 7 | type StringFunction = () => string; 8 | type NoticeStringFunction = (value: string, valueRaw: string) => string; 9 | type NoticeLimitFunction = (maxItemCount: number) => string; 10 | type FilterFunction = (value: string) => boolean; 11 | type ValueCompareFunction = (value1: string, value2: string) => boolean; 12 | interface RecordToCompare { 13 | value?: StringUntrusted | string; 14 | label?: StringUntrusted | string; 15 | } 16 | type ValueOf = T[keyof T]; 17 | type CustomProperties = Record | string; 18 | } 19 | -------------------------------------------------------------------------------- /public/types/src/scripts/lib/choice-input.d.ts: -------------------------------------------------------------------------------- 1 | import { InputChoice } from '../interfaces/input-choice'; 2 | import { InputGroup } from '../interfaces/input-group'; 3 | import { GroupFull } from '../interfaces/group-full'; 4 | import { ChoiceFull } from '../interfaces/choice-full'; 5 | type MappedInputTypeToChoiceType = T extends InputGroup ? GroupFull : ChoiceFull; 6 | export declare const coerceBool: (arg: unknown, defaultValue?: boolean) => boolean; 7 | export declare const stringToHtmlClass: (input: string | string[] | undefined) => string[] | undefined; 8 | export declare const mapInputToChoice: (value: T, allowGroup: boolean, allowRawString?: boolean) => MappedInputTypeToChoiceType; 9 | export {}; 10 | -------------------------------------------------------------------------------- /public/types/src/scripts/lib/html-guard-statements.d.ts: -------------------------------------------------------------------------------- 1 | export declare const isHtmlInputElement: (e: Element) => e is HTMLInputElement; 2 | export declare const isHtmlSelectElement: (e: Element) => e is HTMLSelectElement; 3 | export declare const isHtmlOption: (e: Element) => e is HTMLOptionElement; 4 | export declare const isHtmlOptgroup: (e: Element) => e is HTMLOptGroupElement; 5 | -------------------------------------------------------------------------------- /public/types/src/scripts/lib/utils.d.ts: -------------------------------------------------------------------------------- 1 | import { EventTypes } from '../interfaces/event-type'; 2 | import { StringUntrusted } from '../interfaces/string-untrusted'; 3 | import { StringPreEscaped } from '../interfaces/string-pre-escaped'; 4 | import { ChoiceFull } from '../interfaces/choice-full'; 5 | import { Types } from '../interfaces/types'; 6 | export declare const generateId: (element: HTMLInputElement | HTMLSelectElement, prefix: string) => string; 7 | export declare const getAdjacentEl: (startEl: HTMLElement, selector: string, direction?: number) => HTMLElement | null; 8 | export declare const isScrolledIntoView: (element: HTMLElement, parent: HTMLElement, direction?: number) => boolean; 9 | export declare const sanitise: (value: T | StringUntrusted | StringPreEscaped | string) => T | string; 10 | export declare const strToEl: (str: string) => Element; 11 | export declare const resolveNoticeFunction: (fn: Types.NoticeStringFunction | string, value: string) => string; 12 | export declare const resolveStringFunction: (fn: Types.StringFunction | string) => string; 13 | export declare const unwrapStringForRaw: (s?: StringUntrusted | StringPreEscaped | string) => string; 14 | export declare const unwrapStringForEscaped: (s?: StringUntrusted | StringPreEscaped | string) => string; 15 | export declare const escapeForTemplate: (allowHTML: boolean, s: StringUntrusted | StringPreEscaped | string) => string; 16 | export declare const setElementHtml: (el: HTMLElement, allowHtml: boolean, html: StringUntrusted | StringPreEscaped | string) => void; 17 | export declare const sortByAlpha: ({ value, label }: Types.RecordToCompare, { value: value2, label: label2 }: Types.RecordToCompare) => number; 18 | export declare const sortByScore: (a: Pick, b: Pick) => number; 19 | export declare const sortByRank: (a: Pick, b: Pick) => number; 20 | export declare const dispatchEvent: (element: HTMLElement, type: EventTypes, customArgs?: object | null) => boolean; 21 | export declare const cloneObject: (obj: T) => T; 22 | /** 23 | * Returns an array of keys present on the first but missing on the second object 24 | */ 25 | export declare const diff: (a: Record, b: Record) => string[]; 26 | export declare const getClassNames: (ClassNames: Array | string) => Array; 27 | export declare const getClassNamesSelector: (option: string | Array | null) => string; 28 | export declare const addClassesToElement: (element: HTMLElement, className: Array | string) => void; 29 | export declare const removeClassesFromElement: (element: HTMLElement, className: Array | string) => void; 30 | export declare const parseCustomProperties: (customProperties?: string) => object | string; 31 | export declare const updateClassList: (item: ChoiceFull, add: string | string[], remove: string | string[]) => void; 32 | -------------------------------------------------------------------------------- /public/types/src/scripts/reducers/choices.d.ts: -------------------------------------------------------------------------------- 1 | import { Options, State } from '../interfaces'; 2 | import { StateUpdate } from '../interfaces/store'; 3 | import { ChoiceActions } from '../actions/choices'; 4 | import { ItemActions } from '../actions/items'; 5 | type ActionTypes = ChoiceActions | ItemActions; 6 | type StateType = State['choices']; 7 | export default function choices(s: StateType, action: ActionTypes, context?: Options): StateUpdate; 8 | export {}; 9 | -------------------------------------------------------------------------------- /public/types/src/scripts/reducers/groups.d.ts: -------------------------------------------------------------------------------- 1 | import { GroupActions } from '../actions/groups'; 2 | import { State } from '../interfaces/state'; 3 | import { StateUpdate } from '../interfaces/store'; 4 | import { ChoiceActions } from '../actions/choices'; 5 | type ActionTypes = ChoiceActions | GroupActions; 6 | type StateType = State['groups']; 7 | export default function groups(s: StateType, action: ActionTypes): StateUpdate; 8 | export {}; 9 | -------------------------------------------------------------------------------- /public/types/src/scripts/reducers/items.d.ts: -------------------------------------------------------------------------------- 1 | import { ItemActions } from '../actions/items'; 2 | import { State } from '../interfaces/state'; 3 | import { ChoiceActions } from '../actions/choices'; 4 | import { Options } from '../interfaces'; 5 | import { StateUpdate } from '../interfaces/store'; 6 | type ActionTypes = ChoiceActions | ItemActions; 7 | type StateType = State['items']; 8 | export default function items(s: StateType, action: ActionTypes, context?: Options): StateUpdate; 9 | export {}; 10 | -------------------------------------------------------------------------------- /public/types/src/scripts/search/fuse.d.ts: -------------------------------------------------------------------------------- 1 | import { default as FuseFull, IFuseOptions } from 'fuse.js'; 2 | import { default as FuseBasic } from 'fuse.js/basic'; 3 | import { Options } from '../interfaces/options'; 4 | import { Searcher, SearchResult } from '../interfaces/search'; 5 | export declare class SearchByFuse implements Searcher { 6 | _fuseOptions: IFuseOptions; 7 | _haystack: T[]; 8 | _fuse: FuseFull | FuseBasic | undefined; 9 | constructor(config: Options); 10 | index(data: T[]): void; 11 | reset(): void; 12 | isEmptyIndex(): boolean; 13 | search(needle: string): SearchResult[]; 14 | } 15 | -------------------------------------------------------------------------------- /public/types/src/scripts/search/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Options } from '../interfaces'; 2 | import { Searcher } from '../interfaces/search'; 3 | export declare function getSearcher(config: Options): Searcher; 4 | -------------------------------------------------------------------------------- /public/types/src/scripts/search/kmp.d.ts: -------------------------------------------------------------------------------- 1 | import { Options } from '../interfaces'; 2 | import { Searcher, SearchResult } from '../interfaces/search'; 3 | export declare class SearchByKMP implements Searcher { 4 | _fields: string[]; 5 | _haystack: T[]; 6 | constructor(config: Options); 7 | index(data: T[]): void; 8 | reset(): void; 9 | isEmptyIndex(): boolean; 10 | search(_needle: string): SearchResult[]; 11 | } 12 | -------------------------------------------------------------------------------- /public/types/src/scripts/search/prefix-filter.d.ts: -------------------------------------------------------------------------------- 1 | import { Options } from '../interfaces'; 2 | import { Searcher, SearchResult } from '../interfaces/search'; 3 | export declare class SearchByPrefixFilter implements Searcher { 4 | _fields: string[]; 5 | _haystack: T[]; 6 | constructor(config: Options); 7 | index(data: T[]): void; 8 | reset(): void; 9 | isEmptyIndex(): boolean; 10 | search(_needle: string): SearchResult[]; 11 | } 12 | -------------------------------------------------------------------------------- /public/types/src/scripts/store/store.d.ts: -------------------------------------------------------------------------------- 1 | import { AnyAction, Store as IStore, StoreListener } from '../interfaces/store'; 2 | import { StateChangeSet, State } from '../interfaces/state'; 3 | import { ChoiceFull } from '../interfaces/choice-full'; 4 | import { GroupFull } from '../interfaces/group-full'; 5 | export default class Store implements IStore { 6 | _state: State; 7 | _listeners: StoreListener[]; 8 | _txn: number; 9 | _changeSet?: StateChangeSet; 10 | _context: T; 11 | constructor(context: T); 12 | get defaultState(): State; 13 | changeSet(init: boolean): StateChangeSet; 14 | reset(): void; 15 | subscribe(onChange: StoreListener): this; 16 | dispatch(action: AnyAction): void; 17 | withTxn(func: () => void): void; 18 | /** 19 | * Get store object 20 | */ 21 | get state(): State; 22 | /** 23 | * Get items from store 24 | */ 25 | get items(): ChoiceFull[]; 26 | /** 27 | * Get highlighted items from store 28 | */ 29 | get highlightedActiveItems(): ChoiceFull[]; 30 | /** 31 | * Get choices from store 32 | */ 33 | get choices(): ChoiceFull[]; 34 | /** 35 | * Get active choices from store 36 | */ 37 | get activeChoices(): ChoiceFull[]; 38 | /** 39 | * Get choices that can be searched (excluding placeholders or disabled choices) 40 | */ 41 | get searchableChoices(): ChoiceFull[]; 42 | /** 43 | * Get groups from store 44 | */ 45 | get groups(): GroupFull[]; 46 | /** 47 | * Get active groups from store 48 | */ 49 | get activeGroups(): GroupFull[]; 50 | inTxn(): boolean; 51 | /** 52 | * Get single choice by it's ID 53 | */ 54 | getChoiceById(id: number): ChoiceFull | undefined; 55 | /** 56 | * Get group by group id 57 | */ 58 | getGroupById(id: number): GroupFull | undefined; 59 | } 60 | -------------------------------------------------------------------------------- /public/types/src/scripts/templates.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helpers to create HTML elements used by Choices 3 | * Can be overridden by providing `callbackOnCreateTemplates` option. 4 | * `Choices.defaults.templates` allows access to the default template methods from `callbackOnCreateTemplates` 5 | */ 6 | import { Templates as TemplatesInterface } from './interfaces/templates'; 7 | declare const templates: TemplatesInterface; 8 | export default templates; 9 | -------------------------------------------------------------------------------- /scripts/lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.js': ['eslint --fix --quiet -f visualstudio', 'git add'], 3 | '*.{ts,scss,yaml,yml,md,html,json,babelrc,eslintrc}': [ 4 | 'prettier --write', 5 | 'git add', 6 | ], 7 | 'src/icons/*.svg': [ 8 | 'prettier --write --parser=html --html-whitespace-sensitivity=ignore', 9 | 'git add', 10 | ], 11 | '.codecov.yml': () => 12 | 'curl -f --silent --data-binary @.codecov.yml https://codecov.io/validate', 13 | 'src/scripts/**/*.js': () => 'npm run test:unit', 14 | }; 15 | -------------------------------------------------------------------------------- /scripts/server.mjs: -------------------------------------------------------------------------------- 1 | import dev from 'rollup-plugin-dev'; 2 | 3 | export default function server() { 4 | const WATCH_HOST = process.env.WATCH_HOST; 5 | 6 | if (!WATCH_HOST) { 7 | return void 0; 8 | } 9 | const WATCH_PORT = process.env.WATCH_PORT || 3001; 10 | 11 | return dev({ 12 | dirs: ['public'], 13 | host: WATCH_HOST, 14 | port: WATCH_PORT, 15 | force: !!process.env.CI, 16 | // silent: !!process.env.CI 17 | }); 18 | }; -------------------------------------------------------------------------------- /src/entry.js: -------------------------------------------------------------------------------- 1 | import Choices from './scripts/choices'; 2 | 3 | export default Choices; 4 | -------------------------------------------------------------------------------- /src/icons/cross-inverse.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/cross.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Choices from './scripts/choices'; 2 | 3 | export * from './scripts/interfaces'; 4 | export * from './scripts/constants'; 5 | export * from './scripts/defaults'; 6 | export { default as templates } from './scripts/templates'; 7 | 8 | export default Choices; 9 | -------------------------------------------------------------------------------- /src/scripts/actions/choices.ts: -------------------------------------------------------------------------------- 1 | import { ChoiceFull } from '../interfaces/choice-full'; 2 | import { ActionType } from '../interfaces'; 3 | import { SearchResult } from '../interfaces/search'; 4 | import { AnyAction } from '../interfaces/store'; 5 | 6 | export type ChoiceActions = 7 | | AddChoiceAction 8 | | RemoveChoiceAction 9 | | FilterChoicesAction 10 | | ActivateChoicesAction 11 | | ClearChoicesAction; 12 | 13 | export interface AddChoiceAction extends AnyAction { 14 | choice: ChoiceFull; 15 | } 16 | 17 | export interface RemoveChoiceAction extends AnyAction { 18 | choice: ChoiceFull; 19 | } 20 | 21 | export interface FilterChoicesAction extends AnyAction { 22 | results: SearchResult[]; 23 | } 24 | 25 | export interface ActivateChoicesAction extends AnyAction { 26 | active: boolean; 27 | } 28 | 29 | /** 30 | * @deprecated use clearStore() or clearChoices() instead. 31 | */ 32 | export interface ClearChoicesAction extends AnyAction {} 33 | 34 | export const addChoice = (choice: ChoiceFull): AddChoiceAction => ({ 35 | type: ActionType.ADD_CHOICE, 36 | choice, 37 | }); 38 | 39 | export const removeChoice = (choice: ChoiceFull): RemoveChoiceAction => ({ 40 | type: ActionType.REMOVE_CHOICE, 41 | choice, 42 | }); 43 | 44 | export const filterChoices = (results: SearchResult[]): FilterChoicesAction => ({ 45 | type: ActionType.FILTER_CHOICES, 46 | results, 47 | }); 48 | 49 | export const activateChoices = (active = true): ActivateChoicesAction => ({ 50 | type: ActionType.ACTIVATE_CHOICES, 51 | active, 52 | }); 53 | 54 | /** 55 | * @deprecated use clearStore() or clearChoices() instead. 56 | */ 57 | export const clearChoices = (): ClearChoicesAction => ({ 58 | type: ActionType.CLEAR_CHOICES, 59 | }); 60 | -------------------------------------------------------------------------------- /src/scripts/actions/groups.ts: -------------------------------------------------------------------------------- 1 | import { GroupFull } from '../interfaces/group-full'; 2 | import { ActionType } from '../interfaces'; 3 | import { AnyAction } from '../interfaces/store'; 4 | 5 | export type GroupActions = AddGroupAction; 6 | 7 | export interface AddGroupAction extends AnyAction { 8 | group: GroupFull; 9 | } 10 | 11 | export const addGroup = (group: GroupFull): AddGroupAction => ({ 12 | type: ActionType.ADD_GROUP, 13 | group, 14 | }); 15 | -------------------------------------------------------------------------------- /src/scripts/actions/items.ts: -------------------------------------------------------------------------------- 1 | import { ChoiceFull } from '../interfaces/choice-full'; 2 | import { ActionType } from '../interfaces'; 3 | import { AnyAction } from '../interfaces/store'; 4 | 5 | export type ItemActions = AddItemAction | RemoveItemAction | HighlightItemAction; 6 | 7 | export interface AddItemAction extends AnyAction { 8 | item: ChoiceFull; 9 | } 10 | 11 | export interface RemoveItemAction extends AnyAction { 12 | item: ChoiceFull; 13 | } 14 | 15 | export interface HighlightItemAction extends AnyAction { 16 | item: ChoiceFull; 17 | highlighted: boolean; 18 | } 19 | 20 | export const addItem = (item: ChoiceFull): AddItemAction => ({ 21 | type: ActionType.ADD_ITEM, 22 | item, 23 | }); 24 | 25 | export const removeItem = (item: ChoiceFull): RemoveItemAction => ({ 26 | type: ActionType.REMOVE_ITEM, 27 | item, 28 | }); 29 | 30 | export const highlightItem = (item: ChoiceFull, highlighted: boolean): HighlightItemAction => ({ 31 | type: ActionType.HIGHLIGHT_ITEM, 32 | item, 33 | highlighted, 34 | }); 35 | -------------------------------------------------------------------------------- /src/scripts/components/container.ts: -------------------------------------------------------------------------------- 1 | import { addClassesToElement, removeClassesFromElement } from '../lib/utils'; 2 | import { ClassNames } from '../interfaces/class-names'; 3 | import { PositionOptionsType } from '../interfaces/position-options-type'; 4 | import { PassedElementType, PassedElementTypes } from '../interfaces/passed-element-type'; 5 | 6 | export default class Container { 7 | element: HTMLElement; 8 | 9 | type: PassedElementType; 10 | 11 | classNames: ClassNames; 12 | 13 | position: PositionOptionsType; 14 | 15 | isOpen: boolean; 16 | 17 | isFlipped: boolean; 18 | 19 | isDisabled: boolean; 20 | 21 | isLoading: boolean; 22 | 23 | constructor({ 24 | element, 25 | type, 26 | classNames, 27 | position, 28 | }: { 29 | element: HTMLElement; 30 | type: PassedElementType; 31 | classNames: ClassNames; 32 | position: PositionOptionsType; 33 | }) { 34 | this.element = element; 35 | this.classNames = classNames; 36 | this.type = type; 37 | this.position = position; 38 | this.isOpen = false; 39 | this.isFlipped = false; 40 | this.isDisabled = false; 41 | this.isLoading = false; 42 | } 43 | 44 | /** 45 | * Determine whether container should be flipped based on passed 46 | * dropdown position 47 | */ 48 | shouldFlip(dropdownPos: number, dropdownHeight: number): boolean { 49 | // If flip is enabled and the dropdown bottom position is 50 | // greater than the window height flip the dropdown. 51 | let shouldFlip = false; 52 | if (this.position === 'auto') { 53 | shouldFlip = 54 | this.element.getBoundingClientRect().top - dropdownHeight >= 0 && 55 | !window.matchMedia(`(min-height: ${dropdownPos + 1}px)`).matches; 56 | } else if (this.position === 'top') { 57 | shouldFlip = true; 58 | } 59 | 60 | return shouldFlip; 61 | } 62 | 63 | setActiveDescendant(activeDescendantID: string): void { 64 | this.element.setAttribute('aria-activedescendant', activeDescendantID); 65 | } 66 | 67 | removeActiveDescendant(): void { 68 | this.element.removeAttribute('aria-activedescendant'); 69 | } 70 | 71 | open(dropdownPos: number, dropdownHeight: number): void { 72 | addClassesToElement(this.element, this.classNames.openState); 73 | this.element.setAttribute('aria-expanded', 'true'); 74 | this.isOpen = true; 75 | 76 | if (this.shouldFlip(dropdownPos, dropdownHeight)) { 77 | addClassesToElement(this.element, this.classNames.flippedState); 78 | this.isFlipped = true; 79 | } 80 | } 81 | 82 | close(): void { 83 | removeClassesFromElement(this.element, this.classNames.openState); 84 | this.element.setAttribute('aria-expanded', 'false'); 85 | this.removeActiveDescendant(); 86 | this.isOpen = false; 87 | 88 | // A dropdown flips if it does not have space within the page 89 | if (this.isFlipped) { 90 | removeClassesFromElement(this.element, this.classNames.flippedState); 91 | this.isFlipped = false; 92 | } 93 | } 94 | 95 | addFocusState(): void { 96 | addClassesToElement(this.element, this.classNames.focusState); 97 | } 98 | 99 | removeFocusState(): void { 100 | removeClassesFromElement(this.element, this.classNames.focusState); 101 | } 102 | 103 | enable(): void { 104 | removeClassesFromElement(this.element, this.classNames.disabledState); 105 | this.element.removeAttribute('aria-disabled'); 106 | if (this.type === PassedElementTypes.SelectOne) { 107 | this.element.setAttribute('tabindex', '0'); 108 | } 109 | this.isDisabled = false; 110 | } 111 | 112 | disable(): void { 113 | addClassesToElement(this.element, this.classNames.disabledState); 114 | this.element.setAttribute('aria-disabled', 'true'); 115 | if (this.type === PassedElementTypes.SelectOne) { 116 | this.element.setAttribute('tabindex', '-1'); 117 | } 118 | this.isDisabled = true; 119 | } 120 | 121 | wrap(element: HTMLElement): void { 122 | const el = this.element; 123 | const { parentNode } = element; 124 | if (parentNode) { 125 | if (element.nextSibling) { 126 | parentNode.insertBefore(el, element.nextSibling); 127 | } else { 128 | parentNode.appendChild(el); 129 | } 130 | } 131 | 132 | el.appendChild(element); 133 | } 134 | 135 | unwrap(element: HTMLElement): void { 136 | const el = this.element; 137 | const { parentNode } = el; 138 | if (parentNode) { 139 | // Move passed element outside this element 140 | parentNode.insertBefore(element, el); 141 | // Remove this element 142 | parentNode.removeChild(el); 143 | } 144 | } 145 | 146 | addLoadingState(): void { 147 | addClassesToElement(this.element, this.classNames.loadingState); 148 | this.element.setAttribute('aria-busy', 'true'); 149 | this.isLoading = true; 150 | } 151 | 152 | removeLoadingState(): void { 153 | removeClassesFromElement(this.element, this.classNames.loadingState); 154 | this.element.removeAttribute('aria-busy'); 155 | this.isLoading = false; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/scripts/components/dropdown.ts: -------------------------------------------------------------------------------- 1 | import { ClassNames } from '../interfaces/class-names'; 2 | import { PassedElementType } from '../interfaces/passed-element-type'; 3 | import { addClassesToElement, removeClassesFromElement } from '../lib/utils'; 4 | 5 | export default class Dropdown { 6 | element: HTMLElement; 7 | 8 | type: PassedElementType; 9 | 10 | classNames: ClassNames; 11 | 12 | isActive: boolean; 13 | 14 | constructor({ 15 | element, 16 | type, 17 | classNames, 18 | }: { 19 | element: HTMLElement; 20 | type: PassedElementType; 21 | classNames: ClassNames; 22 | }) { 23 | this.element = element; 24 | this.classNames = classNames; 25 | this.type = type; 26 | this.isActive = false; 27 | } 28 | 29 | /** 30 | * Show dropdown to user by adding active state class 31 | */ 32 | show(): this { 33 | addClassesToElement(this.element, this.classNames.activeState); 34 | this.element.setAttribute('aria-expanded', 'true'); 35 | this.isActive = true; 36 | 37 | return this; 38 | } 39 | 40 | /** 41 | * Hide dropdown from user 42 | */ 43 | hide(): this { 44 | removeClassesFromElement(this.element, this.classNames.activeState); 45 | this.element.setAttribute('aria-expanded', 'false'); 46 | this.isActive = false; 47 | 48 | return this; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/scripts/components/index.ts: -------------------------------------------------------------------------------- 1 | import Dropdown from './dropdown'; 2 | import Container from './container'; 3 | import Input from './input'; 4 | import List from './list'; 5 | import WrappedInput from './wrapped-input'; 6 | import WrappedSelect from './wrapped-select'; 7 | 8 | export { Dropdown, Container, Input, List, WrappedInput, WrappedSelect }; 9 | -------------------------------------------------------------------------------- /src/scripts/components/input.ts: -------------------------------------------------------------------------------- 1 | import { ClassNames } from '../interfaces/class-names'; 2 | import { PassedElementType, PassedElementTypes } from '../interfaces/passed-element-type'; 3 | 4 | export default class Input { 5 | element: HTMLInputElement; 6 | 7 | type: PassedElementType; 8 | 9 | classNames: ClassNames; 10 | 11 | preventPaste: boolean; 12 | 13 | isFocussed: boolean; 14 | 15 | isDisabled: boolean; 16 | 17 | constructor({ 18 | element, 19 | type, 20 | classNames, 21 | preventPaste, 22 | }: { 23 | element: HTMLInputElement; 24 | type: PassedElementType; 25 | classNames: ClassNames; 26 | preventPaste: boolean; 27 | }) { 28 | this.element = element; 29 | this.type = type; 30 | this.classNames = classNames; 31 | this.preventPaste = preventPaste; 32 | 33 | this.isFocussed = this.element.isEqualNode(document.activeElement); 34 | this.isDisabled = element.disabled; 35 | this._onPaste = this._onPaste.bind(this); 36 | this._onInput = this._onInput.bind(this); 37 | this._onFocus = this._onFocus.bind(this); 38 | this._onBlur = this._onBlur.bind(this); 39 | } 40 | 41 | set placeholder(placeholder: string) { 42 | this.element.placeholder = placeholder; 43 | } 44 | 45 | get value(): string { 46 | return this.element.value; 47 | } 48 | 49 | set value(value: string) { 50 | this.element.value = value; 51 | } 52 | 53 | addEventListeners(): void { 54 | const el = this.element; 55 | el.addEventListener('paste', this._onPaste); 56 | el.addEventListener('input', this._onInput, { 57 | passive: true, 58 | }); 59 | el.addEventListener('focus', this._onFocus, { 60 | passive: true, 61 | }); 62 | el.addEventListener('blur', this._onBlur, { 63 | passive: true, 64 | }); 65 | } 66 | 67 | removeEventListeners(): void { 68 | const el = this.element; 69 | el.removeEventListener('input', this._onInput); 70 | el.removeEventListener('paste', this._onPaste); 71 | el.removeEventListener('focus', this._onFocus); 72 | el.removeEventListener('blur', this._onBlur); 73 | } 74 | 75 | enable(): void { 76 | const el = this.element; 77 | el.removeAttribute('disabled'); 78 | this.isDisabled = false; 79 | } 80 | 81 | disable(): void { 82 | const el = this.element; 83 | el.setAttribute('disabled', ''); 84 | this.isDisabled = true; 85 | } 86 | 87 | focus(): void { 88 | if (!this.isFocussed) { 89 | this.element.focus(); 90 | } 91 | } 92 | 93 | blur(): void { 94 | if (this.isFocussed) { 95 | this.element.blur(); 96 | } 97 | } 98 | 99 | clear(setWidth = true): this { 100 | this.element.value = ''; 101 | if (setWidth) { 102 | this.setWidth(); 103 | } 104 | 105 | return this; 106 | } 107 | 108 | /** 109 | * Set the correct input width based on placeholder 110 | * value or input value 111 | */ 112 | setWidth(): void { 113 | // Resize input to contents or placeholder 114 | const { element } = this; 115 | element.style.minWidth = `${element.placeholder.length + 1}ch`; 116 | element.style.width = `${element.value.length + 1}ch`; 117 | } 118 | 119 | setActiveDescendant(activeDescendantID: string): void { 120 | this.element.setAttribute('aria-activedescendant', activeDescendantID); 121 | } 122 | 123 | removeActiveDescendant(): void { 124 | this.element.removeAttribute('aria-activedescendant'); 125 | } 126 | 127 | _onInput(): void { 128 | if (this.type !== PassedElementTypes.SelectOne) { 129 | this.setWidth(); 130 | } 131 | } 132 | 133 | _onPaste(event: ClipboardEvent): void { 134 | if (this.preventPaste) { 135 | event.preventDefault(); 136 | } 137 | } 138 | 139 | _onFocus(): void { 140 | this.isFocussed = true; 141 | } 142 | 143 | _onBlur(): void { 144 | this.isFocussed = false; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/scripts/components/list.ts: -------------------------------------------------------------------------------- 1 | import { SCROLLING_SPEED } from '../constants'; 2 | 3 | export default class List { 4 | element: HTMLElement; 5 | 6 | scrollPos: number; 7 | 8 | height: number; 9 | 10 | constructor({ element }: { element: HTMLElement }) { 11 | this.element = element; 12 | this.scrollPos = this.element.scrollTop; 13 | this.height = this.element.offsetHeight; 14 | } 15 | 16 | prepend(node: Element | DocumentFragment): void { 17 | const child = this.element.firstElementChild; 18 | if (child) { 19 | this.element.insertBefore(node, child); 20 | } else { 21 | this.element.append(node); 22 | } 23 | } 24 | 25 | scrollToTop(): void { 26 | this.element.scrollTop = 0; 27 | } 28 | 29 | scrollToChildElement(element: HTMLElement, direction: 1 | -1): void { 30 | if (!element) { 31 | return; 32 | } 33 | 34 | const listHeight = this.element.offsetHeight; 35 | // Scroll position of dropdown 36 | const listScrollPosition = this.element.scrollTop + listHeight; 37 | 38 | const elementHeight = element.offsetHeight; 39 | // Distance from bottom of element to top of parent 40 | const elementPos = element.offsetTop + elementHeight; 41 | 42 | // Difference between the element and scroll position 43 | const destination = direction > 0 ? this.element.scrollTop + elementPos - listScrollPosition : element.offsetTop; 44 | 45 | requestAnimationFrame(() => { 46 | this._animateScroll(destination, direction); 47 | }); 48 | } 49 | 50 | _scrollDown(scrollPos: number, strength: number, destination: number): void { 51 | const easing = (destination - scrollPos) / strength; 52 | const distance = easing > 1 ? easing : 1; 53 | 54 | this.element.scrollTop = scrollPos + distance; 55 | } 56 | 57 | _scrollUp(scrollPos: number, strength: number, destination: number): void { 58 | const easing = (scrollPos - destination) / strength; 59 | const distance = easing > 1 ? easing : 1; 60 | 61 | this.element.scrollTop = scrollPos - distance; 62 | } 63 | 64 | _animateScroll(destination: number, direction: number): void { 65 | const strength = SCROLLING_SPEED; 66 | const choiceListScrollTop = this.element.scrollTop; 67 | let continueAnimation = false; 68 | 69 | if (direction > 0) { 70 | this._scrollDown(choiceListScrollTop, strength, destination); 71 | 72 | if (choiceListScrollTop < destination) { 73 | continueAnimation = true; 74 | } 75 | } else { 76 | this._scrollUp(choiceListScrollTop, strength, destination); 77 | 78 | if (choiceListScrollTop > destination) { 79 | continueAnimation = true; 80 | } 81 | } 82 | 83 | if (continueAnimation) { 84 | requestAnimationFrame(() => { 85 | this._animateScroll(destination, direction); 86 | }); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/scripts/components/wrapped-element.ts: -------------------------------------------------------------------------------- 1 | import { ClassNames } from '../interfaces/class-names'; 2 | import { EventTypes } from '../interfaces/event-type'; 3 | import { addClassesToElement, dispatchEvent, removeClassesFromElement } from '../lib/utils'; 4 | import { EventMap } from '../interfaces'; 5 | 6 | export default class WrappedElement { 7 | element: T; 8 | 9 | classNames: ClassNames; 10 | 11 | isDisabled: boolean; 12 | 13 | constructor({ element, classNames }) { 14 | this.element = element; 15 | this.classNames = classNames; 16 | this.isDisabled = false; 17 | } 18 | 19 | get isActive(): boolean { 20 | return this.element.dataset.choice === 'active'; 21 | } 22 | 23 | get dir(): string { 24 | return this.element.dir; 25 | } 26 | 27 | get value(): string { 28 | return this.element.value; 29 | } 30 | 31 | set value(value: string) { 32 | this.element.setAttribute('value', value); 33 | this.element.value = value; 34 | } 35 | 36 | conceal(): void { 37 | const el = this.element; 38 | // Hide passed input 39 | addClassesToElement(el, this.classNames.input); 40 | el.hidden = true; 41 | 42 | // Remove element from tab index 43 | el.tabIndex = -1; 44 | 45 | // Backup original styles if any 46 | const origStyle = el.getAttribute('style'); 47 | 48 | if (origStyle) { 49 | el.setAttribute('data-choice-orig-style', origStyle); 50 | } 51 | 52 | el.setAttribute('data-choice', 'active'); 53 | } 54 | 55 | reveal(): void { 56 | const el = this.element; 57 | // Reinstate passed element 58 | removeClassesFromElement(el, this.classNames.input); 59 | el.hidden = false; 60 | el.removeAttribute('tabindex'); 61 | 62 | // Recover original styles if any 63 | const origStyle = el.getAttribute('data-choice-orig-style'); 64 | 65 | if (origStyle) { 66 | el.removeAttribute('data-choice-orig-style'); 67 | el.setAttribute('style', origStyle); 68 | } else { 69 | el.removeAttribute('style'); 70 | } 71 | el.removeAttribute('data-choice'); 72 | } 73 | 74 | enable(): void { 75 | this.element.removeAttribute('disabled'); 76 | this.element.disabled = false; 77 | this.isDisabled = false; 78 | } 79 | 80 | disable(): void { 81 | this.element.setAttribute('disabled', ''); 82 | this.element.disabled = true; 83 | this.isDisabled = true; 84 | } 85 | 86 | triggerEvent(eventType: EventTypes, data?: EventMap[K]['detail']): void { 87 | dispatchEvent(this.element, eventType, data || {}); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/scripts/components/wrapped-input.ts: -------------------------------------------------------------------------------- 1 | import WrappedElement from './wrapped-element'; 2 | 3 | export default class WrappedInput extends WrappedElement {} 4 | -------------------------------------------------------------------------------- /src/scripts/components/wrapped-select.ts: -------------------------------------------------------------------------------- 1 | import { parseCustomProperties } from '../lib/utils'; 2 | import { ClassNames } from '../interfaces/class-names'; 3 | import WrappedElement from './wrapped-element'; 4 | import { GroupFull } from '../interfaces/group-full'; 5 | import { ChoiceFull } from '../interfaces/choice-full'; 6 | import { stringToHtmlClass } from '../lib/choice-input'; 7 | import { isHtmlOptgroup, isHtmlOption } from '../lib/html-guard-statements'; 8 | 9 | export default class WrappedSelect extends WrappedElement { 10 | classNames: ClassNames; 11 | 12 | template: (data: object) => HTMLOptionElement; 13 | 14 | extractPlaceholder: boolean; 15 | 16 | constructor({ 17 | element, 18 | classNames, 19 | template, 20 | extractPlaceholder, 21 | }: { 22 | element: HTMLSelectElement; 23 | classNames: ClassNames; 24 | template: (data: object) => HTMLOptionElement; 25 | extractPlaceholder: boolean; 26 | }) { 27 | super({ element, classNames }); 28 | this.template = template; 29 | this.extractPlaceholder = extractPlaceholder; 30 | } 31 | 32 | get placeholderOption(): HTMLOptionElement | null { 33 | return ( 34 | this.element.querySelector('option[value=""]') || 35 | // Backward compatibility layer for the non-standard placeholder attribute supported in older versions. 36 | this.element.querySelector('option[placeholder]') 37 | ); 38 | } 39 | 40 | addOptions(choices: ChoiceFull[]): void { 41 | const fragment = document.createDocumentFragment(); 42 | choices.forEach((obj) => { 43 | const choice = obj; 44 | if (choice.element) { 45 | return; 46 | } 47 | 48 | const option = this.template(choice); 49 | fragment.appendChild(option); 50 | choice.element = option; 51 | }); 52 | this.element.appendChild(fragment); 53 | } 54 | 55 | optionsAsChoices(): (ChoiceFull | GroupFull)[] { 56 | const choices: (ChoiceFull | GroupFull)[] = []; 57 | 58 | this.element.querySelectorAll(':scope > option, :scope > optgroup').forEach((e) => { 59 | if (isHtmlOption(e)) { 60 | choices.push(this._optionToChoice(e)); 61 | } else if (isHtmlOptgroup(e)) { 62 | choices.push(this._optgroupToChoice(e)); 63 | } 64 | // todo: hr as empty optgroup, requires displaying empty opt-groups to be useful 65 | }); 66 | 67 | return choices; 68 | } 69 | 70 | // eslint-disable-next-line class-methods-use-this 71 | _optionToChoice(option: HTMLOptionElement): ChoiceFull { 72 | // option.value returns the label if there is no value attribute, which can break legacy placeholder attribute support 73 | if (!option.hasAttribute('value') && option.hasAttribute('placeholder')) { 74 | option.setAttribute('value', ''); 75 | option.value = ''; 76 | } 77 | 78 | return { 79 | id: 0, 80 | group: null, 81 | score: 0, 82 | rank: 0, 83 | value: option.value, 84 | // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/option 85 | // This attribute is text for the label indicating the meaning of the option. If the `label` attribute isn't defined, its value is that of the element text content (ie `innerText`). 86 | label: option.label, 87 | element: option, 88 | active: true, 89 | // this returns true if nothing is selected on initial load, which will break placeholder support 90 | selected: this.extractPlaceholder ? option.selected : option.hasAttribute('selected'), 91 | disabled: option.disabled, 92 | highlighted: false, 93 | placeholder: this.extractPlaceholder && (!option.value || option.hasAttribute('placeholder')), 94 | labelClass: 95 | typeof option.dataset.labelClass !== 'undefined' ? stringToHtmlClass(option.dataset.labelClass) : undefined, 96 | labelDescription: 97 | typeof option.dataset.labelDescription !== 'undefined' ? option.dataset.labelDescription : undefined, 98 | customProperties: parseCustomProperties(option.dataset.customProperties), 99 | }; 100 | } 101 | 102 | _optgroupToChoice(optgroup: HTMLOptGroupElement): GroupFull { 103 | const options = optgroup.querySelectorAll('option'); 104 | const choices = Array.from(options).map((option) => this._optionToChoice(option)); 105 | 106 | return { 107 | id: 0, 108 | label: optgroup.label || '', 109 | element: optgroup, 110 | active: !!choices.length, 111 | disabled: optgroup.disabled, 112 | choices, 113 | }; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/scripts/constants.ts: -------------------------------------------------------------------------------- 1 | export const SCROLLING_SPEED: number = 4 as const; 2 | -------------------------------------------------------------------------------- /src/scripts/defaults.ts: -------------------------------------------------------------------------------- 1 | import { ClassNames } from './interfaces/class-names'; 2 | import { Options } from './interfaces/options'; 3 | import { sortByAlpha } from './lib/utils'; 4 | 5 | export const DEFAULT_CLASSNAMES: ClassNames = { 6 | containerOuter: ['choices'], 7 | containerInner: ['choices__inner'], 8 | input: ['choices__input'], 9 | inputCloned: ['choices__input--cloned'], 10 | list: ['choices__list'], 11 | listItems: ['choices__list--multiple'], 12 | listSingle: ['choices__list--single'], 13 | listDropdown: ['choices__list--dropdown'], 14 | item: ['choices__item'], 15 | itemSelectable: ['choices__item--selectable'], 16 | itemDisabled: ['choices__item--disabled'], 17 | itemChoice: ['choices__item--choice'], 18 | description: ['choices__description'], 19 | placeholder: ['choices__placeholder'], 20 | group: ['choices__group'], 21 | groupHeading: ['choices__heading'], 22 | button: ['choices__button'], 23 | activeState: ['is-active'], 24 | focusState: ['is-focused'], 25 | openState: ['is-open'], 26 | disabledState: ['is-disabled'], 27 | highlightedState: ['is-highlighted'], 28 | selectedState: ['is-selected'], 29 | flippedState: ['is-flipped'], 30 | loadingState: ['is-loading'], 31 | notice: ['choices__notice'], 32 | addChoice: ['choices__item--selectable', 'add-choice'], 33 | noResults: ['has-no-results'], 34 | noChoices: ['has-no-choices'], 35 | } as const; 36 | 37 | export const DEFAULT_CONFIG: Options = { 38 | items: [], 39 | choices: [], 40 | silent: false, 41 | renderChoiceLimit: -1, 42 | maxItemCount: -1, 43 | closeDropdownOnSelect: 'auto', 44 | singleModeForMultiSelect: false, 45 | addChoices: false, 46 | addItems: true, 47 | addItemFilter: (value: string): boolean => !!value && value !== '', 48 | removeItems: true, 49 | removeItemButton: false, 50 | removeItemButtonAlignLeft: false, 51 | editItems: false, 52 | allowHTML: false, 53 | allowHtmlUserInput: false, 54 | duplicateItemsAllowed: true, 55 | delimiter: ',', 56 | paste: true, 57 | searchEnabled: true, 58 | searchChoices: true, 59 | searchFloor: 1, 60 | searchResultLimit: 4, 61 | searchFields: ['label', 'value'], 62 | position: 'auto', 63 | resetScrollPosition: true, 64 | shouldSort: true, 65 | shouldSortItems: false, 66 | sorter: sortByAlpha, 67 | shadowRoot: null, 68 | placeholder: true, 69 | placeholderValue: null, 70 | searchPlaceholderValue: null, 71 | prependValue: null, 72 | appendValue: null, 73 | renderSelectedChoices: 'auto', 74 | loadingText: 'Loading...', 75 | noResultsText: 'No results found', 76 | noChoicesText: 'No choices to choose from', 77 | itemSelectText: 'Press to select', 78 | uniqueItemText: 'Only unique values can be added', 79 | customAddItemText: 'Only values matching specific conditions can be added', 80 | addItemText: (value: string) => `Press Enter to add "${value}"`, 81 | removeItemIconText: (): string => `Remove item`, 82 | removeItemLabelText: (value: string): string => `Remove item: ${value}`, 83 | maxItemText: (maxItemCount: number): string => `Only ${maxItemCount} values can be added`, 84 | valueComparer: (value1: string, value2: string): boolean => value1 === value2, 85 | fuseOptions: { 86 | includeScore: true, 87 | }, 88 | labelId: '', 89 | callbackOnInit: null, 90 | callbackOnCreateTemplates: null, 91 | classNames: DEFAULT_CLASSNAMES, 92 | appendGroupInSearch: false, 93 | } as const; 94 | -------------------------------------------------------------------------------- /src/scripts/interfaces/action-type.ts: -------------------------------------------------------------------------------- 1 | import { Types } from './types'; 2 | 3 | export const ActionType = { 4 | ADD_CHOICE: 'ADD_CHOICE', 5 | REMOVE_CHOICE: 'REMOVE_CHOICE', 6 | FILTER_CHOICES: 'FILTER_CHOICES', 7 | ACTIVATE_CHOICES: 'ACTIVATE_CHOICES', 8 | CLEAR_CHOICES: 'CLEAR_CHOICES', 9 | ADD_GROUP: 'ADD_GROUP', 10 | ADD_ITEM: 'ADD_ITEM', 11 | REMOVE_ITEM: 'REMOVE_ITEM', 12 | HIGHLIGHT_ITEM: 'HIGHLIGHT_ITEM', 13 | } as const; 14 | 15 | export type ActionTypes = Types.ValueOf; 16 | -------------------------------------------------------------------------------- /src/scripts/interfaces/build-flags.ts: -------------------------------------------------------------------------------- 1 | export const canUseDom: boolean = 2 | process.env.CHOICES_CAN_USE_DOM !== undefined 3 | ? process.env.CHOICES_CAN_USE_DOM === '1' 4 | : !!(typeof document !== 'undefined' && document.createElement); 5 | 6 | export const searchFuse: string | undefined = process.env.CHOICES_SEARCH_FUSE; 7 | export const searchKMP: boolean = process.env.CHOICES_SEARCH_KMP === '1'; 8 | 9 | /** 10 | * These are not directly used, as an exported object (even as const) will prevent tree-shake away code paths 11 | */ 12 | 13 | export const BuildFlags = { 14 | searchFuse, 15 | searchKMP, 16 | canUseDom, 17 | } as const; 18 | -------------------------------------------------------------------------------- /src/scripts/interfaces/choice-full.ts: -------------------------------------------------------------------------------- 1 | import { StringUntrusted } from './string-untrusted'; 2 | import { Types } from './types'; 3 | // eslint-disable-next-line import/no-cycle 4 | import { GroupFull } from './group-full'; 5 | 6 | /* 7 | A disabled choice appears in the choice dropdown but cannot be selected 8 | A selected choice has been added to the passed input's value (added as an item) 9 | An active choice appears within the choice dropdown (ie search sets active to false if it doesn't match) 10 | */ 11 | export interface ChoiceFull { 12 | id: number; 13 | highlighted: boolean; 14 | element?: HTMLOptionElement | HTMLOptGroupElement; 15 | itemEl?: HTMLElement; 16 | choiceEl?: HTMLElement; 17 | labelClass?: Array; 18 | labelDescription?: string; 19 | customProperties?: Types.CustomProperties; 20 | disabled: boolean; 21 | active: boolean; 22 | elementId?: string; 23 | group: GroupFull | null; 24 | label: StringUntrusted | string; 25 | placeholder: boolean; 26 | selected: boolean; 27 | value: string; 28 | score: number; 29 | rank: number; 30 | } 31 | -------------------------------------------------------------------------------- /src/scripts/interfaces/class-names.ts: -------------------------------------------------------------------------------- 1 | /** Classes added to HTML generated by By default classnames follow the BEM notation. */ 2 | export interface ClassNames { 3 | /** @default ['choices'] */ 4 | containerOuter: string | Array; 5 | /** @default ['choices__inner'] */ 6 | containerInner: string | Array; 7 | /** @default ['choices__input'] */ 8 | input: string | Array; 9 | /** @default ['choices__input--cloned'] */ 10 | inputCloned: string | Array; 11 | /** @default ['choices__list'] */ 12 | list: string | Array; 13 | /** @default ['choices__list--multiple'] */ 14 | listItems: string | Array; 15 | /** @default ['choices__list--single'] */ 16 | listSingle: string | Array; 17 | /** @default ['choices__list--dropdown'] */ 18 | listDropdown: string | Array; 19 | /** @default ['choices__item'] */ 20 | item: string | Array; 21 | /** @default ['choices__item--selectable'] */ 22 | itemSelectable: string | Array; 23 | /** @default ['choices__item--disabled'] */ 24 | itemDisabled: string | Array; 25 | /** @default ['choices__item--choice'] */ 26 | itemChoice: string | Array; 27 | /** @default ['choices__description'] */ 28 | description: string | Array; 29 | /** @default ['choices__placeholder'] */ 30 | placeholder: string | Array; 31 | /** @default ['choices__group'] */ 32 | group: string | Array; 33 | /** @default ['choices__heading'] */ 34 | groupHeading: string | Array; 35 | /** @default ['choices__button'] */ 36 | button: string | Array; 37 | /** @default ['is-active'] */ 38 | activeState: string | Array; 39 | /** @default ['is-focused'] */ 40 | focusState: string | Array; 41 | /** @default ['is-open'] */ 42 | openState: string | Array; 43 | /** @default ['is-disabled'] */ 44 | disabledState: string | Array; 45 | /** @default ['is-highlighted'] */ 46 | highlightedState: string | Array; 47 | /** @default ['is-selected'] */ 48 | selectedState: string | Array; 49 | /** @default ['is-flipped'] */ 50 | flippedState: string | Array; 51 | /** @default ['is-loading'] */ 52 | loadingState: string | Array; 53 | /** @default ['choices__notice'] */ 54 | notice: string | Array; 55 | /** @default ['choices__item--selectable', 'add-choice'] */ 56 | addChoice: string | Array; 57 | /** @default ['has-no-results'] */ 58 | noResults: string | Array; 59 | /** @default ['has-no-choices'] */ 60 | noChoices: string | Array; 61 | } 62 | -------------------------------------------------------------------------------- /src/scripts/interfaces/event-choice.ts: -------------------------------------------------------------------------------- 1 | import { InputChoice } from './input-choice'; 2 | 3 | export type EventChoiceValueType = B extends true ? string : EventChoice; 4 | 5 | export interface EventChoice extends InputChoice { 6 | element?: HTMLOptionElement | HTMLOptGroupElement; 7 | groupValue?: string; 8 | keyCode?: number; 9 | } 10 | -------------------------------------------------------------------------------- /src/scripts/interfaces/event-type.ts: -------------------------------------------------------------------------------- 1 | import { Types } from './types'; 2 | 3 | export const EventType = { 4 | showDropdown: 'showDropdown', 5 | hideDropdown: 'hideDropdown', 6 | change: 'change', 7 | choice: 'choice', 8 | search: 'search', 9 | addItem: 'addItem', 10 | removeItem: 'removeItem', 11 | highlightItem: 'highlightItem', 12 | highlightChoice: 'highlightChoice', 13 | unhighlightItem: 'unhighlightItem', 14 | } as const; 15 | 16 | export type EventTypes = Types.ValueOf; 17 | -------------------------------------------------------------------------------- /src/scripts/interfaces/group-full.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-cycle 2 | import { ChoiceFull } from './choice-full'; 3 | 4 | export interface GroupFull { 5 | id: number; 6 | active: boolean; 7 | disabled: boolean; 8 | label?: string; 9 | element?: HTMLOptGroupElement; 10 | groupEl?: HTMLElement; 11 | choices: ChoiceFull[]; 12 | } 13 | -------------------------------------------------------------------------------- /src/scripts/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './action-type'; 2 | export * from './input-choice'; 3 | export * from './input-group'; 4 | export * from './event-choice'; 5 | export * from './class-names'; 6 | export * from './event-type'; 7 | export * from './item'; 8 | export * from './keycode-map'; 9 | export * from './options'; 10 | export * from './passed-element'; 11 | export * from './passed-element-type'; 12 | export * from './position-options-type'; 13 | export * from './state'; 14 | export * from './types'; 15 | -------------------------------------------------------------------------------- /src/scripts/interfaces/input-choice.ts: -------------------------------------------------------------------------------- 1 | import { StringUntrusted } from './string-untrusted'; 2 | import { Types } from './types'; 3 | 4 | export interface InputChoice { 5 | id?: number; 6 | highlighted?: boolean; 7 | labelClass?: string | Array; 8 | labelDescription?: string; 9 | customProperties?: Types.CustomProperties; 10 | disabled?: boolean; 11 | active?: boolean; 12 | label: StringUntrusted | string; 13 | placeholder?: boolean; 14 | selected?: boolean; 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | value: any; 17 | } 18 | -------------------------------------------------------------------------------- /src/scripts/interfaces/input-group.ts: -------------------------------------------------------------------------------- 1 | import { InputChoice } from './input-choice'; 2 | import { StringUntrusted } from './string-untrusted'; 3 | 4 | export interface InputGroup { 5 | id?: number; 6 | active?: boolean; 7 | disabled?: boolean; 8 | label?: StringUntrusted | string; 9 | value: string; 10 | choices: InputChoice[]; 11 | } 12 | -------------------------------------------------------------------------------- /src/scripts/interfaces/item.ts: -------------------------------------------------------------------------------- 1 | import { InputChoice } from './input-choice'; 2 | import { InputGroup } from './input-group'; 3 | 4 | /** 5 | * @deprecated Use InputChoice instead 6 | */ 7 | export interface Item extends InputChoice {} 8 | 9 | /** 10 | * @deprecated Use InputChoice instead 11 | */ 12 | export interface Choice extends InputChoice {} 13 | 14 | /** 15 | * @deprecated Use InputGroup instead 16 | */ 17 | export interface Group extends InputGroup {} 18 | -------------------------------------------------------------------------------- /src/scripts/interfaces/keycode-map.ts: -------------------------------------------------------------------------------- 1 | export const KeyCodeMap = { 2 | TAB_KEY: 9, 3 | SHIFT_KEY: 16, 4 | BACK_KEY: 46, 5 | DELETE_KEY: 8, 6 | ENTER_KEY: 13, 7 | A_KEY: 65, 8 | ESC_KEY: 27, 9 | UP_KEY: 38, 10 | DOWN_KEY: 40, 11 | PAGE_UP_KEY: 33, 12 | PAGE_DOWN_KEY: 34, 13 | } as const; 14 | -------------------------------------------------------------------------------- /src/scripts/interfaces/passed-element-type.ts: -------------------------------------------------------------------------------- 1 | import { Types } from './types'; 2 | 3 | export const PassedElementTypes = { 4 | Text: 'text', 5 | SelectOne: 'select-one', 6 | SelectMultiple: 'select-multiple', 7 | } as const; 8 | 9 | export type PassedElementType = Types.ValueOf; 10 | -------------------------------------------------------------------------------- /src/scripts/interfaces/passed-element.ts: -------------------------------------------------------------------------------- 1 | import { InputChoice } from './input-choice'; 2 | import { EventChoice } from './event-choice'; 3 | 4 | /** 5 | * 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. 6 | */ 7 | export interface EventMap { 8 | /** 9 | * Triggered each time an item is added (programmatically or by the user). 10 | * 11 | * **Input types affected:** text, select-one, select-multiple 12 | * 13 | * Arguments: id, value, label, groupValue 14 | */ 15 | addItem: CustomEvent; 16 | 17 | /** 18 | * Triggered each time an item is removed (programmatically or by the user). 19 | * 20 | * **Input types affected:** text, select-one, select-multiple 21 | * 22 | * Arguments: id, value, label, groupValue 23 | */ 24 | removeItem: CustomEvent; 25 | 26 | /** 27 | * Triggered each time an item is highlighted. 28 | * 29 | * **Input types affected:** text, select-multiple 30 | * 31 | * Arguments: id, value, label, groupValue 32 | */ 33 | highlightItem: CustomEvent; 34 | 35 | /** 36 | * Triggered each time an item is unhighlighted. 37 | * 38 | * **Input types affected:** text, select-multiple 39 | * 40 | * Arguments: id, value, label, groupValue 41 | */ 42 | unhighlightItem: CustomEvent; 43 | 44 | /** 45 | * Triggered each time a choice is selected **by a user**, regardless if it changes the value of the input. 46 | * 47 | * **Input types affected:** select-one, select-multiple 48 | * 49 | * Arguments: choice: Choice 50 | */ 51 | choice: CustomEvent<{ choice: InputChoice }>; 52 | 53 | /** 54 | * Triggered each time an item is added/removed **by a user**. 55 | * 56 | * **Input types affected:** text, select-one, select-multiple 57 | * 58 | * Arguments: value 59 | */ 60 | change: CustomEvent<{ value: string }>; 61 | 62 | /** 63 | * Triggered when a user types into an input to search choices. When a search is ended, a search event with an empty value with no resultCount is triggered. 64 | * 65 | * **Input types affected:** select-one, select-multiple 66 | * 67 | * Arguments: value, resultCount 68 | */ 69 | search: CustomEvent<{ value: string; resultCount: number }>; 70 | 71 | /** 72 | * Triggered when the dropdown is shown. 73 | * 74 | * **Input types affected:** select-one, select-multiple 75 | * 76 | * Arguments: - 77 | */ 78 | showDropdown: CustomEvent; 79 | 80 | /** 81 | * Triggered when the dropdown is hidden. 82 | * 83 | * **Input types affected:** select-one, select-multiple 84 | * 85 | * Arguments: - 86 | */ 87 | hideDropdown: CustomEvent; 88 | 89 | /** 90 | * Triggered when a choice from the dropdown is highlighted. 91 | * 92 | * Input types affected: select-one, select-multiple 93 | * Arguments: el is the choice.passedElement that was affected. 94 | */ 95 | highlightChoice: CustomEvent<{ el: HTMLElement }>; 96 | } 97 | -------------------------------------------------------------------------------- /src/scripts/interfaces/position-options-type.ts: -------------------------------------------------------------------------------- 1 | export type PositionOptionsType = 'auto' | 'top' | 'bottom'; 2 | -------------------------------------------------------------------------------- /src/scripts/interfaces/search.ts: -------------------------------------------------------------------------------- 1 | export interface SearchResult { 2 | item: T; 3 | score: number; 4 | rank: number; // values of 0 means this item is not in the search-result set, and should be discarded 5 | } 6 | 7 | export interface Searcher { 8 | reset(): void; 9 | isEmptyIndex(): boolean; 10 | index(data: T[]): void; 11 | search(needle: string): SearchResult[]; 12 | } 13 | -------------------------------------------------------------------------------- /src/scripts/interfaces/state.ts: -------------------------------------------------------------------------------- 1 | import { ChoiceFull } from './choice-full'; 2 | import { GroupFull } from './group-full'; 3 | 4 | export interface State { 5 | choices: ChoiceFull[]; 6 | groups: GroupFull[]; 7 | items: ChoiceFull[]; 8 | } 9 | 10 | export type StateChangeSet = { 11 | [K in keyof State]: boolean; 12 | }; 13 | -------------------------------------------------------------------------------- /src/scripts/interfaces/store.ts: -------------------------------------------------------------------------------- 1 | import { StateChangeSet, State } from './state'; 2 | import { ChoiceFull } from './choice-full'; 3 | import { GroupFull } from './group-full'; 4 | import { ActionTypes } from './action-type'; 5 | 6 | export interface AnyAction { 7 | type: A; 8 | } 9 | 10 | export interface StateUpdate { 11 | update: boolean; 12 | state: T; 13 | } 14 | 15 | export type Reducer = (state: T, action: AnyAction, context?: unknown) => StateUpdate; 16 | 17 | export type StoreListener = (changes: StateChangeSet) => void; 18 | 19 | export interface Store { 20 | dispatch(action: AnyAction): void; 21 | 22 | subscribe(onChange: StoreListener): void; 23 | 24 | withTxn(func: () => void): void; 25 | 26 | reset(): void; 27 | 28 | get defaultState(): State; 29 | 30 | /** 31 | * Get store object 32 | */ 33 | get state(): State; 34 | 35 | /** 36 | * Get items from store 37 | */ 38 | get items(): ChoiceFull[]; 39 | 40 | /** 41 | * Get highlighted items from store 42 | */ 43 | get highlightedActiveItems(): ChoiceFull[]; 44 | 45 | /** 46 | * Get choices from store 47 | */ 48 | get choices(): ChoiceFull[]; 49 | 50 | /** 51 | * Get active choices from store 52 | */ 53 | get activeChoices(): ChoiceFull[]; 54 | 55 | /** 56 | * Get choices that can be searched (excluding placeholders) 57 | */ 58 | get searchableChoices(): ChoiceFull[]; 59 | 60 | /** 61 | * Get groups from store 62 | */ 63 | get groups(): GroupFull[]; 64 | 65 | /** 66 | * Get active groups from store 67 | */ 68 | get activeGroups(): GroupFull[]; 69 | 70 | /** 71 | * Get loading state from store 72 | */ 73 | inTxn(): boolean; 74 | 75 | /** 76 | * Get single choice by it's ID 77 | */ 78 | getChoiceById(id: number): ChoiceFull | undefined; 79 | 80 | /** 81 | * Get group by group id 82 | */ 83 | getGroupById(id: number): GroupFull | undefined; 84 | } 85 | -------------------------------------------------------------------------------- /src/scripts/interfaces/string-pre-escaped.ts: -------------------------------------------------------------------------------- 1 | export interface StringPreEscaped { 2 | readonly trusted: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/scripts/interfaces/string-untrusted.ts: -------------------------------------------------------------------------------- 1 | export interface StringUntrusted { 2 | readonly escaped: string; 3 | 4 | readonly raw: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/scripts/interfaces/templates.ts: -------------------------------------------------------------------------------- 1 | import { PassedElementType } from './passed-element-type'; 2 | import { StringPreEscaped } from './string-pre-escaped'; 3 | import { ChoiceFull } from './choice-full'; 4 | import { GroupFull } from './group-full'; 5 | // eslint-disable-next-line import/no-cycle 6 | import { Options } from './options'; 7 | import { Types } from './types'; 8 | 9 | export type TemplateOptions = Pick< 10 | Options, 11 | | 'classNames' 12 | | 'allowHTML' 13 | | 'removeItemButtonAlignLeft' 14 | | 'removeItemIconText' 15 | | 'removeItemLabelText' 16 | | 'searchEnabled' 17 | | 'labelId' 18 | >; 19 | 20 | export const NoticeTypes = { 21 | noChoices: 'no-choices', 22 | noResults: 'no-results', 23 | addChoice: 'add-choice', 24 | generic: '', 25 | } as const; 26 | export type NoticeType = Types.ValueOf; 27 | 28 | export type CallbackOnCreateTemplatesFn = ( 29 | template: Types.StrToEl, 30 | escapeForTemplate: Types.EscapeForTemplateFn, 31 | getClassNames: Types.GetClassNamesFn, 32 | ) => Partial; 33 | 34 | export interface Templates { 35 | containerOuter( 36 | options: TemplateOptions, 37 | dir: HTMLElement['dir'], 38 | isSelectElement: boolean, 39 | isSelectOneElement: boolean, 40 | searchEnabled: boolean, 41 | passedElementType: PassedElementType, 42 | labelId: string, 43 | ): HTMLDivElement; 44 | 45 | containerInner({ classNames: { containerInner } }: TemplateOptions): HTMLDivElement; 46 | 47 | itemList(options: TemplateOptions, isSelectOneElement: boolean): HTMLDivElement; 48 | 49 | placeholder(options: TemplateOptions, value: StringPreEscaped | string): HTMLDivElement; 50 | 51 | item(options: TemplateOptions, choice: ChoiceFull, removeItemButton: boolean): HTMLDivElement; 52 | 53 | choiceList(options: TemplateOptions, isSelectOneElement: boolean): HTMLDivElement; 54 | 55 | choiceGroup(options: TemplateOptions, group: GroupFull): HTMLDivElement; 56 | 57 | choice(options: TemplateOptions, choice: ChoiceFull, selectText: string, groupText?: string): HTMLDivElement; 58 | 59 | input(options: TemplateOptions, placeholderValue: string | null): HTMLInputElement; 60 | 61 | dropdown(options: TemplateOptions): HTMLDivElement; 62 | 63 | notice(options: TemplateOptions, innerText: string, type: NoticeType): HTMLDivElement; 64 | 65 | option(choice: ChoiceFull): HTMLOptionElement; 66 | } 67 | -------------------------------------------------------------------------------- /src/scripts/interfaces/types.ts: -------------------------------------------------------------------------------- 1 | import { StringUntrusted } from './string-untrusted'; 2 | import { StringPreEscaped } from './string-pre-escaped'; 3 | 4 | export namespace Types { 5 | export type StrToEl = (str: string) => HTMLElement | HTMLInputElement | HTMLOptionElement; 6 | export type EscapeForTemplateFn = (allowHTML: boolean, s: StringUntrusted | StringPreEscaped | string) => string; 7 | export type GetClassNamesFn = (s: string | Array) => string; 8 | export type StringFunction = () => string; 9 | export type NoticeStringFunction = (value: string, valueRaw: string) => string; 10 | export type NoticeLimitFunction = (maxItemCount: number) => string; 11 | export type FilterFunction = (value: string) => boolean; 12 | export type ValueCompareFunction = (value1: string, value2: string) => boolean; 13 | 14 | export interface RecordToCompare { 15 | value?: StringUntrusted | string; 16 | label?: StringUntrusted | string; 17 | } 18 | export type ValueOf = T[keyof T]; 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | export type CustomProperties = Record | string; 21 | } 22 | -------------------------------------------------------------------------------- /src/scripts/lib/choice-input.ts: -------------------------------------------------------------------------------- 1 | import { InputChoice } from '../interfaces/input-choice'; 2 | import { InputGroup } from '../interfaces/input-group'; 3 | import { GroupFull } from '../interfaces/group-full'; 4 | import { ChoiceFull } from '../interfaces/choice-full'; 5 | import { sanitise, unwrapStringForRaw } from './utils'; 6 | 7 | type MappedInputTypeToChoiceType = T extends InputGroup 8 | ? GroupFull 9 | : ChoiceFull; 10 | 11 | export const coerceBool = (arg: unknown, defaultValue: boolean = true): boolean => 12 | typeof arg === 'undefined' ? defaultValue : !!arg; 13 | 14 | export const stringToHtmlClass = (input: string | string[] | undefined): string[] | undefined => { 15 | if (typeof input === 'string') { 16 | // eslint-disable-next-line no-param-reassign 17 | input = input.split(' ').filter((s) => s.length); 18 | } 19 | 20 | if (Array.isArray(input) && input.length) { 21 | return input; 22 | } 23 | 24 | return undefined; 25 | }; 26 | 27 | export const mapInputToChoice = ( 28 | value: T, 29 | allowGroup: boolean, 30 | allowRawString: boolean = true, 31 | ): MappedInputTypeToChoiceType => { 32 | if (typeof value === 'string') { 33 | const sanitisedValue = sanitise(value); 34 | const userValue = allowRawString || sanitisedValue === value ? value : { escaped: sanitisedValue, raw: value }; 35 | 36 | const result: ChoiceFull = mapInputToChoice( 37 | { 38 | value, 39 | label: userValue, 40 | selected: true, 41 | }, 42 | false, 43 | ); 44 | 45 | return result as MappedInputTypeToChoiceType; 46 | } 47 | 48 | const groupOrChoice = value as InputChoice | InputGroup; 49 | if ('choices' in groupOrChoice) { 50 | if (!allowGroup) { 51 | // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/optgroup 52 | throw new TypeError(`optGroup is not allowed`); 53 | } 54 | const group = groupOrChoice; 55 | const choices = group.choices.map((e) => mapInputToChoice(e, false)); 56 | 57 | const result: GroupFull = { 58 | id: 0, // actual ID will be assigned during _addGroup 59 | label: unwrapStringForRaw(group.label) || group.value, 60 | active: !!choices.length, 61 | disabled: !!group.disabled, 62 | choices, 63 | }; 64 | 65 | return result as MappedInputTypeToChoiceType; 66 | } 67 | 68 | const choice = groupOrChoice; 69 | 70 | const result: ChoiceFull = { 71 | id: 0, // actual ID will be assigned during _addChoice 72 | group: null, // actual group will be assigned during _addGroup but before _addChoice 73 | score: 0, // used in search 74 | rank: 0, // used in search, stable sort order 75 | value: choice.value, 76 | label: choice.label || choice.value, 77 | active: coerceBool(choice.active), 78 | selected: coerceBool(choice.selected, false), 79 | disabled: coerceBool(choice.disabled, false), 80 | placeholder: coerceBool(choice.placeholder, false), 81 | highlighted: false, 82 | labelClass: stringToHtmlClass(choice.labelClass), 83 | labelDescription: choice.labelDescription, 84 | customProperties: choice.customProperties, 85 | }; 86 | 87 | return result as MappedInputTypeToChoiceType; 88 | }; 89 | -------------------------------------------------------------------------------- /src/scripts/lib/html-guard-statements.ts: -------------------------------------------------------------------------------- 1 | export const isHtmlInputElement = (e: Element): e is HTMLInputElement => e.tagName === 'INPUT'; 2 | 3 | export const isHtmlSelectElement = (e: Element): e is HTMLSelectElement => e.tagName === 'SELECT'; 4 | 5 | export const isHtmlOption = (e: Element): e is HTMLOptionElement => e.tagName === 'OPTION'; 6 | 7 | export const isHtmlOptgroup = (e: Element): e is HTMLOptGroupElement => e.tagName === 'OPTGROUP'; 8 | -------------------------------------------------------------------------------- /src/scripts/reducers/choices.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { ActionType, Options, State } from '../interfaces'; 3 | import { StateUpdate } from '../interfaces/store'; 4 | import { ChoiceActions } from '../actions/choices'; 5 | import { ItemActions } from '../actions/items'; 6 | import { SearchResult } from '../interfaces/search'; 7 | import { ChoiceFull } from '../interfaces/choice-full'; 8 | 9 | type ActionTypes = ChoiceActions | ItemActions; 10 | type StateType = State['choices']; 11 | 12 | export default function choices(s: StateType, action: ActionTypes, context?: Options): StateUpdate { 13 | let state = s; 14 | let update = true; 15 | 16 | switch (action.type) { 17 | case ActionType.ADD_CHOICE: { 18 | state.push(action.choice); 19 | break; 20 | } 21 | 22 | case ActionType.REMOVE_CHOICE: { 23 | action.choice.choiceEl = undefined; 24 | 25 | if (action.choice.group) { 26 | action.choice.group.choices = action.choice.group.choices.filter((obj) => obj.id !== action.choice.id); 27 | } 28 | state = state.filter((obj) => obj.id !== action.choice.id); 29 | break; 30 | } 31 | 32 | case ActionType.ADD_ITEM: 33 | case ActionType.REMOVE_ITEM: { 34 | action.item.choiceEl = undefined; 35 | break; 36 | } 37 | 38 | case ActionType.FILTER_CHOICES: { 39 | // avoid O(n^2) algorithm complexity when searching/filtering choices 40 | const scoreLookup: SearchResult[] = []; 41 | action.results.forEach((result) => { 42 | scoreLookup[result.item.id] = result; 43 | }); 44 | 45 | state.forEach((choice) => { 46 | const result = scoreLookup[choice.id]; 47 | if (result !== undefined) { 48 | choice.score = result.score; 49 | choice.rank = result.rank; 50 | choice.active = true; 51 | } else { 52 | choice.score = 0; 53 | choice.rank = 0; 54 | choice.active = false; 55 | } 56 | if (context && context.appendGroupInSearch) { 57 | choice.choiceEl = undefined; 58 | } 59 | }); 60 | 61 | break; 62 | } 63 | 64 | case ActionType.ACTIVATE_CHOICES: { 65 | state.forEach((choice) => { 66 | choice.active = action.active; 67 | if (context && context.appendGroupInSearch) { 68 | choice.choiceEl = undefined; 69 | } 70 | }); 71 | break; 72 | } 73 | 74 | case ActionType.CLEAR_CHOICES: { 75 | state = []; 76 | break; 77 | } 78 | 79 | default: { 80 | update = false; 81 | break; 82 | } 83 | } 84 | 85 | return { state, update }; 86 | } 87 | -------------------------------------------------------------------------------- /src/scripts/reducers/groups.ts: -------------------------------------------------------------------------------- 1 | import { GroupActions } from '../actions/groups'; 2 | import { State } from '../interfaces/state'; 3 | import { ActionType } from '../interfaces'; 4 | import { StateUpdate } from '../interfaces/store'; 5 | import { ChoiceActions } from '../actions/choices'; 6 | 7 | type ActionTypes = ChoiceActions | GroupActions; 8 | type StateType = State['groups']; 9 | 10 | export default function groups(s: StateType, action: ActionTypes): StateUpdate { 11 | let state = s; 12 | let update = true; 13 | 14 | switch (action.type) { 15 | case ActionType.ADD_GROUP: { 16 | state.push(action.group); 17 | break; 18 | } 19 | 20 | case ActionType.CLEAR_CHOICES: { 21 | state = []; 22 | break; 23 | } 24 | 25 | default: { 26 | update = false; 27 | break; 28 | } 29 | } 30 | 31 | return { state, update }; 32 | } 33 | -------------------------------------------------------------------------------- /src/scripts/reducers/items.ts: -------------------------------------------------------------------------------- 1 | import { ItemActions } from '../actions/items'; 2 | import { State } from '../interfaces/state'; 3 | import { ChoiceActions } from '../actions/choices'; 4 | import { ActionType, Options, PassedElementTypes } from '../interfaces'; 5 | import { StateUpdate } from '../interfaces/store'; 6 | import { isHtmlSelectElement } from '../lib/html-guard-statements'; 7 | import { ChoiceFull } from '../interfaces/choice-full'; 8 | import { updateClassList } from '../lib/utils'; 9 | 10 | type ActionTypes = ChoiceActions | ItemActions; 11 | type StateType = State['items']; 12 | 13 | const removeItem = (item: ChoiceFull): void => { 14 | const { itemEl } = item; 15 | if (itemEl) { 16 | itemEl.remove(); 17 | item.itemEl = undefined; 18 | } 19 | }; 20 | 21 | export default function items(s: StateType, action: ActionTypes, context?: Options): StateUpdate { 22 | let state = s; 23 | let update = true; 24 | 25 | switch (action.type) { 26 | case ActionType.ADD_ITEM: { 27 | action.item.selected = true; 28 | const el = action.item.element as HTMLOptionElement | undefined; 29 | if (el) { 30 | el.selected = true; 31 | el.setAttribute('selected', ''); 32 | } 33 | 34 | state.push(action.item); 35 | break; 36 | } 37 | 38 | case ActionType.REMOVE_ITEM: { 39 | action.item.selected = false; 40 | const el = action.item.element as HTMLOptionElement | undefined; 41 | if (el) { 42 | el.selected = false; 43 | el.removeAttribute('selected'); 44 | // For a select-one, if all options are deselected, the first item is selected. To set a black value, select.value needs to be set 45 | const select = el.parentElement; 46 | if (select && isHtmlSelectElement(select) && select.type === PassedElementTypes.SelectOne) { 47 | select.value = ''; 48 | } 49 | } 50 | // this is mixing concerns, but this is *so much faster* 51 | removeItem(action.item); 52 | state = state.filter((choice) => choice.id !== action.item.id); 53 | break; 54 | } 55 | 56 | case ActionType.REMOVE_CHOICE: { 57 | removeItem(action.choice); 58 | state = state.filter((item) => item.id !== action.choice.id); 59 | break; 60 | } 61 | 62 | case ActionType.HIGHLIGHT_ITEM: { 63 | const { highlighted } = action; 64 | const item = state.find((obj) => obj.id === action.item.id); 65 | if (item && item.highlighted !== highlighted) { 66 | item.highlighted = highlighted; 67 | if (context) { 68 | updateClassList( 69 | item, 70 | highlighted ? context.classNames.highlightedState : context.classNames.selectedState, 71 | highlighted ? context.classNames.selectedState : context.classNames.highlightedState, 72 | ); 73 | } 74 | } 75 | 76 | break; 77 | } 78 | 79 | default: { 80 | update = false; 81 | break; 82 | } 83 | } 84 | 85 | return { state, update }; 86 | } 87 | -------------------------------------------------------------------------------- /src/scripts/search/fuse.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-named-default 2 | import { default as FuseFull, IFuseOptions } from 'fuse.js'; 3 | // eslint-disable-next-line import/no-named-default 4 | import { default as FuseBasic } from 'fuse.js/basic'; 5 | import { Options } from '../interfaces/options'; 6 | import { Searcher, SearchResult } from '../interfaces/search'; 7 | import { searchFuse } from '../interfaces/build-flags'; 8 | 9 | export class SearchByFuse implements Searcher { 10 | _fuseOptions: IFuseOptions; 11 | 12 | _haystack: T[] = []; 13 | 14 | _fuse: FuseFull | FuseBasic | undefined; 15 | 16 | constructor(config: Options) { 17 | this._fuseOptions = { 18 | ...config.fuseOptions, 19 | keys: [...config.searchFields], 20 | includeMatches: true, 21 | }; 22 | } 23 | 24 | index(data: T[]): void { 25 | this._haystack = data; 26 | if (this._fuse) { 27 | this._fuse.setCollection(data); 28 | } 29 | } 30 | 31 | reset(): void { 32 | this._haystack = []; 33 | this._fuse = undefined; 34 | } 35 | 36 | isEmptyIndex(): boolean { 37 | return !this._haystack.length; 38 | } 39 | 40 | search(needle: string): SearchResult[] { 41 | if (!this._fuse) { 42 | if (searchFuse === 'full') { 43 | this._fuse = new FuseFull(this._haystack, this._fuseOptions); 44 | } else { 45 | this._fuse = new FuseBasic(this._haystack, this._fuseOptions); 46 | } 47 | } 48 | 49 | const results = this._fuse.search(needle); 50 | 51 | return results.map((value, i): SearchResult => { 52 | return { 53 | item: value.item, 54 | score: value.score || 0, 55 | rank: i + 1, // If value.score is used for sorting, this can create non-stable sorts! 56 | }; 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/scripts/search/index.ts: -------------------------------------------------------------------------------- 1 | import { Options } from '../interfaces'; 2 | import { Searcher } from '../interfaces/search'; 3 | import { SearchByPrefixFilter } from './prefix-filter'; 4 | import { SearchByFuse } from './fuse'; 5 | import { SearchByKMP } from './kmp'; 6 | import { searchFuse, searchKMP } from '../interfaces/build-flags'; 7 | 8 | export function getSearcher(config: Options): Searcher { 9 | if (searchFuse && !searchKMP) { 10 | return new SearchByFuse(config); 11 | } 12 | if (searchKMP) { 13 | return new SearchByKMP(config); 14 | } 15 | 16 | return new SearchByPrefixFilter(config); 17 | } 18 | -------------------------------------------------------------------------------- /src/scripts/search/kmp.ts: -------------------------------------------------------------------------------- 1 | import { Options } from '../interfaces'; 2 | import { Searcher, SearchResult } from '../interfaces/search'; 3 | 4 | function kmpSearch(pattern: string, text: string): number { 5 | if (pattern.length === 0) { 6 | return 0; // Immediate match 7 | } 8 | 9 | // Compute longest suffix-prefix table 10 | const lsp = [0]; // Base case 11 | for (let i = 1; i < pattern.length; i++) { 12 | let j = lsp[i - 1]; // Start by assuming we're extending the previous LSP 13 | while (j > 0 && pattern.charAt(i) !== pattern.charAt(j)) { 14 | j = lsp[j - 1]; 15 | } 16 | if (pattern.charAt(i) === pattern.charAt(j)) { 17 | j++; 18 | } 19 | lsp.push(j); 20 | } 21 | 22 | // Walk through text string 23 | let j = 0; // Number of chars matched in pattern 24 | for (let i = 0; i < text.length; i++) { 25 | while (j > 0 && text.charAt(i) !== pattern.charAt(j)) { 26 | j = lsp[j - 1]; // Fall back in the pattern 27 | } 28 | if (text.charAt(i) === pattern.charAt(j)) { 29 | j++; // Next char matched, increment position 30 | if (j === pattern.length) { 31 | return i - (j - 1); 32 | } 33 | } 34 | } 35 | 36 | return -1; // Not found 37 | } 38 | 39 | export class SearchByKMP implements Searcher { 40 | _fields: string[]; 41 | 42 | _haystack: T[] = []; 43 | 44 | constructor(config: Options) { 45 | this._fields = config.searchFields; 46 | } 47 | 48 | index(data: T[]): void { 49 | this._haystack = data; 50 | } 51 | 52 | reset(): void { 53 | this._haystack = []; 54 | } 55 | 56 | isEmptyIndex(): boolean { 57 | return !this._haystack.length; 58 | } 59 | 60 | search(_needle: string): SearchResult[] { 61 | const fields = this._fields; 62 | if (!fields || !fields.length || !_needle) { 63 | return []; 64 | } 65 | const needle = _needle.toLowerCase(); 66 | 67 | const results: SearchResult[] = []; 68 | 69 | let count = 0; 70 | for (let i = 0, j = this._haystack.length; i < j; i++) { 71 | const obj = this._haystack[i]; 72 | for (let k = 0, l = this._fields.length; k < l; k++) { 73 | const field = this._fields[k]; 74 | if (field in obj && kmpSearch(needle, (obj[field] as string).toLowerCase()) !== -1) { 75 | results.push({ 76 | item: obj[field], 77 | score: count, 78 | rank: count + 1, 79 | }); 80 | count++; 81 | } 82 | } 83 | } 84 | 85 | return results; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/scripts/search/prefix-filter.ts: -------------------------------------------------------------------------------- 1 | import { Options } from '../interfaces'; 2 | import { Searcher, SearchResult } from '../interfaces/search'; 3 | 4 | export class SearchByPrefixFilter implements Searcher { 5 | _fields: string[]; 6 | 7 | _haystack: T[] = []; 8 | 9 | constructor(config: Options) { 10 | this._fields = config.searchFields; 11 | } 12 | 13 | index(data: T[]): void { 14 | this._haystack = data; 15 | } 16 | 17 | reset(): void { 18 | this._haystack = []; 19 | } 20 | 21 | isEmptyIndex(): boolean { 22 | return !this._haystack.length; 23 | } 24 | 25 | search(_needle: string): SearchResult[] { 26 | const fields = this._fields; 27 | if (!fields || !fields.length || !_needle) { 28 | return []; 29 | } 30 | const needle = _needle.toLowerCase(); 31 | 32 | return this._haystack 33 | .filter((obj) => fields.some((field) => field in obj && (obj[field] as string).toLowerCase().startsWith(needle))) 34 | .map((value, index): SearchResult => { 35 | return { 36 | item: value, 37 | score: index, 38 | rank: index + 1, 39 | }; 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/scripts/store/store.ts: -------------------------------------------------------------------------------- 1 | import { AnyAction, Reducer, Store as IStore, StoreListener } from '../interfaces/store'; 2 | import { StateChangeSet, State } from '../interfaces/state'; 3 | import { ChoiceFull } from '../interfaces/choice-full'; 4 | import { GroupFull } from '../interfaces/group-full'; 5 | import items from '../reducers/items'; 6 | import groups from '../reducers/groups'; 7 | import choices from '../reducers/choices'; 8 | 9 | type ReducerList = { [K in keyof State]: Reducer }; 10 | 11 | const reducers: ReducerList = { 12 | groups, 13 | items, 14 | choices, 15 | } as const; 16 | 17 | export default class Store implements IStore { 18 | _state: State = this.defaultState; 19 | 20 | _listeners: StoreListener[] = []; 21 | 22 | _txn: number = 0; 23 | 24 | _changeSet?: StateChangeSet; 25 | 26 | _context: T; 27 | 28 | constructor(context: T) { 29 | this._context = context; 30 | } 31 | 32 | // eslint-disable-next-line class-methods-use-this 33 | get defaultState(): State { 34 | return { 35 | groups: [], 36 | items: [], 37 | choices: [], 38 | }; 39 | } 40 | 41 | // eslint-disable-next-line class-methods-use-this 42 | changeSet(init: boolean): StateChangeSet { 43 | return { 44 | groups: init, 45 | items: init, 46 | choices: init, 47 | }; 48 | } 49 | 50 | reset(): void { 51 | this._state = this.defaultState; 52 | const changes = this.changeSet(true); 53 | if (this._txn) { 54 | this._changeSet = changes; 55 | } else { 56 | this._listeners.forEach((l) => l(changes)); 57 | } 58 | } 59 | 60 | subscribe(onChange: StoreListener): this { 61 | this._listeners.push(onChange); 62 | 63 | return this; 64 | } 65 | 66 | dispatch(action: AnyAction): void { 67 | const state = this._state; 68 | let hasChanges = false; 69 | const changes = this._changeSet || this.changeSet(false); 70 | 71 | Object.keys(reducers).forEach((key: string) => { 72 | const stateUpdate = (reducers[key] as Reducer)(state[key], action, this._context); 73 | if (stateUpdate.update) { 74 | hasChanges = true; 75 | changes[key] = true; 76 | state[key] = stateUpdate.state; 77 | } 78 | }); 79 | 80 | if (hasChanges) { 81 | if (this._txn) { 82 | this._changeSet = changes; 83 | } else { 84 | this._listeners.forEach((l) => l(changes)); 85 | } 86 | } 87 | } 88 | 89 | withTxn(func: () => void): void { 90 | this._txn++; 91 | try { 92 | func(); 93 | } finally { 94 | this._txn = Math.max(0, this._txn - 1); 95 | 96 | if (!this._txn) { 97 | const changeSet = this._changeSet; 98 | if (changeSet) { 99 | this._changeSet = undefined; 100 | this._listeners.forEach((l) => l(changeSet)); 101 | } 102 | } 103 | } 104 | } 105 | 106 | /** 107 | * Get store object 108 | */ 109 | get state(): State { 110 | return this._state; 111 | } 112 | 113 | /** 114 | * Get items from store 115 | */ 116 | get items(): ChoiceFull[] { 117 | return this.state.items; 118 | } 119 | 120 | /** 121 | * Get highlighted items from store 122 | */ 123 | get highlightedActiveItems(): ChoiceFull[] { 124 | return this.items.filter((item) => item.active && item.highlighted); 125 | } 126 | 127 | /** 128 | * Get choices from store 129 | */ 130 | get choices(): ChoiceFull[] { 131 | return this.state.choices; 132 | } 133 | 134 | /** 135 | * Get active choices from store 136 | */ 137 | get activeChoices(): ChoiceFull[] { 138 | return this.choices.filter((choice) => choice.active); 139 | } 140 | 141 | /** 142 | * Get choices that can be searched (excluding placeholders or disabled choices) 143 | */ 144 | get searchableChoices(): ChoiceFull[] { 145 | return this.choices.filter((choice) => !choice.disabled && !choice.placeholder); 146 | } 147 | 148 | /** 149 | * Get groups from store 150 | */ 151 | get groups(): GroupFull[] { 152 | return this.state.groups; 153 | } 154 | 155 | /** 156 | * Get active groups from store 157 | */ 158 | get activeGroups(): GroupFull[] { 159 | return this.state.groups.filter((group) => { 160 | const isActive = group.active && !group.disabled; 161 | const hasActiveOptions = this.state.choices.some((choice) => choice.active && !choice.disabled); 162 | 163 | return isActive && hasActiveOptions; 164 | }, []); 165 | } 166 | 167 | inTxn(): boolean { 168 | return this._txn > 0; 169 | } 170 | 171 | /** 172 | * Get single choice by it's ID 173 | */ 174 | getChoiceById(id: number): ChoiceFull | undefined { 175 | return this.activeChoices.find((choice) => choice.id === id); 176 | } 177 | 178 | /** 179 | * Get group by group id 180 | */ 181 | getGroupById(id: number): GroupFull | undefined { 182 | return this.groups.find((group) => group.id === id); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/styles/base.scss: -------------------------------------------------------------------------------- 1 | /* ============================================= 2 | = Generic styling = 3 | ============================================= */ 4 | 5 | $global-guttering: 24px; 6 | $global-font-size-h1: 32px; 7 | $global-font-size-h2: 24px; 8 | $global-font-size-h3: 20px; 9 | $global-font-size-h4: 18px; 10 | $global-font-size-h5: 16px; 11 | $global-font-size-h6: 14px; 12 | 13 | * { 14 | -webkit-font-smoothing: antialiased; 15 | -moz-osx-font-smoothing: grayscale; 16 | } 17 | 18 | *, 19 | *::before, 20 | *::after { 21 | box-sizing: border-box; 22 | } 23 | 24 | html, 25 | body { 26 | position: relative; 27 | margin: 0; 28 | width: 100%; 29 | height: 100%; 30 | } 31 | 32 | body { 33 | font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; 34 | font-size: 16px; 35 | line-height: 1.4; 36 | color: #fff; 37 | background-color: #333; 38 | overflow-x: hidden; 39 | } 40 | 41 | label { 42 | display: block; 43 | margin-bottom: 8px; 44 | font-size: 14px; 45 | font-weight: 500; 46 | cursor: pointer; 47 | } 48 | 49 | p { 50 | margin-top: 0; 51 | margin-bottom: 8px; 52 | } 53 | 54 | hr { 55 | display: block; 56 | margin: $global-guttering * 1.25 0; 57 | border: 0; 58 | border-bottom: 1px solid #eaeaea; 59 | height: 1px; 60 | } 61 | 62 | h1, 63 | h2, 64 | h3, 65 | h4, 66 | h5, 67 | h6 { 68 | margin-top: 0; 69 | margin-bottom: $global-guttering * 0.5; 70 | font-weight: 400; 71 | line-height: 1.2; 72 | } 73 | 74 | a, 75 | a:visited, 76 | a:focus { 77 | color: #fff; 78 | text-decoration: none; 79 | font-weight: 600; 80 | } 81 | 82 | .form-control { 83 | display: block; 84 | width: 100%; 85 | background-color: #f9f9f9; 86 | padding: 12px; 87 | border: 1px solid #ddd; 88 | border-radius: 2.5px; 89 | font-size: 14px; 90 | appearance: none; 91 | margin-bottom: $global-guttering; 92 | } 93 | 94 | h1, 95 | .h1 { 96 | font-size: $global-font-size-h1; 97 | } 98 | 99 | h2, 100 | .h2 { 101 | font-size: $global-font-size-h2; 102 | } 103 | 104 | h3, 105 | .h3 { 106 | font-size: $global-font-size-h3; 107 | } 108 | 109 | h4, 110 | .h4 { 111 | font-size: $global-font-size-h4; 112 | } 113 | 114 | h5, 115 | .h5 { 116 | font-size: $global-font-size-h5; 117 | } 118 | 119 | h6, 120 | .h6 { 121 | font-size: $global-font-size-h6; 122 | } 123 | 124 | label + p { 125 | margin-top: -4px; 126 | } 127 | 128 | .container { 129 | display: block; 130 | margin: auto; 131 | max-width: 40em; 132 | padding: $global-guttering * 2; 133 | 134 | @media (max-width: 620px) { 135 | padding: 0; 136 | } 137 | } 138 | 139 | .section { 140 | background-color: #fff; 141 | padding: $global-guttering; 142 | color: #333; 143 | 144 | a, 145 | a:visited, 146 | a:focus { 147 | color: #005F75; 148 | } 149 | } 150 | 151 | .logo { 152 | display: block; 153 | margin-bottom: $global-guttering * 0.5; 154 | } 155 | 156 | .logo-img { 157 | width: 100%; 158 | height: auto; 159 | display: inline-block; 160 | max-width: 100%; 161 | vertical-align: top; 162 | padding: $global-guttering * 0.25 0; 163 | } 164 | 165 | .visible-ie { 166 | display: none; 167 | } 168 | 169 | .push-bottom { 170 | margin-bottom: $global-guttering; 171 | } 172 | 173 | .zero-bottom { 174 | margin-bottom: 0; 175 | } 176 | 177 | .zero-top { 178 | margin-top: 0; 179 | } 180 | 181 | .text-center { 182 | text-align: center; 183 | } 184 | 185 | [data-test-hook] { 186 | margin-bottom: $global-guttering; 187 | } 188 | 189 | /* ===== End of Section comment block ====== */ 190 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es6", 4 | "lib": ["es2017", "dom"], 5 | "target": "es5", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "resolveJsonModule": true, 9 | "esModuleInterop": true, 10 | "strict": false, 11 | "noImplicitAny": false, 12 | "allowJs": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "strictNullChecks": true, 16 | "newLine": "lf", 17 | "declaration": false, 18 | "declarationMap": false 19 | }, 20 | "include": ["."], 21 | "exclude": ["**/node_modules", "**/public"] 22 | } -------------------------------------------------------------------------------- /test-e2e/__screenshots__/chromium-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Choices-js/Choices/8c0c11e26f2d1446263c20f85e0c437c6e37f74e/test-e2e/__screenshots__/chromium-darwin.png -------------------------------------------------------------------------------- /test-e2e/__screenshots__/chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Choices-js/Choices/8c0c11e26f2d1446263c20f85e0c437c6e37f74e/test-e2e/__screenshots__/chromium-linux.png -------------------------------------------------------------------------------- /test-e2e/__screenshots__/chromium-win32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Choices-js/Choices/8c0c11e26f2d1446263c20f85e0c437c6e37f74e/test-e2e/__screenshots__/chromium-win32.png -------------------------------------------------------------------------------- /test-e2e/__screenshots__/firefox-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Choices-js/Choices/8c0c11e26f2d1446263c20f85e0c437c6e37f74e/test-e2e/__screenshots__/firefox-linux.png -------------------------------------------------------------------------------- /test-e2e/__screenshots__/webkit-darwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Choices-js/Choices/8c0c11e26f2d1446263c20f85e0c437c6e37f74e/test-e2e/__screenshots__/webkit-darwin.png -------------------------------------------------------------------------------- /test-e2e/__screenshots__/webkit-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Choices-js/Choices/8c0c11e26f2d1446263c20f85e0c437c6e37f74e/test-e2e/__screenshots__/webkit-linux.png -------------------------------------------------------------------------------- /test-e2e/bundle-test.ts: -------------------------------------------------------------------------------- 1 | import { test as base } from '@playwright/test'; 2 | 3 | export type BundleTest = { 4 | bundle: string | undefined; 5 | }; 6 | 7 | export const test = base.extend({ 8 | // Define an option and provide a default value. 9 | // We can later override it in the config. 10 | bundle: [undefined, { option: true }], 11 | }); 12 | -------------------------------------------------------------------------------- /test-e2e/merge.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | testDir: 'e2e', 3 | reporter: [['html', { open: 'never' }]], 4 | }; 5 | -------------------------------------------------------------------------------- /test-e2e/select-test-suit.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Locator, type Page } from '@playwright/test'; 2 | import { TestSuit } from './test-suit'; 3 | 4 | export class SelectTestSuit extends TestSuit { 5 | readonly wrappedSelect: Locator; 6 | 7 | readonly choices: Locator; 8 | 9 | readonly selectableChoices: Locator; 10 | 11 | readonly itemsWithPlaceholder: Locator; 12 | 13 | constructor(page: Page, choicesBundle: string | undefined, url: string, testId: string) { 14 | super(page, choicesBundle, url, testId); 15 | 16 | this.wrappedSelect = this.group.locator('select'); 17 | this.choices = this.dropdown.locator('.choices__item'); 18 | this.selectableChoices = this.dropdown.locator('.choices__item:not(.choices__placeholder):not(.choices__notice)'); 19 | this.itemsWithPlaceholder = this.itemList.locator('.choices__item'); 20 | } 21 | 22 | async startWithClick(): Promise { 23 | await this.start(); 24 | await this.selectByClick(); 25 | await this.expectVisibleDropdown(); 26 | } 27 | 28 | async delayData(): Promise<() => void> { 29 | let stopJsonWaiting = (): void => {}; 30 | const jsonWaiting = new Promise((f) => { 31 | stopJsonWaiting = f; 32 | }); 33 | 34 | await this.page.route('**/data.json', async (route) => { 35 | await jsonWaiting; 36 | 37 | const fakeData = [...new Array(10)].map((_, index) => ({ 38 | label: `Label ${index + 1}`, 39 | value: `Value ${index + 1}`, 40 | })); 41 | 42 | await route.fulfill({ 43 | status: 200, 44 | contentType: 'application/json', 45 | body: JSON.stringify(fakeData), 46 | }); 47 | }); 48 | 49 | return stopJsonWaiting; 50 | } 51 | 52 | async delayDisaabledData(): Promise<() => void> { 53 | let stopJsonWaiting = (): void => {}; 54 | const jsonWaiting = new Promise((f) => { 55 | stopJsonWaiting = f; 56 | }); 57 | 58 | await this.page.route('**/disabled-data.json', async (route) => { 59 | await jsonWaiting; 60 | 61 | const fakeData = [...new Array(10)].map((_, index) => ({ 62 | label: `Disabled Label ${index + 1}`, 63 | value: `Disabled Value ${index + 1}`, 64 | disabled: true, 65 | })); 66 | 67 | await route.fulfill({ 68 | status: 200, 69 | contentType: 'application/json', 70 | body: JSON.stringify(fakeData), 71 | }); 72 | }); 73 | 74 | return stopJsonWaiting; 75 | } 76 | 77 | getWrappedElement(): Locator { 78 | return this.wrappedSelect; 79 | } 80 | 81 | async expectChoiceCount(count: number): Promise { 82 | await expect(this.selectableChoices).toHaveCount(count); 83 | } 84 | 85 | getChoiceWithText(text: string): Locator { 86 | return this.selectableChoices.filter({ hasText: text }); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /test-e2e/tests/demo-page.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@playwright/test'; 2 | import { test } from '../bundle-test'; 3 | import { SelectTestSuit } from '../select-test-suit'; 4 | // import { mkdirSync } from 'fs'; 5 | // import path from 'path'; 6 | 7 | const { describe } = test; 8 | // describe.configure({ mode: 'serial', retries: 0 }); 9 | 10 | describe(`Choices`, () => { 11 | const testUrl = '/index.html'; 12 | describe(`slow`, () => { 13 | test.setTimeout(30000); 14 | const testId = 'custom-templates'; 15 | 16 | test('screenshot', async ({ page, bundle }) => { 17 | const suite = new SelectTestSuit(page, bundle, testUrl, testId); 18 | 19 | await page.routeFromHAR('./test-e2e/hars/discogs.har', { 20 | url: 'https://api.discogs.com/**', 21 | update: false, // https://playwright.dev/docs/mock#replaying-from-har 22 | }); 23 | 24 | await suite.startWithClick(); 25 | await suite.expectVisibleDropdown(); 26 | await suite.input.press('ArrowDown'); 27 | await suite.input.press('ArrowDown'); 28 | await suite.advanceClock(); 29 | 30 | await expect(page).toHaveScreenshot({ 31 | fullPage: true, 32 | maxDiffPixels: 200, 33 | timeout: 30000, 34 | }); 35 | }); 36 | }); 37 | 38 | describe(`functionality`, () => { 39 | test('reset form', async ({ page, bundle }) => { 40 | const testId = 'reset-simple'; 41 | const suite = new SelectTestSuit(page, bundle, testUrl, testId); 42 | await suite.startWithClick(); 43 | 44 | await suite.expectedItemCount(1); 45 | await expect(suite.items.first()).toHaveText('Option 2'); 46 | 47 | await suite.selectableChoices.first().click(); 48 | await suite.expectedItemCount(1); 49 | await expect(suite.items.first()).toHaveText('Option 1'); 50 | 51 | await page.getByTestId('reset-form').getByRole('button', { name: /reset/i }).click(); 52 | 53 | await suite.expectedItemCount(1); 54 | await expect(suite.items.first()).toHaveText('Option 2'); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /test-e2e/text-test-suit.ts: -------------------------------------------------------------------------------- 1 | import { type Locator, type Page } from '@playwright/test'; 2 | import { TestSuit } from './test-suit'; 3 | 4 | export class TextTestSuit extends TestSuit { 5 | readonly wrappedInput: Locator; 6 | 7 | constructor(page: Page, choicesBundle: string | undefined, url: string, testId: string) { 8 | super(page, choicesBundle, url, testId); 9 | 10 | this.wrappedInput = this.group.locator('input[hidden]'); 11 | } 12 | 13 | getWrappedElement(): Locator { 14 | return this.wrappedInput; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test-e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es6", 4 | "lib": ["es2017", "dom"], 5 | "target": "ES2020", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "resolveJsonModule": true, 9 | "esModuleInterop": true, 10 | "strict": false, 11 | "noImplicitAny": false, 12 | "allowJs": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "strictNullChecks": true, 16 | "newLine": "lf", 17 | "declaration": false, 18 | "declarationMap": false, 19 | "types": ["@types/node"], 20 | }, 21 | "include": [".", "../src"], 22 | "exclude": ["**/node_modules", "**/public"] 23 | } 24 | -------------------------------------------------------------------------------- /test/scripts/actions/choices.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as actions from '../../../src/scripts/actions/choices'; 3 | import { cloneObject } from '../../../src/scripts/lib/utils'; 4 | import { ChoiceFull } from '../../../src/scripts/interfaces/choice-full'; 5 | import { ActionType } from '../../../src'; 6 | import { stringToHtmlClass } from '../../../src/scripts/lib/choice-input'; 7 | 8 | describe('actions/choices', () => { 9 | const choice: ChoiceFull = { 10 | highlighted: false, 11 | value: 'test', 12 | label: 'test', 13 | id: 1, 14 | groupId: 1, 15 | score: 0, 16 | rank: 0, 17 | disabled: false, 18 | elementId: '1', 19 | labelClass: stringToHtmlClass('test foo--bar'), 20 | labelDescription: 'test', 21 | customProperties: { 22 | test: true, 23 | }, 24 | placeholder: true, 25 | active: false, 26 | selected: false, 27 | }; 28 | 29 | describe('addChoice action', () => { 30 | it('returns ADD_CHOICE action', () => { 31 | const expectedAction: actions.AddChoiceAction = { 32 | type: ActionType.ADD_CHOICE, 33 | choice, 34 | }; 35 | 36 | expect(actions.addChoice(cloneObject(choice))).to.deep.equal(expectedAction); 37 | }); 38 | }); 39 | 40 | describe('filterChoices action', () => { 41 | it('returns FILTER_CHOICES action', () => { 42 | const results = Array(10); 43 | const expectedAction: actions.FilterChoicesAction = { 44 | type: ActionType.FILTER_CHOICES, 45 | results, 46 | }; 47 | 48 | expect(actions.filterChoices(results)).to.deep.equal(expectedAction); 49 | }); 50 | }); 51 | 52 | describe('activateChoices action', () => { 53 | describe('not passing active parameter', () => { 54 | it('returns ACTIVATE_CHOICES action', () => { 55 | const expectedAction: actions.ActivateChoicesAction = { 56 | type: ActionType.ACTIVATE_CHOICES, 57 | active: true, 58 | }; 59 | 60 | expect(actions.activateChoices()).to.deep.equal(expectedAction); 61 | }); 62 | }); 63 | 64 | describe('passing active parameter', () => { 65 | it('returns ACTIVATE_CHOICES action', () => { 66 | const active = true; 67 | const expectedAction: actions.ActivateChoicesAction = { 68 | type: ActionType.ACTIVATE_CHOICES, 69 | active, 70 | }; 71 | 72 | expect(actions.activateChoices(active)).to.deep.equal(expectedAction); 73 | }); 74 | }); 75 | }); 76 | 77 | describe('removeChoice action', () => { 78 | it('returns REMOVE_CHOICE action', () => { 79 | const expectedAction: actions.RemoveChoiceAction = { 80 | type: ActionType.REMOVE_CHOICE, 81 | choice, 82 | }; 83 | 84 | expect(actions.removeChoice(cloneObject(choice))).to.deep.equal(expectedAction); 85 | }); 86 | }); 87 | 88 | describe('clearChoices action', () => { 89 | it('returns CLEAR_CHOICES action', () => { 90 | const expectedAction: actions.ClearChoicesAction = { 91 | type: ActionType.CLEAR_CHOICES, 92 | }; 93 | 94 | expect(actions.clearChoices()).to.deep.equal(expectedAction); 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /test/scripts/actions/groups.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as actions from '../../../src/scripts/actions/groups'; 3 | import { GroupFull } from '../../../src/scripts/interfaces/group-full'; 4 | import { cloneObject } from '../../../src/scripts/lib/utils'; 5 | import { ActionType } from '../../../src'; 6 | 7 | describe('actions/groups', () => { 8 | describe('addGroup action', () => { 9 | it('returns ADD_GROUP action', () => { 10 | const group: GroupFull = { 11 | label: 'test', 12 | id: 1, 13 | active: false, 14 | disabled: false, 15 | choices: [], 16 | }; 17 | 18 | const expectedAction: actions.AddGroupAction = { 19 | type: ActionType.ADD_GROUP, 20 | group: cloneObject(group), 21 | }; 22 | 23 | expect(actions.addGroup(group)).to.deep.equal(expectedAction); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/scripts/actions/items.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as actions from '../../../src/scripts/actions/items'; 3 | import { cloneObject } from '../../../src/scripts/lib/utils'; 4 | import { ChoiceFull } from '../../../src/scripts/interfaces/choice-full'; 5 | import { ActionType } from '../../../src'; 6 | 7 | describe('actions/items', () => { 8 | const item: ChoiceFull = { 9 | highlighted: false, 10 | active: false, 11 | disabled: false, 12 | selected: false, 13 | value: 'test', 14 | label: 'test', 15 | id: 1, 16 | groupId: 1, 17 | score: 0, 18 | rank: 0, 19 | customProperties: { test: true }, 20 | placeholder: true, 21 | }; 22 | 23 | describe('addItem action', () => { 24 | it('returns ADD_ITEM action', () => { 25 | const expectedAction: actions.AddItemAction = { 26 | type: ActionType.ADD_ITEM, 27 | item, 28 | }; 29 | 30 | expect(actions.addItem(cloneObject(item))).to.deep.equal(expectedAction); 31 | }); 32 | }); 33 | 34 | describe('removeItem action', () => { 35 | it('returns REMOVE_ITEM action', () => { 36 | const expectedAction: actions.RemoveItemAction = { 37 | type: ActionType.REMOVE_ITEM, 38 | item, 39 | }; 40 | 41 | expect(actions.removeItem(cloneObject(item))).to.deep.equal(expectedAction); 42 | }); 43 | }); 44 | 45 | describe('highlightItem action', () => { 46 | it('returns HIGHLIGHT_ITEM action', () => { 47 | const highlighted = true; 48 | const expectedAction: actions.HighlightItemAction = { 49 | type: ActionType.HIGHLIGHT_ITEM, 50 | item, 51 | highlighted, 52 | }; 53 | 54 | expect(actions.highlightItem(cloneObject(item), highlighted)).to.deep.equal(expectedAction); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /test/scripts/components/dropdown.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { DEFAULT_CLASSNAMES } from '../../../src'; 3 | import Dropdown from '../../../src/scripts/components/dropdown'; 4 | import { getClassNames } from '../../../src/scripts/lib/utils'; 5 | 6 | describe('components/dropdown', () => { 7 | let instance: Dropdown | null; 8 | let choicesElement: HTMLDivElement; 9 | 10 | beforeEach(() => { 11 | choicesElement = document.createElement('div'); 12 | document.body.appendChild(choicesElement); 13 | instance = new Dropdown({ 14 | element: choicesElement, 15 | type: 'text', 16 | classNames: DEFAULT_CLASSNAMES, 17 | }); 18 | }); 19 | 20 | afterEach(() => { 21 | document.body.innerHTML = ''; 22 | instance = null; 23 | }); 24 | 25 | describe('constructor', () => { 26 | it('assigns choices element to instance', () => { 27 | expect(instance).to.not.be.null; 28 | if (!instance) { 29 | return; 30 | } 31 | expect(instance.element).to.equal(choicesElement); 32 | }); 33 | 34 | it('assigns classnames to instance', () => { 35 | expect(instance).to.not.be.null; 36 | if (!instance) { 37 | return; 38 | } 39 | expect(instance.classNames).to.deep.equal(DEFAULT_CLASSNAMES); 40 | }); 41 | }); 42 | 43 | describe('show', () => { 44 | let actualResponse; 45 | 46 | beforeEach(() => { 47 | expect(instance).to.not.be.null; 48 | if (!instance) { 49 | return; 50 | } 51 | actualResponse = instance.show(); 52 | }); 53 | 54 | afterEach(() => { 55 | expect(instance).to.not.be.null; 56 | if (!instance) { 57 | return; 58 | } 59 | instance.hide(); 60 | }); 61 | 62 | it('adds active class', () => { 63 | getClassNames(DEFAULT_CLASSNAMES.activeState).forEach((c) => { 64 | expect(instance).to.not.be.null; 65 | if (!instance) { 66 | return; 67 | } 68 | expect(instance.element.classList.contains(c)).to.equal(true); 69 | }); 70 | }); 71 | 72 | it('sets expanded attribute', () => { 73 | expect(instance).to.not.be.null; 74 | if (!instance) { 75 | return; 76 | } 77 | expect(instance.element.getAttribute('aria-expanded')).to.equal('true'); 78 | }); 79 | 80 | it('sets isActive instance flag', () => { 81 | expect(instance).to.not.be.null; 82 | if (!instance) { 83 | return; 84 | } 85 | expect(instance.isActive).to.equal(true); 86 | }); 87 | 88 | it('returns instance', () => { 89 | expect(actualResponse).to.deep.equal(instance); 90 | }); 91 | }); 92 | 93 | describe('hide', () => { 94 | let actualResponse; 95 | 96 | beforeEach(() => { 97 | expect(instance).to.not.be.null; 98 | if (!instance) { 99 | return; 100 | } 101 | actualResponse = instance.hide(); 102 | }); 103 | 104 | afterEach(() => { 105 | expect(instance).to.not.be.null; 106 | if (!instance) { 107 | return; 108 | } 109 | instance.show(); 110 | }); 111 | 112 | it('adds active class', () => { 113 | getClassNames(DEFAULT_CLASSNAMES.activeState).forEach((c) => { 114 | expect(instance).to.not.be.null; 115 | if (!instance) { 116 | return; 117 | } 118 | expect(instance.element.classList.contains(c)).to.equal(false); 119 | }); 120 | }); 121 | 122 | it('sets expanded attribute', () => { 123 | expect(instance).to.not.be.null; 124 | if (!instance) { 125 | return; 126 | } 127 | expect(instance.element.getAttribute('aria-expanded')).to.equal('false'); 128 | }); 129 | 130 | it('sets isActive instance flag', () => { 131 | expect(instance).to.not.be.null; 132 | if (!instance) { 133 | return; 134 | } 135 | expect(instance.isActive).to.equal(false); 136 | }); 137 | 138 | it('returns instance', () => { 139 | expect(actualResponse).to.deep.equal(instance); 140 | }); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /test/scripts/components/list.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import List from '../../../src/scripts/components/list'; 3 | 4 | describe('components/list', () => { 5 | let instance: List | null; 6 | let choicesElement: HTMLDivElement; 7 | 8 | beforeEach(() => { 9 | choicesElement = document.createElement('div'); 10 | instance = new List({ 11 | element: choicesElement, 12 | }); 13 | }); 14 | 15 | afterEach(() => { 16 | document.body.innerHTML = ''; 17 | instance = null; 18 | }); 19 | 20 | describe('constructor', () => { 21 | it('assigns choices element to class', () => { 22 | expect(instance).to.not.be.null; 23 | if (!instance) { 24 | return; 25 | } 26 | expect(instance.element).to.equal(choicesElement); 27 | }); 28 | 29 | it('sets the height of the element', () => { 30 | expect(instance).to.not.be.null; 31 | if (!instance) { 32 | return; 33 | } 34 | expect(instance.height).to.deep.equal(choicesElement.scrollTop); 35 | }); 36 | }); 37 | 38 | describe('scrollToTop', () => { 39 | it("sets the position's scroll position to 0", () => { 40 | expect(instance).to.not.be.null; 41 | if (!instance) { 42 | return; 43 | } 44 | instance.element.scrollTop = 10; 45 | instance.scrollToTop(); 46 | expect(instance.element.scrollTop).to.equal(0); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/scripts/components/wrapped-input.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { stub } from 'sinon'; 3 | import { DEFAULT_CLASSNAMES } from '../../../src'; 4 | import WrappedElement from '../../../src/scripts/components/wrapped-element'; 5 | import WrappedInput from '../../../src/scripts/components/wrapped-input'; 6 | 7 | describe('components/wrappedInput', () => { 8 | let instance: WrappedInput | null; 9 | let element: HTMLInputElement; 10 | 11 | beforeEach(() => { 12 | element = document.createElement('input'); 13 | instance = new WrappedInput({ 14 | element, 15 | classNames: DEFAULT_CLASSNAMES, 16 | }); 17 | }); 18 | 19 | afterEach(() => { 20 | document.body.innerHTML = ''; 21 | instance = null; 22 | }); 23 | 24 | describe('constructor', () => { 25 | it('assigns choices element to class', () => { 26 | expect(instance).to.not.be.null; 27 | if (!instance) { 28 | return; 29 | } 30 | expect(instance.element).to.equal(element); 31 | }); 32 | 33 | it('assigns classnames to class', () => { 34 | expect(instance).to.not.be.null; 35 | if (!instance) { 36 | return; 37 | } 38 | expect(instance.classNames).to.deep.equal(DEFAULT_CLASSNAMES); 39 | }); 40 | }); 41 | 42 | describe('inherited methods', () => { 43 | const methods: string[] = ['conceal', 'reveal', 'enable', 'disable']; 44 | 45 | methods.forEach((method) => { 46 | describe(method, () => { 47 | beforeEach(() => { 48 | stub(WrappedElement.prototype, method as keyof WrappedElement); 49 | }); 50 | 51 | afterEach(() => { 52 | WrappedElement.prototype[method].restore(); 53 | }); 54 | 55 | it(`calls super.${method}`, () => { 56 | expect(instance).to.not.be.null; 57 | if (!instance) { 58 | return; 59 | } 60 | expect(WrappedElement.prototype[method].called).to.equal(false); 61 | instance[method](); 62 | expect(WrappedElement.prototype[method].called).to.equal(true); 63 | }); 64 | }); 65 | }); 66 | }); 67 | 68 | describe('value setter', () => { 69 | it('sets the value of the input to the given value', () => { 70 | expect(instance).to.not.be.null; 71 | if (!instance) { 72 | return; 73 | } 74 | const newValue = 'Value 1, Value 2, Value 3'; 75 | expect(instance.element.value).to.equal(''); 76 | instance.value = newValue; 77 | expect(instance.value).to.equal(newValue); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /test/scripts/components/wrapped-select.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { stub, spy } from 'sinon'; 3 | import WrappedElement from '../../../src/scripts/components/wrapped-element'; 4 | import WrappedSelect from '../../../src/scripts/components/wrapped-select'; 5 | import Templates from '../../../src/scripts/templates'; 6 | import { DEFAULT_CLASSNAMES } from '../../../src'; 7 | 8 | describe('components/wrappedSelect', () => { 9 | let instance: WrappedSelect | null; 10 | let element: HTMLSelectElement; 11 | 12 | beforeEach(() => { 13 | element = document.createElement('select'); 14 | element.id = 'target'; 15 | for (let i = 0; i <= 4; i++) { 16 | const option = document.createElement('option'); 17 | 18 | if (i === 0) { 19 | option.value = ''; 20 | option.innerHTML = 'Placeholder label'; 21 | } else { 22 | option.value = `Value ${i}`; 23 | if (i % 2 === 0) { 24 | option.innerHTML = `Label ${i}`; 25 | } else { 26 | option.label = `Label ${i}`; 27 | } 28 | } 29 | 30 | if (i === 1) { 31 | option.setAttribute('placeholder', ''); 32 | } 33 | 34 | element.appendChild(option); 35 | } 36 | document.body.appendChild(element); 37 | 38 | instance = new WrappedSelect({ 39 | element: document.getElementById('target') as HTMLSelectElement, 40 | classNames: DEFAULT_CLASSNAMES, 41 | template: spy(Templates.option), 42 | extractPlaceholder: true, 43 | }); 44 | }); 45 | 46 | afterEach(() => { 47 | document.body.innerHTML = ''; 48 | instance = null; 49 | }); 50 | 51 | describe('constructor', () => { 52 | it('assigns choices element to class', () => { 53 | expect(instance).to.not.be.null; 54 | if (!instance) { 55 | return; 56 | } 57 | expect(instance.element).to.equal(element); 58 | }); 59 | 60 | it('assigns classnames to class', () => { 61 | expect(instance).to.not.be.null; 62 | if (!instance) { 63 | return; 64 | } 65 | expect(instance.classNames).to.deep.equal(DEFAULT_CLASSNAMES); 66 | }); 67 | }); 68 | 69 | describe('inherited methods', () => { 70 | const methods: string[] = ['conceal', 'reveal', 'enable', 'disable']; 71 | 72 | methods.forEach((method) => { 73 | beforeEach(() => { 74 | stub(WrappedElement.prototype, method as keyof WrappedElement); 75 | }); 76 | 77 | afterEach(() => { 78 | WrappedElement.prototype[method].restore(); 79 | }); 80 | 81 | describe(method, () => { 82 | it(`calls super.${method}`, () => { 83 | expect(instance).to.not.be.null; 84 | if (!instance) { 85 | return; 86 | } 87 | expect(WrappedElement.prototype[method].called).to.equal(false); 88 | instance[method](); 89 | expect(WrappedElement.prototype[method].called).to.equal(true); 90 | }); 91 | }); 92 | }); 93 | }); 94 | 95 | describe('placeholderOption getter', () => { 96 | it('returns option element with empty value attribute', () => { 97 | expect(instance).to.not.be.null; 98 | if (!instance) { 99 | return; 100 | } 101 | expect(instance.placeholderOption).to.be.instanceOf(HTMLOptionElement); 102 | if (instance.placeholderOption) { 103 | expect(instance.placeholderOption.value).to.equal(''); 104 | } 105 | }); 106 | 107 | it('returns option element with placeholder attribute as fallback', () => { 108 | expect(instance).to.not.be.null; 109 | if (!instance) { 110 | return; 111 | } 112 | expect(instance.element.firstChild).to.not.be.null; 113 | if (instance.element.firstChild) { 114 | instance.element.removeChild(instance.element.firstChild); 115 | } 116 | 117 | expect(instance.placeholderOption).to.be.instanceOf(HTMLOptionElement); 118 | if (instance.placeholderOption) { 119 | expect(instance.placeholderOption.value).to.equal('Value 1'); 120 | } 121 | }); 122 | }); 123 | 124 | describe('options getter', () => { 125 | it('returns all option elements', () => { 126 | expect(instance).to.not.be.null; 127 | if (!instance) { 128 | return; 129 | } 130 | const optionsAsChoices = instance.optionsAsChoices(); 131 | expect(optionsAsChoices).to.be.an('array'); 132 | }); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /test/scripts/reducers/groups.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import groups from '../../../src/scripts/reducers/groups'; 3 | import { cloneObject } from '../../../src/scripts/lib/utils'; 4 | import { GroupFull } from '../../../src/scripts/interfaces/group-full'; 5 | import { ActionType } from '../../../src'; 6 | import { StateUpdate } from '../../../src/scripts/interfaces/store'; 7 | 8 | describe('reducers/groups', () => { 9 | describe('when groups do not exist', () => { 10 | describe('ADD_GROUP', () => { 11 | it('adds group', () => { 12 | const group: GroupFull = { 13 | active: true, 14 | disabled: false, 15 | id: 1, 16 | label: 'Group one', 17 | choices: [], 18 | }; 19 | 20 | const expectedResponse: StateUpdate = { 21 | update: true, 22 | state: [group], 23 | }; 24 | 25 | const actualResponse = groups([], { 26 | type: ActionType.ADD_GROUP, 27 | group: cloneObject(group), 28 | }); 29 | 30 | expect(actualResponse).to.deep.equal(expectedResponse); 31 | }); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/scripts/reducers/items.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import items from '../../../src/scripts/reducers/items'; 3 | import { RemoveItemAction } from '../../../src/scripts/actions/items'; 4 | import { cloneObject } from '../../../src/scripts/lib/utils'; 5 | import { ChoiceFull } from '../../../src/scripts/interfaces/choice-full'; 6 | import { ActionType } from '../../../src'; 7 | import { StateUpdate } from '../../../src/scripts/interfaces/store'; 8 | 9 | describe('reducers/items', () => { 10 | describe('when items do not exist', () => { 11 | describe('ADD_ITEM', () => { 12 | const choice: ChoiceFull = { 13 | value: 'Item one', 14 | label: 'Item one', 15 | id: 1234, 16 | group: null, 17 | score: 0, 18 | rank: 0, 19 | customProperties: { 20 | property: 'value', 21 | }, 22 | placeholder: true, 23 | active: true, 24 | disabled: false, 25 | selected: true, 26 | highlighted: false, 27 | }; 28 | 29 | describe('passing expected values', () => { 30 | let actualResponse: ChoiceFull[]; 31 | 32 | beforeEach(() => { 33 | actualResponse = items([], { 34 | type: ActionType.ADD_ITEM, 35 | item: cloneObject(choice), 36 | }).state; 37 | }); 38 | 39 | it('adds item', () => { 40 | const expectedResponse = [choice]; 41 | 42 | expect(actualResponse).to.deep.equal(expectedResponse); 43 | }); 44 | 45 | it('unhighlights all highlighted items', () => { 46 | actualResponse.forEach((item) => { 47 | expect(item.highlighted).to.equal(false); 48 | }); 49 | }); 50 | }); 51 | 52 | describe('fallback values', () => { 53 | describe('passing no placeholder value', () => { 54 | const item = Object.assign(cloneObject(choice), { 55 | placeholder: false, 56 | }); 57 | it('adds item with placeholder set to false', () => { 58 | const expectedResponse: StateUpdate = { 59 | update: true, 60 | state: [item], 61 | }; 62 | 63 | const actualResponse = items([], { 64 | type: ActionType.ADD_ITEM, 65 | item: cloneObject(item), 66 | }); 67 | 68 | expect(actualResponse).to.deep.equal(expectedResponse); 69 | }); 70 | }); 71 | }); 72 | }); 73 | }); 74 | 75 | describe('when items exist', () => { 76 | let state: ChoiceFull[]; 77 | 78 | beforeEach(() => { 79 | state = [ 80 | { 81 | id: 1, 82 | group: null, 83 | score: 0, 84 | rank: 0, 85 | value: 'Item one', 86 | label: 'Item one', 87 | active: false, 88 | highlighted: false, 89 | customProperties: {}, 90 | placeholder: false, 91 | disabled: false, 92 | selected: false, 93 | }, 94 | { 95 | id: 2, 96 | group: null, 97 | score: 0, 98 | rank: 0, 99 | value: 'Item one', 100 | label: 'Item one', 101 | active: true, 102 | highlighted: false, 103 | customProperties: {}, 104 | placeholder: false, 105 | disabled: false, 106 | selected: false, 107 | }, 108 | ]; 109 | }); 110 | 111 | describe('REMOVE_ITEM', () => { 112 | it('sets an item to be inactive based on passed ID', () => { 113 | const expectedResponse: StateUpdate = { 114 | update: true, 115 | state: [ 116 | { 117 | ...state[0], 118 | }, 119 | ], 120 | }; 121 | 122 | const actualResponse = items(cloneObject(state), { 123 | type: ActionType.REMOVE_ITEM, 124 | item: cloneObject(state[1]), 125 | } as RemoveItemAction); 126 | 127 | expect(actualResponse).to.deep.equal(expectedResponse); 128 | }); 129 | }); 130 | 131 | describe('REMOVE_CHOICE', () => { 132 | it('the item is removed', () => { 133 | const choice = state[0]; 134 | const expectedResponse: StateUpdate = { 135 | update: true, 136 | state: state.filter((s) => s.id !== choice.id), 137 | }; 138 | 139 | const actualResponse = items(cloneObject(state), { 140 | type: ActionType.REMOVE_CHOICE, 141 | choice: cloneObject(choice), 142 | }); 143 | 144 | expect(actualResponse).to.deep.equal(expectedResponse); 145 | }); 146 | }); 147 | 148 | describe('HIGHLIGHT_ITEM', () => { 149 | it('sets an item to be inactive based on passed ID', () => { 150 | const expectedResponse: StateUpdate = { 151 | update: true, 152 | state: [ 153 | { 154 | ...state[0], 155 | }, 156 | { 157 | ...state[1], 158 | highlighted: true, 159 | }, 160 | ], 161 | }; 162 | 163 | const actualResponse = items(cloneObject(state), { 164 | type: ActionType.HIGHLIGHT_ITEM, 165 | item: cloneObject(state[1]), 166 | highlighted: true, 167 | }); 168 | 169 | expect(actualResponse).to.deep.equal(expectedResponse); 170 | }); 171 | }); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /test/scripts/search/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { beforeEach } from 'vitest'; 3 | import { DEFAULT_CONFIG } from '../../../src'; 4 | import { cloneObject } from '../../../src/scripts/lib/utils'; 5 | import { SearchByFuse } from '../../../src/scripts/search/fuse'; 6 | import { SearchByKMP } from '../../../src/scripts/search/kmp'; 7 | import { SearchByPrefixFilter } from '../../../src/scripts/search/prefix-filter'; 8 | 9 | export interface SearchableShape { 10 | label: string; 11 | value: string; 12 | } 13 | 14 | describe('search', () => { 15 | const options = DEFAULT_CONFIG; 16 | const haystack: SearchableShape[] = []; 17 | Array.from(Array(10).keys()).forEach((i) => 18 | haystack.push({ 19 | label: `label ${i}`, 20 | value: `value ${i}`, 21 | }), 22 | ); 23 | 24 | describe('fuse-full', () => { 25 | let searcher: SearchByFuse; 26 | 27 | beforeEach(() => { 28 | process.env.CHOICES_SEARCH_FUSE = 'full'; 29 | searcher = new SearchByFuse(options); 30 | searcher.index(haystack); 31 | }); 32 | it('empty result', () => { 33 | const results = searcher.search(''); 34 | expect(results.length).eq(0); 35 | }); 36 | it('label prefix', () => { 37 | const results = searcher.search('label'); 38 | expect(results.length).eq(haystack.length); 39 | }); 40 | it('label suffix', () => { 41 | const results = searcher.search(`${haystack.length - 1}`); 42 | expect(results.length).eq(1); 43 | }); 44 | 45 | it('search order', () => { 46 | const results = searcher.search('label'); 47 | 48 | expect(results.length).eq(haystack.length); 49 | haystack.forEach((value, index) => { 50 | expect(results[index].item.value).eq(value.value); 51 | }); 52 | }); 53 | 54 | it('search order - custom sortFn', () => { 55 | const opts = cloneObject(options); 56 | opts.fuseOptions.sortFn = (a, b) => { 57 | if (a.score === b.score) { 58 | return a.idx < b.idx ? 1 : -1; 59 | } 60 | 61 | return a.score < b.score ? 1 : -1; 62 | }; 63 | 64 | searcher = new SearchByFuse(opts); 65 | searcher.index(haystack); 66 | const results = searcher.search('label'); 67 | 68 | expect(results.length).eq(haystack.length); 69 | haystack.reverse().forEach((value, index) => { 70 | expect(results[index].item.value).eq(value.value); 71 | }); 72 | }); 73 | }); 74 | 75 | describe('fuse-basic', () => { 76 | let searcher: SearchByFuse; 77 | beforeEach(() => { 78 | process.env.CHOICES_SEARCH_FUSE = 'basic'; 79 | searcher = new SearchByFuse(options); 80 | searcher.index(haystack); 81 | }); 82 | it('empty result', () => { 83 | const results = searcher.search(''); 84 | expect(results.length).eq(0); 85 | }); 86 | it('label prefix', () => { 87 | const results = searcher.search('label'); 88 | expect(results.length).eq(haystack.length); 89 | }); 90 | it('label suffix', () => { 91 | const results = searcher.search(`${haystack.length - 1}`); 92 | expect(results.length).eq(1); 93 | }); 94 | it('search order', () => { 95 | const results = searcher.search('label'); 96 | 97 | expect(results.length).eq(haystack.length); 98 | haystack.forEach((value, index) => { 99 | expect(results[index].item.value).eq(value.value); 100 | }); 101 | }); 102 | }); 103 | 104 | describe('kmp', () => { 105 | let searcher: SearchByKMP; 106 | beforeEach(() => { 107 | process.env.CHOICES_SEARCH_KMP = '1'; 108 | searcher = new SearchByKMP(options); 109 | searcher.index(haystack); 110 | }); 111 | it('empty result', () => { 112 | const results = searcher.search(''); 113 | expect(results.length).eq(0); 114 | }); 115 | it('label prefix', () => { 116 | const results = searcher.search('label'); 117 | expect(results.length).eq(haystack.length); 118 | }); 119 | it('label suffix', () => { 120 | const results = searcher.search(`${haystack.length - 1}`); 121 | expect(results.length).eq(2); 122 | }); 123 | }); 124 | 125 | describe('prefix-filter', () => { 126 | let searcher: SearchByPrefixFilter; 127 | beforeEach(() => { 128 | process.env.CHOICES_SEARCH_FUSE = undefined; 129 | searcher = new SearchByPrefixFilter(options); 130 | searcher.index(haystack); 131 | }); 132 | it('empty result', () => { 133 | const results = searcher.search(''); 134 | expect(results.length).eq(0); 135 | }); 136 | it('label prefix', () => { 137 | const results = searcher.search('label'); 138 | expect(results.length).eq(haystack.length); 139 | }); 140 | it('label suffix', () => { 141 | const results = searcher.search(`${haystack.length - 1}`); 142 | expect(results.length).eq(0); 143 | }); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /test/setupFiles/window-matchMedia.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll } from 'vitest'; 2 | 3 | beforeAll(() => { 4 | Object.defineProperty(globalThis.window, 'matchMedia', { 5 | writable: true, 6 | value: vi.fn().mockImplementation((query) => ({ 7 | matches: false, 8 | media: query, 9 | onchange: null, 10 | addListener: vi.fn(), // deprecated 11 | removeListener: vi.fn(), // deprecated 12 | addEventListener: vi.fn(), 13 | removeEventListener: vi.fn(), 14 | dispatchEvent: vi.fn(), 15 | })), 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es6", 4 | "lib": ["es2017", "dom"], 5 | "target": "ES2020", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "resolveJsonModule": true, 9 | "esModuleInterop": true, 10 | "strict": false, 11 | "noImplicitAny": false, 12 | "allowJs": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "strictNullChecks": true, 16 | "newLine": "lf", 17 | "declaration": false, 18 | "declarationMap": false, 19 | "types": ["@types/node", "vitest/globals", "vitest/jsdom"], 20 | }, 21 | "include": [".", "../src"], 22 | "exclude": ["**/node_modules", "**/public"] 23 | } 24 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: 'jsdom', 7 | setupFiles: ['./test/setupFiles/window-matchMedia.ts'], 8 | include: ['test/**/*.{test,spec}.?(c|m)[jt]s?(x)'], 9 | }, 10 | esbuild: { 11 | target: 'es2017', 12 | }, 13 | }); 14 | --------------------------------------------------------------------------------