Skip to content

Commit 95fd47f

Browse files
authored
feat: switch element (#2)
1 parent 6bc4cc6 commit 95fd47f

File tree

16 files changed

+2430
-35
lines changed

16 files changed

+2430
-35
lines changed

.eslintrc.cjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ module.exports = {
1313
env: { browser: true },
1414
overrides: [
1515
{
16-
files: ['src/**/*.spec.ts'],
16+
files: ['packages/**/*.test.ts'],
17+
globals: { describe: true, it: true, beforeEach: true },
1718
rules: {
1819
'@typescript-eslint/no-non-null-assertion': 'off',
1920
},

package.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,26 @@
1212
"lint": "eslint 'packages/**/*.ts'",
1313
"playground": "yarn workspace @abeidahmed/playground dev",
1414
"prettier": "prettier --check --log-level warn .",
15-
"prettier:fix": "prettier --write --log-level warn ."
15+
"prettier:fix": "prettier --write --log-level warn .",
16+
"test": "web-test-runner",
17+
"test:watch": "web-test-runner --watch"
1618
},
1719
"devDependencies": {
20+
"@ambiki/impulse": "^0.2.0",
21+
"@open-wc/testing": "^4.0.0",
1822
"@rollup/plugin-typescript": "^11.1.5",
23+
"@types/mocha": "^10.0.6",
1924
"@typescript-eslint/eslint-plugin": "^6.16.0",
2025
"@typescript-eslint/parser": "^6.16.0",
26+
"@web/dev-server-esbuild": "^1.0.1",
27+
"@web/test-runner": "^0.18.0",
2128
"concurrently": "^8.2.2",
2229
"eslint": "^8.56.0",
2330
"eslint-config-prettier": "^9.1.0",
2431
"eslint-plugin-prettier": "^5.1.2",
2532
"prettier": "^3.1.1",
2633
"rollup": "^4.9.1",
34+
"sinon": "^17.0.1",
2735
"typescript": "^5.3.3"
2836
}
2937
}

packages/core/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,9 @@
3030
"scripts": {
3131
"build": "rollup --config rollup.config.js",
3232
"build:watch": "rollup --config rollup.config.js --watch"
33-
}
33+
},
34+
"peerDependencies": {
35+
"@ambiki/impulse": "^0.2.0"
36+
},
37+
"devDependencies": {}
3438
}

packages/core/rollup.config.js

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,29 @@ import typescript from '@rollup/plugin-typescript';
55
*/
66
export default [
77
{
8-
input: 'src/index.ts',
8+
input: ['./src/index.ts'],
99
output: [
1010
{
11-
file: 'dist/index.umd.js',
12-
format: 'umd',
13-
name: 'TailwindCSSElements',
11+
dir: 'dist',
12+
entryFileNames: '[name].js',
13+
format: 'es',
14+
preserveModules: true,
15+
preserveModulesRoot: 'src',
1416
},
17+
],
18+
external: ['@ambiki/impulse'],
19+
plugins: [typescript({ tsconfig: './tsconfig.build.json' })],
20+
},
21+
{
22+
input: './src/index.ts',
23+
output: [
1524
{
16-
file: 'dist/index.js',
17-
format: 'es',
25+
name: 'TailwindCSSElements',
26+
file: 'dist/index.umd.js',
27+
format: 'umd',
28+
globals: {
29+
'@ambiki/impulse': 'Impulse',
30+
},
1831
},
1932
],
2033
plugins: [typescript({ tsconfig: './tsconfig.build.json' })],
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Switch
2+
3+
Switches are a pleasant interface for toggling a value between two states and offer the same semantics and keyboard
4+
navigation as native checkbox elements.
5+
6+
## Usage
7+
8+
```html
9+
<twc-switch>
10+
<button
11+
type="button"
12+
class="group relative inline-flex h-[38px] w-[74px] shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-white/75 bg-indigo-50 aria-checked:bg-indigo-600"
13+
data-target="twc-switch.trigger"
14+
>
15+
<span class="sr-only">Use setting</span>
16+
<span
17+
aria-hidden="true"
18+
class="pointer-events-none inline-block h-[34px] w-[34px] transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out translate-x-0 group-aria-checked:translate-x-9"
19+
></span>
20+
</button>
21+
</twc-switch>
22+
```
23+
24+
## Examples
25+
26+
### Default to the checked state
27+
28+
You can set the `checked` attribute on the element and it will be checked by default.
29+
30+
```html
31+
<twc-switch checked>
32+
<button type="button">...</button>
33+
</twc-switch>
34+
```
35+
36+
### Programatically toggling the checked state
37+
38+
```html
39+
<twc-switch>
40+
<button type="button">...</button>
41+
</twc-switch>
42+
```
43+
44+
```js
45+
const switch = document.querySelector('twc-switch');
46+
47+
// On
48+
switch.checked = true;
49+
50+
// Off
51+
switch.checked = false;
52+
```
53+
54+
## Styling different states
55+
56+
Each component exposes the `data-headlessui-state` attribute that you can use to conditionally apply the classes. You
57+
can use the [`@headlessui/tailwindcss`](https://github.com/tailwindlabs/headlessui/tree/main/packages/%40headlessui-tailwindcss)
58+
plugin to target this attribute.
59+
60+
## Events
61+
62+
| Name | Bubbles | Description |
63+
| ------ | --------- | ------------ |
64+
| `change` | `false` | This event fires when the switch is toggled on/off via user interaction. |
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { expect, fixture, html } from '@open-wc/testing';
2+
import SwitchElement from './index';
3+
import Sinon from 'sinon';
4+
5+
describe('Switch', () => {
6+
it('sets the attributes', async () => {
7+
const el = await fixture<SwitchElement>(html`
8+
<twc-switch>
9+
<button type="button" data-target="twc-switch.trigger">Switch</button>
10+
</twc-switch>
11+
`);
12+
13+
expect(el).not.to.have.attribute('checked');
14+
15+
const trigger = el.querySelector('button');
16+
expect(trigger).to.have.attribute('role', 'switch');
17+
expect(trigger).to.have.attribute('tabindex', '0');
18+
expect(trigger).to.have.attribute('aria-checked', 'false');
19+
expect(el).to.have.attribute('data-headlessui-state', '');
20+
});
21+
22+
it('defaults to the checked state', async () => {
23+
const el = await fixture<SwitchElement>(html`
24+
<twc-switch checked>
25+
<button type="button" data-target="twc-switch.trigger">Switch</button>
26+
</twc-switch>
27+
`);
28+
29+
expect(el).to.have.attribute('checked');
30+
31+
const trigger = el.querySelector('button');
32+
expect(trigger).to.have.attribute('aria-checked', 'true');
33+
expect(el).to.have.attribute('data-headlessui-state', 'checked');
34+
});
35+
36+
it('toggles the checked state', async () => {
37+
const el = await fixture<SwitchElement>(html`
38+
<twc-switch>
39+
<button type="button" data-target="twc-switch.trigger">Switch</button>
40+
</twc-switch>
41+
`);
42+
const changeHandler = Sinon.spy();
43+
el.addEventListener('change', changeHandler);
44+
45+
const trigger = el.querySelector('button')!;
46+
trigger.click();
47+
expect(el).to.have.attribute('checked');
48+
expect(trigger).to.have.attribute('aria-checked', 'true');
49+
expect(el).to.have.attribute('data-headlessui-state', 'checked');
50+
expect(changeHandler.calledOnce).to.be.true;
51+
52+
trigger.click();
53+
expect(el).not.to.have.attribute('checked');
54+
expect(trigger).to.have.attribute('aria-checked', 'false');
55+
expect(el).to.have.attribute('data-headlessui-state', '');
56+
expect(changeHandler.calledTwice).to.be.true;
57+
});
58+
59+
it('programatically toggling the checked state', async () => {
60+
const el = await fixture<SwitchElement>(html`
61+
<twc-switch>
62+
<button type="button" data-target="twc-switch.trigger">Switch</button>
63+
</twc-switch>
64+
`);
65+
const changeHandler = Sinon.spy();
66+
el.addEventListener('change', changeHandler);
67+
68+
el.checked = true;
69+
70+
const trigger = el.querySelector('button')!;
71+
expect(trigger).to.have.attribute('aria-checked', 'true');
72+
expect(el).to.have.attribute('data-headlessui-state', 'checked');
73+
expect(changeHandler.called).to.be.false;
74+
75+
el.checked = false;
76+
expect(trigger).to.have.attribute('aria-checked', 'false');
77+
expect(el).to.have.attribute('data-headlessui-state', '');
78+
expect(changeHandler.called).to.be.false;
79+
});
80+
81+
it('specified tabindex is not overridden', async () => {
82+
const el = await fixture<SwitchElement>(html`
83+
<twc-switch>
84+
<button type="button" data-target="twc-switch.trigger" tabindex="-1">Switch</button>
85+
</twc-switch>
86+
`);
87+
88+
const trigger = el.querySelector('button')!;
89+
expect(trigger).to.have.attribute('tabindex', '-1');
90+
});
91+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { ImpulseElement, property, registerElement, target } from '@ambiki/impulse';
2+
3+
@registerElement('twc-switch')
4+
export default class SwitchElement extends ImpulseElement {
5+
/**
6+
* Whether or not the switch is checked. To make the switch checked by default, set the `checked` attribute.
7+
*/
8+
@property({ type: Boolean }) checked = false;
9+
10+
@target() trigger: HTMLButtonElement;
11+
12+
constructor() {
13+
super();
14+
this.handleToggle = this.handleToggle.bind(this);
15+
}
16+
17+
/**
18+
* Called when the `trigger` element is connected to the DOM.
19+
*/
20+
triggerConnected(trigger: HTMLButtonElement) {
21+
trigger.setAttribute('role', 'switch');
22+
if (!trigger.hasAttribute('tabindex')) {
23+
trigger.setAttribute('tabindex', '0');
24+
}
25+
this.syncState(this.checked);
26+
trigger.addEventListener('click', this.handleToggle);
27+
}
28+
29+
/**
30+
* Called when the `trigger` element is removed from the DOM.
31+
*/
32+
triggerDisconnected(trigger: HTMLButtonElement) {
33+
trigger.removeEventListener('click', this.handleToggle);
34+
}
35+
36+
/**
37+
* Called when the `checked` property changes.
38+
*/
39+
checkedChanged(value: boolean) {
40+
this.syncState(value);
41+
}
42+
43+
private handleToggle() {
44+
this.checked = !this.checked;
45+
this.emit('change', { prefix: false, bubbles: false });
46+
}
47+
48+
private syncState(state: boolean) {
49+
this.trigger.setAttribute('aria-checked', state.toString());
50+
this.setAttribute('data-headlessui-state', state ? 'checked' : '');
51+
}
52+
}
53+
54+
declare global {
55+
interface Window {
56+
SwitchElement: typeof SwitchElement;
57+
}
58+
interface HTMLElementTagNameMap {
59+
'twc-switch': SwitchElement;
60+
}
61+
}

packages/core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export default {};
1+
import './elements/switch';

packages/core/tsconfig.build.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
{
22
"extends": "./tsconfig.json",
33
"compilerOptions": {
4-
"outDir": "./dist",
5-
"strictPropertyInitialization": true
4+
"outDir": "./dist"
65
},
76
"include": ["./src"],
8-
"exclude": ["./test"]
7+
"exclude": ["./src/**/*.test.ts"]
98
}

packages/core/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{
2-
"extends": "../../tsconfig.json"
2+
"extends": "../../tsconfig.json",
3+
"include": ["./src/*"]
34
}

0 commit comments

Comments
 (0)