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';