diff --git a/e2e/nextjs-app/src/app/components/radio-button/focusable-when-disabled.e2e.tsx b/e2e/nextjs-app/src/app/components/radio-button/focusable-when-disabled.e2e.tsx new file mode 100644 index 000000000..b401d41d0 --- /dev/null +++ b/e2e/nextjs-app/src/app/components/radio-button/focusable-when-disabled.e2e.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { RadioButton } from "@lifesg/react-design-system/radio-button"; +import { useState } from "react"; + +export default function Story() { + const [changeCount, setChangeCount] = useState(0); + + return ( +
+ + + setChangeCount((value) => value + 1)} + /> + + {changeCount} +
+ ); +} diff --git a/e2e/nextjs-app/src/app/components/radio-button/keyboard-navigation.e2e.tsx b/e2e/nextjs-app/src/app/components/radio-button/keyboard-navigation.e2e.tsx new file mode 100644 index 000000000..031fa72d1 --- /dev/null +++ b/e2e/nextjs-app/src/app/components/radio-button/keyboard-navigation.e2e.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { RadioButton } from "@lifesg/react-design-system/radio-button"; +import { useState } from "react"; + +export default function Story() { + const [checked, setChecked] = useState(false); + const [changeCount, setChangeCount] = useState(0); + + return ( +
+ + + { + setChecked(true); + setChangeCount((value) => value + 1); + }} + /> + + {changeCount} +
+ ); +} diff --git a/e2e/nextjs-app/src/app/components/radio-button/variants.e2e.tsx b/e2e/nextjs-app/src/app/components/radio-button/variants.e2e.tsx new file mode 100644 index 000000000..93c4c1cd1 --- /dev/null +++ b/e2e/nextjs-app/src/app/components/radio-button/variants.e2e.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { RadioButton } from "@lifesg/react-design-system/radio-button"; + +export default function Story() { + return ( +
+
+ + + + +
+
+ + + + +
+
+ ); +} diff --git a/e2e/tests/components/radio-button/__screenshots__/chromium/RadioButton-Variants--hover-checked-disabled.png b/e2e/tests/components/radio-button/__screenshots__/chromium/RadioButton-Variants--hover-checked-disabled.png new file mode 100644 index 000000000..35595d016 Binary files /dev/null and b/e2e/tests/components/radio-button/__screenshots__/chromium/RadioButton-Variants--hover-checked-disabled.png differ diff --git a/e2e/tests/components/radio-button/__screenshots__/chromium/RadioButton-Variants--hover-checked-enabled.png b/e2e/tests/components/radio-button/__screenshots__/chromium/RadioButton-Variants--hover-checked-enabled.png new file mode 100644 index 000000000..14bab8dbe Binary files /dev/null and b/e2e/tests/components/radio-button/__screenshots__/chromium/RadioButton-Variants--hover-checked-enabled.png differ diff --git a/e2e/tests/components/radio-button/__screenshots__/chromium/RadioButton-Variants--hover-disabled.png b/e2e/tests/components/radio-button/__screenshots__/chromium/RadioButton-Variants--hover-disabled.png new file mode 100644 index 000000000..7989890ba Binary files /dev/null and b/e2e/tests/components/radio-button/__screenshots__/chromium/RadioButton-Variants--hover-disabled.png differ diff --git a/e2e/tests/components/radio-button/__screenshots__/chromium/RadioButton-Variants--hover-enabled.png b/e2e/tests/components/radio-button/__screenshots__/chromium/RadioButton-Variants--hover-enabled.png new file mode 100644 index 000000000..11744dc87 Binary files /dev/null and b/e2e/tests/components/radio-button/__screenshots__/chromium/RadioButton-Variants--hover-enabled.png differ diff --git a/e2e/tests/components/radio-button/__screenshots__/chromium/RadioButton-Variants--mount.png b/e2e/tests/components/radio-button/__screenshots__/chromium/RadioButton-Variants--mount.png new file mode 100644 index 000000000..eb9b0f075 Binary files /dev/null and b/e2e/tests/components/radio-button/__screenshots__/chromium/RadioButton-Variants--mount.png differ diff --git a/e2e/tests/components/radio-button/radio-button.e2e.spec.ts b/e2e/tests/components/radio-button/radio-button.e2e.spec.ts new file mode 100644 index 000000000..efe08d664 --- /dev/null +++ b/e2e/tests/components/radio-button/radio-button.e2e.spec.ts @@ -0,0 +1,150 @@ +import { test as base, expect, Locator, Page } from "@playwright/test"; +import { AbstractStoryPage, compareScreenshot } from "../../utils"; + +class StoryPage extends AbstractStoryPage { + protected readonly component = "radio-button"; + + public readonly locators: { + radio: Locator; + + radioUncheckedDefault: Locator; + radioCheckedDefault: Locator; + radioUncheckedDisabled: Locator; + radioCheckedDisabled: Locator; + focusStart: Locator; + changeCount: Locator; + }; + + constructor(page: Page) { + super(page); + + this.locators = { + radio: page.getByRole("radio"), + + radioUncheckedDefault: page.getByTestId("radio-unchecked-default"), + radioCheckedDefault: page.getByTestId("radio-checked-default"), + radioUncheckedDisabled: page.getByTestId( + "radio-unchecked-disabled" + ), + radioCheckedDisabled: page.getByTestId("radio-checked-disabled"), + focusStart: page.getByTestId("focus-start"), + changeCount: page.getByTestId("change-count"), + }; + } + + public getContainer(locator: Locator) { + return locator.locator("xpath=.."); + } +} + +const test = base.extend<{ story: StoryPage }>({ + story: async ({ page }, runStory) => { + const story = new StoryPage(page); + await runStory(story); + }, +}); + +test.describe("RadioButton", () => { + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("variants"); + }); + + test("Variants", async ({ story }) => { + await compareScreenshot(story, "mount"); + + await expect(story.layout).toMatchAriaSnapshot(` + - radio + - radio [checked] + - radio [disabled] [checked] + - radio [disabled] + `); + + await story.locators.radioUncheckedDefault.hover(); + await compareScreenshot(story, "hover-enabled", { + locator: story.getContainer( + story.locators.radioUncheckedDefault + ), + }); + + await story.locators.radioCheckedDefault.hover(); + await compareScreenshot(story, "hover-checked-enabled", { + locator: story.getContainer(story.locators.radioCheckedDefault), + }); + + await story.locators.radioUncheckedDisabled.hover(); + await compareScreenshot(story, "hover-disabled", { + locator: story.getContainer( + story.locators.radioUncheckedDisabled + ), + }); + + await story.locators.radioCheckedDisabled.hover(); + await compareScreenshot(story, "hover-checked-disabled", { + locator: story.getContainer( + story.locators.radioCheckedDisabled + ), + }); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("focusable-when-disabled"); + }); + + test("Focusable when disabled", async ({ story }) => { + await test.step("Remains disabled", async () => { + await expect(story.locators.radio).toHaveAttribute( + "aria-disabled", + "true" + ); + + await expect(story.locators.radio).toBeDisabled(); + }); + + await test.step("Can receive focus", async () => { + await story.locators.focusStart.focus(); + await story.page.keyboard.press("Tab"); + + await expect(story.locators.radio).toBeFocused(); + }); + + await test.step("Do not trigger onChange", async () => { + await expect(story.locators.changeCount).toHaveText("0"); + + await story.locators.radio.click({ + force: true, + }); + + await story.page.keyboard.press("Space"); + + await expect(story.locators.changeCount).toHaveText("0"); + }); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("keyboard-navigation"); + }); + + test("Keyboard navigation", async ({ story }) => { + await test.step("Radio can receive focus", async () => { + await story.locators.focusStart.focus(); + await story.page.keyboard.press("Tab"); + + await expect(story.locators.radio).toBeFocused(); + }); + + await test.step("Space key checks radio and fires onChange", async () => { + await expect(story.locators.changeCount).toHaveText("0"); + + await story.page.keyboard.press("Space"); + + await expect(story.locators.radio).toBeChecked(); + await expect(story.locators.changeCount).toHaveText("1"); + }); + }); + }); +}); diff --git a/src/radio-button/radio-button.styles.ts b/src/radio-button/radio-button.styles.ts new file mode 100644 index 000000000..51dcb9fc2 --- /dev/null +++ b/src/radio-button/radio-button.styles.ts @@ -0,0 +1,66 @@ +import { css } from "@linaria/core"; + +import { Colour, Motion } from "../theme"; + +export const classes = {} as const; + +export const container = css` + display: flex; + justify-content: center; + align-items: center; + position: relative; + + &[data-display-size="small"] { + height: 1.5rem; + width: 1.5rem; + } + + &[data-display-size="default"] { + height: 2rem; + width: 2rem; + } +`; + +export const icon = css` + height: 100%; + width: 100%; + transition: ${Motion["duration-150"]} ${Motion["ease-default"]}; +`; + +export const uncheckedIcon = css` + color: ${Colour["icon-subtle"]}; +`; + +export const uncheckedIconDisabled = css` + color: ${Colour["icon-disabled-subtle"]}; +`; + +export const checkedIcon = css` + color: ${Colour["icon-selected"]}; +`; + +export const checkedIconDisabled = css` + color: ${Colour["icon-selected-disabled"]}; +`; + +export const input = css` + position: absolute; + height: 100%; + width: 100%; + cursor: not-allowed; + z-index: 1; + + appearance: none; + background: transparent; + border: none; +`; + +export const inputActive = css` + cursor: pointer; + + &:hover + svg { + @media (pointer: fine) { + color: ${Colour["icon-hover"]}; + } + } +`; diff --git a/src/radio-button/radio-button.styles.tsx b/src/radio-button/radio-button.styles.tsx deleted file mode 100644 index 6cf39e73b..000000000 --- a/src/radio-button/radio-button.styles.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { CircleDotIcon, CircleIcon } from "@lifesg/react-icons"; -import styled, { css } from "styled-components"; - -import { V3_Colour, V3_Motion } from "../v3_theme"; -import type { RadioButtonSize } from "./types"; - -// ============================================================================= -// STYLE INTERFACE, transient props are denoted with $ -// See more https://styled-components.com/docs/api#transient-props -// ============================================================================= -interface StyleProps { - $selected?: boolean; - $disabled?: boolean; - $displaySize?: RadioButtonSize | undefined; -} - -interface InputStyleProps { - $disabledVisual?: boolean | undefined; -} - -// ============================================================================= -// STYLING -// ============================================================================= - -export const Container = styled.div` - display: flex; - justify-content: center; - align-items: center; - ${(props) => { - if (props.$displaySize === "small") { - return css` - height: 1.5rem; - width: 1.5rem; - `; - } else { - return css` - height: 2rem; - width: 2rem; - `; - } - }} - position: relative; -`; - -export const StyledUnCheckedIcon = styled(CircleIcon)` - height: 100%; - width: 100%; - color: ${(props) => - props.$disabled - ? V3_Colour["icon-disabled-subtle"](props) - : V3_Colour["icon-subtle"](props)}; - transition: ${V3_Motion["duration-150"]} ${V3_Motion["ease-default"]}; -`; - -export const StyledCheckedIcon = styled(CircleDotIcon)` - height: 100%; - width: 100%; - color: ${(props) => - props.$disabled - ? V3_Colour["icon-selected-disabled"](props) - : V3_Colour["icon-selected"](props)}; - - transition: ${V3_Motion["duration-150"]} ${V3_Motion["ease-default"]}; -`; - -export const Input = styled.input` - position: absolute; - height: 100%; - width: 100%; - cursor: ${(props) => (props.$disabledVisual ? "not-allowed" : "pointer")}; - z-index: 1; - - appearance: none; - background: transparent; - border: none; - - &:hover + ${StyledUnCheckedIcon}, &:hover + ${StyledCheckedIcon} { - @media (pointer: fine) { - color: ${(props) => - !props.$disabledVisual && V3_Colour["icon-hover"](props)}; - } - } -`; diff --git a/src/radio-button/radio-button.tsx b/src/radio-button/radio-button.tsx index af842992c..b147130cb 100644 --- a/src/radio-button/radio-button.tsx +++ b/src/radio-button/radio-button.tsx @@ -1,11 +1,8 @@ -import type React from "react"; +import { CircleDotIcon, CircleIcon } from "@lifesg/react-icons"; +import clsx from "clsx"; +import type { ChangeEvent } from "react"; -import { - Container, - Input, - StyledCheckedIcon, - StyledUnCheckedIcon, -} from "./radio-button.styles"; +import * as styles from "./radio-button.styles"; import type { RadioButtonProps } from "./types"; export const RadioButton = ({ @@ -27,7 +24,7 @@ export const RadioButton = ({ // ============================================================================= // EVENT HANDLERS // ============================================================================= - const handleOnChange = (event: React.ChangeEvent) => { + const handleOnChange = (event: ChangeEvent) => { if (disabled) { event.preventDefault(); return; @@ -41,29 +38,35 @@ export const RadioButton = ({ // ============================================================================= const renderIcon = () => { return checked ? ( - ) : ( - ); }; return ( - - {renderIcon()} - + ); }; diff --git a/tests/radio-button/radio-button.spec.tsx b/tests/radio-button/radio-button.spec.tsx new file mode 100644 index 000000000..47bf6353b --- /dev/null +++ b/tests/radio-button/radio-button.spec.tsx @@ -0,0 +1,37 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { RadioButton } from "src/radio-button"; + +describe("RadioButton", () => { + it("should fire onChange when enabled", () => { + const onChange = jest.fn(); + render(); + + fireEvent.click(screen.getByTestId("radio-input")); + + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("should not fire onChange when disabled", () => { + const onChange = jest.fn(); + render(); + + fireEvent.click(screen.getByTestId("radio-input")); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("should remain focusable when disabled and focusableWhenDisabled is true", () => { + const onChange = jest.fn(); + render( + + ); + + const input = screen.getByTestId("radio-input"); + fireEvent.click(input); + + expect(input).not.toBeDisabled(); + expect(input).toHaveAttribute("aria-disabled", "true"); + expect(input).toHaveAttribute("tabIndex", "0"); + expect(onChange).not.toHaveBeenCalled(); + }); +});