diff --git a/src/community/components/form/toggle/toggle.stories.tsx b/src/community/components/form/toggle/toggle.stories.tsx index 960fd575b..2390d7060 100644 --- a/src/community/components/form/toggle/toggle.stories.tsx +++ b/src/community/components/form/toggle/toggle.stories.tsx @@ -9,6 +9,11 @@ import Toggle, { ToggleProps } from './toggle'; const meta: Meta = { component: Toggle, title: 'Community/Form/Toggle', + parameters: { + status: { + type: ['deprecated', 'ExistsInTediReady'], + }, + }, }; export default meta; diff --git a/src/tedi/components/form/toggle/toggle.module.scss b/src/tedi/components/form/toggle/toggle.module.scss new file mode 100644 index 000000000..e16e69194 --- /dev/null +++ b/src/tedi/components/form/toggle/toggle.module.scss @@ -0,0 +1,167 @@ +@mixin toggle-variant($color, $type) { + @if $type == filled { + --toggle-bg-default: var(--form-toggle-#{$color}-inactive-default); + --toggle-bg-hover: var(--form-toggle-#{$color}-inactive-hover); + --toggle-bg-active: var(--form-toggle-#{$color}-inactive-active); + --toggle-bg-checked: var(--form-toggle-#{$color}-active-default); + --toggle-bg-checked-hover: var(--form-toggle-#{$color}-active-hover); + --toggle-bg-checked-active: var(--form-toggle-#{$color}-active-active); + --toggle-indicator-default: var(--form-toggle-#{$color}-inactive-indicator); + --toggle-indicator-checked: var(--form-toggle-#{$color}-active-indicator); + --toggle-icon-default: var(--form-toggle-#{$color}-inactive-icon); + --toggle-icon-checked: var(--form-toggle-#{$color}-active-icon); + } + + @if $type == outlined { + --toggle-border-default: var(--form-toggle-#{$color}-inactive-default); + --toggle-border-hover: var(--form-toggle-#{$color}-inactive-hover); + --toggle-border-active: var(--form-toggle-#{$color}-inactive-active); + --toggle-border-checked: var(--form-toggle-#{$color}-active-default); + --toggle-border-checked-hover: var(--form-toggle-#{$color}-active-hover); + --toggle-border-checked-active: var(--form-toggle-#{$color}-active-active); + --toggle-indicator-default: var(--form-toggle-#{$color}-inactive-default); + --toggle-indicator-checked: var(--form-toggle-#{$color}-active-default); + --toggle-icon-default: var(--form-toggle-#{$color}-inactive-icon-outlined); + --toggle-icon-checked: var(--form-toggle-#{$color}-active-icon-outlined); + } +} + +.tedi-toggle { + position: relative; + display: block; + + &--default { + --toggle-indicator: var(--form-toggle-default-indicator); + + width: var(--form-toggle-default-width); + height: var(--form-toggle-default-height); + + .tedi-toggle__slider { + width: var(--form-toggle-default-indicator); + height: var(--form-toggle-default-indicator); + } + } + + &--large { + --toggle-indicator: var(--form-toggle-large-indicator); + + width: var(--form-toggle-large-width); + height: var(--form-toggle-large-height); + + .tedi-toggle__slider { + width: var(--form-toggle-large-indicator); + height: var(--form-toggle-large-indicator); + } + } + + &--primary-filled { + @include toggle-variant(primary, filled); + } + + &--primary-outlined { + @include toggle-variant(primary, outlined); + } + + &--colored-filled { + @include toggle-variant(colored, filled); + } + + &--colored-outlined { + @include toggle-variant(colored, outlined); + } + + &__input { + width: 100%; + height: 100%; + margin: 0; + appearance: none; + cursor: pointer; + background: var(--toggle-bg-default); + border: var(--tedi-borders-01) solid var(--toggle-border-default); + border-radius: var(--form-toggle-radius); + transition: background 0.15s ease, border-color 0.15s ease; + + &:hover:not(:disabled) { + background: var(--toggle-bg-hover); + border-color: var(--toggle-border-hover); + } + + &:active:not(:disabled) { + background: var(--toggle-bg-active); + border-color: var(--toggle-border-active); + } + + &:checked { + background: var(--toggle-bg-checked); + border-color: var(--toggle-border-checked); + + &:hover:not(:disabled) { + background: var(--toggle-bg-checked-hover); + border-color: var(--toggle-border-checked-hover); + } + + &:active:not(:disabled) { + background: var(--toggle-bg-checked-active); + border-color: var(--toggle-border-checked-active); + } + } + + &:focus-visible { + outline: calc(2 * var(--borders-01)) solid var(--form-toggle-primary-active-default); + outline-offset: var(--borders-01); + } + + &:checked + .tedi-toggle__slider { + left: calc(100% - var(--toggle-indicator) - var(--form-toggle-padding)); + color: var(--toggle-icon-checked); + background-color: var(--toggle-indicator-checked); + transform: translateY(-50%); + + .tedi-toggle__icon { + stroke: var(--toggle-icon-checked); + } + } + } + + &__slider { + position: absolute; + top: 50%; + left: var(--form-toggle-padding); + display: flex; + align-items: center; + justify-content: center; + color: var(--toggle-icon-default); + pointer-events: none; + background-color: var(--toggle-indicator-default); + border-radius: 50%; + transition: left 0.17s ease-out, background 0.15s ease; + transform: translateY(-50%); + + .tedi-toggle__icon { + stroke: var(--toggle-icon-default); + } + } + + &--disabled { + cursor: not-allowed; + opacity: 0.5; + + .tedi-toggle__input { + cursor: not-allowed; + } + } + + &__control { + display: flex; + gap: var(--layout-grid-gutters-08); + align-items: center; + + &--label-left { + flex-direction: row; + } + + &--label-right { + flex-direction: row-reverse; + } + } +} diff --git a/src/tedi/components/form/toggle/toggle.spec.tsx b/src/tedi/components/form/toggle/toggle.spec.tsx new file mode 100644 index 000000000..246582b88 --- /dev/null +++ b/src/tedi/components/form/toggle/toggle.spec.tsx @@ -0,0 +1,157 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import Toggle, { ToggleProps } from './toggle'; + +import '@testing-library/jest-dom'; + +describe('Toggle component', () => { + const defaultProps: ToggleProps = { + id: 'test-toggle', + label: 'Enable notifications', + }; + + it('renders with default properties', () => { + render(); + + const input = screen.getByRole('switch'); + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute('id', 'test-toggle'); + expect(input).toHaveAttribute('type', 'checkbox'); + }); + + it('renders the label correctly', () => { + render(); + + const label = screen.getByText(/enable notifications/i); + expect(label).toBeInTheDocument(); + }); + + it('renders hidden label when hideLabel is true', () => { + render(); + + const label = screen.getByText(/enable notifications/i); + expect(label).toBeInTheDocument(); + expect(label.closest('label')).toHaveClass('tedi-toggle__label'); + }); + + it('calls onChange when toggled', async () => { + const handleChange = jest.fn(); + const user = userEvent.setup(); + + render(); + + const toggle = screen.getByRole('switch'); + await user.click(toggle); + + expect(handleChange).toHaveBeenCalledTimes(1); + expect(handleChange).toHaveBeenCalledWith(true); + }); + + it('respects controlled checked state', () => { + const { rerender } = render(); + + let toggle = screen.getByRole('switch'); + expect(toggle).not.toBeChecked(); + + rerender(); + toggle = screen.getByRole('switch'); + expect(toggle).toBeChecked(); + }); + + it('renders as uncontrolled with defaultChecked', () => { + render(); + + const toggle = screen.getByRole('switch'); + expect(toggle).toBeChecked(); + }); + + it('disables the toggle when disabled prop is true', () => { + render(); + + const toggle = screen.getByRole('switch'); + expect(toggle).toBeDisabled(); + }); + + it('disables the toggle and prevents onChange when isLoading is true', async () => { + const handleChange = jest.fn(); + const user = userEvent.setup(); + + render(); + + const toggle = screen.getByRole('switch'); + expect(toggle).toBeDisabled(); + + await user.click(toggle); + expect(handleChange).not.toHaveBeenCalled(); + }); + + it('shows loading spinner when isLoading is true', () => { + render(); + + const spinner = screen.getByTestId('tedi-spinner'); + expect(spinner).toBeInTheDocument(); + }); + + it('shows lock icon when icon prop is true', () => { + render(); + + const icon = screen.getByRole('img', { hidden: true }); + expect(icon).toBeInTheDocument(); + }); + + it('renders label on the right when labelPosition="right"', () => { + render(); + + const control = screen.getByRole('switch').closest('.tedi-toggle__control'); + expect(control).toHaveClass('tedi-toggle__control--label-right'); + }); + + it('renders label on the left by default', () => { + render(); + + const control = screen.getByRole('switch').closest('.tedi-toggle__control'); + expect(control).toHaveClass('tedi-toggle__control--label-left'); + }); + + it('renders helper text when provided', () => { + render(); + + const helper = screen.getByText(/this is a helper message/i); + expect(helper).toBeInTheDocument(); + }); + + it('applies correct size classes', () => { + const { rerender } = render(); + + const toggleContainer = screen.getByRole('switch').closest('.tedi-toggle'); + expect(toggleContainer).toHaveClass('tedi-toggle--default'); + + rerender(); + expect(toggleContainer).toHaveClass('tedi-toggle--large'); + }); + + it('applies variant classes correctly', () => { + render(); + + const toggleContainer = screen.getByRole('switch').closest('.tedi-toggle'); + expect(toggleContainer).toHaveClass('tedi-toggle--colored-outlined'); + }); + + it('applies active class when checked', () => { + render(); + const toggleContainer = screen.getByRole('switch').closest('.tedi-toggle'); + expect(toggleContainer).toHaveClass('tedi-toggle--active'); + }); + + it('applies disabled class when disabled or loading', () => { + const { rerender } = render(); + + let toggleContainer = screen.getByRole('switch').closest('.tedi-toggle'); + expect(toggleContainer).toHaveClass('tedi-toggle--disabled'); + + rerender(); + toggleContainer = screen.getByRole('switch').closest('.tedi-toggle'); + expect(toggleContainer).toHaveClass('tedi-toggle--disabled'); + }); +}); diff --git a/src/tedi/components/form/toggle/toggle.stories.tsx b/src/tedi/components/form/toggle/toggle.stories.tsx new file mode 100644 index 000000000..978b7538c --- /dev/null +++ b/src/tedi/components/form/toggle/toggle.stories.tsx @@ -0,0 +1,294 @@ +import { Meta, StoryFn, StoryObj } from '@storybook/react'; + +import { Text, TextProps } from '../../base/typography/text/text'; +import { Col, Row } from '../../layout/grid'; +import { VerticalSpacing } from '../../layout/vertical-spacing'; +import Toggle, { ToggleProps } from './toggle'; + +/** + * Figma ↗
+ * Zeroheight ↗ + */ + +const meta: Meta = { + component: Toggle, + title: 'TEDI-Ready/Components/Form/Toggle', + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.45.68?node-id=4536-77367&m=dev', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const sizeArray: readonly ['default', 'large'] = ['default', 'large']; +const stateArray = ['Default', 'Hover', 'Active', 'Focus', 'Disabled', 'Loading']; +interface TemplateMultipleProps extends ToggleProps { + array: readonly Type[]; + titleColor?: TextProps['color']; + size: ToggleProps['size']; + property?: keyof ToggleProps; +} + +const TemplateColumn: StoryFn = (args) => { + const { array } = args; + + return ( +
+ {array.map((value, key) => ( + + + {value ? value.charAt(0).toUpperCase() + value.slice(1) : ''} + + + + + + ))} +
+ ); +}; + +const TemplateStates: StoryFn = (args) => { + const { array, titleColor, ...toggleProps } = args; + + return ( + <> + + {array.map((value, key) => ( + + + + {value} + + + + + + + + + + + + + + + + ))} + + + ); +}; + +export const Default: Story = { + args: { + label: 'Toggle', + }, +}; + +export const Size: StoryObj = { + render: TemplateColumn, + args: { + array: sizeArray, + }, +}; + +export const Type = () => { + return ( + + + + + + + + + + + + + + + ); +}; + +export const LabelPosition = () => { + return ( + + + + + + + + + ); +}; + +export const States = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export const Primary: StoryObj = { + render: TemplateStates, + args: { + array: stateArray, + hideLabel: true, + }, + parameters: { + pseudo: { + hover: '#Hover', + active: '#Active', + focusVisible: '#Focus', + }, + }, +}; + +export const PrimaryOutlined: StoryObj = { + render: TemplateStates, + args: { + array: stateArray, + type: 'outlined', + hideLabel: true, + }, + parameters: { + pseudo: { + hover: '#Hover', + active: '#Active', + focusVisible: '#Focus', + }, + }, +}; + +export const Colored: StoryObj = { + render: TemplateStates, + args: { + array: stateArray, + color: 'colored', + hideLabel: true, + }, + parameters: { + pseudo: { + hover: '#Hover', + active: '#Active', + focusVisible: '#Focus', + }, + }, +}; + +export const ColoredOutlined: StoryObj = { + render: TemplateStates, + args: { + array: stateArray, + color: 'colored', + type: 'outlined', + hideLabel: true, + }, + parameters: { + pseudo: { + hover: '#Hover', + active: '#Active', + focusVisible: '#Focus', + }, + }, +}; + +export const WithFeedbackText: Story = { + args: { + label: 'Label', + helper: { + text: 'Something went wrong', + type: 'error', + }, + }, +}; diff --git a/src/tedi/components/form/toggle/toggle.tsx b/src/tedi/components/form/toggle/toggle.tsx new file mode 100644 index 000000000..403d8652a --- /dev/null +++ b/src/tedi/components/form/toggle/toggle.tsx @@ -0,0 +1,198 @@ +import cn from 'classnames'; +import React, { forwardRef, useState } from 'react'; + +import { Icon } from '../../base/icon/icon'; +import { Spinner } from '../../loaders/spinner/spinner'; +import FeedbackText, { FeedbackTextProps } from '../feedback-text/feedback-text'; +import FormLabel from '../form-label/form-label'; +import styles from './toggle.module.scss'; + +export interface ToggleProps { + /** + * Unique identifier for the toggle input. + * Required for accessibility and to link the label with the input. + */ + id: string; + /** + * Label text or element displayed next to the toggle. + * This is shown to users and should clearly describe what the toggle does. + */ + label: React.ReactNode; + /** + * Visually hides the label while keeping it accessible to screen readers. + * Useful when the toggle's purpose is clear from context (e.g., in a settings row). + * @default false + */ + hideLabel?: boolean; + /** + * Position of the label relative to the toggle switch. + * @default right + */ + labelPosition?: 'left' | 'right'; + /** + * Optional helper text displayed below the toggle. + * Can be used to provide additional context or validation messages. + */ + helper?: FeedbackTextProps; + /** + * Additional CSS class name(s) to apply to the toggle wrapper. + * Useful for custom styling or theming from parent components. + */ + className?: string; + /** + * Controlled state of the toggle. + * Use this together with `onChange` for full control over the checked state. + */ + checked?: boolean; + /** + * Initial checked state for uncontrolled usage. + * Ignored if `checked` prop is provided. + */ + defaultChecked?: boolean; + /** + * Callback fired when the toggle state changes. + * @param value - The new checked state (`true` or `false`) + */ + onChange?(value: boolean): void; + /** + * Size of the toggle switch. + * @default default + */ + size?: 'default' | 'large'; + /** + * Color variant of the toggle. + * - `primary`: Standard toggle (usually blue/brand color) + * - `colored`: Alternative accent color (e.g. for special settings) + * @default primary + */ + color?: 'primary' | 'colored'; + /** + * Visual style variant of the toggle. + * @default filled + */ + type?: 'filled' | 'outlined'; + /** + * Shows a lock icon inside the toggle knob. + * Typically used with `size="large"` to indicate secure/private settings. + * @default false + */ + icon?: boolean; + /** + * Disables the toggle, preventing user interaction. + * @default false + */ + disabled?: boolean; + /** + * Shows a loading spinner inside the toggle instead of the icon or dot. + * Useful for async operations (e.g. saving settings). + * When `true`, `onChange` will not be triggered. + * @default false + */ + isLoading?: boolean; + /** + * Tooltip content shown when hovering over the label. + * Useful for providing extra explanation without cluttering the UI. + */ + tooltip?: string; +} + +export const Toggle = forwardRef((props, ref) => { + const { + id, + label, + hideLabel = false, + labelPosition = 'left', + helper, + className, + checked, + defaultChecked, + onChange, + size: propSize, + color = 'primary', + type = 'filled', + icon, + disabled = false, + isLoading = false, + tooltip, + ...rest + } = props; + const helperId = helper ? `${id}-helper` : undefined; + + const [internalChecked, setInternalChecked] = useState(defaultChecked ?? false); + + const isControlled = typeof checked !== 'undefined'; + const isChecked = isControlled ? checked : internalChecked; + + const size = propSize || (icon ? 'large' : 'default'); + + const handleChange = (e: React.ChangeEvent) => { + if (isLoading) return; + + if (!isControlled) { + setInternalChecked(e.target.checked); + } + + onChange?.(e.target.checked); + }; + + const toggleClass = cn( + styles['tedi-toggle'], + styles[`tedi-toggle--${size}`], + styles[`tedi-toggle--${color}-${type}`], + { + [styles['tedi-toggle--active']]: isChecked, + [styles['tedi-toggle--disabled']]: disabled || isLoading, + }, + className + ); + + const controlClass = cn(styles['tedi-toggle__control'], styles[`tedi-toggle__control--label-${labelPosition}`]); + + return ( + <> +
+ {label && ( + + )} + +
+ + + + {isLoading ? ( + + ) : icon ? ( + + ) : null} + +
+
+ {helper && ( + + )} + + ); +}); + +Toggle.displayName = 'Toggle'; + +export default Toggle; diff --git a/src/tedi/index.ts b/src/tedi/index.ts index 421a95baf..0782dc525 100644 --- a/src/tedi/index.ts +++ b/src/tedi/index.ts @@ -27,6 +27,7 @@ export * from './components/navigation/link/link'; export * from './components/form/textfield/textfield'; export * from './components/form/textarea/textarea'; export * from './components/form/number-field/number-field'; +export * from './components/form/toggle/toggle'; export * from './components/form/feedback-text/feedback-text'; export * from './components/form/search/search'; export * from './components/form/radio/radio';