├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── LICENSE.md ├── gulpfile.js ├── karma.conf.js ├── package-lock.json ├── package.json ├── readme.md ├── scripts └── gh_package_swap.js ├── src ├── sass │ └── SimpleSwitch.scss └── typescript │ ├── index.ts │ ├── switch.ts │ └── tsconfig.json ├── test ├── karma │ └── simple_switch_test.js └── manualTest │ └── basic.html └── webpack.config.js /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | packages: write 13 | id-token: write 14 | 15 | steps: 16 | - name: Check out code 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: '20.x' 23 | 24 | - name: Install dependencies and build 25 | run: | 26 | npm ci 27 | npm run build 28 | npm run release 29 | 30 | - name: Publish package to GitHub and NPM 31 | uses: ava-cassiopeia/publish-to-npm-and-github@1.0.0 32 | with: 33 | npm-package-name: 'a-simple-switch' 34 | github-package-name: '@ava-cassiopeia/a-simple-switch' 35 | npm-token: ${{ secrets.NPM_TOKEN }} 36 | github-token: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build-and-test: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [16.x, 18.x, 20.x, 22.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - uses: browser-actions/setup-chrome@latest 21 | - run: chrome --version 22 | - name: Build and Test 23 | run: | 24 | npm install && 25 | npm run build && 26 | npm run test 27 | env: 28 | CI: true 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | release/ 4 | releases/ 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2025 Ava Mattie 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require("gulp"); 2 | const sass = require("gulp-sass")(require("node-sass")); 3 | const webpackStream = require("webpack-stream"); 4 | const zip = require("gulp-zip"); 5 | const clean = require("gulp-clean"); 6 | const ts = require("gulp-typescript"); 7 | const args = require("really-simple-args")(); 8 | 9 | const OUTPUT_DIR = "dist"; 10 | 11 | const JS_SRCS = ["src/javascript/*.js", "src/javascript/**/*.js"]; 12 | const TS_SRCS = ["src/typescript/*.ts", "src/typescript/**/*.ts"]; 13 | const CSS_SRCS = ["src/sass/*.scss", "src/sass/**/*.scss"]; 14 | 15 | /** 16 | * ============================================================================= 17 | * | DEV BUILDS 18 | * ============================================================================= 19 | */ 20 | 21 | function buildCSS() { 22 | return gulp.src(CSS_SRCS) 23 | .pipe(sass({ 24 | outputStyle: "compressed" 25 | })) 26 | .pipe(gulp.dest(`${OUTPUT_DIR}/css/`)); 27 | } 28 | 29 | function buildReleaseJS() { 30 | return gulp.src(TS_SRCS) 31 | .pipe(webpackStream({ 32 | mode: "production", 33 | entry: { 34 | SimpleSwitch: "./src/typescript/index.ts", 35 | }, 36 | module: { 37 | rules: [ 38 | { 39 | test: /\.tsx?$/, 40 | use: 'ts-loader', 41 | exclude: /node_modules/, 42 | }, 43 | ], 44 | }, 45 | resolve: { 46 | extensions: ['.ts', '.js'], 47 | }, 48 | output: { 49 | filename: '[name].min.js', 50 | library: 'SimpleSwitch', 51 | }, 52 | })) 53 | .pipe(gulp.dest(`${OUTPUT_DIR}/js/`)); 54 | } 55 | 56 | function buildJS() { 57 | return gulp.src(TS_SRCS) 58 | .pipe(ts({ 59 | target: "es6", 60 | module: "nodenext", 61 | moduleResolution: "nodenext", 62 | declaration: true, // generate .d.ts files 63 | })) 64 | .pipe(gulp.dest(`${OUTPUT_DIR}/commonjs/`)); 65 | } 66 | 67 | function watch() { 68 | gulp.watch(CSS_SRCS, buildCSS); 69 | gulp.watch(JS_SRCS, buildJS, buildReleaseJS); 70 | } 71 | 72 | /** 73 | * ============================================================================= 74 | * | RELEASES 75 | * ============================================================================= 76 | */ 77 | 78 | function buildRelease() { 79 | let version = ""; 80 | if(args.hasParameter("version")) { 81 | version = args.getParameter("version"); 82 | } else { 83 | version = require("./package.json").version; 84 | } 85 | 86 | const releaseName = `simple-switch_v${version}.zip`; 87 | 88 | return gulp.src(["release/*", "release/**/*"]) 89 | .pipe(zip(releaseName)) 90 | .pipe(gulp.dest("releases/")); 91 | } 92 | 93 | function prepareRelease() { 94 | return gulp.src(["dist/*", "dist/**/*"]) 95 | .pipe(gulp.dest("release/")); 96 | } 97 | 98 | function prepareReleaseSass() { 99 | return gulp.src(CSS_SRCS) 100 | .pipe(gulp.dest("dist/sass/")); 101 | } 102 | 103 | function cleanBuildArtifacts() { 104 | return gulp.src([ 105 | "dist/", 106 | "release/", 107 | "releases/" 108 | ]).pipe(clean()); 109 | } 110 | 111 | exports.default = gulp.parallel(buildCSS, buildJS, buildReleaseJS); 112 | exports.buildCSS = buildCSS; 113 | exports.buildJS = buildJS; 114 | exports.watch = watch; 115 | exports.buildRelease = gulp.series( 116 | gulp.parallel(buildCSS, buildJS, buildReleaseJS, prepareReleaseSass), 117 | prepareRelease, 118 | buildRelease, 119 | ); 120 | exports.clean = cleanBuildArtifacts; 121 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Sun Nov 20 2022 13:26:51 GMT-0700 (Mountain Standard Time) 3 | 4 | module.exports = function(config) { 5 | config.set({ 6 | 7 | // base path that will be used to resolve all patterns (eg. files, exclude) 8 | basePath: '', 9 | 10 | 11 | // frameworks to use 12 | // available frameworks: https://www.npmjs.com/search?q=keywords:karma-adapter 13 | frameworks: ['jasmine'], 14 | 15 | 16 | // list of files / patterns to load in the browser 17 | files: [ 18 | 'dist/js/*.js', 19 | 'test/karma/*.js' 20 | ], 21 | 22 | 23 | // list of files / patterns to exclude 24 | exclude: [ 25 | ], 26 | 27 | 28 | // preprocess matching files before serving them to the browser 29 | // available preprocessors: https://www.npmjs.com/search?q=keywords:karma-preprocessor 30 | preprocessors: { 31 | }, 32 | 33 | 34 | // test results reporter to use 35 | // possible values: 'dots', 'progress' 36 | // available reporters: https://www.npmjs.com/search?q=keywords:karma-reporter 37 | reporters: ['progress'], 38 | 39 | 40 | // web server port 41 | port: 9876, 42 | 43 | 44 | // enable / disable colors in the output (reporters and logs) 45 | colors: true, 46 | 47 | 48 | // level of logging 49 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 50 | logLevel: config.LOG_INFO, 51 | 52 | 53 | // enable / disable watching file and executing tests whenever any file changes 54 | autoWatch: false, 55 | 56 | 57 | // start these browsers 58 | // available browser launchers: https://www.npmjs.com/search?q=keywords:karma-launcher 59 | browsers: ['ChromeHeadless'], 60 | 61 | 62 | // Continuous Integration mode 63 | // if true, Karma captures browsers, runs the tests and exits 64 | singleRun: false, 65 | 66 | // Concurrency level 67 | // how many browser instances should be started simultaneously 68 | concurrency: Infinity 69 | }) 70 | } 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "a-simple-switch", 3 | "version": "0.9.13", 4 | "description": "Vanilla JS/CSS Switch UI element", 5 | "main": "release/commonjs/index.js", 6 | "files": [ 7 | "release", 8 | "readme.md" 9 | ], 10 | "scripts": { 11 | "test": "karma start --single-run", 12 | "manualTest": "http-server", 13 | "build": "gulp", 14 | "clean": "gulp clean", 15 | "release": "gulp buildRelease", 16 | "gh-release-package-swap": "node scripts/gh_package_swap.js" 17 | }, 18 | "author": "Ava Mattie", 19 | "license": "ISC", 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/ava-cassiopeia/simple-switch.git" 23 | }, 24 | "keywords": [ 25 | "vanilla js", 26 | "switch", 27 | "checkbox", 28 | "ui", 29 | "material design", 30 | "material", 31 | "vanilla" 32 | ], 33 | "devDependencies": { 34 | "@babel/core": "^7.26.0", 35 | "@babel/preset-env": "^7.26.0", 36 | "babel-loader": "^8.4.1", 37 | "babel-preset-env": "^1.7.0", 38 | "gulp": "^4.0.2", 39 | "gulp-clean": "^0.4.0", 40 | "gulp-sass": "^5.1.0", 41 | "gulp-typescript": "^6.0.0-alpha.1", 42 | "gulp-uglify": "^3.0.2", 43 | "gulp-webpack": "^1.5.0", 44 | "gulp-zip": "^5.1.0", 45 | "http-server": "^14.1.1", 46 | "jasmine-core": "^4.6.1", 47 | "karma": "^6.4.4", 48 | "karma-chrome-launcher": "^3.2.0", 49 | "karma-jasmine": "^5.1.0", 50 | "node-sass": "^7.0.3", 51 | "really-simple-args": "^1.2.1", 52 | "ts-loader": "^9.5.1", 53 | "typescript": "^4.9.5", 54 | "webpack": "^5.97.1", 55 | "webpack-stream": "^7.0.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Simple Switch 2 | 3 | [![npm version](https://badge.fury.io/js/a-simple-switch.svg)](https://badge.fury.io/js/a-simple-switch) 4 | [![Build](https://github.com/ava-cassiopeia/simple-switch/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/ava-cassiopeia/simple-switch/actions/workflows/test.yml) 5 | 6 | Simple, accessible, performant implementation of the Switch UI element. 7 | 8 | ![Demo gif of switch in both material and normal mode](https://user-images.githubusercontent.com/6314286/27511703-357cf59c-58e8-11e7-81b7-cf87b1a0408a.gif)
9 | *Above shows both the normal and 'material' mode available for the switch* 10 | 11 | **Features:** 12 | 13 | - Vanilla JS/CSS: doesn't require any outside library to function 14 | - Accessible: works properly with screenreaders and the ARIA spec 15 | - Performant: uses proper layering and transitioning to ensure high performance 16 | 17 | --- 18 | 19 | - [Installation](#installation) 20 | - [Installing the Javascript](#installing-the-javascript) 21 | - [Installing the CSS](#installing-the-css) 22 | - [Installing the SASS](#installing-the-sass) 23 | - [Creating a Simple Switch](#creating-a-simple-switch) 24 | - [Automatically Creating Switches](#automatically-creating-switches) 25 | - [Manually Creating Switches](#manually-creating-switches) 26 | - [SASS Variables](#sass-variables) 27 | - [Controlling the switch via JavaScript](#controlling-the-switch-via-javascript) 28 | - [Contributing](#contributing) 29 | 30 | ## Installation 31 | 32 | To install the Switch code, you will need to in some way include the Simple 33 | Switch Javascript and CSS into your page/build. Please follow the relevant 34 | instructions below for more information. 35 | 36 | To get the latest minified/production ready files, please see the 37 | [releases page](https://github.com/aeolingamenfel/simple-switch/releases). 38 | 39 | ### Installing the Javascript 40 | 41 | The Javascript is available as a NPM package, buildable through Webpack, or as 42 | a minified/uglified file that can be directly imported into the page. 43 | 44 | The minified Javascript file is available on the 45 | [releases page](https://github.com/aeolingamenfel/simple-switch/releases), 46 | within the release `.zip` file, under `js/`. 47 | 48 | --- 49 | 50 | Alternatively, the Webpack package can be installed by running: 51 | 52 | ``` 53 | npm i --save a-simple-switch 54 | ``` 55 | 56 | And then importing it into your webpack build by saying: 57 | 58 | ```Javascript 59 | import * as SimpleSwitch from "a-simple-switch"; 60 | ``` 61 | 62 | ### Installing the CSS 63 | 64 | You can install the CSS by downloading the compiled CSS file from the 65 | [releases page](https://github.com/aeolingamenfel/simple-switch/releases), 66 | under `css/`. 67 | 68 | ### Installing the SASS 69 | 70 | You can import the relevant SASS file into your SASS build by either downloading 71 | the latest release from the 72 | [releases page](https://github.com/aeolingamenfel/simple-switch/releases) and 73 | grabbing the SASS file from the `sass/` directory in the release, *or* you may 74 | directly import it from the source code: 75 | 76 | ```SASS 77 | @import "path/to/SimpleSwitch/src/sass/SimpleSwitch.scss"; 78 | ``` 79 | 80 | ## Creating a Simple Switch 81 | 82 | There are two ways to create a Simple Switch. On page load, the Simple Switch 83 | code will automatically detect checkboxes that are flagged as switches, and 84 | upgrade them, *or* you may manually instantiate a switch. See below for more 85 | details. 86 | 87 | ### Automatically Creating Switches 88 | 89 | To have a switch be automatically upgraded, simply add the `data-type` attribute 90 | to any checkbox-type input that you want upgraded, and set that attribute to the 91 | value of `simple-switch`. Then, at the end of your `` tag, simply call 92 | `SimpleSwitch.init()` to initialize all of the switches marked as noted above. 93 | 94 | In addition, the Switch has an additional "material" mode, which can be toggled 95 | per switch using the `data-material` attribute. 96 | 97 | *Example:* 98 | 99 | Standard Switch: 100 | 101 | ```HTML 102 | 103 | ``` 104 | 105 | Material Switch: 106 | 107 | ```HTML 108 | 109 | ``` 110 | 111 | Javascript Setup: 112 | 113 | ```HTML 114 | 115 | 118 | 119 | ``` 120 | 121 | ### Parameters 122 | 123 | There are a few different parameters that can be provided to configure SimpleSwitch: 124 | 125 | **Parameters:** 126 | 127 | | Name | Index | Value | Default Value | Required? | Description | 128 | | ---- | ----- | ----- | ------------- | --------- | ----------- | 129 | | Element | `element` | HTMLElement | `null` | Yes* | This is the checkbox HTMLElement that will be upgraded to a Switch. Required if the `selector` parameter is not set | 130 | | Selector | `selector` | String | `null` | Yes* | This is the CSS selector that specifies the checkbox HTMLElement that will be upgraded to a Switch. Required if the `element` parameter is not set | 131 | | Material Style | `material` | Boolean | `false` | No | If set, will set the Switch to have an alternative style that matches the [Material.io spec](https://material.io/guidelines/components/selection-controls.html#selection-controls-switch) for Switches | 132 | | Update Size from Font | `matchSizeToFont` | Boolean | `false` | No | If set, will cause the Switch to attempt to match its size to the font size of the containing element | 133 | 134 | ### Manually Creating Switches 135 | 136 | You may also manually instantiate a switch, which may be useful for 137 | lazily-loaded UI elements or parts of the page. The `Switch` class which handles 138 | upgrading and controlling Switches is available under the `SimpleSwitch` 139 | namespace. 140 | 141 | *Example:* 142 | 143 | HTML: 144 | 145 | ```HTML 146 | 147 | ``` 148 | 149 | Javascript: 150 | 151 | ```Javascript 152 | let myCheckbox = document.getElementById("my-checkbox"); 153 | 154 | new SimpleSwitch.Switch({ 155 | element: myCheckbox, 156 | material: true 157 | }); 158 | ``` 159 | 160 | ## SASS Variables 161 | 162 | If you have chosen to include the SASS version of the styles for the Switch 163 | into your project, there are a number of variables available to you to override 164 | to customize the look and feel of the Switch. See more information about these 165 | below. 166 | 167 | | Name | Value | Default Value | Description | 168 | | ---- | ----- | ------------- | ----------- | 169 | | `$simple-switch_color` | Color | `#f44336` | Determines the color of the Switch, which isn't visible until the user has checked/switched "on" the switch | 170 | | `$simple-switch_focus-color` | Color | `#03A9F4` | Determines the color that the outline around the Switch will be, where the outline only appears when the Switch gains focus | 171 | | `$simple-switch_focus-ring-size` | Size Unit (px) | `7px` | On the Material version of the Switch, determines how much larger the radius of the focus ring is than the handle of the Switch | 172 | | `$simple-switch_handle-color` | Color | `#fff` | Determines the color of the Switch's handle | 173 | | `$simple-switch_outline-size` | Size Unit (px) | `3px` | Determines how thick the outline around the Switch's track is, both for the focus ring and the padding around the actual handle of the Switch | 174 | | `$simple-switch_size` | Size Unit (px) | `12px` | By default, the Switch matches its size to the inherited `font-size` of the Switch, so that it can match any label/text next to it in terms of size. However, on older browsers that don't support CSS Variables, this is the fallback that the CSS goes to | 175 | | `$simple-switch_switch-speed` | Timing Unit | `250ms` | The amount of time it takes the Switch animation to finish moving between the "on" and "off" state | 176 | | `$simple-switch_tray-color` | Color | `#ccc` | The color of the tray of the Switch | 177 | 178 | ## Controlling the switch via JavaScript 179 | 180 | The switch can be toggled via JavaScript: 181 | 182 | ```js 183 | SimpleSwitch.toggle(checkboxElement); 184 | ``` 185 | 186 | It can also be set specifically to on or off: 187 | 188 | ```js 189 | SimpleSwitch.toggle(checkboxElement, true); 190 | 191 | SimpleSwitch.toggle(checkboxElement, false); 192 | ``` 193 | 194 | ## Contributing 195 | 196 | Feel free to send pull requests, issues, feature requests, etc. There is no 197 | SLA for responses on this repo, but I trying to respond to issues and PRs in a 198 | timely manner. 199 | 200 | When sending PRs please follow the Google TypeScript style guide 201 | ([style guide link](https://google.github.io/styleguide/tsguide.html)) and try 202 | to add test coverage where possible. 203 | 204 | Most of the codebase and NPM commands (like `npm test`) should work 205 | out-of-the-box, but you will need Chrome installed to run headless tests. 206 | -------------------------------------------------------------------------------- /scripts/gh_package_swap.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Script which swaps the name of this package for release on 3 | * GitHub. 4 | */ 5 | 6 | const fs = require("fs"); 7 | const path = require("path"); 8 | 9 | const packagePath = path.join(__dirname, "..", "package.json"); 10 | const package = JSON.parse(fs.readFileSync(packagePath, {encoding: "utf-8"})); 11 | 12 | const newPackage = {...package}; 13 | newPackage.name = `@ava-cassiopeia/${package.name}`; 14 | 15 | const newPackageStr = JSON.stringify(newPackage, /* replacer= */ null, 2); 16 | fs.writeFileSync(packagePath, newPackageStr, {encoding: "utf-8"}); 17 | 18 | console.log(`Updated package name to "${newPackage.name}"!`); 19 | -------------------------------------------------------------------------------- /src/sass/SimpleSwitch.scss: -------------------------------------------------------------------------------- 1 | $simple-switch_color: #f44336 !default; 2 | $simple-switch_focus-color: #03A9F4 !default; 3 | $simple-switch_focus-ring-size: 7px !default; 4 | $simple-switch_handle-color: #fff !default; 5 | $simple-switch_outline-size: 3px !default; 6 | // this is just a default value/fallback for older browsers 7 | $simple-switch_size: 18px !default; 8 | $simple-switch_switch-speed: 250ms !default; 9 | $simple-switch_tray-color: #ccc !default; 10 | $simple-switch-disable-color: color-mix(in srgb, $simple-switch_color 70%, white) !default; 11 | 12 | 13 | // Hide the checkbox, but keep it in the DOM so it is visible to screen readers 14 | ._simple-switch-checkbox { 15 | height: 0px; 16 | width: 0px; 17 | overflow: hidden; 18 | opacity: 0; 19 | } 20 | 21 | ._simple-switch-track { 22 | --simple-switch_size: #{$simple-switch_size}; 23 | 24 | font-size: inherit; 25 | display: inline-block; 26 | position: relative; 27 | vertical-align: baseline; 28 | background: $simple-switch_tray-color; 29 | border-radius: $simple-switch_size; 30 | padding: 0 calc((#{$simple-switch_size} * 1.25) - #{$simple-switch_outline-size * 2}) 0 0; 31 | border: #{$simple-switch_outline-size} solid $simple-switch_tray-color; 32 | transition: background $simple-switch_switch-speed ease-out, 33 | border $simple-switch_switch-speed ease-out; 34 | outline: none; 35 | box-sizing: padding-box; 36 | 37 | @supports(--foobar: false) { 38 | border-radius: var(--simple-switch_size); 39 | padding: 0 calc((var(--simple-switch_size) * 1.25) - #{$simple-switch_outline-size * 2}) 0 0; 40 | } 41 | 42 | &.on { 43 | background: $simple-switch_color; 44 | border: #{$simple-switch_outline-size} solid $simple-switch_color; 45 | 46 | .handle { 47 | transform: translateX(calc((#{$simple-switch_size} * 1.25) - #{$simple-switch_outline-size * 2})); 48 | 49 | @supports(--foobar: false) { 50 | transform: translateX(calc((var(--simple-switch_size) * 1.25) - #{$simple-switch_outline-size * 2})); 51 | } 52 | } 53 | } 54 | 55 | &.focus { 56 | border: #{$simple-switch_outline-size} solid $simple-switch_focus-color; 57 | } 58 | 59 | .handle { 60 | position: relative; 61 | width: calc(#{$simple-switch_size - ($simple-switch_outline-size * 2)}); 62 | height: calc(#{$simple-switch_size - ($simple-switch_outline-size * 2)}); 63 | border-radius: $simple-switch_size; 64 | background: $simple-switch_handle-color; 65 | display: block; 66 | transition: transform $simple-switch_switch-speed ease-out; 67 | will-change: transition; 68 | z-index: 2; 69 | 70 | @supports(--foobar: false) { 71 | width: calc(var(--simple-switch_size) - #{$simple-switch_outline-size * 2}); 72 | height: calc(var(--simple-switch_size) - #{$simple-switch_outline-size * 2}); 73 | border-radius: var(--simple-switch_size); 74 | } 75 | } 76 | 77 | &._simple-switch_disabled { 78 | background-color: $simple-switch_disable-color; 79 | border-color: $simple-switch_disable-color; 80 | cursor: default; 81 | } 82 | 83 | // Material mode (makes the Switch match the material.io spec) 84 | &._material { 85 | padding: 0; 86 | margin: $simple-switch_outline-size 0; 87 | height: #{$simple-switch_size - $simple-switch_outline-size}; 88 | width: #{$simple-switch_size * 1.5}; 89 | border: none; 90 | vertical-align: top; 91 | 92 | @supports(--foobar: false) { 93 | height: calc(var(--simple-switch_size) - #{$simple-switch_outline-size}); 94 | width: calc(var(--simple-switch_size) * 1.5); 95 | } 96 | 97 | &.on { 98 | background: $simple-switch_color; 99 | 100 | &:after { 101 | transform: translateX(#{$simple-switch_size - $simple-switch_outline-size}); 102 | 103 | @supports(--foobar: false) { 104 | transform: translateX(calc(var(--simple-switch_size) - #{$simple-switch_outline-size})); 105 | } 106 | } 107 | 108 | .handle { 109 | background: color-mix(in srgb, $simple-switch_color 85%, black); 110 | transform: translateX(#{$simple-switch_size - $simple-switch_outline-size}); 111 | 112 | @supports(--foobar: false) { 113 | transform: translateX(calc(var(--simple-switch_size) - #{$simple-switch_outline-size})); 114 | } 115 | } 116 | } 117 | 118 | // this represents the focus state ring 119 | &:after { 120 | $_extra: $simple-switch_focus-ring-size; 121 | 122 | content: ""; 123 | position: absolute; 124 | top: #{(-0.75 * $simple-switch_outline-size) - $_extra}; 125 | left: #{(-1 * $simple-switch_outline-size) - $_extra}; 126 | width: ($simple-switch_size + ($_extra * 2)); 127 | height: ($simple-switch_size + ($_extra * 2)); 128 | z-index: 1; 129 | background: rgba(0, 0, 0, 0.125); 130 | border-radius: ($simple-switch_size + ($_extra * 2)); 131 | opacity: 0; 132 | will-change: opacity; 133 | transition: opacity $simple-switch_switch-speed ease-out, 134 | transform $simple-switch_switch-speed ease-out; 135 | 136 | @supports(--foobar: false) { 137 | width: calc(var(--simple-switch_size) + #{$_extra * 2}); 138 | height: calc(var(--simple-switch_size) + #{$_extra * 2}); 139 | border-radius: calc(var(--simple-switch_size) + #{$_extra * 2}); 140 | } 141 | } 142 | 143 | &.focus:after { 144 | opacity: 1; 145 | } 146 | 147 | .handle { 148 | position: absolute; 149 | top: #{-0.75 * $simple-switch_outline-size}; 150 | left: #{-1 * $simple-switch_outline-size}; 151 | width: $simple-switch_size; 152 | height: $simple-switch_size; 153 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); 154 | transition: transform $simple-switch_switch-speed ease-out, 155 | background $simple-switch_switch-speed ease-out; 156 | 157 | @supports(--foobar: false) { 158 | width: var(--simple-switch_size); 159 | height: var(--simple-switch_size); 160 | } 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/typescript/index.ts: -------------------------------------------------------------------------------- 1 | import {Switch, SimpleSwitchConfig} from "./switch"; 2 | 3 | // Export the Switch class so that users of this code can create new Switches by 4 | // calling new SimpleSwitch.Switch() 5 | export {Switch}; 6 | 7 | /** 8 | * Finds all Switches within the existing HTML (at the time this is called) 9 | * and attempts to initialize them using the given options. 10 | */ 11 | export function init(options: SimpleSwitchConfig = {}) { 12 | const switches = document.querySelectorAll( 13 | "[data-type='simple-switch']:not(._simple-switch-checkbox)"); 14 | 15 | for (const _switch of switches) { 16 | new Switch({ 17 | element: _switch as HTMLInputElement, 18 | ...options, 19 | }); 20 | } 21 | } 22 | 23 | /** 24 | * Toggles the given switch element. 25 | */ 26 | export function toggle( 27 | element: HTMLInputElement, newState: boolean|undefined = undefined) { 28 | if (typeof newState === "undefined" || !!element.checked !== newState) { 29 | // @ts-ignore: This is set up in the Switch construction. 30 | const ref = element["_simple-switch-ref"] as Switch; 31 | ref.toggle(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/typescript/switch.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wrapper class that handles upgrading a standard HTMLElement checkbox into a 3 | * SimpleSwitch. 4 | */ 5 | export class Switch { 6 | 7 | static readonly CHECKED_CLASS_NAME = "on"; 8 | static readonly FOCUSED_CLASS_NAME = "focus"; 9 | static readonly DISABLED_CLASS_NAME = "_simple-switch_disabled"; 10 | 11 | checked: boolean; 12 | disabled: boolean = false; 13 | 14 | readonly element: HTMLInputElement; 15 | readonly isMaterial: boolean; 16 | readonly matchSizeToFont: boolean; 17 | 18 | private track?: HTMLButtonElement; 19 | private handle?: HTMLSpanElement; 20 | private observer?: MutationObserver; 21 | 22 | constructor(config: SimpleSwitchConfig) { 23 | this.element = config.element! || document.querySelector(config.selector!); 24 | this.isMaterial = 25 | typeof config.material !== 'undefined' ? config.material : false; 26 | this.checked = this.element.checked; 27 | this.matchSizeToFont = 28 | typeof config.matchSizeToFont !== 'undefined' 29 | ? config.matchSizeToFont : false; 30 | 31 | // Override from property 32 | if (this.element.dataset.material 33 | && this.element.dataset.material === "true") { 34 | this.isMaterial = true; 35 | } 36 | 37 | // Actually create the elements that make up the Switch 38 | this.setup(); 39 | // Create a back reference for accessing via JS 40 | // @ts-ignore: This is intentionally modifying the DOM representation here. 41 | this.element["_simple-switch-ref"] = this; 42 | } 43 | 44 | /** 45 | * Toggles the state of the Switch. Also takes care of making sure the 46 | * wrapped checkbox is also updated. 47 | */ 48 | toggle() { 49 | this.checked = this.track!.classList.toggle(Switch.CHECKED_CLASS_NAME); 50 | this.syncState(); 51 | } 52 | 53 | /** 54 | * Creates the elements that match up the Switch. 55 | */ 56 | private setup() { 57 | this.track = document.createElement("button"); 58 | this.handle = document.createElement("span"); 59 | 60 | this.element.classList.add("_simple-switch-checkbox"); 61 | this.track.classList.add("_simple-switch-track"); 62 | this.handle.classList.add("handle"); 63 | 64 | if (this.isMaterial) { 65 | this.track.classList.add("_material"); 66 | } 67 | 68 | if (this.checked) { 69 | this.track.classList.add(Switch.CHECKED_CLASS_NAME); 70 | } 71 | 72 | // Syncronize disabled state 73 | this.checkboxDisabled(!!this.element.disabled); 74 | 75 | // The track itself, despite being a button, shouldn't be tabbed to. 76 | // Instead, when the original checkbox gains focus, this code will 77 | // update the track. This is so that screenreaders still read the 78 | // Switch as a checkbox. 79 | this.track.setAttribute("tabindex", "-1"); 80 | 81 | this.bind(); 82 | 83 | this.track.appendChild(this.handle); 84 | this.element.insertAdjacentElement('afterend', this.track); 85 | 86 | this.updateSize(); 87 | } 88 | 89 | /** 90 | * Updates the size of the Switch to match the inherited font size. Only 91 | * works on browsers that support CSS variables. 92 | */ 93 | private updateSize() { 94 | if (!this.matchSizeToFont) return; 95 | 96 | const _style = window.getComputedStyle(this.track!); 97 | // @ts-ignore: font-size is known to exist 98 | const inheritedFontSize = _style['font-size']; 99 | 100 | this.track!.style.setProperty('--simple-switch_size', inheritedFontSize); 101 | } 102 | 103 | /** 104 | * Takes care of binding all relevant events from the checkbox so that the 105 | * Switch can update itself when those events happen. 106 | */ 107 | private bind() { 108 | this.track!.addEventListener( 109 | "click", this.handleTrackClick.bind(this), false); 110 | this.element.addEventListener( 111 | "focus", this.handleElementFocus.bind(this), false); 112 | this.element.addEventListener( 113 | "blur", this.handleElementBlur.bind(this), false); 114 | this.element.addEventListener( 115 | "click", this.handleElementClick.bind(this), false); 116 | 117 | // Bind changes to the attributes 118 | this.observer = new MutationObserver(this.handleMutation.bind(this)); 119 | this.observer.observe(this.element, { attributes: true }); 120 | } 121 | 122 | /** 123 | * Called automatically when the wrapped checkbox gains focus. 124 | */ 125 | private checkboxFocused(e: FocusEvent) { 126 | this.track!.classList.add(Switch.FOCUSED_CLASS_NAME); 127 | } 128 | 129 | /** 130 | * Called automatically when the wrapped checkbox loses focus. 131 | */ 132 | private checkboxBlurred(e: FocusEvent) { 133 | this.track!.classList.remove(Switch.FOCUSED_CLASS_NAME); 134 | } 135 | 136 | /** 137 | * Called automatically when the Switch track is clicked. 138 | */ 139 | private trackClicked(e: MouseEvent) { 140 | this.toggle(); 141 | } 142 | 143 | /** 144 | * Called automatically when the wrapped checkbox is clicked. 145 | */ 146 | private checkboxToggled(e: MouseEvent) { 147 | this.toggle(); 148 | } 149 | 150 | /** 151 | * Called automatically when the wrapped checkbox is disabled. 152 | */ 153 | private checkboxDisabled(disabled: boolean) { 154 | this.disabled = disabled; 155 | 156 | if (this.disabled) { 157 | this.track!.classList.add(Switch.DISABLED_CLASS_NAME); 158 | } else { 159 | this.track!.classList.remove(Switch.DISABLED_CLASS_NAME); 160 | } 161 | } 162 | 163 | /** 164 | * Manages syncing the state between the Switch and the wrapped checkbox. 165 | */ 166 | private syncState() { 167 | this.element.checked = this.checked; 168 | this.dispatchEvent(); 169 | } 170 | 171 | /** 172 | * Dispatches relevant events for the element changing, trying to emulate 173 | * standard elements as much as possible. 174 | */ 175 | private dispatchEvent() { 176 | // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/change_event 177 | const changeEvent = new Event("change"); 178 | this.element.dispatchEvent(changeEvent); 179 | // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event 180 | const inputEvent = new Event("input"); 181 | this.element.dispatchEvent(inputEvent); 182 | } 183 | 184 | private handleTrackClick(e: MouseEvent) { 185 | if (this.disabled) { 186 | e.preventDefault(); 187 | return; 188 | } 189 | this.trackClicked(e); 190 | } 191 | 192 | private handleElementFocus(e: FocusEvent) { 193 | this.checkboxFocused(e); 194 | } 195 | 196 | private handleElementBlur(e: FocusEvent) { 197 | this.checkboxBlurred(e); 198 | } 199 | 200 | private handleElementClick(e: MouseEvent) { 201 | if (this.disabled) { 202 | e.preventDefault(); 203 | return; 204 | } 205 | 206 | this.checkboxToggled(e); 207 | } 208 | 209 | private handleMutation(mutations: MutationRecord[]) { 210 | mutations.forEach((mutation) => { 211 | if (mutation.type !== "attributes") { 212 | return; 213 | } 214 | // Check the modified attributeName is "disabled" 215 | if (mutation.attributeName === "disabled") { 216 | // @ts-ignore: target is definitely an HTMLElement here, not a Node. 217 | const disabled = !!mutation.target.attributes["disabled"]; 218 | this.checkboxDisabled(disabled); 219 | } 220 | }); 221 | } 222 | 223 | } 224 | 225 | /** 226 | * Config passed through to the constructor of a Switch. 227 | */ 228 | export interface SimpleSwitchConfig { 229 | /** 230 | * The HTMLElement representing the checkbox to upgrade. Either this or the 231 | * selector field MUST be present. 232 | */ 233 | readonly element?: HTMLInputElement; 234 | /** 235 | * The CSS selector that specifies the checkbox to be upgraded. Either this or 236 | * the element field MUST be present. 237 | */ 238 | readonly selector?: string; 239 | /** 240 | * Optional. Defaults to false. If true, will render the new Switch in a 241 | * Material Design-inspired look. 242 | */ 243 | readonly material?: boolean; 244 | /** 245 | * Optional. Defaults to false. If true, will attempt to figure out the 246 | * implied font size for the Switch, and match its size to that font size. 247 | */ 248 | readonly matchSizeToFont?: boolean; 249 | } 250 | -------------------------------------------------------------------------------- /src/typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "noImplicitAny": true, 5 | "removeComments": true, 6 | "preserveConstEnums": true, 7 | "sourceMap": false 8 | }, 9 | "include": [ 10 | "**/*.ts" 11 | ], 12 | "files": [ 13 | "index.ts", 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /test/karma/simple_switch_test.js: -------------------------------------------------------------------------------- 1 | describe("SimpleSwitch", () => { 2 | afterEach(async () => { 3 | await cleanupTestingSwitches(); 4 | }); 5 | 6 | it("should be available in the browser", () => { 7 | expect(SimpleSwitch).toBeTruthy(); 8 | }); 9 | 10 | it("should have an init() function", () => { 11 | expect(SimpleSwitch.init).toBeTruthy(); 12 | expect(typeof SimpleSwitch.init).toEqual("function"); 13 | }); 14 | 15 | it("should make the Switch class available", () => { 16 | expect(SimpleSwitch.Switch).toBeTruthy(); 17 | }); 18 | 19 | it("should attach correctly", async () => { 20 | const {simpleSwitch} = await createSwitch(); 21 | expect(simpleSwitch).toBeTruthy(); 22 | }); 23 | 24 | describe(".Switch", () => { 25 | describe("instance", () => { 26 | it("should match initial element state", async () => { 27 | const {simpleSwitch, checkboxElement} = await createSwitch(); 28 | expect(simpleSwitch.checked).toEqual(checkboxElement.checked); 29 | expect(simpleSwitch.disabled).toEqual(checkboxElement.disabled); 30 | }); 31 | 32 | it("should add a backreference to itself on the element", async () => { 33 | const {simpleSwitch, checkboxElement} = await createSwitch(); 34 | expect(checkboxElement["_simple-switch-ref"]).toBe(simpleSwitch); 35 | }); 36 | 37 | it("should set the material state to false from attribute", async () => { 38 | const {simpleSwitch, checkboxElement} = await createSwitch(); 39 | expect(simpleSwitch.checked).toEqual(checkboxElement.checked); 40 | expect(simpleSwitch.disabled).toEqual(checkboxElement.disabled); 41 | expect(simpleSwitch.isMaterial).toBe(false); 42 | }); 43 | 44 | it("should set the material state to true from attribute", async () => { 45 | const {simpleSwitch, checkboxElement} = 46 | await createSwitch(/* isMaterial= */ true); 47 | expect(simpleSwitch.checked).toEqual(checkboxElement.checked); 48 | expect(simpleSwitch.disabled).toEqual(checkboxElement.disabled); 49 | expect(simpleSwitch.isMaterial).toBe(true); 50 | }); 51 | 52 | it("should update when clicked", async () => { 53 | const {simpleSwitch, checkboxElement} = await createSwitch(); 54 | 55 | expect(simpleSwitch.checked).toBe(false); 56 | expect(checkboxElement.checked).toBe(false); 57 | checkboxElement.click(); 58 | await waitForAnimationFrame(); 59 | expect(simpleSwitch.checked).toBe(true); 60 | expect(checkboxElement.checked).toBe(true); 61 | }); 62 | 63 | it("should update when disabled", async () => { 64 | const {simpleSwitch, checkboxElement} = await createSwitch(); 65 | 66 | expect(simpleSwitch.disabled).toBe(false); 67 | expect(checkboxElement.disabled).toBe(false); 68 | checkboxElement.disabled = true; 69 | await waitForAnimationFrame(); 70 | expect(simpleSwitch.disabled).toBe(true); 71 | expect(checkboxElement.disabled).toBe(true); 72 | }); 73 | }); 74 | 75 | describe(".toggle()", () => { 76 | it("should toggle the element state", async () => { 77 | const {simpleSwitch, checkboxElement} = await createSwitch(); 78 | expect(simpleSwitch.checked).toEqual(checkboxElement.checked); 79 | simpleSwitch.toggle(); 80 | expect(simpleSwitch.checked).toBe(true); 81 | expect(simpleSwitch.checked).toEqual(checkboxElement.checked); 82 | }); 83 | }); 84 | }); 85 | 86 | describe(".toggle()", () => { 87 | it("toggles the given Switch element", async () => { 88 | const {simpleSwitch, checkboxElement} = await createSwitch(); 89 | 90 | expect(simpleSwitch.checked).toBe(false); 91 | SimpleSwitch.toggle(checkboxElement); 92 | await waitForAnimationFrame(); 93 | expect(simpleSwitch.checked).toBe(true); 94 | SimpleSwitch.toggle(checkboxElement); 95 | await waitForAnimationFrame(); 96 | expect(simpleSwitch.checked).toBe(false); 97 | }); 98 | 99 | it("toggles the given Switch element to false", async () => { 100 | const {simpleSwitch, checkboxElement} = await createSwitch(); 101 | 102 | expect(simpleSwitch.checked).toBe(false); 103 | SimpleSwitch.toggle(checkboxElement, false); 104 | await waitForAnimationFrame(); 105 | expect(simpleSwitch.checked).toBe(false); 106 | }); 107 | 108 | it("toggles the given Switch element to true", async () => { 109 | const {simpleSwitch, checkboxElement} = await createSwitch(); 110 | 111 | expect(simpleSwitch.checked).toBe(false); 112 | SimpleSwitch.toggle(checkboxElement, true); 113 | await waitForAnimationFrame(); 114 | expect(simpleSwitch.checked).toBe(true); 115 | }); 116 | }); 117 | 118 | describe(".init()", () => { 119 | it("inits all simple switches on the page", async () => { 120 | // create inner element 121 | const checkboxElementProto = document.createElement("input"); 122 | checkboxElementProto.type = "checkbox"; 123 | checkboxElementProto.setAttribute("data-type", "simple-switch"); 124 | checkboxElementProto.classList.add("testing-checkbox"); 125 | document.body.appendChild(checkboxElementProto); 126 | await waitForAnimationFrame(); 127 | 128 | // verify inner element has not been init'd yet 129 | const checkboxElement = document.querySelector(".testing-checkbox"); 130 | expect(checkboxElement.classList.contains("_simple-switch-checkbox")) 131 | .toBe(false); 132 | expect(checkboxElement["_simple-switch-ref"]).toBeFalsy(); 133 | 134 | // actually initialize 135 | SimpleSwitch.init(); 136 | await waitForAnimationFrame(); 137 | 138 | // verify inner element is now init'd 139 | expect(checkboxElement.classList.contains("_simple-switch-checkbox")) 140 | .toBe(true); 141 | expect(checkboxElement["_simple-switch-ref"]).toBeTruthy(); 142 | }); 143 | }); 144 | }); 145 | 146 | async function createSwitch(isMaterial = false) { 147 | // First create the core element 148 | const checkboxElement = document.createElement("input"); 149 | checkboxElement.type = "checkbox"; 150 | checkboxElement.classList.add("testing-checkbox"); 151 | if (isMaterial) { 152 | checkboxElement.setAttribute("data-material", "true"); 153 | } 154 | 155 | document.body.appendChild(checkboxElement); 156 | await waitForAnimationFrame(); 157 | 158 | const simpleSwitch = new SimpleSwitch.Switch({ 159 | selector: ".testing-checkbox", 160 | }); 161 | 162 | return {checkboxElement, simpleSwitch}; 163 | } 164 | 165 | async function cleanupTestingSwitches() { 166 | const switches = document.querySelectorAll(".testing-checkbox"); 167 | 168 | for (const switchEl of switches) { 169 | switchEl.remove(); 170 | } 171 | 172 | await waitForAnimationFrame(); 173 | } 174 | 175 | function waitForAnimationFrame() { 176 | return new Promise((resolve) => { 177 | requestAnimationFrame(() => { 178 | resolve(); 179 | }); 180 | }); 181 | } 182 | -------------------------------------------------------------------------------- /test/manualTest/basic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Simple Switch 6 | 7 | 17 | 18 | 19 | 20 |
21 | 28 |
29 |
30 | 35 |
36 |
37 | 45 |
46 |
47 | 56 |
57 | 58 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | 3 | module.exports = { 4 | entry: './src/javascript/index.js', 5 | output: { 6 | path: path.resolve(__dirname, 'dist/js/'), 7 | filename: 'SimpleSwitch.min.js', 8 | library: 'SimpleSwitch' 9 | }, 10 | module: { 11 | loaders: [ 12 | { 13 | test: /\.js$/, 14 | exclude: /(node_modules)|(dist)/, 15 | loader: "babel-loader", 16 | options: { 17 | presets: ['@babel/preset-env'] 18 | } 19 | } 20 | ] 21 | } 22 | }; 23 | --------------------------------------------------------------------------------