diff --git a/.changeset/poor-spiders-clap.md b/.changeset/poor-spiders-clap.md
new file mode 100644
index 000000000..72193b11f
--- /dev/null
+++ b/.changeset/poor-spiders-clap.md
@@ -0,0 +1,6 @@
+---
+'@qwik-ui/headless': major
+'@qwik-ui/styled': major
+---
+
+add a new switch component
diff --git a/apps/component-tests/src/global.css b/apps/component-tests/src/global.css
index 77ef5bc24..2082d957d 100644
--- a/apps/component-tests/src/global.css
+++ b/apps/component-tests/src/global.css
@@ -36,9 +36,15 @@
--alert: 0 84.2% 60.2%;
--alert-foreground: 210 40% 98%;
--ring: 222.2 47.4% 11.2%;
+ --switch-thumb-color: 0 0% 100%;
+ --switch-thumb-color-highlight: 0, 0%, 72%, 0.25;
+ --switch-track-color-inactive: 80 0% 80%;
}
.dark {
+ --switch-thumb-color-highlight: 0, 0%, 100%, 0.25;
+ --switch-thumb-color: 0 0% 100%;
+ --switch-track-color-inactive: 240, 10%, 50%;
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
diff --git a/apps/component-tests/src/routes/[kit]/[component]/[example]/index.tsx b/apps/component-tests/src/routes/[kit]/[component]/[example]/index.tsx
index 05cdec8cf..e0d821320 100644
--- a/apps/component-tests/src/routes/[kit]/[component]/[example]/index.tsx
+++ b/apps/component-tests/src/routes/[kit]/[component]/[example]/index.tsx
@@ -1,6 +1,5 @@
import { component$ } from '@builder.io/qwik';
import { ShowcaseTest } from '../../../../components/showcase-test/showcase-test';
-
export default component$(() => {
// Need to center the content in the screen
// so that tests like popover placement can
diff --git a/apps/website/src/global.css b/apps/website/src/global.css
index d622123a7..2082d957d 100644
--- a/apps/website/src/global.css
+++ b/apps/website/src/global.css
@@ -36,9 +36,15 @@
--alert: 0 84.2% 60.2%;
--alert-foreground: 210 40% 98%;
--ring: 222.2 47.4% 11.2%;
+ --switch-thumb-color: 0 0% 100%;
+ --switch-thumb-color-highlight: 0, 0%, 72%, 0.25;
+ --switch-track-color-inactive: 80 0% 80%;
}
.dark {
+ --switch-thumb-color-highlight: 0, 0%, 100%, 0.25;
+ --switch-thumb-color: 0 0% 100%;
+ --switch-track-color-inactive: 240, 10%, 50%;
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
@@ -1363,7 +1369,7 @@ body {
min-height: 100%;
}
-/* Utilities layer for animations. The current arbitrary & docs tailwind animation guidelines are not maintainable long term.
+/* Utilities layer for animations. The current arbitrary & docs tailwind animation guidelines are not maintainable long term.
It would make more sense to supply the user with the animation declaration in the docs.
*/
@layer utilities {
diff --git a/apps/website/src/routes/docs/headless/menu.md b/apps/website/src/routes/docs/headless/menu.md
index 55474c23d..15097bac1 100644
--- a/apps/website/src/routes/docs/headless/menu.md
+++ b/apps/website/src/routes/docs/headless/menu.md
@@ -32,3 +32,4 @@
- [Tooltip](/docs/headless/tooltip)
- [Toggle](/docs/headless/toggle)
- [Toggle Group](/docs/headless/toggle-group)
+- [Switch](/docs/headless/switch)
diff --git a/apps/website/src/routes/docs/headless/switch/examples/checked.tsx b/apps/website/src/routes/docs/headless/switch/examples/checked.tsx
new file mode 100644
index 000000000..2057e77ec
--- /dev/null
+++ b/apps/website/src/routes/docs/headless/switch/examples/checked.tsx
@@ -0,0 +1,18 @@
+import { component$, useStyles$, useSignal } from '@builder.io/qwik';
+import { Switch } from '@qwik-ui/headless';
+
+
+export default component$(() => {
+ const checked = useSignal(true)
+ useStyles$(styles);
+ return (
+
+ test
+
+
+ );
+});
+
+import styles from '../snippets/switch.css?inline';
+
+
diff --git a/apps/website/src/routes/docs/headless/switch/examples/defaultChecked.tsx b/apps/website/src/routes/docs/headless/switch/examples/defaultChecked.tsx
new file mode 100644
index 000000000..7baaf8bc9
--- /dev/null
+++ b/apps/website/src/routes/docs/headless/switch/examples/defaultChecked.tsx
@@ -0,0 +1,16 @@
+import { component$, useSignal } from '@builder.io/qwik';
+import { Switch } from '@qwik-ui/headless';
+
+
+export default component$(() => {
+ const checked = useSignal(false)
+ return (
+
+ test
+
+
+ );
+});
+
+
+
diff --git a/apps/website/src/routes/docs/headless/switch/examples/disabled.tsx b/apps/website/src/routes/docs/headless/switch/examples/disabled.tsx
new file mode 100644
index 000000000..7754d549f
--- /dev/null
+++ b/apps/website/src/routes/docs/headless/switch/examples/disabled.tsx
@@ -0,0 +1,18 @@
+import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
+import { Switch } from '@qwik-ui/headless';
+
+
+export default component$(() => {
+ const checked = useSignal(false)
+ useStyles$(styles);
+ return (
+
+ test
+
+
+ );
+});
+
+import styles from '../snippets/switch.css?inline';
+
+
diff --git a/apps/website/src/routes/docs/headless/switch/examples/hero.tsx b/apps/website/src/routes/docs/headless/switch/examples/hero.tsx
new file mode 100644
index 000000000..6b92a5372
--- /dev/null
+++ b/apps/website/src/routes/docs/headless/switch/examples/hero.tsx
@@ -0,0 +1,24 @@
+import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
+import { Switch } from '@qwik-ui/headless';
+
+
+export default component$(() => {
+ const checked = useSignal(false)
+ const count = useSignal(0);
+ useStyles$(styles);
+
+ return (
+ count.value++}
+ >
+ test{count.value}
+
+
+ );
+});
+
+import styles from '../snippets/switch.css?inline';
+
+
diff --git a/apps/website/src/routes/docs/headless/switch/index.mdx b/apps/website/src/routes/docs/headless/switch/index.mdx
new file mode 100644
index 000000000..2ef7b344e
--- /dev/null
+++ b/apps/website/src/routes/docs/headless/switch/index.mdx
@@ -0,0 +1,143 @@
+---
+title: Qwik UI | Switch
+---
+
+import { FeatureList } from '~/components/feature-list/feature-list';
+
+import { statusByComponent } from '~/_state/component-statuses';
+
+
+
+# Switch
+
+A toggleable control for user interactions.
+
+
+
+## ✨ Features
+
+
+
+
+## Building blocks
+
+
+
+## Anatomy
+
+
+
+## Why use a headless Switch?
+
+The native `` element presents several challenges regarding styling, behavior, and user experience.
+
+### Native Switch pain points
+
+
+
+### Native effort
+
+While there are efforts to enhance the native checkbox element, such as the [Open UI group](https://open-ui.org/components/switch/), these solutions often fall short in terms of flexibility and customization. A headless Switch component allows developers to create a fully tailored user experience without the constraints of native elements.
+
+## Behavior Tests
+
+### Mouse Interaction
+
+
+
+- **Toggle State**: Ensures that clicking the switch toggles its checked state correctly.
+- **Trigger onChange**: Verifies that the onChange callback is triggered when the switch is clicked.
+
+### Keyboard Interaction
+
+
+
+- **Enter Key**: Tests that pressing the Enter key toggles the switch's state.
+- **Space Key**: Checks that pressing the Space key toggles the switch's state.
+
+### Default Properties
+
+
+
+- **Checked by Default**: Confirms that the switch is checked upon initial render if set so.
+- **Disabled State**: Ensures that the switch is disabled and does not respond to user interactions when set so.
+
+## API
+
+### Switch.Root
+
+
+
+
+
diff --git a/apps/website/src/routes/docs/headless/switch/snippets/building-blocks.tsx b/apps/website/src/routes/docs/headless/switch/snippets/building-blocks.tsx
new file mode 100644
index 000000000..cb5803b3b
--- /dev/null
+++ b/apps/website/src/routes/docs/headless/switch/snippets/building-blocks.tsx
@@ -0,0 +1,21 @@
+import { component$, useSignal, useStyles$ } from '@builder.io/qwik';
+import { Switch } from '@qwik-ui/headless';
+
+
+export default component$(() => {
+ const checked = useSignal(false)
+ useStyles$(styles);
+
+ return (
+
+ test
+
+
+ );
+});
+
+import styles from '../snippets/switch.css?inline';
+
+
diff --git a/apps/website/src/routes/docs/headless/switch/snippets/switch.css b/apps/website/src/routes/docs/headless/switch/snippets/switch.css
new file mode 100644
index 000000000..0ee46ce4a
--- /dev/null
+++ b/apps/website/src/routes/docs/headless/switch/snippets/switch.css
@@ -0,0 +1,83 @@
+/* Define default light theme colors */
+.switch {
+ --thumb-color: hsla(var(--switch-thumb-color));
+ --track-color-inactive: hsla(var(--switch-track-color-inactive));
+ --track-color-active: hsla(var(--primary));
+ --isLTR: 1;
+ flex-direction: row-reverse;
+ display: flex;
+ align-items: center;
+ gap: 1ch;
+ justify-content: space-between;
+ cursor: pointer;
+ user-select: none;
+ -webkit-tap-highlight-color: transparent;
+
+ &>input {
+ --thumb-position: 0%;
+ --thumb-transition-duration: .25s;
+ padding: 2px;
+ background: var(--track-color-inactive);
+ inline-size: 4rem;
+ block-size: 2rem;
+ border-radius: 4rem;
+ appearance: none;
+ pointer-events: none;
+ touch-action: pan-y;
+ border: none;
+ outline-offset: 5px;
+ box-sizing: content-box;
+ flex-shrink: 0;
+ display: grid;
+ align-items: center;
+ grid: [track] 1fr / [track] 1fr;
+ transition: background-color .25s ease;
+
+ &::before {
+ --highlight-size: 0;
+ content: "";
+ cursor: pointer;
+ pointer-events: auto;
+ grid-area: track;
+ inline-size: 2rem;
+ block-size: 2rem;
+ background: var(--thumb-color);
+ box-shadow: 0 0 0 var(--highlight-size) hsla(var(--switch-thumb-color-highlight));
+ border-radius: 50%;
+ transform: translateX(var(--thumb-position));
+
+ @media (--motionOK) {
+ transition:
+ transform var(--thumb-transition-duration) ease,
+ box-shadow .25s ease;
+ }
+ }
+
+ &:not(:disabled):hover::before {
+ --highlight-size: .5rem;
+ }
+
+ &:checked {
+ background: var(--track-color-active);
+ --thumb-position: calc((4rem - 100%) * var(--isLTR));
+ }
+
+ &:indeterminate {
+ --thumb-position: calc(1rem * var(--isLTR));
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.35;
+ &::before {
+ cursor: not-allowed;
+ box-shadow: inset 0 0 0 2px hsl(0 0% 100% / 10%);
+ }
+ }
+
+ &:focus {
+ outline: 2px solid hsl(var(--primary));
+ outline-offset: 2px;
+ }
+ }
+}
diff --git a/apps/website/src/routes/docs/styled/switch/examples/hero.tsx b/apps/website/src/routes/docs/styled/switch/examples/hero.tsx
new file mode 100644
index 000000000..cb3c9263a
--- /dev/null
+++ b/apps/website/src/routes/docs/styled/switch/examples/hero.tsx
@@ -0,0 +1,15 @@
+import { component$, useSignal } from '@builder.io/qwik';
+import { Switch } from '~/components/ui';
+
+
+export default component$(() => {
+ const checked = useSignal(false);
+ return (
+ <>
+
+ test
+
+
+ >
+ );
+});
diff --git a/apps/website/src/routes/docs/styled/switch/index.mdx b/apps/website/src/routes/docs/styled/switch/index.mdx
new file mode 100644
index 000000000..22cf1caa3
--- /dev/null
+++ b/apps/website/src/routes/docs/styled/switch/index.mdx
@@ -0,0 +1,5 @@
+---
+title: Qwik UI | Styled Radio Group Component
+---
+
+sdsds
diff --git a/packages/kit-headless/src/components/switch/index.ts b/packages/kit-headless/src/components/switch/index.ts
new file mode 100644
index 000000000..234a55a00
--- /dev/null
+++ b/packages/kit-headless/src/components/switch/index.ts
@@ -0,0 +1,3 @@
+export { SwitchRoot as Root } from './switch-root'
+export { SwitchInput as Input } from './switch-input'
+export { SwitchLable as Label } from './switch-lable'
diff --git a/packages/kit-headless/src/components/switch/switch-context.tsx b/packages/kit-headless/src/components/switch/switch-context.tsx
new file mode 100644
index 000000000..aa56f8241
--- /dev/null
+++ b/packages/kit-headless/src/components/switch/switch-context.tsx
@@ -0,0 +1,14 @@
+import { createContextId, QRL, type Signal } from '@builder.io/qwik';
+
+export interface SwitchState {
+ 'bind:checked': Signal;
+ defaultChecked?: boolean;
+ disabled?: boolean;
+ onChange$?: QRL<(checked: boolean, event: MouseEvent | KeyboardEvent) => void>
+ onClick$?: QRL<(checked: boolean, event: MouseEvent | KeyboardEvent) => void>,
+ autoFocus?: boolean
+}
+//
+export type SwitchContextState = Omit & { bindChecked: Signal, switchRef?: Signal }
+
+export const SwitchContext = createContextId('SwitchContext');
diff --git a/packages/kit-headless/src/components/switch/switch-input.tsx b/packages/kit-headless/src/components/switch/switch-input.tsx
new file mode 100644
index 000000000..13aa32933
--- /dev/null
+++ b/packages/kit-headless/src/components/switch/switch-input.tsx
@@ -0,0 +1,69 @@
+
+import { component$, PropsOf, sync$, useContext, useId, $ } from '@builder.io/qwik';
+import { SwitchContext } from './switch-context';
+export const SwitchInput = component$>(() => {
+ const context = useContext(SwitchContext)
+ const id = useId()
+ if(context.defaultChecked && context.bindChecked && !context.bindChecked.value){
+ context.bindChecked.value = !context.bindChecked.value
+ }
+
+ if(context.autoFocus && !context.switchRef?.value){
+ context.switchRef?.value?.focus()
+ }
+
+ const handleClick$ = $((e: MouseEvent | KeyboardEvent) => {
+ const keys = [
+ 'Enter',
+ ' ',
+ ];
+ if((e as KeyboardEvent)?.key !== undefined && !keys.includes((e as KeyboardEvent).key)){
+ return
+ }
+ // keycode
+
+ context.switchRef?.value?.focus()
+ context.bindChecked.value = !context.bindChecked.value;
+ if(context.onChange$){
+ context.onChange$(context.bindChecked.value, e)
+ }
+
+ if(context.onClick$){
+ context.onClick$(context.bindChecked.value, e)
+ }
+
+ });
+ const handleClickSync$ = sync$((e: MouseEvent) => {
+ e.preventDefault();
+ });
+
+ const handleKeyPressSync$ = sync$((e: KeyboardEvent) => {
+ const keys = [
+ 'Enter',
+ ' ',
+ ];
+ if (keys.includes(e.key)) {
+ e.preventDefault();
+ }
+ });
+
+
+ return (
+
+ );
+ },
+);
diff --git a/packages/kit-headless/src/components/switch/switch-lable.tsx b/packages/kit-headless/src/components/switch/switch-lable.tsx
new file mode 100644
index 000000000..3b0469dfe
--- /dev/null
+++ b/packages/kit-headless/src/components/switch/switch-lable.tsx
@@ -0,0 +1,11 @@
+
+import { component$, PropsOf, Slot, useId } from '@builder.io/qwik';
+export const SwitchLable = component$>(() => {
+ const id = useId()
+ return (
+
+ );
+ },
+);
diff --git a/packages/kit-headless/src/components/switch/switch-root.tsx b/packages/kit-headless/src/components/switch/switch-root.tsx
new file mode 100644
index 000000000..9d8ca6595
--- /dev/null
+++ b/packages/kit-headless/src/components/switch/switch-root.tsx
@@ -0,0 +1,35 @@
+import {
+ component$,
+ Slot,
+ useContextProvider,
+ useSignal,
+ type PropsOf
+} from '@builder.io/qwik';
+import { type SwitchContextState, type SwitchState, SwitchContext } from './switch-context';
+export type SwitchProps = PropsOf<'div'> & SwitchState;
+
+
+
+export const SwitchRoot = component$(({defaultChecked, disabled, onChange$, ...rest}: SwitchProps) => {
+ const switchRef = useSignal()
+ const context: SwitchContextState = {
+ defaultChecked,
+ disabled,
+ bindChecked: rest['bind:checked'],
+ onChange$: onChange$,
+ switchRef: switchRef
+ }
+
+ useContextProvider(SwitchContext, context)
+
+ return (
+
+
+
+ );
+})
diff --git a/packages/kit-headless/src/components/switch/switch.driver.ts b/packages/kit-headless/src/components/switch/switch.driver.ts
new file mode 100644
index 000000000..155d2e1f5
--- /dev/null
+++ b/packages/kit-headless/src/components/switch/switch.driver.ts
@@ -0,0 +1,37 @@
+import { type Locator, type Page } from '@playwright/test';
+type OpenKeys = 'ArrowUp' | 'Enter' | 'Space' | 'ArrowDown';
+export type DriverLocator = Locator | Page;
+
+export function createTestDriver(rootLocator: T) {
+ const getRoot = () => {
+ return rootLocator;
+ };
+
+ const getTrigger = () => {
+ return getRoot().locator('[data-value]')
+ };
+
+ const getTriggerlaBle = () => {
+ return getRoot().locator('[data-switch-lable]')
+ }
+
+ const openListbox = async (key: OpenKeys | 'click') => {
+ await getTrigger().focus();
+
+ if (key !== 'click') {
+ await getTrigger().press(key);
+ } else {
+ await getTrigger().click();
+ }
+
+ };
+
+ return {
+ ...rootLocator,
+ locator: rootLocator,
+ getRoot,
+ getTrigger,
+ openListbox,
+ getTriggerlaBle
+ };
+}
diff --git a/packages/kit-headless/src/components/switch/switch.test.ts b/packages/kit-headless/src/components/switch/switch.test.ts
new file mode 100644
index 000000000..cc7c54975
--- /dev/null
+++ b/packages/kit-headless/src/components/switch/switch.test.ts
@@ -0,0 +1,99 @@
+import { type Page, test, expect } from '@playwright/test';
+import { createTestDriver } from './switch.driver';
+
+declare global {
+ interface Window {
+ onChangeTriggered: boolean;
+ onChangeHandler: () => void;
+ }
+}
+async function setup(page: Page, exampleName: string) {
+ await page.goto(`/headless/switch/${exampleName}`);
+
+ const driver = createTestDriver(page.locator('[data-qui-switch]'));
+
+ return {
+ driver,
+ };
+}
+
+test.describe('Mouse Behavior', () => {
+ test(`GIVEN a hero switch
+ WHEN toggled
+ THEN the checked property should correctly reflect the toggle state`, async ({ page }) => {
+ const { driver: d } = await setup(page, 'hero');
+ await expect(d.getTrigger()).not.toBeChecked();
+ await expect(d.getTrigger()).toHaveAttribute('data-checked', 'flase');
+ await d.getTrigger().click();
+ await expect(d.getTrigger()).toHaveAttribute('data-checked', 'true');
+ await expect(d.getTrigger()).toBeChecked()
+ })
+
+ test(`GIVEN a hero switch
+ WHEN clicked
+ THEN the onChange callback should be triggered`, async ({ page }) => {
+ const { driver: d } = await setup(page, 'hero');
+ await expect(d.getTriggerlaBle()).toHaveText('0')
+ await d.getTrigger().click();
+ await expect(d.getTriggerlaBle()).toHaveText('1')
+ })
+
+})
+
+test.describe('Keyboard Behavior', () => {
+ test(`GIVEN a hero switch
+ WHEN focusing the trigger and pressing the Enter key
+ THEN the checked property should toggle`, async ({ page }) => {
+ const { driver: d } = await setup(page, 'hero');
+ await d.getTrigger().focus();
+ await expect(d.getTrigger()).not.toBeChecked();
+ await d.getTrigger().press('Enter');
+ await expect(d.getTrigger()).toBeChecked();
+ await d.getTrigger().press('Enter');
+ await expect(d.getTrigger()).not.toBeChecked();
+ });
+
+ test(`GIVEN a hero switch
+ WHEN focusing the trigger and pressing the Space key
+ THEN the checked property should toggle`, async ({ page }) => {
+ const { driver: d } = await setup(page, 'hero');
+ await d.getTrigger().focus();
+ await expect(d.getTrigger()).not.toBeChecked();
+ await d.getTrigger().press(' ');
+ await expect(d.getTrigger()).toBeChecked();
+ await d.getTrigger().press(' ');
+ await expect(d.getTrigger()).not.toBeChecked();
+ });
+})
+
+test.describe('Default property ', () => {
+ test(`
+ GIVEN a checked switch
+ WHEN the switch is mounted
+ THEN the switch should be checked
+ `, async ({ page }) => {
+ const { driver: d } = await setup(page, 'checked');
+ await expect(d.getTrigger()).toBeChecked();
+ await expect(d.getTrigger()).toHaveAttribute('data-checked', 'true');
+ await expect(d.getTrigger()).toHaveAttribute('aria-checked', 'true');
+ })
+
+ test(`
+ GIVEN a defaultChecked switch
+ WHEN the switch is mounted
+ THEN the switch should be checked
+ `, async ({ page }) => {
+ const { driver: d } = await setup(page, 'defaultChecked');
+ await expect(d.getTrigger()).toBeChecked();
+ })
+
+ test(`
+ GIVEN a disabled switch
+ WHEN the switch is mounted
+ THEN the switch should be disabled
+ `, async ({ page }) => {
+ const { driver: d } = await setup(page, 'disabled');
+ await expect(d.getTrigger()).toHaveAttribute('data-disabled', 'true');
+
+ })
+})
diff --git a/packages/kit-headless/src/index.ts b/packages/kit-headless/src/index.ts
index 86198fa29..8e8ddd3a5 100644
--- a/packages/kit-headless/src/index.ts
+++ b/packages/kit-headless/src/index.ts
@@ -19,3 +19,4 @@ export * as Tooltip from './components/tooltip';
export * as Dropdown from './components/dropdown';
export * as Combobox from './components/combobox';
export { Polymorphic } from './components/polymorphic';
+export * as Switch from './components/switch';
diff --git a/packages/kit-styled/src/components/switch/switch.tsx b/packages/kit-styled/src/components/switch/switch.tsx
new file mode 100644
index 000000000..a3cbf1a13
--- /dev/null
+++ b/packages/kit-styled/src/components/switch/switch.tsx
@@ -0,0 +1,37 @@
+import { type PropsOf, component$, Slot } from '@builder.io/qwik';
+import { Switch as HeadlessSwitch } from '@qwik-ui/headless';
+import { cn } from '@qwik-ui/utils';
+
+const Root = component$>(({ ...props }) => {
+ return (
+
+
+
+ );
+});
+
+
+const Label = component$>(({ ...props }) => {
+ return (
+
+
+
+ );
+});
+
+const Input = component$>(({ ...props }) => {
+ return ;
+});
+
+
+export const Switch = {
+ Root,
+ Label,
+ Input,
+};
diff --git a/packages/kit-styled/src/index.ts b/packages/kit-styled/src/index.ts
index 6c2b24bf7..1746808e8 100644
--- a/packages/kit-styled/src/index.ts
+++ b/packages/kit-styled/src/index.ts
@@ -21,3 +21,4 @@ export * from './components/textarea/textarea';
export * from './components/toggle/toggle';
export * from './components/toggle-group/toggle-group';
export * from './components/dropdown/dropdown';
+export * from './components/switch/switch';