Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ module.exports = {
env: { browser: true },
overrides: [
{
files: ['src/**/*.spec.ts'],
files: ['packages/**/*.test.ts'],
globals: { describe: true, it: true, beforeEach: true },
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
Expand Down
10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,26 @@
"lint": "eslint 'packages/**/*.ts'",
"playground": "yarn workspace @abeidahmed/playground dev",
"prettier": "prettier --check --log-level warn .",
"prettier:fix": "prettier --write --log-level warn ."
"prettier:fix": "prettier --write --log-level warn .",
"test": "web-test-runner",
"test:watch": "web-test-runner --watch"
},
"devDependencies": {
"@ambiki/impulse": "^0.2.0",
"@open-wc/testing": "^4.0.0",
"@rollup/plugin-typescript": "^11.1.5",
"@types/mocha": "^10.0.6",
"@typescript-eslint/eslint-plugin": "^6.16.0",
"@typescript-eslint/parser": "^6.16.0",
"@web/dev-server-esbuild": "^1.0.1",
"@web/test-runner": "^0.18.0",
"concurrently": "^8.2.2",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.2",
"prettier": "^3.1.1",
"rollup": "^4.9.1",
"sinon": "^17.0.1",
"typescript": "^5.3.3"
}
}
6 changes: 5 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,9 @@
"scripts": {
"build": "rollup --config rollup.config.js",
"build:watch": "rollup --config rollup.config.js --watch"
}
},
"peerDependencies": {
"@ambiki/impulse": "^0.2.0"
},
"devDependencies": {}
}
25 changes: 19 additions & 6 deletions packages/core/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,29 @@ import typescript from '@rollup/plugin-typescript';
*/
export default [
{
input: 'src/index.ts',
input: ['./src/index.ts'],
output: [
{
file: 'dist/index.umd.js',
format: 'umd',
name: 'TailwindCSSElements',
dir: 'dist',
entryFileNames: '[name].js',
format: 'es',
preserveModules: true,
preserveModulesRoot: 'src',
},
],
external: ['@ambiki/impulse'],
plugins: [typescript({ tsconfig: './tsconfig.build.json' })],
},
{
input: './src/index.ts',
output: [
{
file: 'dist/index.js',
format: 'es',
name: 'TailwindCSSElements',
file: 'dist/index.umd.js',
format: 'umd',
globals: {
'@ambiki/impulse': 'Impulse',
},
},
],
plugins: [typescript({ tsconfig: './tsconfig.build.json' })],
Expand Down
64 changes: 64 additions & 0 deletions packages/core/src/elements/switch/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Switch

Switches are a pleasant interface for toggling a value between two states and offer the same semantics and keyboard
navigation as native checkbox elements.

## Usage

```html
<twc-switch>
<button
type="button"
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"
data-target="twc-switch.trigger"
>
<span class="sr-only">Use setting</span>
<span
aria-hidden="true"
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"
></span>
</button>
</twc-switch>
```

## Examples

### Default to the checked state

You can set the `checked` attribute on the element and it will be checked by default.

```html
<twc-switch checked>
<button type="button">...</button>
</twc-switch>
```

### Programatically toggling the checked state

```html
<twc-switch>
<button type="button">...</button>
</twc-switch>
```

```js
const switch = document.querySelector('twc-switch');

// On
switch.checked = true;

// Off
switch.checked = false;
```

## Styling different states

Each component exposes the `data-headlessui-state` attribute that you can use to conditionally apply the classes. You
can use the [`@headlessui/tailwindcss`](https://github.com/tailwindlabs/headlessui/tree/main/packages/%40headlessui-tailwindcss)
plugin to target this attribute.

## Events

| Name | Bubbles | Description |
| ------ | --------- | ------------ |
| `change` | `false` | This event fires when the switch is toggled on/off via user interaction. |
91 changes: 91 additions & 0 deletions packages/core/src/elements/switch/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { expect, fixture, html } from '@open-wc/testing';
import SwitchElement from './index';
import Sinon from 'sinon';

describe('Switch', () => {
it('sets the attributes', async () => {
const el = await fixture<SwitchElement>(html`
<twc-switch>
<button type="button" data-target="twc-switch.trigger">Switch</button>
</twc-switch>
`);

expect(el).not.to.have.attribute('checked');

const trigger = el.querySelector('button');
expect(trigger).to.have.attribute('role', 'switch');
expect(trigger).to.have.attribute('tabindex', '0');
expect(trigger).to.have.attribute('aria-checked', 'false');
expect(el).to.have.attribute('data-headlessui-state', '');
});

it('defaults to the checked state', async () => {
const el = await fixture<SwitchElement>(html`
<twc-switch checked>
<button type="button" data-target="twc-switch.trigger">Switch</button>
</twc-switch>
`);

expect(el).to.have.attribute('checked');

const trigger = el.querySelector('button');
expect(trigger).to.have.attribute('aria-checked', 'true');
expect(el).to.have.attribute('data-headlessui-state', 'checked');
});

it('toggles the checked state', async () => {
const el = await fixture<SwitchElement>(html`
<twc-switch>
<button type="button" data-target="twc-switch.trigger">Switch</button>
</twc-switch>
`);
const changeHandler = Sinon.spy();
el.addEventListener('change', changeHandler);

const trigger = el.querySelector('button')!;
trigger.click();
expect(el).to.have.attribute('checked');
expect(trigger).to.have.attribute('aria-checked', 'true');
expect(el).to.have.attribute('data-headlessui-state', 'checked');
expect(changeHandler.calledOnce).to.be.true;

trigger.click();
expect(el).not.to.have.attribute('checked');
expect(trigger).to.have.attribute('aria-checked', 'false');
expect(el).to.have.attribute('data-headlessui-state', '');
expect(changeHandler.calledTwice).to.be.true;
});

it('programatically toggling the checked state', async () => {
const el = await fixture<SwitchElement>(html`
<twc-switch>
<button type="button" data-target="twc-switch.trigger">Switch</button>
</twc-switch>
`);
const changeHandler = Sinon.spy();
el.addEventListener('change', changeHandler);

el.checked = true;

const trigger = el.querySelector('button')!;
expect(trigger).to.have.attribute('aria-checked', 'true');
expect(el).to.have.attribute('data-headlessui-state', 'checked');
expect(changeHandler.called).to.be.false;

el.checked = false;
expect(trigger).to.have.attribute('aria-checked', 'false');
expect(el).to.have.attribute('data-headlessui-state', '');
expect(changeHandler.called).to.be.false;
});

it('specified tabindex is not overridden', async () => {
const el = await fixture<SwitchElement>(html`
<twc-switch>
<button type="button" data-target="twc-switch.trigger" tabindex="-1">Switch</button>
</twc-switch>
`);

const trigger = el.querySelector('button')!;
expect(trigger).to.have.attribute('tabindex', '-1');
});
});
61 changes: 61 additions & 0 deletions packages/core/src/elements/switch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { ImpulseElement, property, registerElement, target } from '@ambiki/impulse';

@registerElement('twc-switch')
export default class SwitchElement extends ImpulseElement {
/**
* Whether or not the switch is checked. To make the switch checked by default, set the `checked` attribute.
*/
@property({ type: Boolean }) checked = false;

@target() trigger: HTMLButtonElement;

constructor() {
super();
this.handleToggle = this.handleToggle.bind(this);
}

/**
* Called when the `trigger` element is connected to the DOM.
*/
triggerConnected(trigger: HTMLButtonElement) {
trigger.setAttribute('role', 'switch');
if (!trigger.hasAttribute('tabindex')) {
trigger.setAttribute('tabindex', '0');
}
this.syncState(this.checked);
trigger.addEventListener('click', this.handleToggle);
}

/**
* Called when the `trigger` element is removed from the DOM.
*/
triggerDisconnected(trigger: HTMLButtonElement) {
trigger.removeEventListener('click', this.handleToggle);
}

/**
* Called when the `checked` property changes.
*/
checkedChanged(value: boolean) {
this.syncState(value);
}

private handleToggle() {
this.checked = !this.checked;
this.emit('change', { prefix: false, bubbles: false });
}

private syncState(state: boolean) {
this.trigger.setAttribute('aria-checked', state.toString());
this.setAttribute('data-headlessui-state', state ? 'checked' : '');
}
}

declare global {
interface Window {
SwitchElement: typeof SwitchElement;
}
interface HTMLElementTagNameMap {
'twc-switch': SwitchElement;
}
}
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export default {};
import './elements/switch';
5 changes: 2 additions & 3 deletions packages/core/tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"strictPropertyInitialization": true
"outDir": "./dist"
},
"include": ["./src"],
"exclude": ["./test"]
"exclude": ["./src/**/*.test.ts"]
}
3 changes: 2 additions & 1 deletion packages/core/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"extends": "../../tsconfig.json"
"extends": "../../tsconfig.json",
"include": ["./src/*"]
}
18 changes: 17 additions & 1 deletion packages/playground/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,24 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Playground</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<body class="min-h-screen flex items-center justify-center">
<div class="p-4">
<twc-switch>
<button
type="button"
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"
data-target="twc-switch.trigger"
>
<span class="sr-only">Use setting</span>
<span
aria-hidden="true"
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"
></span>
</button>
</twc-switch>
</div>
<script type="module" src="/src/index.ts"></script>
</body>
</html>
1 change: 1 addition & 0 deletions packages/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"preview": "vite preview"
},
"devDependencies": {
"@abeidahmed/tailwindcss-elements": "^*",
"vite": "^5.0.8"
}
}
2 changes: 1 addition & 1 deletion packages/playground/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export default {};
import '@abeidahmed/tailwindcss-elements';
3 changes: 1 addition & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,5 @@
"strictPropertyInitialization": false,
"target": "ES2017",
"useDefineForClassFields": false
},
"include": ["packages/*"]
}
}
20 changes: 20 additions & 0 deletions web-test-runner.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { esbuildPlugin } from '@web/dev-server-esbuild';

// https://modern-web.dev/docs/test-runner/cli-and-configuration/
export default {
rootDir: './packages/core',
files: ['./packages/**/*.test.ts'],
concurrentBrowsers: 3,
nodeResolve: true,
preserveSymlinks: true,
testRunnerHtml: (testFramework) => `
<html lang="en-US">
<head></head>
<body>
<script type="module" src="dist/index.js"></script>
<script type="module" src="${testFramework}"></script>
</body>
</html>
`,
plugins: [esbuildPlugin({ ts: true, target: 'auto' })],
};
Loading