├── .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 | [](https://badge.fury.io/js/a-simple-switch)
4 | [](https://github.com/ava-cassiopeia/simple-switch/actions/workflows/test.yml)
5 |
6 | Simple, accessible, performant implementation of the Switch UI element.
7 |
8 | 
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 |
--------------------------------------------------------------------------------