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 ( - - - +
- {indicator && renderIndicator()} - - + {subLabel && renderSubLabel()} - - - - +
+ + {removable && ( - Remove - + )} -
+ ); }; 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()} - + ); };