├── .gitignore ├── CODEOWNERS ├── test ├── .eslintrc.json └── test.js ├── .eslintrc.json ├── tsconfig.json ├── karma.config.cjs ├── .github └── workflows │ ├── publish.yml │ └── nodejs.yml ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── examples └── index.html ├── LICENSE ├── package.json ├── README.md └── src └── index.ts /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @github/primer-reviewers 2 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "globals": { 6 | "assert": true 7 | }, 8 | "extends": "../.eslintrc.json" 9 | } 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:github/recommended", 4 | "plugin:github/browser", 5 | "plugin:github/typescript" 6 | ], 7 | "overrides": [{"files": "**/*.js", "parser": "espree", "parserOptions": {"ecmaVersion": 8}}] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "es2017", 5 | "strict": true, 6 | "declaration": true, 7 | "outDir": "dist", 8 | "removeComments": true 9 | }, 10 | "files": [ 11 | "src/index.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /karma.config.cjs: -------------------------------------------------------------------------------- 1 | process.env.CHROME_BIN = require('chromium').path 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | frameworks: ['mocha', 'chai'], 6 | files: [ 7 | {pattern: 'dist/index.js', type: 'module'}, 8 | {pattern: 'test/test.js', type: 'module'} 9 | ], 10 | reporters: ['mocha'], 11 | port: 9876, 12 | colors: true, 13 | logLevel: config.LOG_INFO, 14 | browsers: ['ChromeHeadless'], 15 | autoWatch: false, 16 | singleRun: true, 17 | concurrency: Infinity 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | release: 7 | types: [created] 8 | 9 | jobs: 10 | publish-npm: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 22 17 | registry-url: https://registry.npmjs.org/ 18 | cache: npm 19 | - run: npm ci 20 | - run: npm test 21 | - run: npm version ${TAG_NAME} --git-tag-version=false 22 | env: 23 | TAG_NAME: ${{ github.event.release.tag_name }} 24 | - run: npm whoami; npm --ignore-scripts publish 25 | env: 26 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 27 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | permissions: 5 | contents: read 6 | jobs: 7 | build: 8 | runs-on: ${{ matrix.os }} 9 | 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | os: [ubuntu-22.04, windows-latest, macos-latest] 14 | 15 | steps: 16 | - name: Set git to not change EoL 17 | run: | 18 | git config --global core.autocrlf false 19 | - uses: actions/checkout@v4 20 | - name: Use Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 22.x 24 | - name: npm install, build, and test 25 | run: | 26 | npm install 27 | npm run build --if-present 28 | npm test 29 | env: 30 | CI: true 31 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster 4 | ARG VARIANT="16" 5 | FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT} 6 | 7 | # [Optional] Uncomment this section to install additional OS packages. 8 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | # && apt-get -y install --no-install-recommends 10 | 11 | # [Optional] Uncomment if you want to install an additional version of node using nvm 12 | # ARG EXTRA_NODE_VERSION=10 13 | # RUN su node -c "source/usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 14 | 15 | # [Optional] Uncomment if you want to install more global node modules 16 | # RUN su node -c "npm install -g " 17 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | check-all demo 6 | 7 | 8 |
9 |

Check-All Example

10 |
11 | Count: 0 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-2020 GitHub, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node 3 | { 4 | "name": "Node.js", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick a Node version: 16, 14, 12. 8 | // Append -bullseye or -buster to pin to an OS version. 9 | // Use -bullseye variants on local arm64/Apple Silicon. 10 | "args": { "VARIANT": "16" } 11 | }, 12 | 13 | // Set *default* container specific settings.json values on container create. 14 | "settings": {}, 15 | 16 | // Add the IDs of extensions you want installed when the container is created. 17 | "extensions": [ 18 | "dbaeumer.vscode-eslint" 19 | ], 20 | 21 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 22 | // "forwardPorts": [], 23 | 24 | // Use 'postCreateCommand' to run commands after the container is created. 25 | // "postCreateCommand": "yarn install", 26 | 27 | // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 28 | "remoteUser": "node", 29 | "features": { 30 | "git": "latest" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@github/check-all", 3 | "description": "Multiple checkbox selection helper.", 4 | "version": "0.4.0", 5 | "main": "dist/index.js", 6 | "module": "dist/index.js", 7 | "type": "module", 8 | "types": "dist/index.d.ts", 9 | "license": "MIT", 10 | "repository": "github/check-all", 11 | "files": [ 12 | "dist" 13 | ], 14 | "scripts": { 15 | "clean": "rm -rf dist", 16 | "lint": "eslint . --ext .js,.ts && tsc --noEmit", 17 | "prebuild": "npm run clean && npm run lint && mkdir dist", 18 | "build": "tsc", 19 | "pretest": "npm run build", 20 | "test": "karma start karma.config.cjs", 21 | "prepublishOnly": "npm run build", 22 | "postpublish": "npm publish --ignore-scripts --@github:registry='https://npm.pkg.github.com'" 23 | }, 24 | "prettier": "@github/prettier-config", 25 | "devDependencies": { 26 | "@github/prettier-config": "0.0.4", 27 | "chai": "^4.2.0", 28 | "chromium": "^3.0.3", 29 | "eslint": "^6.8.0", 30 | "eslint-plugin-github": "^4.0.0", 31 | "karma": "^6.3.16", 32 | "karma-chai": "^0.1.0", 33 | "karma-chrome-launcher": "^3.1.0", 34 | "karma-mocha": "^2.0.1", 35 | "karma-mocha-reporter": "^2.2.5", 36 | "mocha": "^10.2.0", 37 | "typescript": "^3.9.3" 38 | }, 39 | "eslintIgnore": [ 40 | "dist" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Check All 2 | 3 | - Check/uncheck `[data-check-all]` to check/uncheck all checkboxes in a container. 4 | - Shift click on `[data-check-all-item]` to select all checkboxes between the last checked checkbox and the target checkbox. 5 | - Auto-update `[data-check-all-count]` to count of checked items. 6 | 7 | ## Installation 8 | 9 | ``` 10 | $ npm install @github/check-all 11 | ``` 12 | 13 | ## Usage 14 | 15 | ### JS 16 | 17 | ```js 18 | import checkAll from '@github/check-all' 19 | checkAll(document.querySelector('[data-check-all-container]')) 20 | ``` 21 | 22 | Using a library like [selector-observer](https://github.com/josh/selector-observer), the behavior can automatically be applied to any container matching a selector. 23 | 24 | ```js 25 | import {observe} from 'selector-observer' 26 | import checkAll from '@github/check-all' 27 | 28 | observe('[data-check-all-container]', { subscribe: checkAll }) 29 | ``` 30 | 31 | ### HTML 32 | 33 | ```html 34 |
35 | Count: 0 36 | 37 | 38 | 39 | 40 | 41 |
42 | ``` 43 | 44 | ## Development 45 | 46 | ``` 47 | npm install 48 | npm test 49 | ``` 50 | 51 | ## License 52 | 53 | Distributed under the MIT license. See LICENSE for details. 54 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | import subscribe from '../dist/index.js' 2 | 3 | describe('check-all', function () { 4 | let subscription 5 | beforeEach(function () { 6 | document.body.innerHTML = ` 7 |
8 | Count: 0 9 | 10 | 11 | 12 | 13 | 14 |
15 | ` 16 | subscription = subscribe(document.querySelector('[data-check-all-container]')) 17 | }) 18 | 19 | afterEach(function () { 20 | subscription.unsubscribe() 21 | document.body.innerHTML = '' 22 | }) 23 | 24 | it('checks all', function () { 25 | const checkAll = document.querySelector('[data-check-all]') 26 | const count = document.querySelector('[data-check-all-count]') 27 | const firstItem = document.querySelector('[data-check-all-item]') 28 | checkAll.click() 29 | assert.equal(count.textContent, '4') 30 | assert.equal(document.querySelectorAll('[data-check-all-item]:checked').length, 4) 31 | checkAll.click() 32 | assert.equal(count.textContent, '0') 33 | assert.equal(document.querySelectorAll('[data-check-all-item]:checked').length, 0) 34 | assert.notOk(checkAll.indeterminate) 35 | firstItem.click() 36 | assert.ok(checkAll.indeterminate) 37 | assert.notOk(checkAll.checked) 38 | checkAll.checked = false 39 | checkAll.dispatchEvent(new Event('change', {bubbles: true})) 40 | assert.notOk(checkAll.indeterminate) 41 | }) 42 | 43 | it('checks range', function () { 44 | const checkAll = document.querySelector('[data-check-all]') 45 | const count = document.querySelector('[data-check-all-count]') 46 | const checkboxes = document.querySelectorAll('[data-check-all-item]') 47 | checkboxes[1].dispatchEvent(new MouseEvent('click')) 48 | assert.equal(count.textContent, '1') 49 | assert.equal(document.querySelectorAll('[data-check-all-item]:checked').length, 1) 50 | assert(checkAll.indeterminate) 51 | 52 | checkboxes[3].dispatchEvent(new MouseEvent('mousedown', {shiftKey: true, bubbles: true})) 53 | checkboxes[3].dispatchEvent(new MouseEvent('click', {shiftKey: true, bubbles: true})) 54 | assert.equal(count.textContent, '3') 55 | assert.notOk(checkboxes[0].checked) 56 | assert(checkboxes[1].checked) 57 | assert(checkboxes[2].checked) 58 | assert(checkboxes[3].checked) 59 | assert(checkAll.indeterminate) 60 | }) 61 | 62 | it('checks range with label click', function () { 63 | const checkAll = document.querySelector('[data-check-all]') 64 | const count = document.querySelector('[data-check-all-count]') 65 | const checkboxes = document.querySelectorAll('[data-check-all-item]') 66 | checkboxes[1].click() 67 | assert.equal(count.textContent, '1') 68 | assert.equal(document.querySelectorAll('[data-check-all-item]:checked').length, 1) 69 | assert(checkAll.indeterminate) 70 | 71 | const label = checkboxes[3].closest('label') 72 | label.dispatchEvent(new MouseEvent('mousedown', {shiftKey: true, bubbles: true})) 73 | label.dispatchEvent(new MouseEvent('click', {shiftKey: true, bubbles: true})) 74 | assert.equal(count.textContent, '3') 75 | assert.notOk(checkboxes[0].checked) 76 | assert(checkboxes[1].checked) 77 | assert(checkboxes[2].checked) 78 | assert(checkboxes[3].checked) 79 | assert(checkAll.indeterminate) 80 | }) 81 | 82 | it('checks all without disabled', function () { 83 | const checkAll = document.querySelector('[data-check-all]') 84 | const count = document.querySelector('[data-check-all-count]') 85 | const checkboxes = document.querySelectorAll('[data-check-all-item]') 86 | checkboxes[1].disabled = true 87 | checkboxes[2].disabled = true 88 | checkboxes[3].disabled = true 89 | checkAll.click() 90 | assert.equal(count.textContent, '1') 91 | assert.equal(document.querySelectorAll('[data-check-all-item]:checked').length, 1) 92 | assert.notOk(checkAll.indeterminate) 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | type Subscription = { 2 | unsubscribe: () => void 3 | } 4 | 5 | export default function subscribe(container: HTMLElement): Subscription { 6 | let shiftKey = false 7 | let lastCheckbox: HTMLInputElement | null = null 8 | 9 | container.addEventListener('mousedown', onMouseDown) 10 | container.addEventListener('change', onChange) 11 | 12 | function setChecked(target: Element, input: HTMLElement, checked: boolean, indeterminate = false): void { 13 | if (!(input instanceof HTMLInputElement) || input.disabled) return 14 | 15 | input.indeterminate = indeterminate 16 | if (input.checked !== checked) { 17 | input.checked = checked 18 | 19 | setTimeout(() => { 20 | const event = new CustomEvent('change', { 21 | bubbles: true, 22 | cancelable: false, 23 | detail: {relatedTarget: target} 24 | }) 25 | input.dispatchEvent(event) 26 | }) 27 | } 28 | } 29 | 30 | function onChange(event: Event): void { 31 | const target = event.target 32 | if (!(target instanceof Element)) return 33 | if (target.hasAttribute('data-check-all')) { 34 | onCheckAll(event) 35 | } else if (target.hasAttribute('data-check-all-item')) { 36 | onCheckAllItem(event) 37 | } 38 | } 39 | 40 | function onCheckAll(event: Event): void { 41 | if (event instanceof CustomEvent && event.detail) { 42 | const {relatedTarget} = event.detail 43 | if (relatedTarget && relatedTarget.hasAttribute('data-check-all-item')) { 44 | return 45 | } 46 | } 47 | const target = event.target 48 | if (!(target instanceof HTMLInputElement)) return 49 | lastCheckbox = null 50 | 51 | for (const input of container.querySelectorAll('[data-check-all-item]')) { 52 | setChecked(target, input, target.checked) 53 | } 54 | 55 | target.indeterminate = false 56 | updateCount() 57 | } 58 | 59 | function onMouseDown(event: MouseEvent): void { 60 | if (!(event.target instanceof Element)) return 61 | const target = event.target instanceof HTMLLabelElement ? event.target.control || event.target : event.target 62 | if (target.hasAttribute('data-check-all-item')) { 63 | shiftKey = event.shiftKey 64 | } 65 | } 66 | 67 | function onCheckAllItem(event: Event): void { 68 | if (event instanceof CustomEvent && event.detail) { 69 | const {relatedTarget} = event.detail 70 | if ( 71 | relatedTarget && 72 | (relatedTarget.hasAttribute('data-check-all') || relatedTarget.hasAttribute('data-check-all-item')) 73 | ) { 74 | return 75 | } 76 | } 77 | const target = event.target 78 | if (!(target instanceof HTMLInputElement)) return 79 | 80 | const itemCheckboxes = Array.from(container.querySelectorAll('[data-check-all-item]')) 81 | if (shiftKey && lastCheckbox) { 82 | const [start, end] = [itemCheckboxes.indexOf(lastCheckbox), itemCheckboxes.indexOf(target)].sort() 83 | for (const input of itemCheckboxes.slice(start, +end + 1 || 9e9)) { 84 | setChecked(target, input, target.checked) 85 | } 86 | } 87 | 88 | shiftKey = false 89 | lastCheckbox = target 90 | 91 | const allCheckbox = container.querySelector('[data-check-all]') 92 | if (allCheckbox) { 93 | const total = itemCheckboxes.length 94 | const count = itemCheckboxes.filter(checkbox => checkbox instanceof HTMLInputElement && checkbox.checked).length 95 | const checked = count === total 96 | const indeterminate = total > count && count > 0 97 | setChecked(target, allCheckbox, checked, indeterminate) 98 | } 99 | updateCount() 100 | } 101 | 102 | function updateCount() { 103 | // Update count of optional `[data-check-all-count]` element. 104 | const countContainer = container.querySelector('[data-check-all-count]') 105 | if (countContainer) { 106 | const count = container.querySelectorAll('[data-check-all-item]:checked').length 107 | countContainer.textContent = count.toString() 108 | } 109 | } 110 | 111 | return { 112 | unsubscribe: () => { 113 | container.removeEventListener('mousedown', onMouseDown) 114 | container.removeEventListener('change', onChange) 115 | } 116 | } 117 | } 118 | --------------------------------------------------------------------------------