diff --git a/packages/form/src/__tests__/useIndeterminateChecked.tsx b/packages/form/src/__tests__/useIndeterminateChecked.tsx new file mode 100644 index 0000000000..da9e8bd610 --- /dev/null +++ b/packages/form/src/__tests__/useIndeterminateChecked.tsx @@ -0,0 +1,288 @@ +import React from "react"; +import { fireEvent, render } from "@testing-library/react"; + +import { useIndeterminateChecked } from "../useIndeterminateChecked"; +import { Checkbox } from "../toggle"; +import { MenuItemCheckbox } from "../menu/MenuItemCheckbox"; + +const values = ["a", "b", "c", "d"] as const; +const LABELS = { + a: "Label 1", + b: "Label 2", + c: "Label 3", + d: "Label 4", +} as const; + +describe("useIndeterminateChecked", () => { + it("should work with normal checkboxes", () => { + function Test() { + const { rootProps, getProps } = useIndeterminateChecked(values); + return ( + <> + + {values.map((value, i) => ( + + ))} + + ); + } + const { getByRole } = render(); + + const root = getByRole("checkbox", { + name: "Toggle All", + }) as HTMLInputElement; + const checkbox1 = getByRole("checkbox", { + name: "Label 1", + }) as HTMLInputElement; + const checkbox2 = getByRole("checkbox", { + name: "Label 2", + }) as HTMLInputElement; + const checkbox3 = getByRole("checkbox", { + name: "Label 3", + }) as HTMLInputElement; + const checkbox4 = getByRole("checkbox", { + name: "Label 4", + }) as HTMLInputElement; + + expect(root.checked).toBe(false); + expect(checkbox1.checked).toBe(false); + expect(checkbox2.checked).toBe(false); + expect(checkbox3.checked).toBe(false); + expect(checkbox4.checked).toBe(false); + + fireEvent.click(root); + expect(root.checked).toBe(true); + expect(checkbox1.checked).toBe(true); + expect(checkbox2.checked).toBe(true); + expect(checkbox3.checked).toBe(true); + expect(checkbox4.checked).toBe(true); + + fireEvent.click(checkbox1); + expect(root.checked).toBe(true); + expect(checkbox1.checked).toBe(false); + expect(checkbox2.checked).toBe(true); + expect(checkbox3.checked).toBe(true); + expect(checkbox4.checked).toBe(true); + + fireEvent.click(root); + expect(root.checked).toBe(true); + expect(checkbox1.checked).toBe(true); + expect(checkbox2.checked).toBe(true); + expect(checkbox3.checked).toBe(true); + expect(checkbox4.checked).toBe(true); + + fireEvent.click(root); + expect(root.checked).toBe(false); + expect(checkbox1.checked).toBe(false); + expect(checkbox2.checked).toBe(false); + expect(checkbox3.checked).toBe(false); + expect(checkbox4.checked).toBe(false); + + fireEvent.click(checkbox2); + expect(root.checked).toBe(true); + expect(checkbox1.checked).toBe(false); + expect(checkbox2.checked).toBe(true); + expect(checkbox3.checked).toBe(false); + expect(checkbox4.checked).toBe(false); + }); + + it("should work for MenuItemCheckbox", () => { + function Test() { + const { rootProps, getProps } = useIndeterminateChecked(values, { + menu: true, + }); + return ( + <> + + Toggle All + + {values.map((value, i) => ( + + {LABELS[value]} + + ))} + + ); + } + + const { getByRole } = render(); + + const root = getByRole("menuitemcheckbox", { + name: "Toggle All", + }) as HTMLInputElement; + const checkbox1 = getByRole("menuitemcheckbox", { + name: "Label 1", + }) as HTMLInputElement; + const checkbox2 = getByRole("menuitemcheckbox", { + name: "Label 2", + }) as HTMLInputElement; + const checkbox3 = getByRole("menuitemcheckbox", { + name: "Label 3", + }) as HTMLInputElement; + const checkbox4 = getByRole("menuitemcheckbox", { + name: "Label 4", + }) as HTMLInputElement; + + expect(root).toHaveAttribute("aria-checked", "false"); + expect(checkbox1).toHaveAttribute("aria-checked", "false"); + expect(checkbox2).toHaveAttribute("aria-checked", "false"); + expect(checkbox3).toHaveAttribute("aria-checked", "false"); + expect(checkbox4).toHaveAttribute("aria-checked", "false"); + + fireEvent.click(root); + expect(root).toHaveAttribute("aria-checked", "true"); + expect(checkbox1).toHaveAttribute("aria-checked", "true"); + expect(checkbox2).toHaveAttribute("aria-checked", "true"); + expect(checkbox3).toHaveAttribute("aria-checked", "true"); + expect(checkbox4).toHaveAttribute("aria-checked", "true"); + + fireEvent.click(checkbox1); + expect(root).toHaveAttribute("aria-checked", "true"); + expect(checkbox1).toHaveAttribute("aria-checked", "false"); + expect(checkbox2).toHaveAttribute("aria-checked", "true"); + expect(checkbox3).toHaveAttribute("aria-checked", "true"); + expect(checkbox4).toHaveAttribute("aria-checked", "true"); + + fireEvent.click(root); + expect(root).toHaveAttribute("aria-checked", "true"); + expect(checkbox1).toHaveAttribute("aria-checked", "true"); + expect(checkbox2).toHaveAttribute("aria-checked", "true"); + expect(checkbox3).toHaveAttribute("aria-checked", "true"); + expect(checkbox4).toHaveAttribute("aria-checked", "true"); + + fireEvent.click(root); + expect(root).toHaveAttribute("aria-checked", "false"); + expect(checkbox1).toHaveAttribute("aria-checked", "false"); + expect(checkbox2).toHaveAttribute("aria-checked", "false"); + expect(checkbox3).toHaveAttribute("aria-checked", "false"); + expect(checkbox4).toHaveAttribute("aria-checked", "false"); + + fireEvent.click(checkbox2); + expect(root).toHaveAttribute("aria-checked", "true"); + expect(checkbox1).toHaveAttribute("aria-checked", "false"); + expect(checkbox2).toHaveAttribute("aria-checked", "true"); + expect(checkbox3).toHaveAttribute("aria-checked", "false"); + expect(checkbox4).toHaveAttribute("aria-checked", "false"); + }); + + it("should allow for default values or another onChange handler", () => { + const onChange = jest.fn(); + const defaultCheckedValues = ["b"] as const; + function Test() { + const { rootProps, getProps } = useIndeterminateChecked( + values, + defaultCheckedValues, + onChange + ); + return ( + <> + + {values.map((value, i) => ( + + ))} + + ); + } + const { getByRole } = render(); + const root = getByRole("checkbox", { + name: "Toggle All", + }) as HTMLInputElement; + const checkbox1 = getByRole("checkbox", { + name: "Label 1", + }) as HTMLInputElement; + const checkbox2 = getByRole("checkbox", { + name: "Label 2", + }) as HTMLInputElement; + const checkbox3 = getByRole("checkbox", { + name: "Label 3", + }) as HTMLInputElement; + const checkbox4 = getByRole("checkbox", { + name: "Label 4", + }) as HTMLInputElement; + + expect(root.checked).toBe(true); + expect(checkbox1.checked).toBe(false); + expect(checkbox2.checked).toBe(true); + expect(checkbox3.checked).toBe(false); + expect(checkbox4.checked).toBe(false); + + fireEvent.click(checkbox1); + expect(onChange).toBeCalledWith(["b", "a"]); + + expect(root.checked).toBe(true); + expect(checkbox1.checked).toBe(true); + expect(checkbox2.checked).toBe(true); + expect(checkbox3.checked).toBe(false); + expect(checkbox4.checked).toBe(false); + }); + + it("should allow for default values or another onChange handler with an options object", () => { + const onChange = jest.fn(); + const defaultCheckedValues = ["b"] as const; + function Test() { + const { rootProps, getProps } = useIndeterminateChecked(values, { + onChange, + defaultCheckedValues, + }); + + return ( + <> + + {values.map((value, i) => ( + + ))} + + ); + } + const { getByRole } = render(); + const root = getByRole("checkbox", { + name: "Toggle All", + }) as HTMLInputElement; + const checkbox1 = getByRole("checkbox", { + name: "Label 1", + }) as HTMLInputElement; + const checkbox2 = getByRole("checkbox", { + name: "Label 2", + }) as HTMLInputElement; + const checkbox3 = getByRole("checkbox", { + name: "Label 3", + }) as HTMLInputElement; + const checkbox4 = getByRole("checkbox", { + name: "Label 4", + }) as HTMLInputElement; + + expect(root.checked).toBe(true); + expect(checkbox1.checked).toBe(false); + expect(checkbox2.checked).toBe(true); + expect(checkbox3.checked).toBe(false); + expect(checkbox4.checked).toBe(false); + + fireEvent.click(checkbox1); + expect(onChange).toBeCalledWith(["b", "a"]); + + expect(root.checked).toBe(true); + expect(checkbox1.checked).toBe(true); + expect(checkbox2.checked).toBe(true); + expect(checkbox3.checked).toBe(false); + expect(checkbox4.checked).toBe(false); + }); +}); diff --git a/packages/form/src/useIndeterminateChecked.ts b/packages/form/src/useIndeterminateChecked.ts index a23c59e580..877bb6c8b0 100644 --- a/packages/form/src/useIndeterminateChecked.ts +++ b/packages/form/src/useIndeterminateChecked.ts @@ -59,6 +59,34 @@ export interface BaseProvidedIndeterminateCheckboxProps { indeterminate: boolean; } +/** + * @remarks \@since 2.8.5 + * @internal + */ +export interface ProvidedIndeterminateCheckboxProps + extends BaseProvidedIndeterminateCheckboxProps { + onChange(): void; +} + +/** + * @remarks \@since 2.8.5 + * @internal + */ +export interface ProvidedIndeterminateMenuItemCheckboxProps + extends BaseProvidedIndeterminateCheckboxProps { + onCheckedChange(): void; +} + +/** + * @remarks \@since 2.8.5 + * @internal + */ +interface ProvidedCombinedIndeterminateProps + extends BaseProvidedIndeterminateCheckboxProps { + onChange?(): void; + onCheckedChange?(): void; +} + /** * @remarks \@since 2.8.5 * @typeParam V - The values allowed for the list of checkboxes. @@ -77,6 +105,38 @@ export interface BaseProvidedIndeterminateControlledCheckboxProps< checked: boolean; } +/** + * @remarks \@since 2.8.5 + * @typeParam V - The values allowed for the list of checkboxes. + * @internal + */ +export interface ProvidedIndeterminateControlledCheckboxProps + extends BaseProvidedIndeterminateControlledCheckboxProps { + onChange(): void; +} + +/** + * @remarks \@since 2.8.5 + * @typeParam V - The values allowed for the list of checkboxes. + * @internal + */ +export interface ProvidedIndeterminateControlledMenuItemCheckboxProps< + V extends string +> extends BaseProvidedIndeterminateControlledCheckboxProps { + onCheckedChange(): void; +} + +/** + * @remarks \@since 2.8.5 + * @typeParam V - The values allowed for the list of checkboxes. + * @internal + */ +interface ProvidedCombinedIndeterminateControlledProps + extends BaseProvidedIndeterminateControlledCheckboxProps { + onChange?(): void; + onCheckedChange?(): void; +} + /** * @remarks \@since 2.8.5 * @typeParam V - The values allowed for the list of checkboxes. @@ -101,10 +161,8 @@ export interface BaseIndeterminateCheckedHookReturnValue { */ interface OnChangeReturnValue extends BaseIndeterminateCheckedHookReturnValue { - rootProps: BaseProvidedIndeterminateCheckboxProps & { onChange(): void }; - getProps( - value: V - ): BaseProvidedIndeterminateControlledCheckboxProps & { onChange(): void }; + rootProps: ProvidedIndeterminateCheckboxProps; + getProps(value: V): ProvidedIndeterminateControlledCheckboxProps; } /** @@ -114,12 +172,8 @@ interface OnChangeReturnValue */ interface OnCheckedChangeReturnValue extends BaseIndeterminateCheckedHookReturnValue { - rootProps: BaseProvidedIndeterminateCheckboxProps & { - onCheckedChange(): void; - }; - getProps(value: V): BaseProvidedIndeterminateControlledCheckboxProps & { - onCheckedChange(): void; - }; + rootProps: ProvidedIndeterminateMenuItemCheckboxProps; + getProps(value: V): ProvidedIndeterminateControlledMenuItemCheckboxProps; } /** @@ -129,14 +183,8 @@ interface OnCheckedChangeReturnValue */ interface CombinedReturnValue extends BaseIndeterminateCheckedHookReturnValue { - rootProps: BaseProvidedIndeterminateCheckboxProps & { - onChange?(): void; - onCheckedChange?(): void; - }; - getProps(value: V): BaseProvidedIndeterminateControlledCheckboxProps & { - onChange?(): void; - onCheckedChange?(): void; - }; + rootProps: ProvidedCombinedIndeterminateProps; + getProps(value: V): ProvidedCombinedIndeterminateControlledProps; } /** @@ -297,22 +345,24 @@ export function useIndeterminateChecked( setCheckedValues(values); }; - const onChange = (): void => { - updateCheckedValues( - checkedValues.length === 0 || indeterminate ? values : [] - ); + const rootProps: ProvidedCombinedIndeterminateProps = { + "aria-checked": indeterminate ? "mixed" : undefined, + checked, + indeterminate, + [menu ? "onCheckedChange" : "onChange"]: () => { + updateCheckedValues( + checkedValues.length === 0 || indeterminate ? values : [] + ); + }, }; - return { - rootProps: { - "aria-checked": indeterminate ? "mixed" : undefined, - checked, - indeterminate, - onChange: menu ? undefined : onChange, - onCheckedChange: menu ? onChange : undefined, - }, - getProps: (value) => { - const onChange = (): void => { + const getProps = ( + value: V + ): ProvidedCombinedIndeterminateControlledProps => { + return { + value, + checked: checkedValues.includes(value), + [menu ? "onCheckedChange" : "onChange"]: () => { const i = checkedValues.indexOf(value); const nextChecked = checkedValues.slice(); if (i === -1) { @@ -322,15 +372,13 @@ export function useIndeterminateChecked( } updateCheckedValues(nextChecked); - }; + }, + }; + }; - return { - value, - checked: checkedValues.includes(value), - onChange: menu ? undefined : onChange, - onCheckedChange: menu ? onChange : undefined, - }; - }, + return { + rootProps, + getProps, checkedValues, setCheckedValues, };