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