diff --git a/frontend/packages/ui/src/index.ts b/frontend/packages/ui/src/index.ts index 679e7350eb..6bbc858162 100644 --- a/frontend/packages/ui/src/index.ts +++ b/frontend/packages/ui/src/index.ts @@ -47,3 +47,5 @@ export type { HintMessageProps, HintMessageType, } from "./lib/controls/utilities/HintMessage"; +export { Switch } from "./lib/controls/Switch"; +export type { SwitchProps } from "./lib/controls/Switch"; diff --git a/frontend/packages/ui/src/lib/controls/Switch.module.scss b/frontend/packages/ui/src/lib/controls/Switch.module.scss new file mode 100644 index 0000000000..eaf6cf1e36 --- /dev/null +++ b/frontend/packages/ui/src/lib/controls/Switch.module.scss @@ -0,0 +1,108 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +@use "../_ds/_borders.scss" as *; +@use "../_ds/_utils.scss" as *; +@use "../_ds/typography.scss" as t; + +.switch { + --switch-label-foreground-color: var(--color-foreground-primary); + --switch-track-outline-color: none; + --switch-track-shadow: inset 0 1px 2px var(--color-shadow-light); + --switch-thumb-shadow: 0 1px 2px var(--color-shadow-light); + + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: var(--sp-s); + inline-size: fit-content; + outline: none; +} + +.switch.off { + --switch-track-justify-content: start; + --switch-track-background-color: var(--color-foreground-secondary); + --switch-thumb-width: #{px2rem(14)}; + --switch-thumb-height: #{px2rem(14)}; + --switch-thumb-background-color: var(--color-accent-off); + --switch-thumb-border-radius: #{$br-circle}; +} + +.switch.neutral { + --switch-track-justify-content: center; + --switch-track-background-color: var(--color-accent-tertiary); + --switch-thumb-width: #{px2rem(14)}; + --switch-thumb-height: #{px2rem(4)}; + --switch-thumb-background-color: var(--color-accent-off); + --switch-thumb-border-radius: #{$br-8}; +} + +.switch.on { + --switch-track-justify-content: end; + --switch-track-background-color: var(--color-accent-tertiary); + --switch-thumb-width: #{px2rem(14)}; + --switch-thumb-height: #{px2rem(14)}; + --switch-thumb-background-color: var(--color-accent-off); + --switch-thumb-border-radius: #{$br-circle}; +} + +.switch[data-disabled] { + pointer-events: none; + + --switch-label-foreground-color: var(--color-foreground-secondary); + --switch-track-shadow: none; + --switch-thumb-shadow: none; +} + +.switch.off[data-disabled] { + --switch-track-background-color: var(--color-background-primary); + --switch-track-border-color: var(--color-background-disabled); + --switch-thumb-background-color: var(--color-background-disabled); +} + +.switch.on[data-disabled], +.switch.neutral[data-disabled] { + --switch-track-background-color: var(--color-background-disabled); + --switch-thumb-background-color: var(--color-background-primary); +} + +.switch:focus-visible { + --switch-track-outline-color: var(--color-accent-primary); +} + +.switch:hover { + --switch-thumb-background-color: var(--color-static-white); +} + +.switch-label { + @include t.use-typography("body-small"); + + color: var(--switch-label-foreground-color); + user-select: none; +} + +.switch-track { + display: flex; + align-items: center; + justify-content: var(--switch-track-justify-content); + inline-size: px2rem(30); + block-size: px2rem(18); + padding: var(--sp-xxs); + border-radius: $br-12; + border: $b-1 solid var(--switch-track-border-color); + outline: $b-1 solid var(--switch-track-outline-color); + outline-offset: $b-1; + box-shadow: var(--switch-track-shadow); + background-color: var(--switch-track-background-color); +} + +.switch-thumb { + inline-size: var(--switch-thumb-width); + block-size: var(--switch-thumb-height); + border-radius: var(--switch-thumb-border-radius); + box-shadow: var(--switch-thumb-shadow); + background-color: var(--switch-thumb-background-color); +} diff --git a/frontend/packages/ui/src/lib/controls/Switch.spec.tsx b/frontend/packages/ui/src/lib/controls/Switch.spec.tsx new file mode 100644 index 0000000000..45947c011c --- /dev/null +++ b/frontend/packages/ui/src/lib/controls/Switch.spec.tsx @@ -0,0 +1,138 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +import { fireEvent, render } from "@testing-library/react"; +import { Switch } from "./Switch"; + +describe("Switch", () => { + it("should render successfully", () => { + const { baseElement } = render(); + expect(baseElement).toBeTruthy(); + }); + + it("should render with role=switch", () => { + const { container } = render(); + const el = container.querySelector("[role='switch']"); + expect(el).toBeTruthy(); + }); + + it("should reflect aria-checked=false when defaultChecked is false", () => { + const { container } = render(); + const el = container.querySelector("[role='switch']"); + expect(el?.getAttribute("aria-checked")).toBe("false"); + }); + + it("should reflect aria-checked=true when defaultChecked is true", () => { + const { container } = render(); + const el = container.querySelector("[role='switch']"); + expect(el?.getAttribute("aria-checked")).toBe("true"); + }); + + it("should toggle from false to true on click", () => { + const onChange = vi.fn(); + const { container } = render( + , + ); + const el = container.querySelector("[role='switch']") as HTMLElement; + fireEvent.click(el); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it("should toggle from true to false on click", () => { + const onChange = vi.fn(); + const { container } = render( + , + ); + const el = container.querySelector("[role='switch']") as HTMLElement; + fireEvent.click(el); + expect(onChange).toHaveBeenCalledWith(false); + }); + + it("should toggle on Space key", () => { + const onChange = vi.fn(); + const { container } = render( + , + ); + const el = container.querySelector("[role='switch']") as HTMLElement; + fireEvent.keyDown(el, { key: " " }); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it("should toggle on Enter key", () => { + const onChange = vi.fn(); + const { container } = render( + , + ); + const el = container.querySelector("[role='switch']") as HTMLElement; + fireEvent.keyDown(el, { key: "Enter" }); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it("should not toggle when disabled", () => { + const onChange = vi.fn(); + const { container } = render( + , + ); + const el = container.querySelector("[role='switch']") as HTMLElement; + fireEvent.click(el); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("should set tabIndex=-1 when disabled", () => { + const { container } = render(); + const el = container.querySelector("[role='switch']"); + expect(el?.getAttribute("tabindex")).toBe("-1"); + }); + + it("should set aria-disabled when disabled", () => { + const { container } = render(); + const el = container.querySelector("[role='switch']"); + expect(el?.getAttribute("aria-disabled")).toBe("true"); + }); + + it("should set tabIndex=0 when not disabled", () => { + const { container } = render(); + const el = container.querySelector("[role='switch']"); + expect(el?.getAttribute("tabindex")).toBe("0"); + }); + + it("should render label when provided", () => { + const { container } = render(); + const lbl = container.querySelector("label"); + expect(lbl?.textContent).toBe("Toggle"); + }); + + it("should not render label element when label is not provided", () => { + const { container } = render(); + expect(container.querySelector("label")).toBeNull(); + }); + + it("should use aria-label when no visible label", () => { + const { container } = render(); + const el = container.querySelector("[role='switch']"); + expect(el?.getAttribute("aria-label")).toBe("Toggle feature"); + }); + + it("should not set aria-label on root when label prop is present", () => { + const { container } = render( + , + ); + const el = container.querySelector("[role='switch']"); + expect(el?.getAttribute("aria-label")).toBeNull(); + }); + + it("should merge className", () => { + const { container } = render(); + const el = container.querySelector("[role='switch']"); + expect(el?.getAttribute("class")).toContain("extra"); + }); + + it("should pass through HTML attributes", () => { + const { container } = render(); + const el = container.querySelector("[role='switch']"); + expect(el?.getAttribute("data-testid")).toBe("sw"); + }); +}); diff --git a/frontend/packages/ui/src/lib/controls/Switch.stories.tsx b/frontend/packages/ui/src/lib/controls/Switch.stories.tsx new file mode 100644 index 0000000000..bb87f97ac2 --- /dev/null +++ b/frontend/packages/ui/src/lib/controls/Switch.stories.tsx @@ -0,0 +1,45 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Switch } from "./Switch"; + +const meta = { + title: "Controls/Switch", + component: Switch, + args: { + label: "Enable feature", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const On: Story = { + args: { defaultChecked: true }, +}; + +export const Off: Story = { + args: { defaultChecked: false }, +}; + +export const Neutral: Story = { + args: { defaultChecked: null }, +}; + +export const Disabled: Story = { + args: { disabled: true, defaultChecked: false }, +}; + +export const DisabledOn: Story = { + args: { disabled: true, defaultChecked: true }, +}; + +export const NoLabel: Story = { + args: { label: undefined, "aria-label": "Toggle feature" }, +}; diff --git a/frontend/packages/ui/src/lib/controls/Switch.tsx b/frontend/packages/ui/src/lib/controls/Switch.tsx new file mode 100644 index 0000000000..75659a9067 --- /dev/null +++ b/frontend/packages/ui/src/lib/controls/Switch.tsx @@ -0,0 +1,110 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +import { + type ComponentPropsWithRef, + type KeyboardEvent, + memo, + useCallback, + useId, + useState, +} from "react"; +import clsx from "clsx"; +import styles from "./Switch.module.scss"; + +export interface SwitchProps extends Omit< + ComponentPropsWithRef<"div">, + "onChange" | "defaultChecked" +> { + /** Text label rendered next to the track */ + label?: string; + /** Accessible label used when there is no visible label */ + "aria-label"?: string; + /** Initial checked state (uncontrolled). null = neutral/indeterminate. */ + defaultChecked?: boolean | null; + /** Called with the new boolean state after each toggle */ + onChange?: (checked: boolean) => void; + /** When true the switch cannot be interacted with */ + disabled?: boolean; +} + +function SwitchInner( + { + label, + "aria-label": ariaLabel, + defaultChecked = null, + onChange, + disabled = false, + className, + ...rest + }: SwitchProps, + ref: React.Ref, +) { + const [checked, setChecked] = useState( + defaultChecked ?? null, + ); + const trackId = useId(); + + const handleToggle = useCallback(() => { + if (disabled) return; + const next = !checked; + setChecked(next); + onChange?.(next); + }, [checked, disabled, onChange]); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === " " || event.key === "Enter") { + event.preventDefault(); + handleToggle(); + } + }, + [handleToggle], + ); + + const hasLabel = label != null && label.trim().length > 0; + + const rootClass = clsx( + styles.switch, + { + [styles.off]: checked === false, + [styles.neutral]: checked === null, + [styles.on]: checked === true, + }, + className, + ); + + return ( +
+
+
+
+ {hasLabel && ( + + )} +
+ ); +} + +export const Switch = memo( + SwitchInner as ( + props: SwitchProps & { ref?: React.Ref }, + ) => React.ReactElement | null, +);