From 3a135478abb8509b40a9962f538896f2bededf0f Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:55:12 +0300 Subject: [PATCH 1/6] feat(toggle): new TEDI-Ready component #305 --- .../components/form/toggle/toggle.stories.tsx | 5 + .../components/form/toggle/toggle.module.scss | 167 ++++++++++ .../components/form/toggle/toggle.spec.tsx | 158 ++++++++++ .../components/form/toggle/toggle.stories.tsx | 295 ++++++++++++++++++ src/tedi/components/form/toggle/toggle.tsx | 205 ++++++++++++ src/tedi/index.ts | 1 + 6 files changed, 831 insertions(+) create mode 100644 src/tedi/components/form/toggle/toggle.module.scss create mode 100644 src/tedi/components/form/toggle/toggle.spec.tsx create mode 100644 src/tedi/components/form/toggle/toggle.stories.tsx create mode 100644 src/tedi/components/form/toggle/toggle.tsx 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..8ad8c067a --- /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-toggl-#{$color}-inactive-default); + --toggle-bg-hover: var(--form-toggl-#{$color}-inactive-hover); + --toggle-bg-active: var(--form-toggl-#{$color}-inactive-active); + --toggle-bg-checked: var(--form-toggl-#{$color}-active-default); + --toggle-bg-checked-hover: var(--form-toggl-#{$color}-active-hover); + --toggle-bg-checked-active: var(--form-toggl-#{$color}-active-active); + --toggle-indicator-default: var(--form-toggl-#{$color}-inactive-indicator); + --toggle-indicator-checked: var(--form-toggl-#{$color}-active-indicator); + --toggle-icon-default: var(--form-toggl-#{$color}-inactive-icon); + --toggle-icon-checked: var(--form-toggl-#{$color}-active-icon); + } + + @if $type == outlined { + --toggle-border-default: var(--form-toggl-#{$color}-inactive-default); + --toggle-border-hover: var(--form-toggl-#{$color}-inactive-hover); + --toggle-border-active: var(--form-toggl-#{$color}-inactive-active); + --toggle-border-checked: var(--form-toggl-#{$color}-active-default); + --toggle-border-checked-hover: var(--form-toggl-#{$color}-active-hover); + --toggle-border-checked-active: var(--form-toggl-#{$color}-active-active); + --toggle-indicator-default: var(--form-toggl-#{$color}-inactive-default); + --toggle-indicator-checked: var(--form-toggl-#{$color}-active-default); + --toggle-icon-default: var(--form-toggl-#{$color}-inactive-icon-outlined); + --toggle-icon-checked: var(--form-toggl-#{$color}-active-icon-outlined); + } +} + +.tedi-toggle { + position: relative; + display: block; + + &--default { + --toggle-indicator: var(--form-toggl-default-indicator); + + width: var(--form-toggl-default-width); + height: var(--form-toggl-default-height); + + .tedi-toggle__slider { + width: var(--form-toggl-default-indicator); + height: var(--form-toggl-default-indicator); + } + } + + &--large { + --toggle-indicator: var(--form-toggl-large-indicator); + + width: var(--form-toggl-large-width); + height: var(--form-toggl-large-height); + + .tedi-toggle__slider { + width: var(--form-toggl-large-indicator); + height: var(--form-toggl-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(--borders-01) solid var(--toggle-border-default); + border-radius: var(--form-toggl-radius); + transition: background 0.15s ease, border-color 0.15s ease; + + &:hover { + background: var(--toggle-bg-hover); + border-color: var(--toggle-border-hover); + } + + &:active { + background: var(--toggle-bg-active); + border-color: var(--toggle-border-active); + } + + &:checked { + background: var(--toggle-bg-checked); + border-color: var(--toggle-border-checked); + + &:hover { + background: var(--toggle-bg-checked-hover); + border-color: var(--toggle-border-checked-hover); + } + + &:active { + background: var(--toggle-bg-checked-active); + border-color: var(--toggle-border-checked-active); + } + } + + &:focus-visible { + outline: calc(2 * var(--borders-01)) solid var(--form-toggl-primary-active-default); + outline-offset: var(--borders-01); + } + + &:checked + .tedi-toggle__slider { + left: calc(100% - var(--toggle-indicator) - var(--form-toggl-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-toggl-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; + } + } +} + +.tedi-toggle__control { + display: flex; + gap: var(--layout-grid-gutters-08); + align-items: center; + + &--label-left { + flex-direction: row; + } + + &--label-right { + flex-direction: row; + } +} 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..8bf9f4497 --- /dev/null +++ b/src/tedi/components/form/toggle/toggle.spec.tsx @@ -0,0 +1,158 @@ +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 left when labelPosition="left"', () => { + render(); + + const control = screen.getByRole('switch').closest('label'); + expect(control).toHaveClass('tedi-toggle__control--label-left'); + }); + + it('renders label on the right by default', () => { + render(); + + const control = screen.getByRole('switch').closest('label'); + expect(control).toHaveClass('tedi-toggle__control--label-right'); + }); + + 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..6d4f110d2 --- /dev/null +++ b/src/tedi/components/form/toggle/toggle.stories.tsx @@ -0,0 +1,295 @@ +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', + hideLabel: true, + }, +}; + +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..5c5fd32b6 --- /dev/null +++ b/src/tedi/components/form/toggle/toggle.tsx @@ -0,0 +1,205 @@ +import cn from 'classnames'; +import React, { forwardRef, useId } 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: propId, + 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 generatedId = useId(); + const id = propId || `toggle-${generatedId}`; + const helperId = helper ? `${id}-helper` : undefined; + + const isControlled = typeof checked !== 'undefined'; + const isChecked = isControlled ? checked : defaultChecked ?? false; + + const size = propSize || (icon ? 'large' : 'default'); + + const handleChange = (e: React.ChangeEvent) => { + if (isLoading) return; + 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, + }, + className + ); + + const controlClass = cn(styles['tedi-toggle__control'], styles[`tedi-toggle__control--label-${labelPosition}`]); + + return ( +
+ + + {helper && ( + + )} +
+ ); +}); + +Toggle.displayName = 'Toggle'; + +export default Toggle; diff --git a/src/tedi/index.ts b/src/tedi/index.ts index 319dbf905..1952e34c2 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'; From 7eff60c5682ca70fa32802b158d72bfc49031212 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:28:03 +0300 Subject: [PATCH 2/6] feat(toggle): fix toggling icon #305 --- src/tedi/components/form/toggle/toggle.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/tedi/components/form/toggle/toggle.tsx b/src/tedi/components/form/toggle/toggle.tsx index 5c5fd32b6..b0a13cd29 100644 --- a/src/tedi/components/form/toggle/toggle.tsx +++ b/src/tedi/components/form/toggle/toggle.tsx @@ -1,5 +1,5 @@ import cn from 'classnames'; -import React, { forwardRef, useId } from 'react'; +import React, { forwardRef, useId, useState } from 'react'; import { Icon } from '../../base/icon/icon'; import { Spinner } from '../../loaders/spinner/spinner'; @@ -121,13 +121,20 @@ export const Toggle = forwardRef((props, ref) => const id = propId || `toggle-${generatedId}`; const helperId = helper ? `${id}-helper` : undefined; + const [internalChecked, setInternalChecked] = useState(defaultChecked ?? false); + const isControlled = typeof checked !== 'undefined'; - const isChecked = isControlled ? checked : defaultChecked ?? false; + 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); }; @@ -177,7 +184,7 @@ export const Toggle = forwardRef((props, ref) => {isLoading ? ( ) : icon ? ( - + ) : null} From b90a65c474fee0d50fc2eb412c548239c00174f0 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:19:19 +0300 Subject: [PATCH 3/6] feat(toggle): loading toggle should be also disabled, fix tests #305 --- src/tedi/components/form/toggle/toggle.module.scss | 8 ++++---- src/tedi/components/form/toggle/toggle.spec.tsx | 10 +++++----- src/tedi/components/form/toggle/toggle.tsx | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/tedi/components/form/toggle/toggle.module.scss b/src/tedi/components/form/toggle/toggle.module.scss index 8ad8c067a..a09084bdc 100644 --- a/src/tedi/components/form/toggle/toggle.module.scss +++ b/src/tedi/components/form/toggle/toggle.module.scss @@ -81,12 +81,12 @@ border-radius: var(--form-toggl-radius); transition: background 0.15s ease, border-color 0.15s ease; - &:hover { + &:hover:not(:disabled) { background: var(--toggle-bg-hover); border-color: var(--toggle-border-hover); } - &:active { + &:active:not(:disabled) { background: var(--toggle-bg-active); border-color: var(--toggle-border-active); } @@ -95,12 +95,12 @@ background: var(--toggle-bg-checked); border-color: var(--toggle-border-checked); - &:hover { + &:hover:not(:disabled) { background: var(--toggle-bg-checked-hover); border-color: var(--toggle-border-checked-hover); } - &:active { + &:active:not(:disabled) { background: var(--toggle-bg-checked-active); border-color: var(--toggle-border-checked-active); } diff --git a/src/tedi/components/form/toggle/toggle.spec.tsx b/src/tedi/components/form/toggle/toggle.spec.tsx index 8bf9f4497..c27d1dc36 100644 --- a/src/tedi/components/form/toggle/toggle.spec.tsx +++ b/src/tedi/components/form/toggle/toggle.spec.tsx @@ -100,18 +100,18 @@ describe('Toggle component', () => { expect(icon).toBeInTheDocument(); }); - it('renders label on the left when labelPosition="left"', () => { - render(); + it('renders label on the right when labelPosition="right"', () => { + render(); const control = screen.getByRole('switch').closest('label'); - expect(control).toHaveClass('tedi-toggle__control--label-left'); + expect(control).toHaveClass('tedi-toggle__control--label-right'); }); - it('renders label on the right by default', () => { + it('renders label on the left by default', () => { render(); const control = screen.getByRole('switch').closest('label'); - expect(control).toHaveClass('tedi-toggle__control--label-right'); + expect(control).toHaveClass('tedi-toggle__control--label-left'); }); it('renders helper text when provided', () => { diff --git a/src/tedi/components/form/toggle/toggle.tsx b/src/tedi/components/form/toggle/toggle.tsx index b0a13cd29..3549a68e7 100644 --- a/src/tedi/components/form/toggle/toggle.tsx +++ b/src/tedi/components/form/toggle/toggle.tsx @@ -144,7 +144,7 @@ export const Toggle = forwardRef((props, ref) => styles[`tedi-toggle--${color}-${type}`], { [styles['tedi-toggle--active']]: isChecked, - [styles['tedi-toggle--disabled']]: disabled, + [styles['tedi-toggle--disabled']]: disabled || isLoading, }, className ); @@ -176,7 +176,7 @@ export const Toggle = forwardRef((props, ref) => className={styles['tedi-toggle__input']} checked={isControlled ? isChecked : undefined} defaultChecked={!isControlled ? defaultChecked : undefined} - disabled={disabled} + disabled={disabled || isLoading} onChange={handleChange} /> From 43ad2469206aed9f81611db42c7ba31311f67c64 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:49:42 +0300 Subject: [PATCH 4/6] feat(toggle): code review fixes #305 --- .../components/form/toggle/toggle.module.scss | 20 ++--- .../components/form/toggle/toggle.spec.tsx | 7 +- src/tedi/components/form/toggle/toggle.tsx | 89 ++++++++----------- 3 files changed, 50 insertions(+), 66 deletions(-) diff --git a/src/tedi/components/form/toggle/toggle.module.scss b/src/tedi/components/form/toggle/toggle.module.scss index a09084bdc..bf788cc5f 100644 --- a/src/tedi/components/form/toggle/toggle.module.scss +++ b/src/tedi/components/form/toggle/toggle.module.scss @@ -150,18 +150,18 @@ cursor: not-allowed; } } -} -.tedi-toggle__control { - display: flex; - gap: var(--layout-grid-gutters-08); - align-items: center; + &__control { + display: flex; + gap: var(--layout-grid-gutters-08); + align-items: center; - &--label-left { - flex-direction: row; - } + &--label-left { + flex-direction: row; + } - &--label-right { - 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 index c27d1dc36..246582b88 100644 --- a/src/tedi/components/form/toggle/toggle.spec.tsx +++ b/src/tedi/components/form/toggle/toggle.spec.tsx @@ -103,14 +103,14 @@ describe('Toggle component', () => { it('renders label on the right when labelPosition="right"', () => { render(); - const control = screen.getByRole('switch').closest('label'); + 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('label'); + const control = screen.getByRole('switch').closest('.tedi-toggle__control'); expect(control).toHaveClass('tedi-toggle__control--label-left'); }); @@ -139,8 +139,7 @@ describe('Toggle component', () => { }); it('applies active class when checked', () => { - render(); - + render(); const toggleContainer = screen.getByRole('switch').closest('.tedi-toggle'); expect(toggleContainer).toHaveClass('tedi-toggle--active'); }); diff --git a/src/tedi/components/form/toggle/toggle.tsx b/src/tedi/components/form/toggle/toggle.tsx index 3549a68e7..1c3db75a0 100644 --- a/src/tedi/components/form/toggle/toggle.tsx +++ b/src/tedi/components/form/toggle/toggle.tsx @@ -1,5 +1,5 @@ import cn from 'classnames'; -import React, { forwardRef, useId, useState } from 'react'; +import React, { forwardRef, useState } from 'react'; import { Icon } from '../../base/icon/icon'; import { Spinner } from '../../loaders/spinner/spinner'; @@ -98,7 +98,7 @@ export interface ToggleProps { export const Toggle = forwardRef((props, ref) => { const { - id: propId, + id, label, hideLabel = false, labelPosition = 'left', @@ -116,9 +116,6 @@ export const Toggle = forwardRef((props, ref) => tooltip, ...rest } = props; - - const generatedId = useId(); - const id = propId || `toggle-${generatedId}`; const helperId = helper ? `${id}-helper` : undefined; const [internalChecked, setInternalChecked] = useState(defaultChecked ?? false); @@ -152,53 +149,41 @@ export const Toggle = forwardRef((props, ref) => const controlClass = cn(styles['tedi-toggle__control'], styles[`tedi-toggle__control--label-${labelPosition}`]); return ( -
- +
+ {label && ( + + )} + +
+ + + + {isLoading ? ( + + ) : icon ? ( + + ) : null} + +
{helper && ( From d0902d33467dbeab501f239c8bbfcbbcc5a4d7bb Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:58:15 +0300 Subject: [PATCH 5/6] fix(toggle): place helper outside toggle wrapper #305 --- src/tedi/components/form/toggle/toggle.tsx | 73 +++++++++++----------- 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/src/tedi/components/form/toggle/toggle.tsx b/src/tedi/components/form/toggle/toggle.tsx index 1c3db75a0..403d8652a 100644 --- a/src/tedi/components/form/toggle/toggle.tsx +++ b/src/tedi/components/form/toggle/toggle.tsx @@ -149,46 +149,47 @@ export const Toggle = forwardRef((props, ref) => const controlClass = cn(styles['tedi-toggle__control'], styles[`tedi-toggle__control--label-${labelPosition}`]); return ( -
- {label && ( - - )} - -
- - - - {isLoading ? ( - - ) : icon ? ( - - ) : null} - + <> +
+ {label && ( + + )} + +
+ + + + {isLoading ? ( + + ) : icon ? ( + + ) : null} + +
- {helper && ( )} -
+ ); }); From 2a2a60d4cdc2daafa81da8bd9c1ed28adf33d6a1 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:06:28 +0300 Subject: [PATCH 6/6] fix(toggle): improve stories, replace variables #305 --- .../components/form/toggle/toggle.module.scss | 70 +++++++++---------- .../components/form/toggle/toggle.stories.tsx | 3 +- 2 files changed, 36 insertions(+), 37 deletions(-) diff --git a/src/tedi/components/form/toggle/toggle.module.scss b/src/tedi/components/form/toggle/toggle.module.scss index bf788cc5f..e16e69194 100644 --- a/src/tedi/components/form/toggle/toggle.module.scss +++ b/src/tedi/components/form/toggle/toggle.module.scss @@ -1,28 +1,28 @@ @mixin toggle-variant($color, $type) { @if $type == filled { - --toggle-bg-default: var(--form-toggl-#{$color}-inactive-default); - --toggle-bg-hover: var(--form-toggl-#{$color}-inactive-hover); - --toggle-bg-active: var(--form-toggl-#{$color}-inactive-active); - --toggle-bg-checked: var(--form-toggl-#{$color}-active-default); - --toggle-bg-checked-hover: var(--form-toggl-#{$color}-active-hover); - --toggle-bg-checked-active: var(--form-toggl-#{$color}-active-active); - --toggle-indicator-default: var(--form-toggl-#{$color}-inactive-indicator); - --toggle-indicator-checked: var(--form-toggl-#{$color}-active-indicator); - --toggle-icon-default: var(--form-toggl-#{$color}-inactive-icon); - --toggle-icon-checked: var(--form-toggl-#{$color}-active-icon); + --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-toggl-#{$color}-inactive-default); - --toggle-border-hover: var(--form-toggl-#{$color}-inactive-hover); - --toggle-border-active: var(--form-toggl-#{$color}-inactive-active); - --toggle-border-checked: var(--form-toggl-#{$color}-active-default); - --toggle-border-checked-hover: var(--form-toggl-#{$color}-active-hover); - --toggle-border-checked-active: var(--form-toggl-#{$color}-active-active); - --toggle-indicator-default: var(--form-toggl-#{$color}-inactive-default); - --toggle-indicator-checked: var(--form-toggl-#{$color}-active-default); - --toggle-icon-default: var(--form-toggl-#{$color}-inactive-icon-outlined); - --toggle-icon-checked: var(--form-toggl-#{$color}-active-icon-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); } } @@ -31,26 +31,26 @@ display: block; &--default { - --toggle-indicator: var(--form-toggl-default-indicator); + --toggle-indicator: var(--form-toggle-default-indicator); - width: var(--form-toggl-default-width); - height: var(--form-toggl-default-height); + width: var(--form-toggle-default-width); + height: var(--form-toggle-default-height); .tedi-toggle__slider { - width: var(--form-toggl-default-indicator); - height: var(--form-toggl-default-indicator); + width: var(--form-toggle-default-indicator); + height: var(--form-toggle-default-indicator); } } &--large { - --toggle-indicator: var(--form-toggl-large-indicator); + --toggle-indicator: var(--form-toggle-large-indicator); - width: var(--form-toggl-large-width); - height: var(--form-toggl-large-height); + width: var(--form-toggle-large-width); + height: var(--form-toggle-large-height); .tedi-toggle__slider { - width: var(--form-toggl-large-indicator); - height: var(--form-toggl-large-indicator); + width: var(--form-toggle-large-indicator); + height: var(--form-toggle-large-indicator); } } @@ -77,8 +77,8 @@ appearance: none; cursor: pointer; background: var(--toggle-bg-default); - border: var(--borders-01) solid var(--toggle-border-default); - border-radius: var(--form-toggl-radius); + 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) { @@ -107,12 +107,12 @@ } &:focus-visible { - outline: calc(2 * var(--borders-01)) solid var(--form-toggl-primary-active-default); + 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-toggl-padding)); + left: calc(100% - var(--toggle-indicator) - var(--form-toggle-padding)); color: var(--toggle-icon-checked); background-color: var(--toggle-indicator-checked); transform: translateY(-50%); @@ -126,7 +126,7 @@ &__slider { position: absolute; top: 50%; - left: var(--form-toggl-padding); + left: var(--form-toggle-padding); display: flex; align-items: center; justify-content: center; diff --git a/src/tedi/components/form/toggle/toggle.stories.tsx b/src/tedi/components/form/toggle/toggle.stories.tsx index 6d4f110d2..978b7538c 100644 --- a/src/tedi/components/form/toggle/toggle.stories.tsx +++ b/src/tedi/components/form/toggle/toggle.stories.tsx @@ -60,7 +60,7 @@ const TemplateStates: StoryFn = (args) => { {array.map((value, key) => ( - + {value} @@ -132,7 +132,6 @@ const TemplateStates: StoryFn = (args) => { export const Default: Story = { args: { label: 'Toggle', - hideLabel: true, }, };