diff --git a/.changeset/cuddly-snakes-smell.md b/.changeset/cuddly-snakes-smell.md new file mode 100644 index 000000000..2e59e3991 --- /dev/null +++ b/.changeset/cuddly-snakes-smell.md @@ -0,0 +1,6 @@ +--- +'@qwik-ui/headless': minor +'@qwik-ui/styled': minor +--- + +feat: toggle and togglegroup headless and styled components diff --git a/apps/website/src/_state/component-statuses.ts b/apps/website/src/_state/component-statuses.ts index bff04db40..93da6bd5c 100644 --- a/apps/website/src/_state/component-statuses.ts +++ b/apps/website/src/_state/component-statuses.ts @@ -32,6 +32,8 @@ export const statusByComponent: ComponentKitsStatuses = { Separator: ComponentStatus.Beta, Skeleton: ComponentStatus.Beta, Tabs: ComponentStatus.Beta, + Toggle: ComponentStatus.Draft, + ToggleGroup: ComponentStatus.Draft, Textarea: ComponentStatus.Draft, }, headless: { @@ -49,6 +51,8 @@ export const statusByComponent: ComponentKitsStatuses = { Select: ComponentStatus.Beta, Separator: ComponentStatus.Beta, Tabs: ComponentStatus.Beta, + Toggle: ComponentStatus.Draft, + ToggleGroup: ComponentStatus.Draft, Tooltip: ComponentStatus.Beta, }, }; diff --git a/apps/website/src/routes/docs/contributing/index.mdx b/apps/website/src/routes/docs/contributing/index.mdx index 9659cb284..05ee6f3c3 100644 --- a/apps/website/src/routes/docs/contributing/index.mdx +++ b/apps/website/src/routes/docs/contributing/index.mdx @@ -161,12 +161,8 @@ import { Collapsible } from '@qwik-ui/headless'; export default component$(() => { return ( - - Trigger - - - Content - + Trigger + Content ); }); @@ -230,7 +226,7 @@ async function setup(page: Page, exampleName: string) { } test.describe('Mouse Behavior', () => { - test(`GIVEN a collapsible + test(`GIVEN a collapsible WHEN clicking on the trigger THEN the content should be visible`, async ({ page }) => { const { driver: d } = await setup(page, 'hero'); diff --git a/apps/website/src/routes/docs/headless/menu.md b/apps/website/src/routes/docs/headless/menu.md index 1cd0264e3..21fd6248b 100644 --- a/apps/website/src/routes/docs/headless/menu.md +++ b/apps/website/src/routes/docs/headless/menu.md @@ -29,3 +29,5 @@ - [Separator](/docs/headless/separator) - [Tabs](/docs/headless/tabs) - [Tooltip](/docs/headless/tooltip) +- [Toggle](/docs/headless/toggle) +- [ToggleGroup](/docs/headless/toggle-group) diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/disabled.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/disabled.tsx new file mode 100644 index 000000000..8a59729ba --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/disabled.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/hero.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/hero.tsx new file mode 100644 index 000000000..1b4bdb5ca --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/hero.tsx @@ -0,0 +1,22 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/horizontal-rtl.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/horizontal-rtl.tsx new file mode 100644 index 000000000..1ada2a3e3 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/horizontal-rtl.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/initialValue.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/initialValue.tsx new file mode 100644 index 000000000..21bc6f6ff --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/initialValue.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/item-disabled-center.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/item-disabled-center.tsx new file mode 100644 index 000000000..bf942e3f9 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/item-disabled-center.tsx @@ -0,0 +1,28 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/loop-item-disabled.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/loop-item-disabled.tsx new file mode 100644 index 000000000..6dcceb38e --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/loop-item-disabled.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/loop.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/loop.tsx new file mode 100644 index 000000000..f7fae18e0 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/loop.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/multiple.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/multiple.tsx new file mode 100644 index 000000000..d358c8eb8 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/multiple.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-multiple-bind.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-multiple-bind.tsx new file mode 100644 index 000000000..79e3642ad --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-multiple-bind.tsx @@ -0,0 +1,42 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; +import { ToggleGroup } from '@qwik-ui/headless'; +import { useSignal } from '@builder.io/qwik'; + +export default component$(() => { + useStyles$(styles); + const items = ['left', 'center', 'right']; + const isItemsRenderedSig = useSignal(false); + const valueSelected = useSignal(['left', 'center']); + + return ( +
+ + {isItemsRenderedSig.value && ( +
+ + {items.map((item, index) => ( + + {item} + + ))} + + You selected: {valueSelected.value} + +
+ )} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-multiple.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-multiple.tsx new file mode 100644 index 000000000..3f0b18740 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-multiple.tsx @@ -0,0 +1,32 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; +import { ToggleGroup } from '@qwik-ui/headless'; +import { useSignal } from '@builder.io/qwik'; + +export default component$(() => { + useStyles$(styles); + const items = ['left', 'center', 'right']; + const isItemsRenderedSig = useSignal(false); + + return ( +
+ + {isItemsRenderedSig.value && ( + + {items.map((item, index) => ( + + {item} + + ))} + + )} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-single-bind-center.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-single-bind-center.tsx new file mode 100644 index 000000000..c70b59f3b --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-single-bind-center.tsx @@ -0,0 +1,42 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; +import { ToggleGroup } from '@qwik-ui/headless'; +import { useSignal } from '@builder.io/qwik'; + +export default component$(() => { + useStyles$(styles); + const items = ['left', 'center', 'right']; + const isItemsRenderedSig = useSignal(false); + const valueSelected = useSignal('center'); + + return ( +
+ + {isItemsRenderedSig.value && ( +
+ + {items.map((item, index) => ( + + {item} + + ))} + + You selected: {valueSelected.value} + +
+ )} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-single-bind.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-single-bind.tsx new file mode 100644 index 000000000..8aa21aabf --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-single-bind.tsx @@ -0,0 +1,42 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; +import { ToggleGroup } from '@qwik-ui/headless'; +import { useSignal } from '@builder.io/qwik'; + +export default component$(() => { + useStyles$(styles); + const items = ['left', 'center', 'right']; + const isItemsRenderedSig = useSignal(false); + const valueSelected = useSignal('left'); + + return ( +
+ + {isItemsRenderedSig.value && ( +
+ + {items.map((item, index) => ( + + {item} + + ))} + + You selected: {valueSelected.value} + +
+ )} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-single.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-single.tsx new file mode 100644 index 000000000..9b54bcf95 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-single.tsx @@ -0,0 +1,32 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; +import { ToggleGroup } from '@qwik-ui/headless'; +import { useSignal } from '@builder.io/qwik'; + +export default component$(() => { + useStyles$(styles); + const items = ['left', 'center', 'right']; + const isItemsRenderedSig = useSignal(false); + + return ( +
+ + {isItemsRenderedSig.value && ( + + {items.map((item, index) => ( + + {item} + + ))} + + )} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-disabled-multiple.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-disabled-multiple.tsx new file mode 100644 index 000000000..d4c77df79 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-disabled-multiple.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-disabled-value-multiple.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-disabled-value-multiple.tsx new file mode 100644 index 000000000..1dfad8a4c --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-disabled-value-multiple.tsx @@ -0,0 +1,31 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + const valueSelected = useSignal(['left', 'center']); + useStyles$(styles); + + return ( +
+ (valueSelected.value = v)} + disabled + > + + Left + + + Center + + + Right + + + You selected: {valueSelected.value.join(',')} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-disabled-value.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-disabled-value.tsx new file mode 100644 index 000000000..99dd91206 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-disabled-value.tsx @@ -0,0 +1,33 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + const valueSelected = useSignal('left'); + + useStyles$(styles); + + return ( +
+ { + valueSelected.value = v; + }} + disabled + > + + Left + + + Center + + + Right + + + You selected: {valueSelected.value} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-multiple-center-pressed.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-multiple-center-pressed.tsx new file mode 100644 index 000000000..0eae5dbac --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-multiple-center-pressed.tsx @@ -0,0 +1,26 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+
OutsideRoot Top
+
+ + + Left + + + Center + + + Right + + +
+ +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-multiple.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-multiple.tsx new file mode 100644 index 000000000..50d9602bc --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-multiple.tsx @@ -0,0 +1,26 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+
OutsideRoot Top
+
+ + + Left + + + Center + + + Right + + +
+ +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-single-center-pressed.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-single-center-pressed.tsx new file mode 100644 index 000000000..e7931b542 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-single-center-pressed.tsx @@ -0,0 +1,26 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+
OutsideRoot Top
+
+ + + Left + + + Center + + + Right + + +
+ +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-single.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-single.tsx new file mode 100644 index 000000000..2817070a7 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-single.tsx @@ -0,0 +1,26 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+
OutsideRoot Top
+
+ + + Left + + + Center + + + Right + + +
+ +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-initialValue-multiple.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-initialValue-multiple.tsx new file mode 100644 index 000000000..bec622d8d --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-initialValue-multiple.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-item-disabled-multiple.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-item-disabled-multiple.tsx new file mode 100644 index 000000000..8ff0a990f --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-item-disabled-multiple.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-item-disabled.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-item-disabled.tsx new file mode 100644 index 000000000..4ecd8017f --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-item-disabled.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-loop-horizontal-rtl.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-loop-horizontal-rtl.tsx new file mode 100644 index 000000000..661db3c40 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-loop-horizontal-rtl.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-loop-vertical-rtl.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-loop-vertical-rtl.tsx new file mode 100644 index 000000000..9dac46021 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-loop-vertical-rtl.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-loop-vertical.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-loop-vertical.tsx new file mode 100644 index 000000000..a851747de --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-loop-vertical.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-value-bind-multiple.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-value-bind-multiple.tsx new file mode 100644 index 000000000..efb794c50 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-value-bind-multiple.tsx @@ -0,0 +1,27 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + const valueSelected = useSignal(['left', 'center']); + + useStyles$(styles); + + return ( +
+ + + Left + + + Center + + + Right + + + You selected: {valueSelected.value} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-value-bind.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-value-bind.tsx new file mode 100644 index 000000000..8c87fdb23 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-value-bind.tsx @@ -0,0 +1,27 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + const valueSelected = useSignal('left'); + + useStyles$(styles); + + return ( +
+ + + Left + + + Center + + + Right + + + You selected: {valueSelected.value} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-value-multiple.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-value-multiple.tsx new file mode 100644 index 000000000..303d8f66d --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-value-multiple.tsx @@ -0,0 +1,30 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + const valueSelected = useSignal(['left', 'center']); + useStyles$(styles); + + return ( +
+ (valueSelected.value = v)} + > + + Left + + + Center + + + Right + + + You selected: {valueSelected.value.join(',')} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-vertical-multiple-rtl.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-vertical-multiple-rtl.tsx new file mode 100644 index 000000000..a2ed5cb97 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-vertical-multiple-rtl.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/value-bind.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/value-bind.tsx new file mode 100644 index 000000000..1bf2ed8e1 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/value-bind.tsx @@ -0,0 +1,33 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + const valueSelected = useSignal('left'); + + useStyles$(styles); + + return ( +
+ + + Left + + + Center + + + Right + + + You selected: {valueSelected.value} + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/value.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/value.tsx new file mode 100644 index 000000000..2bfff00a3 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/value.tsx @@ -0,0 +1,32 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + const valueSelected = useSignal('left'); + + useStyles$(styles); + + return ( +
+ { + valueSelected.value = v; + }} + > + + Left + + + Center + + + Right + + + You selected: {valueSelected.value} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/vertical.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/vertical.tsx new file mode 100644 index 000000000..d3c9ec4f6 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/vertical.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/index.mdx b/apps/website/src/routes/docs/headless/toggle-group/index.mdx new file mode 100644 index 000000000..534249f46 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/index.mdx @@ -0,0 +1,213 @@ +--- +title: Qwik UI | Toggle Group +--- + +import { statusByComponent } from '~/_state/component-statuses'; + + + +# Toggle Group + +A set of two-state buttons that can be toggled on or off. + + + +## ✨ Features + + + +## Building blocks + + + +### 🎨 Anatomy + + + +## Usage / Component State + +### Multiple selection + +Pass a `multiple` prop to enable multi-selection. + + + +### Initial Value (Uncontrolled) + +An initial, uncontrolled value can be provided using the `value` prop. + + + +If you want to have some control when an item is selected, like making some side effect you can use +the `onChange$`. The event is fired when the user toggle the button, and receives the new value. + + + +### Reactive Value (Controlled) + +Pass a signal to `bind:value` prop to make the pressed state controlled (binding the value with a signal). + + + +### Disabled + +Pass the `disabled` prop. + + + +You can also disabled specific items, pass the `disabled` prop at the `ToggleGroup.Item` level: +When navigating with key arrows, the disabled item will be skipped. + + + +### Looping Enabled + +Pass the `loop` prop. When enabled, keyboard navigation will loop from last item to first, and vice versa. +If one item is disabled it will skip it. + + + +If one item is disabled it will skip it. + + + +### Vertical Orientation + +Pass the `orientation` prop. + + + +### Right-to-Left (rtl) Direction + +Pass the `direction` prop to `rtl`. + + + +## Accessibility + +### Keyboard interaction + + + +## API + +### ToggleGroup.Root + + void>', + description: 'Called when the state changes.', + }, + { + name: 'bind:value', + type: 'Signal', + description: 'Reactive value (signal) to make the pressed state controlled.', + }, + { + name: 'disabled', + type: 'boolean', + description: 'Disables all items.', + }, + { + name: 'loop', + type: 'boolean', + description: + 'Enable looping when navigating with the keyboard. Default to `false`.', + }, + { + name: 'orientation', + type: '"horizontal" | "vertical"', + description: 'Choose the orientation of the toggle items. Default to "horizontal".', + }, + { + name: 'direction', + type: '"ltr" | "rtl"', + description: 'Choose the direction of the toggle items. Default to "ltr".', + }, + ]} +/> + +### ToggleGroup.Item + + diff --git a/apps/website/src/routes/docs/headless/toggle-group/snippets/building-blocks.tsx b/apps/website/src/routes/docs/headless/toggle-group/snippets/building-blocks.tsx new file mode 100644 index 000000000..0f62459ab --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/snippets/building-blocks.tsx @@ -0,0 +1,18 @@ +import { component$ } from '@builder.io/qwik'; +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + return ( + + + Left + + + Center + + + Right + + + ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/snippets/toggle.css b/apps/website/src/routes/docs/headless/toggle-group/snippets/toggle.css new file mode 100644 index 000000000..9500c9dfb --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/snippets/toggle.css @@ -0,0 +1,65 @@ +.toggle-container { + font-size: 15px; + font-weight: 500; + line-height: 35px; + color: hsl(var(--foreground)); + display: flex; + flex-direction: column; +} + +.toggle { + width: auto; + align-items: center; + justify-content: center; + border-radius: 4px; + padding: 0 10px; + height: 35px; + font-size: 15px; + line-height: 1; + background-color: hsl(var(--background)); + border: 2px dotted hsla(var(--primary) / 1); + border-radius: 0; +} + +/* Default background when aria-pressed is false */ +.toggle[aria-pressed='false'] { + background-color: hsl(var(--background)) !important; + color: hsl(var(--foreground)); +} +/* Ensure focus or active state does not override when aria-pressed is false */ +.toggle[aria-pressed='false']:focus, +.toggle[aria-pressed='false']:active { + background-color: hsl(var(--background)) !important; + color: hsl(var(--foreground)); +} + +.toggle:hover { + background-color: hsla(var(--primary) / 0.08); +} + +/* Focused and Pressed states */ +.toggle:focus-visible { + outline: 2px solid hsla(var(--primary)); + outline-offset: 2px; +} + +.toggle:focus, +.toggle:active { + background-color: hsla(var(--primary)); + color: white; +} + +/* When the toggle is pressed */ +.toggle[aria-pressed='true'] { + background-color: hsla(var(--primary)) !important; + color: white; +} + +.toggle[aria-pressed='true']:focus { + outline: 2px solid hsla(var(--secondary)); +} + +[data-disabled] { + opacity: 0.6; + background: hsl(var(--foreground) / 0.05); +} diff --git a/apps/website/src/routes/docs/headless/toggle/examples/bind-pressed.tsx b/apps/website/src/routes/docs/headless/toggle/examples/bind-pressed.tsx new file mode 100644 index 000000000..8d49106f3 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle/examples/bind-pressed.tsx @@ -0,0 +1,27 @@ +import { component$, useComputed$, useSignal, useStyles$ } from '@builder.io/qwik'; +import { Toggle } from '@qwik-ui/headless'; +import styles from '../snippets/toggle.css?inline'; + +export default component$(() => { + useStyles$(styles); + const pressedState = useSignal(true); + + const text = useComputed$(() => { + return pressedState.value ? 'You pressed me' : 'You unpressed me'; + }); + + return ( +
+ + Hello + + {text.value} + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle/examples/disabled.tsx b/apps/website/src/routes/docs/headless/toggle/examples/disabled.tsx new file mode 100644 index 000000000..a97560b91 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle/examples/disabled.tsx @@ -0,0 +1,14 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import { Toggle } from '@qwik-ui/headless'; +import styles from '../snippets/toggle.css?inline'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + Hello + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle/examples/hero.tsx b/apps/website/src/routes/docs/headless/toggle/examples/hero.tsx index ac3059fcc..c08d3df45 100644 --- a/apps/website/src/routes/docs/headless/toggle/examples/hero.tsx +++ b/apps/website/src/routes/docs/headless/toggle/examples/hero.tsx @@ -1,10 +1,12 @@ -import { $, component$ } from '@builder.io/qwik'; +import { component$, useStyles$ } from '@builder.io/qwik'; import { Toggle } from '@qwik-ui/headless'; +import styles from '../snippets/toggle.css?inline'; export default component$(() => { + useStyles$(styles); return ( - <> - console.log('Toggle'))} /> - +
+ Hello +
); }); diff --git a/apps/website/src/routes/docs/headless/toggle/examples/initialPressed.tsx b/apps/website/src/routes/docs/headless/toggle/examples/initialPressed.tsx new file mode 100644 index 000000000..062a747f2 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle/examples/initialPressed.tsx @@ -0,0 +1,14 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import { Toggle } from '@qwik-ui/headless'; +import styles from '../snippets/toggle.css?inline'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + Hello + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle/examples/pressed.tsx b/apps/website/src/routes/docs/headless/toggle/examples/pressed.tsx new file mode 100644 index 000000000..4a6976ea4 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle/examples/pressed.tsx @@ -0,0 +1,22 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import { Toggle } from '@qwik-ui/headless'; +import styles from '../snippets/toggle.css?inline'; + +export default component$(() => { + useStyles$(styles); + const text = useSignal('Unpress me'); + return ( +
+ + p ? (text.value = 'Unpress me') : (text.value = 'Press me') + } + class="toggle" + > + Hello + + {text.value} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle/examples/test-bind-pressed.tsx b/apps/website/src/routes/docs/headless/toggle/examples/test-bind-pressed.tsx new file mode 100644 index 000000000..5045d581a --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle/examples/test-bind-pressed.tsx @@ -0,0 +1,28 @@ +import { component$, useComputed$, useSignal, useStyles$ } from '@builder.io/qwik'; +import { Toggle } from '@qwik-ui/headless'; +import styles from '../snippets/toggle.css?inline'; + +export default component$(() => { + useStyles$(styles); + const pressedState = useSignal(true); + + const text = useComputed$(() => { + return pressedState.value ? 'You pressed me' : 'You unpressed me'; + }); + + return ( +
+ + Hello + + {text.value} + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle/examples/test-disabled-pressed.tsx b/apps/website/src/routes/docs/headless/toggle/examples/test-disabled-pressed.tsx new file mode 100644 index 000000000..49d4c3b45 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle/examples/test-disabled-pressed.tsx @@ -0,0 +1,14 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import { Toggle } from '@qwik-ui/headless'; +import styles from '../snippets/toggle.css?inline'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + Hello + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle/examples/test-pressed-disabled.tsx b/apps/website/src/routes/docs/headless/toggle/examples/test-pressed-disabled.tsx new file mode 100644 index 000000000..819512528 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle/examples/test-pressed-disabled.tsx @@ -0,0 +1,14 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import { Toggle } from '@qwik-ui/headless'; +import styles from '../snippets/toggle.css?inline'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + Hello + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle/index.mdx b/apps/website/src/routes/docs/headless/toggle/index.mdx index ef32861ef..4c43d688f 100644 --- a/apps/website/src/routes/docs/headless/toggle/index.mdx +++ b/apps/website/src/routes/docs/headless/toggle/index.mdx @@ -8,17 +8,49 @@ import { statusByComponent } from '~/_state/component-statuses'; # Toggle -A toggle component is a primitive that us used to quickly switch between two possible states. +A two-state button that can be either on or off. +## ✨ Features + + + ## Building blocks -## Examples +## Usage / Component State + +### Initial Value (Uncontrolled) + +An initial, uncontrolled value can be provided using the `pressed` prop. + + + +If you want to have some control when the toggle is pressed, like making some side effect you can use +the `onPressedChange$`. The event is fired when the user toggle the button, and receives the new value. + + + +### Reactive Value (Controlled) + +Pass a signal to `bind:value` prop to make the pressed state controlled (binding the value with a signal). -### WIP + + +### Disabled + +Pass the `disabled` prop. + + ## Accessibility @@ -28,45 +60,40 @@ A toggle component is a primitive that us used to quickly switch between two pos keyDescriptors={[ { keyTitle: 'Space', - description: 'Toggle between states', + description: 'Toggles between states.', }, { - keyTitle: 'Tab', - description: 'Moves focus to the next focusable element.', - }, - { - keyTitle: 'Shift + Tab', - description: 'Moves focus to the previous focusable element.', + keyTitle: 'Enter', + description: 'Toggles between states.', }, ]} /> ## API -### Toggle - void>', + description: 'Called when the state changes.', }, { - name: 'onClick$', - info: 'PropFunction<() => void>', - type: 'function', - description: 'A custom click handler to wire to the toggle click event', + name: 'bind:pressed', + type: 'Signal', + description: 'Reactive value (signal) to make the pressed state controlled.', }, { name: 'disabled', type: 'boolean', - description: 'Sets whether the toggle is disabled or not', + description: 'Disables the toggle making the toggle unpressable.', }, ]} /> diff --git a/apps/website/src/routes/docs/headless/toggle/snippets/building-blocks.tsx b/apps/website/src/routes/docs/headless/toggle/snippets/building-blocks.tsx index be31c88c9..86aa2efc6 100644 --- a/apps/website/src/routes/docs/headless/toggle/snippets/building-blocks.tsx +++ b/apps/website/src/routes/docs/headless/toggle/snippets/building-blocks.tsx @@ -1,7 +1,6 @@ -import { component$, useSignal } from '@builder.io/qwik'; +import { component$ } from '@builder.io/qwik'; import { Toggle } from '@qwik-ui/headless'; export default component$(() => { - const toggleChecked = useSignal(false); - return ; + return Hello; }); diff --git a/apps/website/src/routes/docs/headless/toggle/snippets/toggle.css b/apps/website/src/routes/docs/headless/toggle/snippets/toggle.css new file mode 100644 index 000000000..90b375262 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle/snippets/toggle.css @@ -0,0 +1,66 @@ +.toggle-container { + font-size: 15px; + font-weight: 500; + line-height: 35px; + color: hsl(var(--foreground)); + display: flex; + flex-direction: column; + align-items: center; +} + +.toggle { + width: auto; + align-items: center; + justify-content: center; + border-radius: 4px; + padding: 0 10px; + height: 35px; + font-size: 15px; + line-height: 1; + background-color: hsl(var(--background)); + border: 2px dotted hsla(var(--primary) / 1); + border-radius: 0; +} + +/* Default background when aria-pressed is false */ +.toggle[aria-pressed='false'] { + background-color: hsl(var(--background)) !important; + color: hsl(var(--foreground)); +} +/* Ensure focus or active state does not override when aria-pressed is false */ +.toggle[aria-pressed='false']:focus, +.toggle[aria-pressed='false']:active { + background-color: hsl(var(--background)) !important; + color: hsl(var(--foreground)); +} + +.toggle:hover { + background-color: hsla(var(--primary) / 0.08); +} + +/* Focused and Pressed states */ +.toggle:focus-visible { + outline: 2px solid hsla(var(--primary)); + outline-offset: 2px; +} + +.toggle:focus, +.toggle:active { + background-color: hsla(var(--primary)); + color: white; +} + +/* When the toggle is pressed */ +.toggle[aria-pressed='true'] { + background-color: hsla(var(--primary)) !important; + color: white; +} + +.toggle[aria-pressed='true']:focus { + outline: 2px solid hsla(var(--secondary)); +} + +[data-disabled] { + opacity: 0.6; + background: hsl(var(--foreground) / 0.05); +} diff --git a/apps/website/src/routes/docs/styled/menu.md b/apps/website/src/routes/docs/styled/menu.md index 260e7af50..c813c7584 100644 --- a/apps/website/src/routes/docs/styled/menu.md +++ b/apps/website/src/routes/docs/styled/menu.md @@ -35,3 +35,5 @@ - [Skeleton](/docs/styled/skeleton) - [Tabs](/docs/styled/tabs) - [Textarea](/docs/styled/textarea) +- [Toggle](/docs/styled/toggle) +- [ToggleGroup](/docs/styled/toggle-group) diff --git a/apps/website/src/routes/docs/styled/toggle-group/examples/disabled.tsx b/apps/website/src/routes/docs/styled/toggle-group/examples/disabled.tsx new file mode 100644 index 000000000..ae0a5b661 --- /dev/null +++ b/apps/website/src/routes/docs/styled/toggle-group/examples/disabled.tsx @@ -0,0 +1,18 @@ +import { component$ } from '@builder.io/qwik'; +import { ToggleGroup } from '~/components/ui'; + +export default component$(() => { + return ( + + + Left + + + Center + + + Right + + + ); +}); diff --git a/apps/website/src/routes/docs/styled/toggle-group/examples/hero.tsx b/apps/website/src/routes/docs/styled/toggle-group/examples/hero.tsx new file mode 100644 index 000000000..5f7a89437 --- /dev/null +++ b/apps/website/src/routes/docs/styled/toggle-group/examples/hero.tsx @@ -0,0 +1,18 @@ +import { component$ } from '@builder.io/qwik'; +import { ToggleGroup } from '~/components/ui'; + +export default component$(() => { + return ( + + + Left + + + Center + + + Right + + + ); +}); diff --git a/apps/website/src/routes/docs/styled/toggle-group/examples/multiple.tsx b/apps/website/src/routes/docs/styled/toggle-group/examples/multiple.tsx new file mode 100644 index 000000000..ba44dcaea --- /dev/null +++ b/apps/website/src/routes/docs/styled/toggle-group/examples/multiple.tsx @@ -0,0 +1,18 @@ +import { component$ } from '@builder.io/qwik'; +import { ToggleGroup } from '~/components/ui'; + +export default component$(() => { + return ( + + + Left + + + Center + + + Right + + + ); +}); diff --git a/apps/website/src/routes/docs/styled/toggle-group/index.mdx b/apps/website/src/routes/docs/styled/toggle-group/index.mdx new file mode 100644 index 000000000..7ee702d5c --- /dev/null +++ b/apps/website/src/routes/docs/styled/toggle-group/index.mdx @@ -0,0 +1,120 @@ +--- +title: Qwik UI | Styled Toggle Group Component +--- + +import { statusByComponent } from '~/_state/component-statuses'; + + + +# Toggle Group + +A set of two-state buttons that can be toggled on or off. + + + +You can choose a multiple selection variant: + + + +And here is the default look for a disabled `ToggleGroup.Root`: + + + + + To see all features and the API checkout our + + Headless component + + + +## Installation + +**Run the following cli command or copy/paste the component code into your project** + +```sh +qwik-ui add toggle-group +``` + +```tsx +import { + component$, + type PropsOf, + Slot, + useContext, + useContextProvider, +} from '@builder.io/qwik'; +import { cn } from '@qwik-ui/utils'; +import { ToggleGroup as HeadlessToggleGroup } from '@qwik-ui/headless'; + +import { toggleVariants } from '@qwik-ui/styled'; +import type { VariantProps } from 'class-variance-authority'; + +import { createContextId } from '@builder.io/qwik'; + +export const toggleGroupStyledContextId = createContextId( + 'qui-toggle-group-styled', +); + +export type ToggleGroupStyledContext = VariantProps; + +type ToggleGroupRootProps = PropsOf & + VariantProps; + +const Root = component$(({ size, look, ...props }) => { + const contextStyled: ToggleGroupStyledContext = { + size, + look, + }; + useContextProvider(toggleGroupStyledContextId, contextStyled); + + return ( + + + + ); +}); + +type ToggleGroupItemProps = PropsOf & + VariantProps; + +const Item = component$(({ ...props }) => { + const { size, look } = useContext(toggleGroupStyledContextId); + + return ( + + + + ); +}); + +export const ToggleGroup = { + Root, + Item, +}; +``` + +## Usage + +```tsx +import { ToggleGroup } from '~/components/ui'; +``` + +```tsx + + + Left + + + Center + + + Right + + +``` diff --git a/apps/website/src/routes/docs/styled/toggle/examples/bind-pressed.tsx b/apps/website/src/routes/docs/styled/toggle/examples/bind-pressed.tsx new file mode 100644 index 000000000..1f5aecfa7 --- /dev/null +++ b/apps/website/src/routes/docs/styled/toggle/examples/bind-pressed.tsx @@ -0,0 +1,26 @@ +import { component$, useComputed$, useSignal } from '@builder.io/qwik'; +import { Toggle } from '~/components/ui'; + +export default component$(() => { + const pressedState = useSignal(true); + + const text = useComputed$(() => { + return pressedState.value ? 'You pressed me' : 'You unpressed me'; + }); + + return ( +
+ + Hello + + + {text.value} + +
+ ); +}); diff --git a/apps/website/src/routes/docs/styled/toggle/examples/disabled.tsx b/apps/website/src/routes/docs/styled/toggle/examples/disabled.tsx new file mode 100644 index 000000000..025f0e29f --- /dev/null +++ b/apps/website/src/routes/docs/styled/toggle/examples/disabled.tsx @@ -0,0 +1,6 @@ +import { component$ } from '@builder.io/qwik'; +import { Toggle } from '~/components/ui'; + +export default component$(() => { + return Hello; +}); diff --git a/apps/website/src/routes/docs/styled/toggle/examples/hero.tsx b/apps/website/src/routes/docs/styled/toggle/examples/hero.tsx new file mode 100644 index 000000000..85ca8dfdf --- /dev/null +++ b/apps/website/src/routes/docs/styled/toggle/examples/hero.tsx @@ -0,0 +1,6 @@ +import { component$ } from '@builder.io/qwik'; +import { Toggle } from '~/components/ui'; + +export default component$(() => { + return Hello; +}); diff --git a/apps/website/src/routes/docs/styled/toggle/examples/initialPressed.tsx b/apps/website/src/routes/docs/styled/toggle/examples/initialPressed.tsx new file mode 100644 index 000000000..74d80ae04 --- /dev/null +++ b/apps/website/src/routes/docs/styled/toggle/examples/initialPressed.tsx @@ -0,0 +1,6 @@ +import { component$ } from '@builder.io/qwik'; +import { Toggle } from '~/components/ui'; + +export default component$(() => { + return Hello; +}); diff --git a/apps/website/src/routes/docs/styled/toggle/examples/pressed.tsx b/apps/website/src/routes/docs/styled/toggle/examples/pressed.tsx new file mode 100644 index 000000000..4ae94e401 --- /dev/null +++ b/apps/website/src/routes/docs/styled/toggle/examples/pressed.tsx @@ -0,0 +1,19 @@ +import { component$, useSignal } from '@builder.io/qwik'; +import { Toggle } from '~/components/ui'; + +export default component$(() => { + const text = useSignal('Unpress me'); + return ( +
+ + p ? (text.value = 'Unpress me') : (text.value = 'Press me') + } + > + Hello + + {text.value} +
+ ); +}); diff --git a/apps/website/src/routes/docs/styled/toggle/index.mdx b/apps/website/src/routes/docs/styled/toggle/index.mdx index 7e84a095b..1aeeeeb1f 100644 --- a/apps/website/src/routes/docs/styled/toggle/index.mdx +++ b/apps/website/src/routes/docs/styled/toggle/index.mdx @@ -4,8 +4,95 @@ title: Qwik UI | Styled Toggle Component import { statusByComponent } from '~/_state/component-statuses'; + + # Toggle +A two-state button that can be either on or off. + + + In a world of endless choices, sometimes you just need a simple yes or no. The Qwik UI Styled Toggle component is a welcomed rest for the mind. - +## Installation + +**Run the following cli command or copy/paste the component code into your project** + +```sh +qwik-ui add toggle +``` + +```tsx +import { component$, type PropsOf, Slot } from '@builder.io/qwik'; +import { cn } from '@qwik-ui/utils'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { Toggle as HeadlessToggle } from '@qwik-ui/headless'; + +export const toggleVariants = cva( + 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-[pressed=true]:bg-primary aria-[pressed=true]:text-accent-foreground', + { + variants: { + look: { + default: 'border border-input bg-transparent', + outline: + 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground', + }, + + size: { + default: 'h-10 px-3', + sm: 'h-9 px-2.5', + lg: 'h-11 px-5', + }, + }, + defaultVariants: { + look: 'default', + size: 'default', + }, + }, +); + +type ToggleProps = PropsOf & VariantProps; + +export const Toggle = component$(({ size, look, ...props }) => { + return ( + + + + ); +}); +``` + +## Usage + +```tsx +import { Toggle } from '~/components/ui'; +``` + +```tsx +Hello + +If you want to have some control when the toggle is pressed, like making some side effect you can use +the `onPressedChange$`. The event is fired when the user toggle the button, and receives the new value. + + + +### Reactive Value (Controlled) + +Pass a signal to `bind:value` prop to make the pressed state controlled (binding the value with a signal). + + + +### Disabled + +Pass the `disabled` prop. + + diff --git a/cla-signs/v1/cla.json b/cla-signs/v1/cla.json index 72d09f8e6..0b0d0abf4 100644 --- a/cla-signs/v1/cla.json +++ b/cla-signs/v1/cla.json @@ -569,4 +569,4 @@ "pullRequestNo": 957 } ] -} \ No newline at end of file +} diff --git a/packages/kit-headless/src/components/toggle-group/index.tsx b/packages/kit-headless/src/components/toggle-group/index.tsx new file mode 100644 index 000000000..fc5b18aa7 --- /dev/null +++ b/packages/kit-headless/src/components/toggle-group/index.tsx @@ -0,0 +1,6 @@ +import { HToggleGroupItem } from './toggle-group-item'; +import { HToggleGroupRoot } from './toggle-group-root'; +export const ToggleGroup = { + Root: HToggleGroupRoot, + Item: HToggleGroupItem, +}; diff --git a/packages/kit-headless/src/components/toggle-group/toggle-group-context.tsx b/packages/kit-headless/src/components/toggle-group/toggle-group-context.tsx new file mode 100644 index 000000000..776536bc0 --- /dev/null +++ b/packages/kit-headless/src/components/toggle-group/toggle-group-context.tsx @@ -0,0 +1,34 @@ +import type { QRL, Signal } from '@builder.io/qwik'; +import { createContextId } from '@builder.io/qwik'; + +export const toggleGroupRootApiContextId = createContextId( + 'qui-toggle-group-root-api', +); + +export type Orientation = 'horizontal' | 'vertical'; +export type Direction = 'ltr' | 'rtl'; + +export type ItemId = string; +export type Item = { + itemId: ItemId; + ref: Signal; + isPressed: Signal; + isDisabled: boolean; + tabIndex: Signal; +}; + +export type ToggleGroupRootApiContext = { + rootId: string; + rootOrientation: Orientation; + rootDirection: Direction; + rootIsDisabled: boolean; + rootIsLoopEnabled: boolean; + rootMultiple: boolean; + activateItem$: QRL<(itemValue: string) => Promise | void>; + deActivateItem$: QRL<(itemValue: string) => Promise | void>; + getAllItem$: QRL<() => Item[]>; + pressedValuesSig: Signal; + getAndSetTabIndexItem$: QRL<(itemId: ItemId, tabIndexValue: 0 | -1) => void>; + registerItem$: QRL<(itemId: ItemId, itemSig: Signal) => void>; + itemsCSR: Signal; +}; diff --git a/packages/kit-headless/src/components/toggle-group/toggle-group-item.tsx b/packages/kit-headless/src/components/toggle-group/toggle-group-item.tsx new file mode 100644 index 000000000..d690ef9fe --- /dev/null +++ b/packages/kit-headless/src/components/toggle-group/toggle-group-item.tsx @@ -0,0 +1,244 @@ +import type { PropsOf } from '@builder.io/qwik'; +import { + component$, + useContext, + Slot, + $, + useId, + useSignal, + useTask$, +} from '@builder.io/qwik'; +import { Toggle } from '@qwik-ui/headless'; +import { + Direction, + Item, + Orientation, + toggleGroupRootApiContextId, +} from './toggle-group-context'; +import { KeyCode } from '../../utils'; +import { isBrowser, isServer } from '@builder.io/qwik/build'; + +type NavigationKeys = + | KeyCode.ArrowRight + | KeyCode.ArrowLeft + | KeyCode.ArrowDown + | KeyCode.ArrowUp; + +type Step = -1 | 0 | 1; + +const keyNavigationMap: Record< + Orientation, + Record> +> = { + horizontal: { + ltr: { + ArrowRight: 1, + ArrowLeft: -1, + ArrowDown: 0, + ArrowUp: 0, + }, + rtl: { + ArrowRight: -1, + ArrowLeft: 1, + ArrowDown: 0, + ArrowUp: 0, + }, + }, + vertical: { + ltr: { + ArrowDown: 1, + ArrowUp: -1, + ArrowRight: 0, + ArrowLeft: 0, + }, + rtl: { + ArrowDown: -1, + ArrowUp: 1, + ArrowRight: 0, + ArrowLeft: 0, + }, + }, +}; + +type ToggleGroupItemProps = PropsOf & { + value: string; +}; + +export const HToggleGroupItem = component$((props) => { + const { value, disabled: itemDisabled = false, ...itemProps } = props; + + const rootApiContext = useContext(toggleGroupRootApiContextId); + + const disabled = rootApiContext.rootIsDisabled || itemDisabled; + + const itemId = useId(); + const isPressedSig = useSignal(false); + const itemRef = useSignal(); + const itemTabIndex = useSignal(isPressedSig.value ? 0 : -1); + + const itemSig = useSignal(() => ({ + itemId: itemId, + isPressed: isPressedSig, + isDisabled: disabled, + ref: itemRef, + tabIndex: itemTabIndex, + })); + + useTask$(async ({ track }) => { + const pressedValue = track(() => rootApiContext.pressedValuesSig.value); + + if (pressedValue == null) { + itemSig.value.isPressed.value = false; + return; + } + + if (typeof pressedValue === 'string') { + itemSig.value.isPressed.value = pressedValue === value; + } else { + itemSig.value.isPressed.value = pressedValue.includes(value); + } + }); + + //Item instantiation + useTask$(async () => { + /* + Instatiation of items with their itemIds + Attention: in CSR, items are registered "out of order" (itemId generation) + you can notice: + - the first itemId "generate" before the useTask is wrong + - the itemId read within this useTask is not the same as the one read locally. + + Still, the order how items render is correct. + + So we doing stuff on the client (CSR, onKeyDown, etc) + we can't use rootApiContext.getAllItem$() as we get Items "out of order"). + Perhaps this can be fix in v2? + + Solution: if we want to get the list of items in order, we need to use "refs" directly. + Meaning we need to use this api: rootApiContext.itemsCSR + */ + + //Note: this line execute X times in a row. (X = number of items) + await rootApiContext.registerItem$(itemId, itemSig); + + //setup the tabIndex for each item + const allItems = await rootApiContext.getAllItem$(); + + if (isBrowser) return; + + //ensure each pressedItems have tabIndex = 0 + const currentPressedItems = allItems.filter((item) => item.isPressed.value === true); + + if (currentPressedItems.length > 0) { + return currentPressedItems.forEach(async (item) => { + await rootApiContext.getAndSetTabIndexItem$(item.itemId, 0); + }); + } + + //ensure the first item that is not disabled have tabIndex = 0 + const firstNotDisabledItem = allItems.find((item) => item.isDisabled === false); + + if (firstNotDisabledItem !== undefined) { + await rootApiContext.getAndSetTabIndexItem$(firstNotDisabledItem.itemId, 0); + } + }); + + //instantiate setTabIndex for CSR + useTask$(async ({ track }) => { + if (isServer) return; + track(() => itemRef.value); + + //register refs to the Root + if (!itemRef.value) return; + rootApiContext.itemsCSR.value = [...rootApiContext.itemsCSR.value, itemRef.value]; + + if ( + rootApiContext.itemsCSR.value.length === (await rootApiContext.getAllItem$()).length + ) { + const allItems = rootApiContext.itemsCSR.value; + + //ensure each pressedItems have tabIndex = 0 + const currentPressedItems = allItems.filter((item) => item.ariaPressed === 'true'); + + if (currentPressedItems.length > 0) { + return currentPressedItems.forEach(async (item) => { + const itemRef = allItems.find((i) => i.id === item.id); + if (!itemRef) throw 'Item Not Found'; + itemRef.tabIndex = 0; + }); + } + + //ensure the first item that is not disabled have tabIndex = 0 + const firstNotDisabledItem = allItems.find((item) => item.ariaDisabled === 'false'); + + if (firstNotDisabledItem !== undefined) { + firstNotDisabledItem.tabIndex = 0; + } + } + }); + + const handlePressed$ = $((pressed: boolean) => { + if (pressed) { + rootApiContext.activateItem$(value); + } else { + rootApiContext.deActivateItem$(value); + } + }); + + const handleKeyDown$ = $(async (event: KeyboardEvent) => { + //Note: here we can't use use rootApiContext.items.value as when instantiante its [] + //we might need to make a QRL same as "rootApiContext.getAllItems$()" + const items = Array.from( + document.querySelectorAll(`.toggle-group-item-${rootApiContext.rootId}`), + ) as HTMLElement[]; + + if (items.length === 0) return; + + const enabledItems = items.filter((item) => item.ariaDisabled === 'false'); + //each item has an id (see below the Toggle JSX output) + const currentElement = event.target as HTMLElement; + const currentIndex = enabledItems.findIndex((e) => e.id === currentElement.id); + + if (currentIndex === -1) return; + + //read the direction for the key based on the orientation + const direction = + keyNavigationMap[rootApiContext.rootOrientation][rootApiContext.rootDirection][ + event.key as NavigationKeys + ]; + + //find and nextFocus + if (direction !== 0) { + let nextIndex = currentIndex + direction; + if (rootApiContext.rootIsLoopEnabled) { + // If looping is enabled, wrap around, skipping disabled items + nextIndex = + (currentIndex + direction + enabledItems.length) % enabledItems.length; + } else { + // If looping is disabled, clamp to valid indices + if (nextIndex >= enabledItems.length) nextIndex = enabledItems.length - 1; + if (nextIndex < 0) nextIndex = 0; + } + enabledItems[nextIndex]?.focus(); + } + }); + + return ( + + + + ); +}); diff --git a/packages/kit-headless/src/components/toggle-group/toggle-group-root.tsx b/packages/kit-headless/src/components/toggle-group/toggle-group-root.tsx new file mode 100644 index 000000000..37627c800 --- /dev/null +++ b/packages/kit-headless/src/components/toggle-group/toggle-group-root.tsx @@ -0,0 +1,284 @@ +import type { PropsOf, QRL, Signal } from '@builder.io/qwik'; +import { component$, useContextProvider, Slot, useTask$, $ } from '@builder.io/qwik'; +import { + toggleGroupRootApiContextId, + type Direction, + type Orientation, + type ToggleGroupRootApiContext, +} from './toggle-group-context'; +import { useToggleGroup } from './use-toggle'; +import { isBrowser, isServer } from '@builder.io/qwik/build'; + +export type ToggleGroupBaseProps = { + /** + * When true, prevents the user from interacting with the toggle group and all its items. + */ + disabled?: boolean; +}; + +type ToggleGroupNavigationProps = { + /** + * The orientation of the component, which determines how focus moves: + * horizontal for left/right arrows and vertical for up/down arrows. + * Default to (left-to-right) reading mode. + */ + orientation?: Orientation; + /** + * The reading direction of the toggle group. + * Default to (left-to-right) reading mode. + */ + direction?: Direction; + /** + * When true + * keyboard navigation will loop from last item to first, and vice versa. + */ + loop?: boolean; +}; + +export type ToggleGroupSingleProps = { + /** + * Determines if multi selection is enabled. + */ + multiple?: false; + /** + * The initial value of the pressed item (uncontrolled). + * Can be used in conjunction with onChange$. + */ + value?: string; + + /** + * The callback that fires when the value of the toggle group changes. + * Event handler called when the pressed state of an item changes. + */ + onChange$?: QRL<(value: string) => void>; + /** + * The reactive value (a signal) of the pressed item (the signal is the controlled value). + * Controlling the pressed state with a bounded value. + */ + 'bind:value'?: Signal; +}; + +export type ToggleGroupMultipleProps = { + /** + * Determines if multi selection is enabled. + */ + multiple?: true; + /** + * The initial value of the pressed item (uncontrolled). + * Can be used in conjunction with onChange$. + */ + value?: string[]; + /** + * The callback that fires when the value of the toggle group changes. + * Event handler called when the pressed state of an item changes. + */ + onChange$?: QRL<(value: string[]) => void>; + /** + * The reactive value (a signal) of the pressed item (the signal is the controlled value). + * Controlling the pressed state with a bounded value. + */ + 'bind:value'?: Signal; +}; + +export type ToggleGroupApiProps = (ToggleGroupSingleProps | ToggleGroupMultipleProps) & + ToggleGroupBaseProps & + ToggleGroupNavigationProps; + +export type ToggleGroupRootProps = PropsOf<'div'> & ToggleGroupApiProps; + +export const HToggleGroupRoot = component$((props) => { + const { + onChange$: _, + disabled = false, + orientation = 'horizontal', + direction = 'ltr', + loop = false, + ...divProps + } = props; + + const commonProps = { role: 'group', 'aria-orientation': orientation, dir: direction }; + const orientationClass = orientation === 'vertical' ? 'flex-col' : 'flex-row'; + + const api = useToggleGroup(props); + + const rootApiContext: ToggleGroupRootApiContext = { + rootId: api.rootId, + rootOrientation: orientation, + rootDirection: direction, + rootIsDisabled: disabled, + rootIsLoopEnabled: loop, + rootMultiple: api.multiple, + activateItem$: api.activateItem$, + deActivateItem$: api.deActivateItem$, + getAllItem$: api.getAllItems$, + pressedValuesSig: api.pressedValuesSig, + getAndSetTabIndexItem$: api.getAndSetTabIndexItem$, + registerItem$: api.registerItem$, + itemsCSR: api.itemsCSR, + }; + + const setTabIndexInSSR = $(async () => { + const allItems = await rootApiContext.getAllItem$(); + + //if pressedItems exist, we set them to tabIndex = 0 + const currentPressedItems = allItems.filter((item) => item.isPressed.value === true); + + if (currentPressedItems.length > 0) { + currentPressedItems.forEach(async (item) => { + await rootApiContext.getAndSetTabIndexItem$(item.itemId, 0); + }); + + //and we ensure that the rest of items has tabIndex = -1 + allItems + .filter((item) => item.isPressed.value === false) + .forEach(async (item) => { + await rootApiContext.getAndSetTabIndexItem$(item.itemId, -1); + }); + + return; + } + //However, if no pressedItems exit, we only set tabIndexx = 0 on the first item that is not disabled + const firstNotDisabledItem = allItems.find((item) => item.isDisabled === false); + + if (currentPressedItems.length === 0 && firstNotDisabledItem !== undefined) { + if (firstNotDisabledItem !== undefined) { + await rootApiContext.getAndSetTabIndexItem$(firstNotDisabledItem.itemId, 0); + } + + //and we ensure that the rest of items has tabIndex = -1 + allItems + .filter((item) => item.itemId !== firstNotDisabledItem.itemId) + .forEach(async (item) => { + await rootApiContext.getAndSetTabIndexItem$(item.itemId, -1); + }); + + return; + } + }); + + const setTabIndexInCSR = $(async () => { + /* + Note: given a "single" toggle group with one item already pressed. + - if we use: const allItems = rootApiContext.itemsCSR.value; + - and we lookup for the currentPressedItems, we will get 2 items (the previous and the current) + For that reason to get the currentPressedItems we use: rootApiContext.getAllItem$() + However to get the firstNotDisabledItem, we need to use rootApiContext.itemsCSR.value (refs directly) + as rootApiContext.getAllItem$() will be "out of order". + + Ideally, if rootApiContext.getAllItem$() would be in appropriate order, we could use the same logic + for SSR and CSR. + In should be the case in v2, so we will refactor so both SSR and CSR will use the same API. + + + The other solution that I consider was: + to have a similar logic "setTabIndexInCSR" but this time which only use the refs + meaning (rootApiContext.itemsCSR.value) within the "toggle-group-item": + useTask$(async ({ track }) => { + if (isServer) return; + track(() => rootApiContext.pressedValuesSig.value); + await setTabIndexInCSR(); + }); + + However, I decide to use that function in Root to avoid execute that same logic X times + (X being the number of items) and the fact that Items are consumers that should work in isolation. + They should not execute logic for other Items. This is what Root should do. + */ + const allItems = await rootApiContext.getAllItem$(); + //if pressedItems exist, we set them to tabIndex = 0 + const currentPressedItems = allItems.filter((item) => item.isPressed.value === true); + + if (currentPressedItems.length > 0) { + currentPressedItems.forEach(async (item) => { + const pressedItem = allItems.find((i) => i.itemId === item.itemId); + if (!pressedItem) throw 'Item Not Found'; + if (pressedItem.ref.value) { + pressedItem.ref.value.tabIndex = 0; + } + }); + + //and we ensure that the rest of items has tabIndex = -1 + allItems + .filter((item) => item.isPressed.value === false) + .forEach(async (item) => { + const notPressedItem = allItems.find((i) => i.itemId === item.itemId); + if (!notPressedItem) throw 'Item Not Found'; + if (notPressedItem.ref.value) { + notPressedItem.ref.value.tabIndex = -1; + } + }); + + return; + } + + //However, if no pressedItems exit, we only set tabIndexx = 0 on the first item that is not disabled + /* + Unfortunately, rootApiContext.itemsCSR.value is empty because in the toggle-group-item + the first useTask is tracking the pressedValue changes. + If we put that task at the bottom, we will get the register itemsRef in rootApiContext.itemsCSR.value. + However it will cause other missbehaviors. + + Instead the safe way is to populate manually using the "document". + In v2, we will not this all those workarounds as the items will be in order and we will use the same API for both SSR and CSR. + */ + rootApiContext.itemsCSR.value = Array.from( + document.querySelectorAll(`.toggle-group-item-${rootApiContext.rootId}`), + ) as HTMLElement[]; + + const firstNotDisabledItem = rootApiContext.itemsCSR.value.find( + (item) => item.ariaDisabled === 'false', + ); + + if (currentPressedItems.length === 0 && firstNotDisabledItem !== undefined) { + if (firstNotDisabledItem !== undefined) { + firstNotDisabledItem.tabIndex = 0; + } + + //and we ensure that the rest of items has tabIndex = -1 + allItems + .filter((item) => item.itemId !== firstNotDisabledItem.id) + .forEach(async (item) => { + const otherItem = allItems.find((i) => i.itemId === item.itemId); + if (!otherItem) throw 'Item Not Found'; + if (otherItem.ref.value) { + otherItem.ref.value.tabIndex = -1; + } + }); + + return; + } + }); + + /* + TODO: optimize this code to make it faster (its a library) + Optimization = use a for loop instead of iterating multiple times. + Status: As the ToggleGroup component is in "Draft" state, I decided to not optimize it for now. + As it will decrease readability even more. + Decision: wait for v2, to refactor the code and have the same API for both SSR and CSR. + And then make the optimization. + */ + //side-effect, to setTabIndex + useTask$(async ({ track }) => { + track(() => api.pressedValuesSig.value); + + if (isServer) { + await setTabIndexInSSR(); + } + + if (isBrowser) { + await setTabIndexInCSR(); + } + }); + + useContextProvider(toggleGroupRootApiContextId, rootApiContext); + + return ( +
+ +
+ ); +}); diff --git a/packages/kit-headless/src/components/toggle-group/toggle-group.driver.ts b/packages/kit-headless/src/components/toggle-group/toggle-group.driver.ts new file mode 100644 index 000000000..e0115a684 --- /dev/null +++ b/packages/kit-headless/src/components/toggle-group/toggle-group.driver.ts @@ -0,0 +1,34 @@ +import { type Locator, type Page } from '@playwright/test'; +export type DriverLocator = Locator | Page; + +export function createTestDriver(rootLocator: T) { + const getRoot = () => { + return rootLocator; + }; + + const getToggleGroupRoot = () => { + return getRoot().locator('[data-qui-togglegroup-root]'); + }; + + const getItems = () => { + return getRoot().locator('[data-qui-togglegroup-item]'); + }; + + const getItemsLength = () => { + return getItems().count(); + }; + + const getItemByIndex = (index: number) => { + return getItems().nth(index); + }; + + return { + ...rootLocator, + locator: rootLocator, + getRoot, + getToggleGroupRoot, + getItems, + getItemsLength, + getItemByIndex, + }; +} diff --git a/packages/kit-headless/src/components/toggle-group/toggle-group.test.ts b/packages/kit-headless/src/components/toggle-group/toggle-group.test.ts new file mode 100644 index 000000000..984372580 --- /dev/null +++ b/packages/kit-headless/src/components/toggle-group/toggle-group.test.ts @@ -0,0 +1,1496 @@ +import { expect, test, type Page } from '@playwright/test'; +import { createTestDriver } from './toggle-group.driver'; + +async function setup(page: Page, exampleName: string) { + await page.goto(`/headless/toggle-group/${exampleName}`); + + const driver = createTestDriver(page); + return { + driver, + }; +} + +test.describe('Mouse Behavior', () => { + //'single' (multiple = false) + test(`GIVEN a toggle-group with items: left, center, right + WHEN the 'center' item is clicked + THEN the 'center' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.click(); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('tabIndex', '0'); + await expect(leftItem).toHaveAttribute('tabIndex', '-1'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + }); + + test(`GIVEN a toggle-group + WHEN the 'center' item is clicked + AND the 'right' item is clicked + THEN 'right' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //When, Then + await centerItem.click(); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + await rightItem.click(); + await expect(centerItem).toHaveAttribute('tabIndex', '-1'); + await expect(leftItem).toHaveAttribute('tabIndex', '-1'); + await expect(rightItem).toHaveAttribute('tabIndex', '0'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle-group with 'center' clicked + WHEN the 'right' item is clicked + AND the 'center' item is clicked + THEN 'center' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getItems()).toHaveCount(3); + + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + await centerItem.click(); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + //When, Then + await rightItem.click(); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + + await centerItem.click(); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('tabIndex', '0'); + await expect(leftItem).toHaveAttribute('tabIndex', '-1'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //type is 'multiple' + test(`GIVEN a toggle-group with items: left, center, right + WHEN the 'center' item is clicked + THEN the 'center' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.click(); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle-group with 'center' clicked + WHEN the 'right' item is clicked + THEN both 'center' AND right' items should have aria-pressed on true`, async ({ + page, + }) => { + const { driver: d } = await setup(page, 'multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + await centerItem.click(); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + //when + await rightItem.click(); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle-group with 'center' clicked + WHEN the 'right' item is clicked + AND the 'center' item is clicked + THEN 'right' item should have aria-pressed should be true`, async ({ page }) => { + const { driver: d } = await setup(page, 'multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + await centerItem.click(); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + //when + await rightItem.click(); + await centerItem.click(); + + //Then + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //Uncontrolled / Initial (default props) + //single (multiple = false) + test(`GIVEN a toggle-group with 'value' = 'left' + WHEN the 'center' item is clicked + THEN 'center' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'initialValue'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.click(); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //multiple + test(`GIVEN a toggle-group with 'value' = ['left', 'center'] + WHEN the 'center' item is clicked + THEN 'left' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-initialValue-multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.click(); + + //Then + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //Some control (value + onChange$) + //single + test(`GIVEN a toggle-group with 'value' = 'left' + WHEN the 'center' item is clicked + THEN 'center' item should have aria-pressed on true + AND valueSelected should be center`, async ({ page }) => { + const { driver: d } = await setup(page, 'value'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.click(); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //Reactive (controlled) + test(`GIVEN a toggle-group with 'bind:value' = Signal<'left'> + WHEN the 'center' item is clicked + THEN 'center' item should have aria-pressed on true + THEN the span element that store the value of the bounded Signal + should be updated`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-value-bind'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(leftItem).toHaveAttribute('tabIndex', '0'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('tabIndex', '-1'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + + //when + await centerItem.click(); + + //Then + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toHaveAttribute('tabIndex', '-1'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('tabIndex', '0'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + + const spanElement = await d.getRoot().locator('[test-data-bounded-span]'); + await expect(spanElement).toContainText('You selected: center'); + + //when + await centerItem.click(); + + //Then + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toHaveAttribute('tabIndex', '0'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('tabIndex', '-1'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + + await expect(spanElement).toContainText('You selected: '); + }); + + //multiple + test(`GIVEN a toggle-group with 'value' = ['left', 'center'] + WHEN the 'center' item is clicked + THEN 'left' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-value-multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.click(); + + //Then + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + + await expect(leftItem).toHaveAttribute('tabIndex', '0'); + await expect(centerItem).toHaveAttribute('tabIndex', '-1'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + }); + + test(`GIVEN a toggle-group with 'bind:value' = Signal<['left', 'center']> + WHEN the 'center' item is clicked + THEN 'center' item should have aria-pressed on false + THEN the span element that store the value of the bounded Signal + should be updated`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-value-bind-multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.click(); + + //Then + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + const spanElement = await d.getRoot().locator('[test-data-bounded-span]'); + await expect(spanElement).toContainText('You selected: left'); + }); + + //disabled + test(`GIVEN a 'disabled' toggle-group + WHEN the 'center' item is clicked (CAN'T BE CLICKED) + THEN data-disabled should remain on each item`, async ({ page }) => { + const { driver: d } = await setup(page, 'disabled'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('data-disabled'); + await expect(leftItem).toHaveAttribute('tabIndex', '-1'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toBeDisabled(); + await expect(centerItem).toHaveAttribute('data-disabled'); + await expect(centerItem).toHaveAttribute('tabIndex', '-1'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toBeDisabled(); + await expect(rightItem).toHaveAttribute('data-disabled'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + }); + + test(`GIVEN a 'disabled' AND 'multiple' toggle-group + WHEN the 'center' item is clicked (CAN'T BE CLICKED) + THEN data-disabled should remain on each item`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-disabled-multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('data-disabled'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toBeDisabled(); + await expect(centerItem).toHaveAttribute('data-disabled'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toBeDisabled(); + await expect(rightItem).toHaveAttribute('data-disabled'); + }); + + test(`GIVEN a 'disabled' toggle-group with 'value' = 'left' + WHEN the 'center' item is clicked (CAN'T BE CLICKED) + THEN data-disabled should remain on each item`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-disabled-value'); + + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('data-disabled'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toBeDisabled(); + await expect(centerItem).toHaveAttribute('data-disabled'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toBeDisabled(); + await expect(rightItem).toHaveAttribute('data-disabled'); + }); + + test(`GIVEN a 'disabled' toggle-group with 'value' = ['left', 'center'] + WHEN the 'center' item is clicked + THEN data-disabled should remain on each item`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-disabled-value-multiple'); + + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('data-disabled'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toBeDisabled(); + await expect(centerItem).toHaveAttribute('data-disabled'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toBeDisabled(); + await expect(rightItem).toHaveAttribute('data-disabled'); + }); + + test(`GIVEN a toggle-group with a disabled 'left' item + WHEN the 'center' item is clicked + AND the 'right' item is clicked + THEN data-disabled should remain on the 'left' item + AND 'center' item should have aria-pressed on false + AND 'right' item should have aria-pressed on true + `, async ({ page }) => { + const { driver: d } = await setup(page, 'test-item-disabled'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('data-disabled'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await centerItem.click(); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + await rightItem.click(); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('data-disabled'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + }); + + test(`GIVEN a toggle-group with a disabled 'left' item + WHEN the 'center' item is clicked + AND the 'right' item is clicked + THEN data-disabled should remain on the 'left' item + AND both 'center' AND 'right' items should have aria-pressed on false`, async ({ + page, + }) => { + const { driver: d } = await setup(page, 'test-item-disabled-multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('data-disabled'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, then + await centerItem.click(); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + await rightItem.click(); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('data-disabled'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + }); +}); + +test.describe('Keyboard Navigation (Moving and Pressing)', () => { + //'single' (multiple = false) + test(`GIVEN a toggle-group with items: left, center, right + WHEN the 'center' item is 'Enter' pressed + THEN the 'center' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await leftItem.focus(); + await leftItem.press('ArrowRight'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle-group + WHEN the 'center' item is 'Enter' pressed + AND the 'right' item is 'Enter' pressed + THEN 'right' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //When, Then + await leftItem.focus(); + await leftItem.press('ArrowRight'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + await centerItem.press('ArrowRight'); + await expect(rightItem).toBeFocused(); + await rightItem.press('Enter'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + }); + + test(`GIVEN a single toggle-group wrapped into other element + WHEN the 'outsideRoot' element is 'Focused' + AND the 'outsideRoot' element is 'Tab' pressed + THEN 'leftItem' (firstItem) should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-focus-single'); + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toHaveAttribute('tabIndex', '0'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('tabIndex', '-1'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + + const outsideRootTopElement = await d + .getRoot() + .locator('[test-data-outside-root-top]'); + const outsideRootBottomButtonElement = await d + .getRoot() + .locator('[test-data-outside-root-bottom-button]'); + + //When, Then + await outsideRootTopElement.focus(); + await outsideRootTopElement.press('Tab'); + await expect(leftItem).toBeFocused(); + await leftItem.press('Tab'); + await expect(outsideRootBottomButtonElement).toBeFocused(); + }); + + test(`GIVEN a single toggle-group wrapped into other element and center item is pressed + WHEN the 'outsideRoot' element is 'Focused' + AND the 'outsideRoot' element is 'Tab' pressed + THEN 'center' (pressedItem) should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-focus-single-center-pressed'); + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toHaveAttribute('tabIndex', '-1'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('tabIndex', '0'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + + const outsideRootTopElement = await d + .getRoot() + .locator('[test-data-outside-root-top]'); + + //When, Then + await outsideRootTopElement.focus(); + await outsideRootTopElement.press('Tab'); + await expect(centerItem).toBeFocused(); + }); + + test(`GIVEN a multiple toggle-group wrapped into other element + WHEN the 'outsideRoot' element is 'Focused' + AND the 'outsideRoot' element is 'Tab' pressed + THEN 'leftItem' (firstItem) should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-focus-multiple'); + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toHaveAttribute('tabIndex', '0'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('tabIndex', '-1'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + + const outsideRootTopElement = await d + .getRoot() + .locator('[test-data-outside-root-top]'); + const outsideRootBottomButtonElement = await d + .getRoot() + .locator('[test-data-outside-root-bottom-button]'); + + //When, Then + await outsideRootTopElement.focus(); + await outsideRootTopElement.press('Tab'); + await expect(leftItem).toBeFocused(); + await leftItem.press('Tab'); + await expect(outsideRootBottomButtonElement).toBeFocused(); + }); + + test(`GIVEN a multiple toggle-group wrapped into other element and center item is pressed + WHEN the 'outsideRoot' element is 'Focused' + AND the 'outsideRoot' element is 'Tab' pressed + THEN 'center' (pressedItem) should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-focus-multiple-center-pressed'); + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toHaveAttribute('tabIndex', '-1'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('tabIndex', '0'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + + const outsideRootTopElement = await d + .getRoot() + .locator('[test-data-outside-root-top]'); + + //When, Then + await outsideRootTopElement.focus(); + await outsideRootTopElement.press('Tab'); + await expect(centerItem).toBeFocused(); + }); + + test(`GIVEN a toggle-group with 'center' is 'Enter' pressed + WHEN the 'right' item is 'Enter' pressed + AND the 'center' item is 'Enter' pressed + THEN 'center' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + await leftItem.focus(); + await leftItem.press('ArrowRight'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + //When, Then + await centerItem.press('ArrowRight'); + await expect(rightItem).toBeFocused(); + await rightItem.press('Enter'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + + await rightItem.press('ArrowLeft'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //type is 'multiple' + test(`GIVEN a toggle-group with items: left, center, right + WHEN the 'center' item is 'Enter' pressed + THEN the 'center' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await leftItem.focus(); + await leftItem.press('ArrowRight'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle-group with 'center' is 'Enter' pressed + WHEN the 'right' item is 'Enter' pressed + THEN both 'center' AND right' items should have aria-pressed on true`, async ({ + page, + }) => { + const { driver: d } = await setup(page, 'multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + await leftItem.focus(); + await leftItem.press('ArrowRight'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + //when + await centerItem.press('ArrowRight'); + await expect(rightItem).toBeFocused(); + await rightItem.press('Enter'); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle-group with 'center' is 'Enter' pressed + WHEN the 'right' is 'Enter' pressed + AND the 'center' is 'Enter' pressed + THEN 'right' item should have aria-pressed should be true`, async ({ page }) => { + const { driver: d } = await setup(page, 'multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + await centerItem.focus(); + await centerItem.press('Enter'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + //when + await centerItem.press('ArrowRight'); + await expect(rightItem).toBeFocused(); + await rightItem.press('Enter'); + await rightItem.press('ArrowLeft'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + + //Then + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //Uncontrolled / Initial value + //single (multiple = false) + test(`GIVEN a toggle-group with an initial 'value' = 'left' + WHEN the 'center' item is 'Enter' pressed + THEN 'center' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'initialValue'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await leftItem.focus(); + await leftItem.press('ArrowRight'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //multiple + test(`GIVEN a toggle-group with an initial 'value' = ['left', 'center'] + WHEN the 'center' item is 'Enter' pressed + THEN 'left' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-initialValue-multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await leftItem.focus(); + await leftItem.press('ArrowRight'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + + //Then + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //Initial (value) + //single + test(`GIVEN a toggle-group with 'value' = 'left' + WHEN the 'center' item is 'Enter' pressed + THEN 'center' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'value'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await leftItem.focus(); + await leftItem.press('ArrowRight'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //multiple + test(`GIVEN a toggle-group with 'value' = ['left', 'center'] + WHEN the 'center' item is 'Enter' pressed + THEN 'left' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-value-multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await leftItem.focus(); + await leftItem.press('ArrowRight'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + + //Then + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //disabled + test(`GIVEN a 'disabled' toggle-group + WHEN the 'center' item is is 'Enter' pressed (CAN'T BE PRESSED) + THEN aria-disabled should be true on each item`, async ({ page }) => { + const { driver: d } = await setup(page, 'disabled'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-disabled', 'true'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toBeDisabled(); + await expect(centerItem).toHaveAttribute('aria-disabled', 'true'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toBeDisabled(); + await expect(rightItem).toHaveAttribute('aria-disabled', 'true'); + }); + + test(`GIVEN a 'disabled' toggle-group + WHEN the 'center' item is 'Enter' pressed (CAN'T BE PRESSED) + THEN aria-disabled should be true on each item`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-disabled-multiple'); + + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-disabled', 'true'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toBeDisabled(); + await expect(centerItem).toHaveAttribute('aria-disabled', 'true'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toBeDisabled(); + await expect(rightItem).toHaveAttribute('aria-disabled', 'true'); + }); + + test(`GIVEN a 'disabled' toggle-group with 'value' = 'left' + WHEN the 'center' item is 'Enter' pressed (CAN'T BE PRESSED) + THEN aria-disabled should be true on each item`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-disabled-value'); + + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-disabled', 'true'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toBeDisabled(); + await expect(centerItem).toHaveAttribute('aria-disabled', 'true'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toBeDisabled(); + await expect(rightItem).toHaveAttribute('aria-disabled', 'true'); + }); + + test(`GIVEN a 'disabled' toggle-group with 'value' = ['left', 'center'] + WHEN the 'center' item is 'Enter' pressed + THEN aria-disabled should be true on each item`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-disabled-value-multiple'); + + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-disabled', 'true'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toBeDisabled(); + await expect(centerItem).toHaveAttribute('aria-disabled', 'true'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toBeDisabled(); + await expect(rightItem).toHaveAttribute('aria-disabled', 'true'); + }); + + test(`GIVEN a toggle-group with a disabled 'left' item + WHEN the 'center' item is 'Enter' pressed + AND the 'right' item is 'Enter' pressed + THEN aria-disabled should be true on the 'left' item + AND 'center' item should have aria-pressed on false + AND 'right' item should have aria-pressed on true + `, async ({ page }) => { + const { driver: d } = await setup(page, 'test-item-disabled'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-disabled', 'true'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.focus(); + await centerItem.press('Enter'); + + await centerItem.press('ArrowRight'); + await expect(rightItem).toBeFocused(); + await rightItem.press('Enter'); + + //Then + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-disabled', 'true'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle-group with a disabled 'left' item + WHEN the 'center' item is 'Enter' pressed + AND the 'right' item is 'Enter' pressed + THEN aria-disabled should be true on the 'left' item + AND both 'center' AND 'right' items should have aria-pressed on false`, async ({ + page, + }) => { + const { driver: d } = await setup(page, 'test-item-disabled-multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //When, Then + await centerItem.focus(); + await centerItem.press('Enter'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + await centerItem.press('ArrowRight'); + await expect(rightItem).toBeFocused(); + await rightItem.press('Enter'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-disabled', 'true'); + }); +}); + +test.describe('Keyboard Without Looping Behavior (Moving and Pressing)', () => { + //'single' (multiple = false) + test(`GIVEN a toggle-group with items: left, center, right + WHEN the 'left' item is focused + AND the 'ArrowLeft' key is pressed + THEN the 'left' item should remain focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowLeft'); + await expect(leftItem).toBeFocused(); + }); + + test(`GIVEN a toggle-group with items: left, center, right (NO LOOP) + WHEN the 'left' item is focused + AND the 'ArrowLeft' key is pressed + AND the 'Enter' key is pressed + THEN the 'left' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowLeft'); + await expect(leftItem).toBeFocused(); + await leftItem.press('Enter'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle-group with items: left, center, right (NO LOOP) + WHEN the 'right' item is focused + AND the 'ArrowRight' key is pressed + THEN the 'right' item should remain focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await rightItem.focus(); + await expect(rightItem).toBeFocused(); + + await rightItem.press('ArrowRight'); + await expect(rightItem).toBeFocused(); + }); + + test(`GIVEN a toggle-group with items: left, center, right (NO LOOP) + WHEN the 'right' item is focused + AND the 'ArrowRight' key is pressed + AND the 'Enter' key is pressed + THEN the 'right' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await rightItem.focus(); + await expect(rightItem).toBeFocused(); + + await rightItem.press('ArrowRight'); + await expect(rightItem).toBeFocused(); + + await rightItem.press('Enter'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + }); + + //disabled (item) + test(`GIVEN a toggle-group with a disabled 'left' item + WHEN the 'center' item is focused + AND the 'ArrowLeft' key is pressed + THEN the 'center' item should remain focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-item-disabled'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-disabled', 'true'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.focus(); + await expect(centerItem).toBeFocused(); + await centerItem.press('ArrowLeft'); + await expect(centerItem).toBeFocused(); + }); + + //vertical + test(`GIVEN a toggle-group with 'vertical' orientation + WHEN the 'left' item is focused + AND the 'ArrowUp' key is pressed + THEN the 'left' item should remain focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'vertical'); + + //Given + const root = d.getToggleGroupRoot(); + await expect(root).toHaveAttribute('aria-orientation', 'vertical'); + await expect(root).toHaveAttribute('dir', 'ltr'); + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowUp'); + await expect(leftItem).toBeFocused(); + }); + + //vertical and direction rtl + test(`GIVEN a toggle-group with 'vertical' orientation and 'rtl' direction + WHEN the 'left' item is focused + AND the 'ArrowUp' key is pressed + THEN the 'center' item should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-vertical-multiple-rtl'); + + //Given + const root = d.getToggleGroupRoot(); + await expect(root).toHaveAttribute('aria-orientation', 'vertical'); + await expect(root).toHaveAttribute('dir', 'rtl'); + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowUp'); + await expect(centerItem).toBeFocused(); + }); + + //horizontal and direction rtl + test(`GIVEN a toggle-group with 'horizontal' orientation and 'rtl' direction + WHEN the 'left' item is focused + AND the 'ArrowLeft' key is pressed + THEN the 'center' item should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'horizontal-rtl'); + + //Given + const root = d.getToggleGroupRoot(); + await expect(root).toHaveAttribute('aria-orientation', 'horizontal'); + await expect(root).toHaveAttribute('dir', 'rtl'); + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowLeft'); + await expect(centerItem).toBeFocused(); + }); +}); + +test.describe('Keyboard With Looping Behavior (Moving and Pressing)', () => { + //'single' (multiple = false) + test(`GIVEN a toggle-group with items: left, center, right + WHEN the 'left' item is focused + AND the 'ArrowLeft' key is pressed + THEN the 'right' item should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'loop'); + + //Given + const root = d.getToggleGroupRoot(); + await expect(root).toHaveAttribute('aria-orientation', 'horizontal'); + await expect(root).toHaveAttribute('dir', 'ltr'); + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowLeft'); + await expect(rightItem).toBeFocused(); + }); + + test(`GIVEN a toggle-group with items: left, center, right (NO LOOP) + WHEN the 'left' item is focused + AND the 'ArrowLeft' key is pressed + AND the 'Enter' key is pressed + THEN the 'right' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'loop'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowLeft'); + await expect(rightItem).toBeFocused(); + await rightItem.press('Enter'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle-group with items: left, center, right (NO LOOP) + WHEN the 'right' item is focused + AND the 'ArrowRight' key is pressed + THEN the 'left' item should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'loop'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await rightItem.focus(); + await expect(rightItem).toBeFocused(); + + await rightItem.press('ArrowRight'); + await expect(leftItem).toBeFocused(); + }); + + test(`GIVEN a toggle-group with items: left, center, right (NO LOOP) + WHEN the 'right' item is focused + AND the 'ArrowRight' key is pressed + AND the 'Enter' key is pressed + THEN the 'left' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'loop'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await rightItem.focus(); + await expect(rightItem).toBeFocused(); + + await rightItem.press('ArrowRight'); + await expect(leftItem).toBeFocused(); + + await leftItem.press('Enter'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + }); + + //disabled (item) + test(`GIVEN a toggle-group with a disabled 'left' item + WHEN the 'center' item is focused + AND the 'ArrowLeft' key is pressed + THEN the 'right' item should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'loop-item-disabled'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-disabled', 'true'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.focus(); + await expect(centerItem).toBeFocused(); + await centerItem.press('ArrowLeft'); + await expect(rightItem).toBeFocused(); + }); + + //vertical + test(`GIVEN a toggle-group with 'vertical' orientation + WHEN the 'left' item is focused + AND the 'ArrowUp' key is pressed + THEN the 'right' item should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-loop-vertical'); + + //Given + const root = d.getToggleGroupRoot(); + await expect(root).toHaveAttribute('aria-orientation', 'vertical'); + await expect(root).toHaveAttribute('dir', 'ltr'); + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowUp'); + await expect(rightItem).toBeFocused(); + }); + + //vertical and direction rtl + test(`GIVEN a toggle-group with 'vertical' orientation and 'rtl' direction + WHEN the 'left' item is focused + AND the 'ArrowUp' key is pressed + THEN the 'center' item should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-loop-vertical-rtl'); + + //Given + const root = d.getToggleGroupRoot(); + await expect(root).toHaveAttribute('aria-orientation', 'vertical'); + await expect(root).toHaveAttribute('dir', 'rtl'); + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowUp'); + await expect(centerItem).toBeFocused(); + }); + + //horizontal and direction rtl + test(`GIVEN a toggle-group with 'horizontal' orientation and 'rtl' direction + WHEN the 'left' item is focused + AND the 'ArrowLeft' key is pressed + THEN the 'center' item should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-loop-horizontal-rtl'); + + //Given + const root = d.getToggleGroupRoot(); + await expect(root).toHaveAttribute('aria-orientation', 'horizontal'); + await expect(root).toHaveAttribute('dir', 'rtl'); + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowLeft'); + await expect(centerItem).toBeFocused(); + }); +}); diff --git a/packages/kit-headless/src/components/toggle-group/use-toggle.tsx b/packages/kit-headless/src/components/toggle-group/use-toggle.tsx new file mode 100644 index 000000000..3da84ed8f --- /dev/null +++ b/packages/kit-headless/src/components/toggle-group/use-toggle.tsx @@ -0,0 +1,120 @@ +import { $, Signal, useId, useSignal } from '@builder.io/qwik'; + +import { Item, ItemId } from './toggle-group-context'; +import { + ToggleGroupApiProps, + ToggleGroupMultipleProps, + ToggleGroupSingleProps, +} from './toggle-group-root'; +import { useBoundSignal } from '../../utils/bound-signal2'; + +function useRootItemsRepo() { + const items = useSignal>>(new Map()); + + const rootId = useId(); + + //only used to register itemRef in CSR land + const itemsCSR = useSignal([]); + + const registerItem$ = $((itemId: ItemId, itemSig: Signal) => { + items.value = items.value.set(itemId, itemSig); + }); + + const getAndSetTabIndexItem$ = $((itemId: ItemId, tabIndexValue: 0 | -1) => { + const itemSig = items.value.get(itemId); + if (!itemSig) throw 'Item Not Found'; + if (itemSig) { + itemSig.value.tabIndex.value = tabIndexValue; + } + }); + + const getAllItems$ = $(() => + Array.from(items.value.values()).map((signal) => signal.value), + ); + + return { + getAllItems$, + getAndSetTabIndexItem$, + registerItem$, + rootId, + itemsCSR, + } as const; +} + +function useCreateSingleToggleGroup(props: ToggleGroupSingleProps) { + const { multiple = false, value, onChange$, 'bind:value': givenValueSig } = props; + + const pressedValuesSig = useBoundSignal(givenValueSig, value); + const rootItemsRepo = useRootItemsRepo(); + + const handleValueChange$ = $((newValue: string) => { + pressedValuesSig.value = newValue; + + if (onChange$) onChange$(pressedValuesSig.value); + }); + + const activateItem$ = $((itemValue: string) => handleValueChange$(itemValue)); + const deActivateItem$ = $(() => handleValueChange$('')); + + return { + multiple, + pressedValuesSig, + activateItem$, + deActivateItem$, + getAllItems$: rootItemsRepo.getAllItems$, + getAndSetTabIndexItem$: rootItemsRepo.getAndSetTabIndexItem$, + registerItem$: rootItemsRepo.registerItem$, + rootId: rootItemsRepo.rootId, + itemsCSR: rootItemsRepo.itemsCSR, + } as const; +} + +function useCreateMultipleToggleGroup(props: ToggleGroupMultipleProps) { + const { multiple = true, 'bind:value': givenValueSig, value, onChange$ } = props; + + /* + Need to pass an empty array if not I got: TypeError when toggle + Uncaught (in promise) TypeError: pressedValuesSig.value is not iterable + */ + const pressedValuesSig = useBoundSignal(givenValueSig, value || []); + + const rootItemsRepo = useRootItemsRepo(); + + const handleValueChange$ = $((newValue: string[]) => { + pressedValuesSig.value = newValue; + + if (onChange$) onChange$(pressedValuesSig.value); + }); + + const activateItem$ = $((itemValue: string) => + handleValueChange$([...pressedValuesSig.value, itemValue]), + ); + const deActivateItem$ = $((itemValue: string) => + handleValueChange$(pressedValuesSig.value.filter((value) => value !== itemValue)), + ); + + return { + multiple, + pressedValuesSig, + activateItem$, + deActivateItem$, + getAllItems$: rootItemsRepo.getAllItems$, + getAndSetTabIndexItem$: rootItemsRepo.getAndSetTabIndexItem$, + registerItem$: rootItemsRepo.registerItem$, + rootId: rootItemsRepo.rootId, + itemsCSR: rootItemsRepo.itemsCSR, + } as const; +} + +function isSingleProps(props: ToggleGroupApiProps): props is ToggleGroupSingleProps { + return props.multiple === undefined || props.multiple === false; +} + +export function useToggleGroup(props: ToggleGroupApiProps) { + if (isSingleProps(props)) { + // this is fine as the ToggleGroup will always be either Single or Multiple during its lifecycle + // eslint-disable-next-line qwik/use-method-usage + return useCreateSingleToggleGroup(props); + } + return useCreateMultipleToggleGroup(props); +} diff --git a/packages/kit-headless/src/components/toggle/index.ts b/packages/kit-headless/src/components/toggle/index.tsx similarity index 100% rename from packages/kit-headless/src/components/toggle/index.ts rename to packages/kit-headless/src/components/toggle/index.tsx diff --git a/packages/kit-headless/src/components/toggle/toggle.driver.ts b/packages/kit-headless/src/components/toggle/toggle.driver.ts new file mode 100644 index 000000000..c19bb4c80 --- /dev/null +++ b/packages/kit-headless/src/components/toggle/toggle.driver.ts @@ -0,0 +1,31 @@ +import { type Locator, type Page } from '@playwright/test'; +export type DriverLocator = Locator | Page; + +export function createTestDriver(rootLocator: T) { + const getRoot = () => { + return rootLocator; + }; + + const getToggleButton = () => { + return getRoot().getByRole('button').nth(0); + }; + + const pressButtonWithEnter = () => { + getToggleButton().focus(); + return getToggleButton().press('Enter'); + }; + + const pressButtonWithSpace = () => { + getToggleButton().focus(); + return getToggleButton().press('Space'); + }; + + return { + ...rootLocator, + locator: rootLocator, + getRoot, + getToggleButton, + pressButtonWithEnter, + pressButtonWithSpace, + }; +} diff --git a/packages/kit-headless/src/components/toggle/toggle.test.ts b/packages/kit-headless/src/components/toggle/toggle.test.ts new file mode 100644 index 000000000..ced59373e --- /dev/null +++ b/packages/kit-headless/src/components/toggle/toggle.test.ts @@ -0,0 +1,299 @@ +import { expect, test, type Page } from '@playwright/test'; +import { createTestDriver } from './toggle.driver'; + +async function setup(page: Page, exampleName: string) { + await page.goto(`/headless/toggle/${exampleName}`); + + const driver = createTestDriver(page); + + return { + driver, + }; +} + +test.describe('Mouse Behavior', () => { + test(`GIVEN a toggle + WHEN the toggle is clicked + THEN aria-pressed should be true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + + //when + await d.getToggleButton().click(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + }); + + //Uncontrolled / Initial + test(`GIVEN a pressed toggle (with initial 'pressed') + WHEN the toggle is clicked + THEN aria-pressed should be false`, async ({ page }) => { + const { driver: d } = await setup(page, 'initialPressed'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + + //When + await d.getToggleButton().click(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + }); + + //pressed with onPressedChange to have some control + test(`GIVEN a pressed toggle (with 'pressed') + WHEN the toggle is clicked + THEN aria-pressed should be false`, async ({ page }) => { + const { driver: d } = await setup(page, 'pressed'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + + //When + await d.getToggleButton().click(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + }); + + //Controlled / Reactive + //bind:pressed: 1 way binding (reading) + test(`GIVEN a pressed toggle (with 'bind-pressed') + WHEN the toggle is clicked + THEN aria-pressed should be false + AND the span element that store the value of the bounded Signal + should be updated + `, async ({ page }) => { + const { driver: d } = await setup(page, 'test-bind-pressed'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + + //When + await d.getToggleButton().click(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + const spanElement = await d.getRoot().locator('[test-data-bounded-span]'); + await expect(spanElement).toContainText('You unpressed me'); + }); + + //bind:pressed: 2 way binding (writing) + test(`GIVEN a pressed toggle (with 'bind-pressed') + WHEN the toggle is clicked + AND the external button is clicked + THEN aria-pressed should be true + AND the span element that store the value of the bounded Signal + should be updated + `, async ({ page }) => { + const { driver: d } = await setup(page, 'test-bind-pressed'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + + //When + await d.getToggleButton().click(); + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + + const button = await d.getRoot().locator('[test-data-bounded-button]'); + await button.click(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + const spanElement = await d.getRoot().locator('[test-data-bounded-span]'); + await expect(spanElement).toContainText('You pressed me'); + }); + + //disabled + test(`GIVEN a disabled toggle + WHEN the toggle is clicked + THEN data-disabled should remain`, async ({ page }) => { + const { driver: d } = await setup(page, 'disabled'); + + await expect(d.getToggleButton()).toBeDisabled(); + await expect(d.getToggleButton()).toHaveAttribute('data-disabled'); + }); + + test(`GIVEN a disabled and pressed toggle + WHEN the toggle is clicked + THEN aria-pressed should be true + AND data-disabled should remain`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-pressed-disabled'); + + await expect(d.getToggleButton()).toBeDisabled(); + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + await expect(d.getToggleButton()).toHaveAttribute('data-disabled'); + }); + + test(`GIVEN a pressed and disabled toggle + WHEN the toggle is clicked + THEN aria-pressed should remain false + AND data-disabled should remain`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-pressed-disabled'); + + await expect(d.getToggleButton()).toBeDisabled(); + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + await expect(d.getToggleButton()).toHaveAttribute('data-disabled'); + }); +}); + +test.describe('Keyboard Behavior a11y', () => { + test(`GIVEN a toggle + WHEN the toggle is 'Enter' pressed + THEN aria-pressed should be true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + + //when + await d.pressButtonWithEnter(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle + WHEN the toggle is 'Space' pressed + THEN aria-pressed should be true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + + //when + await d.pressButtonWithSpace(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + }); + + //Uncontrolled / Initial + test(`GIVEN a pressed toggle (with initial 'pressed') + WHEN the toggle is 'Enter' pressed + THEN aria-pressed should be false`, async ({ page }) => { + const { driver: d } = await setup(page, 'initialPressed'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + + //When + await d.pressButtonWithEnter(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + }); + test(`GIVEN a pressed toggle (with initial 'pressed') + WHEN the toggle is 'Space' pressed + THEN aria-pressed should be false`, async ({ page }) => { + const { driver: d } = await setup(page, 'initialPressed'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + + //When + await d.pressButtonWithEnter(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + }); + + //Controlled / Reactive + //pressed + test(`GIVEN a pressed toggle (with 'pressed') + WHEN the toggle is 'Enter' pressed + THEN aria-pressed should be false`, async ({ page }) => { + const { driver: d } = await setup(page, 'pressed'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + + //When + await d.getToggleButton().click(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + }); + + test(`GIVEN a pressed toggle (with 'pressed') + WHEN the toggle is 'Space' pressed + THEN aria-pressed should be false`, async ({ page }) => { + const { driver: d } = await setup(page, 'pressed'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + + //When + await d.getToggleButton().click(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + }); + + //disabled + test(`GIVEN a disabled toggle + WHEN the toggle is 'Enter' pressed + THEN aria-disabled should remain true`, async ({ page }) => { + const { driver: d } = await setup(page, 'disabled'); + + //Then + await expect(d.getToggleButton()).toBeDisabled(); + await expect(d.getToggleButton()).toHaveAttribute('aria-disabled', 'true'); + }); + + test(`GIVEN a disabled toggle + WHEN the toggle is 'Space' pressed + THEN aria-disabled should remain true`, async ({ page }) => { + const { driver: d } = await setup(page, 'disabled'); + + //Then + await expect(d.getToggleButton()).toBeDisabled(); + await expect(d.getToggleButton()).toHaveAttribute('aria-disabled', 'true'); + }); + + test(`GIVEN a disabled and pressed toggle + WHEN the toggle is 'Enter' pressed + THEN aria-pressed should remain false + AND data-disabled should remain`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-disabled-pressed'); + + await expect(d.getToggleButton()).toBeDisabled(); + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + await expect(d.getToggleButton()).toHaveAttribute('aria-disabled', 'true'); + }); + + test(`GIVEN a disabled and pressed toggle + WHEN the toggle is 'Space' pressed + THEN aria-pressed should remain false + AND data-disabled should remain`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-disabled-pressed'); + + await expect(d.getToggleButton()).toBeDisabled(); + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + await expect(d.getToggleButton()).toHaveAttribute('aria-disabled', 'true'); + }); + + test(`GIVEN a pressed and disabled toggle + WHEN the toggle is 'Enter' pressed + THEN aria-pressed should remain false + AND data-disabled should remain`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-pressed-disabled'); + + await expect(d.getToggleButton()).toBeDisabled(); + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + await expect(d.getToggleButton()).toHaveAttribute('aria-disabled', 'true'); + }); + test(`GIVEN a pressed and disabled toggle + WHEN the toggle is 'Space' pressed + THEN aria-pressed should remain false + AND data-disabled should remain`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-pressed-disabled'); + + await expect(d.getToggleButton()).toBeDisabled(); + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + await expect(d.getToggleButton()).toHaveAttribute('aria-disabled', 'true'); + }); +}); diff --git a/packages/kit-headless/src/components/toggle/toggle.tsx b/packages/kit-headless/src/components/toggle/toggle.tsx index c997333c6..b023728c0 100644 --- a/packages/kit-headless/src/components/toggle/toggle.tsx +++ b/packages/kit-headless/src/components/toggle/toggle.tsx @@ -1,25 +1,71 @@ -import { PropsOf, component$, useSignal } from '@builder.io/qwik'; +import type { PropsOf, QRL, Signal } from '@builder.io/qwik'; +import { $, component$, Slot, sync$, useTask$ } from '@builder.io/qwik'; +import { useBoundSignal } from '../../utils/bound-signal2'; -export type ToggleProps = PropsOf<'input'> & { +export type ToggleProps = PropsOf<'button'> & { + /** + * When true, prevents the user from interacting with the toggle group and all its items. + */ disabled?: boolean; + /** + * The initial value of the toggle. + * Can be used in conjunction with `onPressedChange` to have more control. + */ pressed?: boolean; - defaultPressed?: boolean; + /** + * The callback that fires when the state of the toggle changes. + */ + onPressedChange$?: QRL<(pressed: boolean) => void>; + /** + * The reactive value (a signal) of the toggle (the signal is the controlled value). + * Controlling the pressed state with a bounded value. + */ + 'bind:pressed'?: Signal; }; -export const HToggle = component$( - ({ pressed, defaultPressed = false, disabled, ...props }) => { - const pressedState = useSignal(pressed || defaultPressed); - - return ( - - ); - }, -); +export const HToggle = component$((props) => { + const { + pressed: pressedProp, + onPressedChange$, + 'bind:pressed': givenValueSig, + ...buttonProps + } = props; + + const pressedSig = useBoundSignal(givenValueSig, pressedProp ? pressedProp : false); + + const handleKeyDownSync$ = sync$((event: KeyboardEvent) => { + if (!['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp'].includes(event.key)) return; + + event.preventDefault(); + }); + + useTask$(async ({ track }) => { + if (pressedProp === undefined) return; + track(() => pressedProp); + pressedSig.value = pressedProp; + }); + + const handleClick$ = $(async () => { + if (!props.disabled) { + pressedSig.value = !pressedSig.value; + if (onPressedChange$) { + onPressedChange$(pressedSig.value); + } + } + }); + + return ( + + ); +}); diff --git a/packages/kit-headless/src/index.ts b/packages/kit-headless/src/index.ts index ec123fc37..77c2e051a 100644 --- a/packages/kit-headless/src/index.ts +++ b/packages/kit-headless/src/index.ts @@ -13,6 +13,7 @@ export * as Progress from './components/progress'; export * from './components/separator'; export * as Tabs from './components/tabs'; export { Toggle } from './components/toggle'; +export { ToggleGroup } from './components/toggle-group'; export * from './utils/visually-hidden'; export * as Tooltip from './components/tooltip'; export * as Dropdown from './components/dropdown'; diff --git a/packages/kit-headless/src/utils/bound-signal2.tsx b/packages/kit-headless/src/utils/bound-signal2.tsx new file mode 100644 index 000000000..d5953de5e --- /dev/null +++ b/packages/kit-headless/src/utils/bound-signal2.tsx @@ -0,0 +1,20 @@ +/** + * Creates a bound signal that synchronizes with an external signal if provided. + * This hook is useful for two-way binding scenarios, especially when dealing with + * component props that may or may not be signals. + * + * @param givenSignal - An optional external signal to bind to. + * @param initialValue - The initial value to use if no external signal is provided. + * @returns A signal that is either bound to the external signal or a new internal signal. + * + * The returned signal will update the external signal (if provided) whenever its value changes, + * and will also update itself when the external signal changes. + */ + +import { createSignal, Signal, useConstant } from '@builder.io/qwik'; + +export const useBoundSignal = ( + givenSignal?: Signal, + initialValue?: T, +): Signal => + useConstant(() => givenSignal || (createSignal(initialValue) as Signal)); diff --git a/packages/kit-styled/src/components/toggle-group/toggle-group.tsx b/packages/kit-styled/src/components/toggle-group/toggle-group.tsx new file mode 100644 index 000000000..cb732f3c8 --- /dev/null +++ b/packages/kit-styled/src/components/toggle-group/toggle-group.tsx @@ -0,0 +1,61 @@ +import { + component$, + type PropsOf, + Slot, + useContext, + useContextProvider, +} from '@builder.io/qwik'; +import { cn } from '@qwik-ui/utils'; +import { ToggleGroup as HeadlessToggleGroup } from '@qwik-ui/headless'; + +import { toggleVariants } from '@qwik-ui/styled'; +import type { VariantProps } from 'class-variance-authority'; + +import { createContextId } from '@builder.io/qwik'; + +export const toggleGroupStyledContextId = createContextId( + 'qui-toggle-group-styled', +); + +export type ToggleGroupStyledContext = VariantProps; + +type ToggleGroupRootProps = PropsOf & + VariantProps; + +const Root = component$(({ size, look, ...props }) => { + const contextStyled: ToggleGroupStyledContext = { + size, + look, + }; + useContextProvider(toggleGroupStyledContextId, contextStyled); + + return ( + + + + ); +}); + +type ToggleGroupItemProps = PropsOf & + VariantProps; + +const Item = component$(({ ...props }) => { + const { size, look } = useContext(toggleGroupStyledContextId); + + return ( + + + + ); +}); + +export const ToggleGroup = { + Root, + Item, +}; diff --git a/packages/kit-styled/src/components/toggle/toggle.tsx b/packages/kit-styled/src/components/toggle/toggle.tsx index e7716f148..ed223bc26 100644 --- a/packages/kit-styled/src/components/toggle/toggle.tsx +++ b/packages/kit-styled/src/components/toggle/toggle.tsx @@ -1,34 +1,37 @@ -import { type PropsOf, component$ } from '@builder.io/qwik'; -import { Toggle as HeadlessToggle } from '@qwik-ui/headless'; +import { component$, type PropsOf, Slot } from '@builder.io/qwik'; import { cn } from '@qwik-ui/utils'; -import { VariantProps, cva } from 'class-variance-authority'; - -type ToggleProps = PropsOf & VariantProps; +import { cva, type VariantProps } from 'class-variance-authority'; +import { Toggle as HeadlessToggle } from '@qwik-ui/headless'; -const toggleVariants = cva( - 'inline-flex items-center justify-center rounded-base text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground', +export const toggleVariants = cva( + 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-[pressed=true]:bg-primary aria-[pressed=true]:text-accent-foreground', { variants: { - variant: { - default: 'bg-transparent', + look: { + default: 'border border-input bg-transparent', outline: - 'border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground', + 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground', }, + size: { - default: 'h-9 px-3', - sm: 'h-8 px-2', - lg: 'h-10 px-3', + default: 'h-10 px-3', + sm: 'h-9 px-2.5', + lg: 'h-11 px-5', }, }, defaultVariants: { - variant: 'default', + look: 'default', size: 'default', }, }, ); -const Toggle = component$(({ variant, size, ...props }) => ( - -)); +type ToggleProps = PropsOf & VariantProps; -export { Toggle, toggleVariants }; +export const Toggle = component$(({ size, look, ...props }) => { + return ( + + + + ); +}); diff --git a/packages/kit-styled/src/index.ts b/packages/kit-styled/src/index.ts index a81e53d3f..6c2b24bf7 100644 --- a/packages/kit-styled/src/index.ts +++ b/packages/kit-styled/src/index.ts @@ -19,4 +19,5 @@ export * from './components/skeleton/skeleton'; export * from './components/tabs/tabs'; export * from './components/textarea/textarea'; export * from './components/toggle/toggle'; +export * from './components/toggle-group/toggle-group'; export * from './components/dropdown/dropdown';