+ );
+});
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