Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add a new switch component #960

Draft
wants to merge 23 commits into
base: main
Choose a base branch
from
Draft
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
6 changes: 6 additions & 0 deletions .changeset/poor-spiders-clap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@qwik-ui/headless': major
'@qwik-ui/styled': major
---

add a new switch component
6 changes: 6 additions & 0 deletions apps/component-tests/src/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 7 additions & 1 deletion apps/website/src/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions apps/website/src/routes/docs/headless/menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@
- [Tooltip](/docs/headless/tooltip)
- [Toggle](/docs/headless/toggle)
- [Toggle Group](/docs/headless/toggle-group)
- [Switch](/docs/headless/switch)
18 changes: 18 additions & 0 deletions apps/website/src/routes/docs/headless/switch/examples/checked.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Switch.Root class="switch" bind:checked={checked}>
<Switch.Label>test</Switch.Label>
<Switch.Input />
</Switch.Root>
);
});

import styles from '../snippets/switch.css?inline';


Original file line number Diff line number Diff line change
@@ -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 (
<Switch.Root class="switch" defaultChecked bind:checked={checked}>
<Switch.Label>test</Switch.Label>
<Switch.Input />
</Switch.Root>
);
});



18 changes: 18 additions & 0 deletions apps/website/src/routes/docs/headless/switch/examples/disabled.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Switch.Root class="switch" disabled bind:checked={checked}>
<Switch.Label>test</Switch.Label>
<Switch.Input/>
</Switch.Root>
);
});

import styles from '../snippets/switch.css?inline';


24 changes: 24 additions & 0 deletions apps/website/src/routes/docs/headless/switch/examples/hero.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Switch.Root
class="switch"
bind:checked={checked}
onChange$={() => count.value++}
>
<Switch.Label>test{count.value}</Switch.Label>
<Switch.Input />
</Switch.Root>
);
});

import styles from '../snippets/switch.css?inline';


143 changes: 143 additions & 0 deletions apps/website/src/routes/docs/headless/switch/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
---
title: Qwik UI | Switch
---

import { FeatureList } from '~/components/feature-list/feature-list';

import { statusByComponent } from '~/_state/component-statuses';

<StatusBanner status={statusByComponent.headless.Switch} />

# Switch

A toggleable control for user interactions.

<Showcase name="hero" />

## ✨ Features

<FeatureList
features={[
'WAI ARIA Switch design pattern',
'Single toggle state (on/off)',
'Reactive state changes',
'Disabled state',
'Keyboard accessibility (Space and Enter keys)',
'Custom styling options',
'Support for labels and descriptions',
'Focus management',
'Accessibility support for screen readers',
]}
roadmap={['Opt-in native form support', 'RTL support']}
/>


## Building blocks

<CodeSnippet name="building-blocks" />

## Anatomy

<AnatomyTable
propDescriptors={[
{
name: 'Switch.Root',
description:
'Defines the component boundary and exposes its internal logic. Must wrap over all other parts.',
},
{
name: 'Switch.Input',
description:
'The actual switch element that users interact with. Can be toggled on or off.',
},
{
name: 'Switch.Label',
description:
'Provides a label for the switch, enhancing accessibility and usability.',
},
]}
/>

## Why use a headless Switch?

The native `<input type="checkbox">` element presents several challenges regarding styling, behavior, and user experience.

### Native Switch pain points

<FeatureList
issues={[
'Limited styling options',
'Inconsistent appearance across browsers',
'Lack of custom animations and transitions',
'Accessibility challenges with native elements',
]}
/>

### 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

<Showcase name="hero" />

- **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

<Showcase name="hero" />

- **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

<Showcase name="checked" />

- **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

<AnatomyTable
propDescriptors={[
{
name: 'bind:checked',
description:
'Two-way data bind of the checked state of the switch to a user-defined signal.',
},
{
name: 'onChange$',
description:
'Callback function that is triggered when the checked state of the switch changes.',
},
{
name: 'disabled',
description:
'Disables the switch, preventing any user interaction.',
},
{
name: 'onClick$',
description:
'Callback function that is triggered when the switch is clicked.',
},
{
name: 'defaultChecked',
description:
'Sets the switch to the checked state by default.',
},
{
name: 'autoFocus',
description:
'Sets the switch to auto focus.',
},
]}
/>



Original file line number Diff line number Diff line change
@@ -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 (
<Switch.Root
bind:checked={checked}
>
<Switch.Label>test</Switch.Label>
<Switch.Input />
</Switch.Root>
);
});

import styles from '../snippets/switch.css?inline';


83 changes: 83 additions & 0 deletions apps/website/src/routes/docs/headless/switch/snippets/switch.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Loading
Loading