Skip to content

Toggle #35

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Sep 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .dev/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}
8 changes: 7 additions & 1 deletion lib/Radio/Radio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,13 @@ const RadioBase = (props: RadioProps, ref: React.Ref<HTMLButtonElement>) => {

const checkBase = useCheckBase({
value,
groupCtx: radioGroupCtx,
groupCtx: radioGroupCtx
? {
value: radioGroupCtx.value,
onChange: radioGroupCtx.onChange,
items: radioGroupCtx.radios
}
: undefined,
strategy: "radio-control",
autoFocus,
disabled,
Expand Down
130 changes: 130 additions & 0 deletions lib/Toggle/Toggle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import cls from "classnames";
import {
itShouldMount,
itSupportsDataSetProps,
itSupportsFocusEvents,
itSupportsRef,
itSupportsStyle,
render,
screen,
userEvent
} from "../../tests/utils";
import Toggle from "./Toggle";

describe("Toggle", () => {
afterEach(jest.clearAllMocks);

itShouldMount(Toggle, { children: "Toggle" });
itSupportsStyle(Toggle, { children: "Toggle" });
itSupportsRef(Toggle, { children: "Toggle" }, HTMLButtonElement);
itSupportsFocusEvents(Toggle, { children: "Toggle" }, "button");
itSupportsDataSetProps(Toggle, { children: "Toggle" });

it("should have the required classNames", async () => {
const { rerender } = render(
<Toggle
active
disabled
className={({ active, disabled, focusedVisible }) =>
cls("toggle", {
"toggle--active": active,
"toggle--disabled": disabled,
"toggle--focus-visible": focusedVisible
})
}
>
Toggle
</Toggle>
);

const toggle = screen.getByRole("button");
await userEvent.tab();

expect(toggle).not.toHaveFocus();
expect(toggle).toHaveClass("toggle", "toggle--active", "toggle--disabled");

rerender(
<Toggle
active
className={({ active, disabled, focusedVisible }) =>
cls("toggle", {
"toggle--active": active,
"toggle--disabled": disabled,
"toggle--focus-visible": focusedVisible
})
}
>
Toggle
</Toggle>
);

await userEvent.tab();
expect(toggle).toHaveClass(
"toggle",
"toggle--active",
"toggle--focus-visible"
);
});

it("renders an unpressed toggle when `active={false}`", () => {
const { unmount: u1 } = render(<Toggle>Toggle</Toggle>);
expect(screen.getByRole("button")).not.toHaveAttribute("data-active");

u1();
const { unmount: u2 } = render(
<Toggle defaultActive={false}>Toggle</Toggle>
);
expect(screen.getByRole("button")).not.toHaveAttribute("data-active");

u2();
render(<Toggle active={false}>Toggle</Toggle>);
expect(screen.getByRole("button")).not.toHaveAttribute("data-active");
});

it("renders a pressed toggle when `active={true}`", () => {
const { unmount: u1 } = render(<Toggle active={true}>Toggle</Toggle>);
expect(screen.getByRole("button")).toHaveAttribute("data-active");

u1();
render(<Toggle defaultActive={true}>Toggle</Toggle>);
expect(screen.getByRole("button")).toHaveAttribute("data-active");
});

it("toggles `active` state with mouse/keyboard interactions and calls `onActiveChange` callback", async () => {
const handleActiveChange = jest.fn<void, [activeState: boolean]>();

userEvent.setup();
render(<Toggle onActiveChange={handleActiveChange}>Toggle</Toggle>);

const toggle = screen.getByRole("button");

await userEvent.click(toggle);

expect(toggle).toHaveAttribute("data-active");
expect(handleActiveChange.mock.calls.length).toBe(1);
expect(handleActiveChange.mock.calls[0]?.[0]).toBe(true);

await userEvent.click(toggle);

expect(toggle).not.toHaveAttribute("data-active");
expect(handleActiveChange.mock.calls.length).toBe(2);
expect(handleActiveChange.mock.calls[1]?.[0]).toBe(false);

handleActiveChange.mockClear();

toggle.focus();
expect(toggle).toHaveFocus();

await userEvent.keyboard("[Space]");

expect(toggle).toHaveAttribute("data-active");
expect(handleActiveChange.mock.calls.length).toBe(1);
expect(handleActiveChange.mock.calls[0]?.[0]).toBe(true);

await userEvent.keyboard("[Enter]");

expect(toggle).not.toHaveAttribute("data-active");
expect(handleActiveChange.mock.calls.length).toBe(2);
expect(handleActiveChange.mock.calls[1]?.[0]).toBe(false);
});
});
192 changes: 192 additions & 0 deletions lib/Toggle/Toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import * as React from "react";
import ToggleGroupContext from "../ToggleGroup/context";
import { type MergeElementProps } from "../typings.d";
import {
componentWithForwardedRef,
computeAccessibleName,
useCheckBase,
useForkedRefs
} from "../utils";

interface SwitchBaseProps {
/**
* The content of the component.
*/
children?:
| React.ReactNode
| ((ctx: {
disabled: boolean;
active: boolean;
focusedVisible: boolean;
}) => React.ReactNode);
/**
* The className applied to the component.
*/
className?:
| string
| ((ctx: {
disabled: boolean;
active: boolean;
focusedVisible: boolean;
}) => string);
/**
* The value of the toggle. Use when it is a ToggleGroup's child.
*/
value?: string;
/**
* If `true`, the toggle will be focused automatically.
* @default false
*/
autoFocus?: boolean;
/**
* If `true`, the toggle will be active.
* @default false
*/
active?: boolean;
/**
* The default state of `active`. Use when the component is not controlled.
* @default false
*/
defaultActive?: boolean;
/**
* If `true`, the toggle will be disabled.
* @default false
*/
disabled?: boolean;
/**
* The Callback fires when the state of `active` has changed.
*/
onActiveChange?: (activeState: boolean) => void;
}

export type ToggleProps = Omit<
MergeElementProps<"button", SwitchBaseProps>,
"defaultValue" | "defaultChecked"
>;

const ToggleBase = (props: ToggleProps, ref: React.Ref<HTMLButtonElement>) => {
const {
value,
children: childrenProp,
className: classNameProp,
defaultActive,
active,
autoFocus = false,
disabled = false,
onActiveChange,
onBlur,
onFocus,
onKeyDown,
onKeyUp,
...otherProps
} = props;

const toggleGroupCtx = React.useContext(ToggleGroupContext);

if (toggleGroupCtx && typeof value === "undefined") {
throw new Error(
[
"[StylelessUI][Toggle]: The `value` property is missing.",
"It's mandatory to provide a `value` property " +
"when <ToggleGroup /> is a wrapper for <Toggle />."
].join("\n")
);
}

const checkBase = useCheckBase({
value,
autoFocus,
disabled,
checked: active,
toggle: true,
keyboardActivationBehavior: toggleGroupCtx?.keyboardActivationBehavior,
strategy: toggleGroupCtx?.multiple ? "check-control" : "radio-control",
defaultChecked: defaultActive,
enterKeyFunctionality: "check",
groupCtx: toggleGroupCtx
? {
value: toggleGroupCtx.value,
onChange: toggleGroupCtx.onChange,
items: toggleGroupCtx.toggles
}
: undefined,
onChange: onActiveChange,
onBlur,
onFocus,
onKeyDown,
onKeyUp
});

const rootRef = React.useRef<HTMLButtonElement>(null);
const handleRef = useForkedRefs(ref, rootRef, checkBase.handleControllerRef);

const renderCtx = {
disabled,
active: checkBase.checked,
focusedVisible: checkBase.isFocusedVisible
};

const className =
typeof classNameProp === "function"
? classNameProp(renderCtx)
: classNameProp;

const children =
typeof childrenProp === "function" ? childrenProp(renderCtx) : childrenProp;

const refCallback = (node: HTMLButtonElement | null) => {
handleRef(node);

if (!node) return;

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
toggleGroupCtx?.registerToggle(value!, rootRef);
if (!toggleGroupCtx) node.tabIndex = disabled ? -1 : 0;

const accessibleName = computeAccessibleName(node);

if (!accessibleName) {
// eslint-disable-next-line no-console
console.error(
[
"[StylelessUI][Toggle]: Can't determine an accessible name.",
"It's mandatory to provide an accessible name for the component. " +
"Possible accessible names:",
". Set `aria-label` attribute.",
". Set `aria-labelledby` attribute.",
". Set `title` attribute.",
". Use an informative content.",
". Use a <label> with `for` attribute referencing to this component."
].join("\n")
);
}
};

return (
<button
{...otherProps}
className={className}
type="button"
ref={refCallback}
data-slot="toggleRoot"
disabled={disabled}
onFocus={checkBase.handleFocus}
onBlur={checkBase.handleBlur}
onKeyDown={checkBase.handleKeyDown}
onKeyUp={checkBase.handleKeyUp}
onClick={checkBase.handleClick}
aria-pressed={checkBase.checked}
{...(checkBase.checked ? { "data-active": "" } : {})}
>
{children}
</button>
);
};

const Toggle = componentWithForwardedRef<
HTMLButtonElement,
ToggleProps,
typeof ToggleBase
>(ToggleBase);

export default Toggle;
1 change: 1 addition & 0 deletions lib/Toggle/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default, type ToggleProps } from "./Toggle";
Loading