diff --git a/e2e/nextjs-app/src/app/components/toggle/checkbox.e2e.tsx b/e2e/nextjs-app/src/app/components/toggle/checkbox.e2e.tsx
new file mode 100644
index 0000000000..7670602606
--- /dev/null
+++ b/e2e/nextjs-app/src/app/components/toggle/checkbox.e2e.tsx
@@ -0,0 +1,207 @@
+"use client";
+import { Toggle } from "@lifesg/react-design-system/toggle";
+
+export default function Story() {
+ return (
+
+ {/* bordered + indicator */}
+
+
+ Bordered + indicator
+
+
+ Bordered + indicator checked
+
+
+ Bordered + indicator disabled
+
+
+ Bordered + indicator checked disabled
+
+
+ Bordered + indicator error
+
+
+ Bordered + indicator error disabled
+
+
+
+ {/* bordered + no indicator */}
+
+ Bordered
+
+ Bordered checked
+
+
+ Bordered disabled
+
+
+ Bordered checked disabled
+
+
+ Bordered error
+
+
+ Bordered error disabled
+
+
+
+ {/* no-border + indicator */}
+
+
+ No-border + indicator
+
+
+ No-border + indicator checked
+
+
+ No-border + indicator disabled
+
+
+ No-border + indicator checked disabled
+
+
+ No-border + indicator error
+
+
+ No-border + indicator error disabled
+
+
+
+ {/* no-border + no indicator */}
+
+
+ No-border
+
+
+ No-border checked
+
+
+ No-border disabled
+
+
+ No-border checked disabled
+
+
+ No-border error
+
+
+ No-border error disabled
+
+
+
+ {/* with sub label */}
+
+
+ With sub label
+
+
+ With sub label checked
+
+
+ With sub label disabled
+
+
+
+ {/* with composite section */}
+
+ Composite section content,
+ collapsible: true,
+ initialExpanded: false,
+ }}
+ >
+ With composite (collapsed)
+
+ Composite section content,
+ collapsible: true,
+ initialExpanded: true,
+ }}
+ >
+ With composite (expanded)
+
+
+
+
+ Composite section content,
+ collapsible: true,
+ initialExpanded: false,
+ errors: ["Error item 1", "Error item 2"],
+ }}
+ >
+ With composite errors (collapsed)
+
+ Composite section content,
+ collapsible: true,
+ initialExpanded: true,
+ errors: ["Error item 1", "Error item 2"],
+ }}
+ >
+ With composite errors (expanded)
+
+
+
+ {/* removable */}
+
+
+ Removable unchecked
+
+
+ Removable checked
+
+
+ Removable disabled
+
+
+
+ );
+}
diff --git a/e2e/nextjs-app/src/app/components/toggle/no.e2e.tsx b/e2e/nextjs-app/src/app/components/toggle/no.e2e.tsx
new file mode 100644
index 0000000000..927b90b49b
--- /dev/null
+++ b/e2e/nextjs-app/src/app/components/toggle/no.e2e.tsx
@@ -0,0 +1,106 @@
+"use client";
+import { Toggle } from "@lifesg/react-design-system/toggle";
+
+export default function Story() {
+ return (
+
+ {/* bordered + indicator */}
+
+
+ Bordered + indicator
+
+
+ Bordered + indicator checked
+
+
+ Bordered + indicator disabled
+
+
+ Bordered + indicator checked disabled
+
+
+ Bordered + indicator error
+
+
+ Bordered + indicator error disabled
+
+
+
+ {/* bordered + no indicator */}
+
+ Bordered
+
+ Bordered checked
+
+
+ Bordered disabled
+
+
+ Bordered checked disabled
+
+
+ Bordered error
+
+
+ Bordered error disabled
+
+
+
+ {/* no-border + indicator */}
+
+
+ No-border + indicator
+
+
+ No-border + indicator checked
+
+
+ No-border + indicator disabled
+
+
+ No-border + indicator checked disabled
+
+
+ No-border + indicator error
+
+
+ No-border + indicator error disabled
+
+
+
+ {/* no-border + no indicator */}
+
+
+ No-border
+
+
+ No-border checked
+
+
+ No-border disabled
+
+
+ No-border checked disabled
+
+
+ No-border error
+
+
+ No-border error disabled
+
+
+
+ );
+}
diff --git a/e2e/nextjs-app/src/app/components/toggle/radio.e2e.tsx b/e2e/nextjs-app/src/app/components/toggle/radio.e2e.tsx
new file mode 100644
index 0000000000..1b755b2b0b
--- /dev/null
+++ b/e2e/nextjs-app/src/app/components/toggle/radio.e2e.tsx
@@ -0,0 +1,106 @@
+"use client";
+import { Toggle } from "@lifesg/react-design-system/toggle";
+
+export default function Story() {
+ return (
+
+ {/* bordered + indicator */}
+
+
+ Bordered + indicator
+
+
+ Bordered + indicator checked
+
+
+ Bordered + indicator disabled
+
+
+ Bordered + indicator checked disabled
+
+
+ Bordered + indicator error
+
+
+ Bordered + indicator error disabled
+
+
+
+ {/* bordered + no indicator */}
+
+ Bordered
+
+ Bordered checked
+
+
+ Bordered disabled
+
+
+ Bordered checked disabled
+
+
+ Bordered error
+
+
+ Bordered error disabled
+
+
+
+ {/* no-border + indicator */}
+
+
+ No-border + indicator
+
+
+ No-border + indicator checked
+
+
+ No-border + indicator disabled
+
+
+ No-border + indicator checked disabled
+
+
+ No-border + indicator error
+
+
+ No-border + indicator error disabled
+
+
+
+ {/* no-border + no indicator */}
+
+
+ No-border
+
+
+ No-border checked
+
+
+ No-border disabled
+
+
+ No-border checked disabled
+
+
+ No-border error
+
+
+ No-border error disabled
+
+
+
+ );
+}
diff --git a/e2e/nextjs-app/src/app/components/toggle/yes.e2e.tsx b/e2e/nextjs-app/src/app/components/toggle/yes.e2e.tsx
new file mode 100644
index 0000000000..9fbcd51b7b
--- /dev/null
+++ b/e2e/nextjs-app/src/app/components/toggle/yes.e2e.tsx
@@ -0,0 +1,106 @@
+"use client";
+import { Toggle } from "@lifesg/react-design-system/toggle";
+
+export default function Story() {
+ return (
+
+ {/* bordered + indicator */}
+
+
+ Bordered + indicator
+
+
+ Bordered + indicator checked
+
+
+ Bordered + indicator disabled
+
+
+ Bordered + indicator checked disabled
+
+
+ Bordered + indicator error
+
+
+ Bordered + indicator error disabled
+
+
+
+ {/* bordered + no indicator */}
+
+ Bordered
+
+ Bordered checked
+
+
+ Bordered disabled
+
+
+ Bordered checked disabled
+
+
+ Bordered error
+
+
+ Bordered error disabled
+
+
+
+ {/* no-border + indicator */}
+
+
+ No-border + indicator
+
+
+ No-border + indicator checked
+
+
+ No-border + indicator disabled
+
+
+ No-border + indicator checked disabled
+
+
+ No-border + indicator error
+
+
+ No-border + indicator error disabled
+
+
+
+ {/* no-border + no indicator */}
+
+
+ No-border
+
+
+ No-border checked
+
+
+ No-border disabled
+
+
+ No-border checked disabled
+
+
+ No-border error
+
+
+ No-border error disabled
+
+
+
+ );
+}
diff --git a/e2e/tests/components/toggle/toggle.e2e.spec.ts b/e2e/tests/components/toggle/toggle.e2e.spec.ts
new file mode 100644
index 0000000000..9795fa647a
--- /dev/null
+++ b/e2e/tests/components/toggle/toggle.e2e.spec.ts
@@ -0,0 +1,81 @@
+import { test as base, Page } from "@playwright/test";
+import { AbstractStoryPage, compareScreenshot } from "../../utils";
+
+class StoryPage extends AbstractStoryPage {
+ protected readonly component = "toggle";
+
+ constructor(page: Page) {
+ super(page);
+ }
+}
+
+const test = base.extend<{ story: StoryPage }>({
+ story: async ({ page }, use) => {
+ const story = new StoryPage(page);
+ await use(story);
+ },
+});
+
+test.describe("Toggle", () => {
+ // -------------------------------------------------------------------------
+ // Checkbox
+ // -------------------------------------------------------------------------
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("checkbox");
+ });
+
+ test("Checkbox", async ({ story }) => {
+ await compareScreenshot(story, "mount");
+ });
+ });
+
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("checkbox", { mode: "dark" });
+ });
+
+ test("Checkbox (dark mode)", async ({ story }) => {
+ await compareScreenshot(story, "mount");
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Radio
+ // -------------------------------------------------------------------------
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("radio");
+ });
+
+ test("Radio", async ({ story }) => {
+ await compareScreenshot(story, "mount");
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Yes
+ // -------------------------------------------------------------------------
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("yes");
+ });
+
+ test("Yes", async ({ story }) => {
+ await compareScreenshot(story, "mount");
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // No
+ // -------------------------------------------------------------------------
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("no");
+ });
+
+ test("No", async ({ story }) => {
+ await compareScreenshot(story, "mount");
+ });
+ });
+});
diff --git a/src/shared/toggle-icon/toggle-icon.styles.tsx b/src/shared/toggle-icon/toggle-icon.styles.tsx
deleted file mode 100644
index 6951ea73e8..0000000000
--- a/src/shared/toggle-icon/toggle-icon.styles.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import styled, { css } from "styled-components";
-
-import { V3_Colour } from "../../v3_theme";
-
-interface StyleProps {
- $active?: boolean;
- $disabled?: boolean;
-}
-
-// =============================================================================
-// STYLING
-// =============================================================================
-export const Wrapper = styled.div`
- height: 1.625rem;
- width: 1.625rem;
- margin-right: 0.5rem;
- flex-shrink: 0;
-
- svg {
- height: 100%;
- width: 100%;
- }
-
- ${(props) => {
- if (props.$disabled) {
- if (props.$active) {
- return css`
- color: ${V3_Colour["icon-selected-disabled"]};
- `;
- } else {
- return css`
- color: ${V3_Colour["icon-disabled-subtle"]};
- `;
- }
- }
-
- if (props.$active) {
- return css`
- color: ${V3_Colour["icon-selected"]};
- `;
- }
-
- return css`
- color: ${V3_Colour["icon-subtle"]};
- `;
- }};
-`;
diff --git a/src/toggle/toggle-icon.styles.ts b/src/toggle/toggle-icon.styles.ts
new file mode 100644
index 0000000000..84e29f180a
--- /dev/null
+++ b/src/toggle/toggle-icon.styles.ts
@@ -0,0 +1,31 @@
+import { css } from "@linaria/core";
+
+import { Colour } from "../theme";
+
+// =============================================================================
+// STYLING
+// =============================================================================
+export const wrapperBase = css`
+ height: 1.625rem;
+ width: 1.625rem;
+ margin-right: 0.5rem;
+ flex-shrink: 0;
+ color: ${Colour["icon-subtle"]};
+
+ svg {
+ height: 100%;
+ width: 100%;
+ }
+`;
+
+export const wrapperDisabled = css`
+ color: ${Colour["icon-disabled-subtle"]};
+`;
+
+export const wrapperActive = css`
+ color: ${Colour["icon-selected"]};
+`;
+
+export const wrapperActiveDisabled = css`
+ color: ${Colour["icon-selected-disabled"]};
+`;
diff --git a/src/shared/toggle-icon/toggle-icon.tsx b/src/toggle/toggle-icon.tsx
similarity index 69%
rename from src/shared/toggle-icon/toggle-icon.tsx
rename to src/toggle/toggle-icon.tsx
index c4ce741a71..73c0c9e525 100644
--- a/src/shared/toggle-icon/toggle-icon.tsx
+++ b/src/toggle/toggle-icon.tsx
@@ -4,8 +4,9 @@ import { CrossIcon } from "@lifesg/react-icons/cross";
import { SquareIcon } from "@lifesg/react-icons/square";
import { SquareTickFillIcon } from "@lifesg/react-icons/square-tick-fill";
import { TickIcon } from "@lifesg/react-icons/tick";
+import clsx from "clsx";
-import { Wrapper } from "./toggle-icon.styles";
+import * as styles from "./toggle-icon.styles";
export type ToggleIconType = "checkbox" | "radio" | "tick" | "cross";
@@ -24,6 +25,22 @@ export const ToggleIcon = ({
}: ToggleIconProps) => {
let component: JSX.Element | null;
+ const getWrapperStateClass = () => {
+ if (active && disabled) {
+ return styles.wrapperActiveDisabled;
+ }
+
+ if (disabled) {
+ return styles.wrapperDisabled;
+ }
+
+ if (active) {
+ return styles.wrapperActive;
+ }
+
+ return undefined;
+ };
+
switch (type) {
case "checkbox":
component = active ? : ;
@@ -43,13 +60,15 @@ export const ToggleIcon = ({
}
return (
-
{component}
-
+
);
};
diff --git a/src/toggle/toggle.styles.ts b/src/toggle/toggle.styles.ts
new file mode 100644
index 0000000000..e0220cc31a
--- /dev/null
+++ b/src/toggle/toggle.styles.ts
@@ -0,0 +1,313 @@
+import { css } from "@linaria/core";
+
+import { Colour, Font, MediaQuery, Radius } from "../theme";
+import * as toggleIconStyles from "./toggle-icon.styles";
+
+export const tokens = {
+ label: {
+ desktopLineClamp: "--fds-internal-toggle-label-desktopLineClamp",
+ tabletLineClamp: "--fds-internal-toggle-label-tabletLineClamp",
+ mobileLineClamp: "--fds-internal-toggle-label-mobileLineClamp",
+ },
+} as const;
+
+// =============================================================================
+// STYLING
+// =============================================================================
+
+export const headerContainer = css`
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+`;
+
+export const textContainer = css`
+ display: flex;
+ flex-direction: column;
+ overflow-wrap: anywhere;
+ width: 100%;
+ overflow: hidden;
+ color: ${Colour.text};
+`;
+
+export const toggleTextContainerSelected = css`
+ color: ${Colour["text-selected"]};
+`;
+
+export const toggleTextContainerDisabledSelected = css`
+ color: ${Colour["text-selected-disabled"]};
+`;
+
+export const container = css`
+ position: relative;
+ display: inline-flex;
+ min-width: 10.375rem;
+ border-radius: ${Radius["sm"]};
+ border-width: 1px;
+ border-style: solid;
+ overflow: hidden;
+ flex-direction: column;
+ height: fit-content;
+ background: ${Colour.bg};
+
+ &:focus-within {
+ outline: 2px solid ${Colour["focus-ring"]};
+ outline-offset: 0;
+ }
+`;
+
+export const noIndicatorContainer = css`
+ justify-content: center;
+`;
+
+export const useContentWidthContainer = css`
+ min-width: unset;
+`;
+
+export const colorBorderError = css`
+ border-color: ${Colour["border-error"]};
+`;
+
+export const toggleContainerNoBorderError = css`
+ &:has(.${headerContainer}:hover) {
+ @media (pointer: fine) {
+ background: ${Colour["bg-hover-subtle"]};
+ }
+ }
+`;
+
+export const toggleContainerNoBorderDisabledSelected = css`
+ border: none;
+ background: ${Colour["bg-selected-disabled"]};
+`;
+
+export const toggleContainerNoBorderDisabled = css`
+ border: none;
+`;
+
+export const toggleContainerNoBorderSelected = css`
+ border: none;
+ background: ${Colour["bg-selected"]};
+
+ &:has(.${headerContainer}:hover) {
+ @media (pointer: fine) {
+ background: ${Colour["bg-selected-hover"]};
+
+ & .${textContainer} {
+ color: ${Colour["text-selected-hover"]};
+ }
+
+ & .${toggleIconStyles.wrapperBase} {
+ color: ${Colour["icon-selected-hover"]};
+ }
+ }
+ }
+`;
+
+export const toggleContainerNoBorder = css`
+ border: none;
+
+ &:has(.${headerContainer}:hover) {
+ @media (pointer: fine) {
+ background: ${Colour["bg-hover-subtle"]};
+ }
+ }
+`;
+
+export const toggleContainerError = css`
+ border-color: ${Colour["border-error"]};
+
+ &:has(.${headerContainer}:hover) {
+ @media (pointer: fine) {
+ background: ${Colour["bg-hover-subtle"]};
+ }
+ }
+`;
+
+export const toggleContainerDisabledSelected = css`
+ border-color: ${Colour["border-selected-disabled"]};
+ background: ${Colour["bg-selected-disabled"]};
+`;
+
+export const toggleContainerDisabled = css`
+ border-color: ${Colour["border-disabled"]};
+ background: ${Colour["bg-disabled"]};
+`;
+
+export const toggleContainerSelected = css`
+ border-color: ${Colour["border-selected"]};
+ background: ${Colour["bg-selected"]};
+
+ &:has(.${headerContainer}:hover) {
+ @media (pointer: fine) {
+ background: ${Colour["bg-selected-hover"]};
+
+ & .${textContainer} {
+ color: ${Colour["text-selected-hover"]};
+ }
+
+ & .${toggleIconStyles.wrapperBase} {
+ color: ${Colour["icon-selected-hover"]};
+ }
+ }
+ }
+`;
+
+export const toggleContainer = css`
+ border-color: ${Colour.border};
+
+ &:has(.${headerContainer}:hover) {
+ @media (pointer: fine) {
+ background: ${Colour["bg-hover-subtle"]};
+ }
+ }
+`;
+
+export const input = css`
+ position: absolute;
+ height: 100%;
+ width: 100%;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ opacity: 0;
+
+ /* Hide appearance but keep it focusable using keyboard interactions */
+ appearance: none;
+ background: transparent;
+ border: none;
+`;
+
+export const toggleInputDisabled = css`
+ cursor: not-allowed;
+`;
+
+export const inputContainer = css`
+ display: flex;
+`;
+
+export const label = css`
+ overflow: hidden;
+ display: -webkit-box;
+ text-overflow: ellipsis;
+ -webkit-box-orient: vertical;
+ overflow-wrap: break-word;
+
+ ${tokens.label.desktopLineClamp}: initial;
+ ${tokens.label.tabletLineClamp}: initial;
+ ${tokens.label.mobileLineClamp}: initial;
+
+ -webkit-line-clamp: var(${tokens.label.desktopLineClamp}, none);
+ ${MediaQuery.MaxWidth.lg} {
+ -webkit-line-clamp: var(${tokens.label.tabletLineClamp}, none);
+ }
+ ${MediaQuery.MaxWidth.sm} {
+ -webkit-line-clamp: var(${tokens.label.mobileLineClamp}, none);
+ }
+`;
+
+export const toggleLabelDefault = css`
+ ${Font["body-baseline-regular"]}
+`;
+
+export const toggleLabelSelected = css`
+ ${Font["body-baseline-semibold"]}
+`;
+
+export const subLabel = css`
+ ${Font["body-md-regular"]}
+ margin-top: 0.5rem;
+
+ z-index: 1; // forces sublabel to render above the input
+ pointer-events: none; // to allow click events to be passed to the input
+
+ strong,
+ b {
+ ${Font["body-md-semibold"]}
+ }
+`;
+
+export const indicatorLabelContainer = css`
+ display: flex;
+ height: 100%;
+ width: 100%;
+ position: relative;
+ padding: 0.6875rem 1rem;
+`;
+
+export const indicatorLabelContainerAddPadding = css`
+ padding: 0.6875rem 0rem 0.6875rem 1rem;
+`;
+
+export const removeButton = css`
+ color: ${Colour["text-error"]};
+ white-space: nowrap;
+ ${Font["body-md-semibold"]}
+ height: fit-content;
+ padding: 0.6875rem 1rem 0.6875rem 0.5rem;
+ border: none;
+ background: none;
+ cursor: pointer;
+`;
+
+export const disabledColorCursor = css`
+ color: ${Colour["text-disabled"]};
+ cursor: not-allowed;
+`;
+
+export const expandButton = css`
+ color: ${Colour["text-primary"]};
+ ${Font["body-baseline-semibold"]}
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ border: none;
+ background-color: ${Colour.bg};
+ cursor: pointer;
+ padding: 0 1rem 0.6875rem 1rem;
+ width: 100%;
+
+ svg {
+ width: 1em;
+ height: 1em;
+ margin-left: 0.5rem;
+ }
+`;
+
+export const expandButtonPaddingTopRequired = css`
+ padding-top: 0.6875rem;
+`;
+
+export const errorContainer = css`
+ width: 100%;
+ color: ${Colour["text-error"]};
+ border: none;
+ background: ${Colour.bg};
+ cursor: pointer;
+ padding: 0.6875rem 1rem 0.5rem 1rem;
+`;
+
+export const children = css`
+ padding: 0 1rem;
+ padding-top: 0.6875rem;
+ padding-bottom: 0.5rem;
+ background-color: ${Colour.bg};
+ color: ${Colour.text};
+`;
+
+export const childrenIsFinalItem = css`
+ padding-bottom: 0.6875rem;
+`;
+
+export const colorTextDisabled = css`
+ color: ${Colour["text-disabled"]};
+`;
+
+export const colorTextError = css`
+ color: ${Colour["text-error"]};
+`;
+
+export const alertContainer = css`
+ width: 100%;
+ user-select: none;
+`;
diff --git a/src/toggle/toggle.styles.tsx b/src/toggle/toggle.styles.tsx
deleted file mode 100644
index de79805c78..0000000000
--- a/src/toggle/toggle.styles.tsx
+++ /dev/null
@@ -1,428 +0,0 @@
-import styled, { css } from "styled-components";
-
-import { Alert } from "../alert";
-import { ToggleIcon } from "../shared/toggle-icon/toggle-icon";
-import { TextList } from "../text-list";
-import { Typography } from "../typography";
-import { V3_Colour, V3_Font, V3_MediaQuery, V3_Radius } from "../v3_theme";
-import type { ToggleStyleType } from "./types";
-
-// =============================================================================
-// STYLE INTERFACES, transient props are denoted with $
-// See more https://styled-components.com/docs/api#transient-props
-// =============================================================================
-interface StyleProps {
- $selected?: boolean;
- $disabled?: boolean;
- $indicator?: boolean;
-}
-
-interface ContainerStyleProps extends StyleProps {
- $styleType?: ToggleStyleType;
- $error?: boolean;
- $useContentWidth?: boolean;
-}
-
-interface IndicatorLabelContainerStyleProps {
- $addPadding?: boolean;
-}
-
-interface LabelStyleProps {
- $maxLines?: { desktop?: number; mobile?: number; tablet?: number };
-}
-
-interface ExpandButtonStyleProps extends StyleProps {
- $paddingTopRequired?: boolean;
-}
-
-interface ChildrenStyleProps extends StyleProps {
- $isFinalItem?: boolean;
-}
-
-interface InteractiveStyleProps {
- $disabledVisual?: boolean;
-}
-
-// =============================================================================
-// STYLING
-// =============================================================================
-
-export const Container = styled.div`
- position: relative;
- display: inline-flex;
- min-width: 10.375rem;
- border-radius: ${V3_Radius["sm"]};
- border-width: 1px;
- border-style: solid;
- overflow: hidden;
- flex-direction: column;
- height: fit-content;
- background: ${V3_Colour.bg};
-
- &:has(input:focus-visible) {
- outline: 2px solid ${V3_Colour["focus-ring"]};
- outline-offset: 0;
- }
-
- ${(props) => {
- if (!props.$indicator) {
- return css`
- justify-content: center;
- `;
- }
- }}
-
- // Container min width to fit content
- ${(props) => {
- if (props.$useContentWidth) {
- return css`
- min-width: unset;
- `;
- }
- }}
-
- // apply container border and header background color
- ${(props) => {
- switch (props.$styleType) {
- case "no-border": {
- if (props.$error) {
- if (props.$disabled) {
- return css`
- border-color: ${V3_Colour["border-error"]};
- `;
- } else {
- return css`
- border-color: ${V3_Colour["border-error"]};
-
- &:has(${HeaderContainer}:hover) {
- @media (pointer: fine) {
- background: ${V3_Colour["bg-hover-subtle"]};
- }
- }
- `;
- }
- }
-
- if (props.$disabled) {
- if (props.$selected) {
- return css`
- border: none;
- background: ${V3_Colour["bg-selected-disabled"]};
- `;
- } else {
- return css`
- border: none;
- `;
- }
- }
-
- if (props.$selected) {
- return css`
- border: none;
- background: ${V3_Colour["bg-selected"]};
-
- &:has(${HeaderContainer}:hover) {
- @media (pointer: fine) {
- background: ${V3_Colour["bg-selected-hover"]};
-
- & ${TextContainer} {
- color: ${V3_Colour["text-selected-hover"]};
- }
-
- & ${StyledToggleIcon} {
- color: ${V3_Colour["icon-selected-hover"]};
- }
- }
- }
- `;
- }
-
- return css`
- border: none;
-
- &:has(${HeaderContainer}:hover) {
- @media (pointer: fine) {
- background: ${V3_Colour["bg-hover-subtle"]};
- }
- }
- `;
- }
-
- default: {
- if (props.$error) {
- if (props.$disabled) {
- return css`
- border-color: ${V3_Colour["border-error"]};
- `;
- } else {
- return css`
- border-color: ${V3_Colour["border-error"]};
-
- &:has(${HeaderContainer}:hover) {
- @media (pointer: fine) {
- background: ${V3_Colour["bg-hover-subtle"]};
- }
- }
- `;
- }
- }
-
- if (props.$disabled) {
- if (props.$selected) {
- return css`
- border-color: ${V3_Colour[
- "border-selected-disabled"
- ]};
- background: ${V3_Colour["bg-selected-disabled"]};
- `;
- } else {
- return css`
- border-color: ${V3_Colour["border-disabled"]};
- background: ${V3_Colour["bg-disabled"]};
- `;
- }
- }
-
- if (props.$selected) {
- return css`
- border-color: ${V3_Colour["border-selected"]};
- background: ${V3_Colour["bg-selected"]};
-
- &:has(${HeaderContainer}:hover) {
- @media (pointer: fine) {
- background: ${V3_Colour["bg-selected-hover"]};
-
- & ${TextContainer} {
- color: ${V3_Colour["text-selected-hover"]};
- }
-
- & ${StyledToggleIcon} {
- color: ${V3_Colour["icon-selected-hover"]};
- }
- }
- }
- `;
- }
-
- return css`
- border-color: ${V3_Colour.border};
-
- &:has(${HeaderContainer}:hover) {
- @media (pointer: fine) {
- background: ${V3_Colour["bg-hover-subtle"]};
- }
- }
- `;
- }
- }
- }}
-`;
-
-export const Input = styled.input`
- position: absolute;
- height: 100%;
- width: 100%;
- cursor: ${(props) => (props.$disabledVisual ? "not-allowed" : "pointer")};
- top: 0;
- left: 0;
- opacity: 0;
-
- /* Hide appearance but keep it focusable using keyboard interactions */
- appearance: none;
- background: transparent;
- border: none;
-`;
-
-export const InputContainer = styled.div`
- display: flex;
-`;
-
-export const TextContainer = styled.div`
- display: flex;
- flex-direction: column;
- overflow-wrap: anywhere;
- width: 100%;
- overflow: hidden;
-
- // apply header container text color
- ${(props) => {
- if (props.$disabled) {
- if (props.$selected) {
- return css`
- color: ${V3_Colour["text-selected-disabled"]};
- `;
- } else {
- return css`
- color: ${V3_Colour["text-disabled"]};
- `;
- }
- }
-
- if (props.$selected) {
- return css`
- color: ${V3_Colour["text-selected"]};
- `;
- }
-
- return css`
- color: ${V3_Colour.text};
- `;
- }}
-`;
-
-export const Label = styled.label`
- ${(props) =>
- props.$selected
- ? V3_Font["body-baseline-semibold"]
- : V3_Font["body-baseline-regular"]};
- overflow: hidden;
- display: -webkit-box;
- text-overflow: ellipsis;
- -webkit-box-orient: vertical;
- overflow-wrap: break-word;
- -webkit-line-clamp: ${(props) => props.$maxLines?.desktop ?? "none"};
- ${V3_MediaQuery.MaxWidth.lg} {
- -webkit-line-clamp: ${(props) => props.$maxLines?.tablet ?? "none"};
- }
- ${V3_MediaQuery.MaxWidth.sm} {
- -webkit-line-clamp: ${(props) => props.$maxLines?.mobile ?? "none"};
- }
-`;
-
-export const SubLabel = styled.div`
- ${V3_Font["body-md-regular"]}
- margin-top: 0.5rem;
-
- z-index: 1; // forces sublabel to render above the input
- pointer-events: none; // to allow click events to be passed to the input
-
- strong,
- b {
- ${V3_Font["body-md-semibold"]}
- }
-`;
-
-export const HeaderContainer = styled.div`
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
-`;
-
-export const IndicatorLabelContainer = styled.div`
- display: flex;
- height: 100%;
- width: 100%;
- position: relative;
- padding: ${(props) =>
- props.$addPadding ? "0.6875rem 0rem 0.6875rem 1rem" : "0.6875rem 1rem"};
-`;
-
-export const RemoveButton = styled.button`
- color: ${(props) =>
- props.$disabled ? V3_Colour["text-disabled"] : V3_Colour["text-error"]};
- white-space: nowrap;
- ${V3_Font["body-md-semibold"]}
- height: fit-content;
- padding: 0.6875rem 1rem 0.6875rem 0.5rem;
- border: none;
- background: none;
-
- cursor: ${(props) => (props.$disabled ? "not-allowed" : "pointer")};
-`;
-
-export const ExpandButton = styled.button`
- color: ${(props) =>
- props.disabled
- ? V3_Colour["text-disabled"]
- : V3_Colour["text-primary"]};
- ${V3_Font["body-baseline-semibold"]}
- display: flex;
- align-items: center;
- justify-content: flex-end;
- border: none;
- background-color: ${V3_Colour.bg};
- cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
- padding: 0 1rem 0.6875rem 1rem;
- padding-top: ${(props) =>
- props.$paddingTopRequired ? "0.6875rem" : "0rem"};
- width: 100%;
-
- svg {
- width: 1em;
- height: 1em;
- margin-left: 0.5rem;
- }
-`;
-
-export const ErrorContainer = styled.div`
- width: 100%;
- color: ${(props) =>
- props.$disabled ? V3_Colour["text-disabled"] : V3_Colour["text-error"]};
- border: none;
- background: ${V3_Colour.bg};
- cursor: ${(props) => (props.$disabled ? "not-allowed" : "pointer")};
- padding: 0.6875rem 1rem 0.5rem 1rem;
-`;
-
-export const AlertContainer = styled(Alert)`
- width: 100%;
- user-select: none;
-`;
-
-export const Children = styled.div`
- padding: 0 1rem;
- padding-top: 0.6875rem;
- padding-bottom: ${(props) => (props.$isFinalItem ? "0.6875rem" : "0.5rem")};
- background-color: ${V3_Colour.bg};
-
- ${(props) => {
- if (props.$disabled) {
- return css`
- color: ${V3_Colour["text-disabled"]};
- `;
- } else if (props.$selected) {
- return css`
- color: ${V3_Colour["text-selected"]};
- `;
- } else {
- return css`
- color: ${V3_Colour.text};
- `;
- }
- }}
-`;
-
-export const ErrorText = styled(Typography.BodyMD)`
- color: ${(props) =>
- props.$disabled ? V3_Colour["text-disabled"] : V3_Colour["text-error"]};
-`;
-
-export const ErrorList = styled(TextList.Ul)`
- color: ${(props) =>
- props.$disabled ? V3_Colour["text-disabled"] : V3_Colour["text-error"]};
-`;
-
-export const StyledToggleIcon = styled(ToggleIcon)`
- ${(props) => {
- if (props.$disabled) {
- if (props.$selected) {
- return css`
- color: ${V3_Colour["icon-selected-disabled"]};
- `;
- } else {
- return css`
- color: ${V3_Colour["icon-disabled-subtle"]};
- `;
- }
- }
-
- if (props.$selected) {
- return css`
- color: ${V3_Colour["icon-selected"]};
- `;
- }
- return css`
- color: ${V3_Colour["icon-subtle"]};
- `;
- }};
-`;
diff --git a/src/toggle/toggle.tsx b/src/toggle/toggle.tsx
index 27e9c85ca3..0c6bc8b04b 100644
--- a/src/toggle/toggle.tsx
+++ b/src/toggle/toggle.tsx
@@ -1,28 +1,17 @@
import { ChevronDownIcon } from "@lifesg/react-icons/chevron-down";
import { ChevronUpIcon } from "@lifesg/react-icons/chevron-up";
+import clsx from "clsx";
import { useEffect, useMemo, useRef, useState } from "react";
+import { Alert } from "../alert";
import { Markup } from "../markup";
-import type { ToggleIconType } from "../shared/toggle-icon/toggle-icon";
+import { TextList } from "../text-list";
+import { useApplyStyle } from "../theme";
+import { Typography } from "../typography";
import { SimpleIdGenerator } from "../util";
-import {
- AlertContainer,
- Children,
- Container,
- ErrorContainer,
- ErrorList,
- ErrorText,
- ExpandButton,
- HeaderContainer,
- IndicatorLabelContainer,
- Input,
- InputContainer,
- Label,
- RemoveButton,
- StyledToggleIcon,
- SubLabel,
- TextContainer,
-} from "./toggle.styles";
+import * as styles from "./toggle.styles";
+import type { ToggleIconType } from "./toggle-icon";
+import { ToggleIcon } from "./toggle-icon";
import type { ToggleProps } from "./types";
export const Toggle = ({
@@ -68,6 +57,22 @@ export const Toggle = ({
const generatedId = id ? `${id}` : `tg-${uniqueId}`;
const inputRef = useRef(null);
+ const labelRef = useRef(null);
+
+ useApplyStyle(labelRef, {
+ [styles.tokens.label.desktopLineClamp]:
+ childrenMaxLines?.desktop == null
+ ? null
+ : `${childrenMaxLines.desktop}`,
+ [styles.tokens.label.tabletLineClamp]:
+ childrenMaxLines?.tablet == null
+ ? null
+ : `${childrenMaxLines.tablet}`,
+ [styles.tokens.label.mobileLineClamp]:
+ childrenMaxLines?.mobile == null
+ ? null
+ : `${childrenMaxLines.mobile}`,
+ });
const isFocusableWhenDisabled = !!disabled && !!focusableWhenDisabled;
const isNativeDisabled = !!disabled && !focusableWhenDisabled;
@@ -138,6 +143,64 @@ export const Toggle = ({
const handleInputClick = (e: React.MouseEvent) => {
e.stopPropagation();
};
+
+ // =============================================================================
+ // HELPER FUNCTIONS
+ // =============================================================================
+
+ const getContainerStateClass = (() => {
+ if (styleType === "no-border") {
+ if (error) {
+ return clsx(
+ styles.colorBorderError,
+ !disabled && styles.toggleContainerError
+ );
+ }
+
+ if (disabled) {
+ return selected
+ ? styles.toggleContainerNoBorderDisabledSelected
+ : styles.toggleContainerNoBorderDisabled;
+ }
+
+ if (selected) {
+ return styles.toggleContainerNoBorderSelected;
+ }
+
+ return styles.toggleContainerNoBorder;
+ }
+
+ if (error) {
+ return disabled
+ ? styles.colorBorderError
+ : styles.toggleContainerError;
+ }
+
+ if (disabled) {
+ return selected
+ ? styles.toggleContainerDisabledSelected
+ : styles.toggleContainerDisabled;
+ }
+
+ if (selected) {
+ return styles.toggleContainerSelected;
+ }
+
+ return styles.toggleContainer;
+ })();
+
+ const getTextContainerStateClass = (() => {
+ if (disabled) {
+ return selected
+ ? styles.toggleTextContainerDisabledSelected
+ : styles.colorTextDisabled;
+ }
+
+ if (selected) {
+ return styles.toggleTextContainerSelected;
+ }
+ })();
+
// =============================================================================
// RENDER FUNCTIONS
// =============================================================================
@@ -158,12 +221,10 @@ export const Toggle = ({
}
return (
-
);
};
@@ -183,28 +244,32 @@ export const Toggle = ({
// Hide sublabel from screen readers as the main input already has aria-describedby that points to the sublabel
return (
-
{component}
-
+
);
};
const renderCompositeChildren = () => {
return (
(!collapsible || expanded) && (
-
{compositeSectionChildren}
-
+
)
);
};
@@ -214,8 +279,13 @@ export const Toggle = ({
return (
collapsible && (
-
)}
-
+
)
);
};
@@ -241,28 +311,33 @@ export const Toggle = ({
.join(" ") || undefined;
return (
-
+
);
};
const renderErrorList = (errors: string[]) => {
return (
<>
-
+
Error
-
-
+
+
{errors?.map((item, index) => {
return (
-
{item}
-
+
);
})}
-
+
>
);
};
@@ -335,21 +433,24 @@ export const Toggle = ({
collapsible &&
!expanded &&
hasCompositeSectionError && (
-
-
{Array.isArray(errors)
? renderErrorList(errors)
: errors}
-
-
+
+
)
);
};
@@ -367,19 +468,19 @@ export const Toggle = ({
};
return (
-
{renderToggleWithRemoveButton()}
{renderCompositeSection()}
-
+
);
};