diff --git a/.storybook/image-snapshots/expected/components_forms_SegmentedToggle_Dark Mode.png b/.storybook/image-snapshots/expected/components_forms_SegmentedToggle_Dark Mode.png new file mode 100644 index 000000000..39bb5ba32 Binary files /dev/null and b/.storybook/image-snapshots/expected/components_forms_SegmentedToggle_Dark Mode.png differ diff --git a/.storybook/image-snapshots/expected/components_forms_SegmentedToggle_Default.png b/.storybook/image-snapshots/expected/components_forms_SegmentedToggle_Default.png index aec6af21b..d2c5974b4 100644 Binary files a/.storybook/image-snapshots/expected/components_forms_SegmentedToggle_Default.png and b/.storybook/image-snapshots/expected/components_forms_SegmentedToggle_Default.png differ diff --git a/.storybook/image-snapshots/expected/components_forms_SegmentedToggle_Disabled.png b/.storybook/image-snapshots/expected/components_forms_SegmentedToggle_Disabled.png index c50b76d5e..478a1a710 100644 Binary files a/.storybook/image-snapshots/expected/components_forms_SegmentedToggle_Disabled.png and b/.storybook/image-snapshots/expected/components_forms_SegmentedToggle_Disabled.png differ diff --git a/src/components/forms/SegmentedToggle/SegmentedToggle.stories.tsx b/src/components/forms/SegmentedToggle/SegmentedToggle.stories.tsx index 30f5edf59..1e9f9c102 100644 --- a/src/components/forms/SegmentedToggle/SegmentedToggle.stories.tsx +++ b/src/components/forms/SegmentedToggle/SegmentedToggle.stories.tsx @@ -1,93 +1,134 @@ import { useState } from 'react'; -import { Meta, StoryFn } from '@storybook/react'; +import { Meta, StoryObj } from '@storybook/react'; -import { SegmentedToggleProps } from './SegmentedToggle.types'; -import { SegmentedToggle, SegmentedToggleItem } from './index'; +import { SegmentedToggle, SegmentedToggleItem } from './SegmentedToggle'; import { SpaceSizes } from '../../../theme/space.enums'; import { Stack } from '../../layout/Stack'; import { Text } from '../../Text'; -export default { +/** + * ```jsx + * import { SegmentedToggle, SegmentedToggleItem } from '@securityscorecard/design-system'; + * ``` + */ + +const meta = { title: 'components/forms/SegmentedToggle', component: SegmentedToggle, argTypes: { - name: { - control: { disable: true }, - description: 'Name parameter for the form', + children: { + description: + 'List of SegmentedToggleItem components that will be rendered as options', + table: { + type: { + summary: 'ReactNode', + }, + }, + // @ts-expect-error Storybook is strangly typed here + type: { + required: true, + }, + }, + group: { + description: + 'The group is used to identify the SegmentedToggle within the form', + }, + isDisabled: { + description: 'Indicates if the SegmentedToggle is disabled or not.', + table: { + type: { + summary: 'boolean', + }, + }, + }, + isExpanded: { + description: + 'Should the SegmentedToggle be expanded to full available width.', + table: { + type: { + summary: 'boolean', + }, + }, + }, + onChange: { + description: 'Callback when the SegmentedToggle has changed.', + table: { + type: { + summary: '(event: ChangeEvent) => void', + }, + }, }, }, -} as Meta; +} satisfies Meta; +export default meta; -export const Playground: StoryFn = (args) => ( - - - - - -); -Playground.args = { - group: 'playground', -}; -Playground.parameters = { - screenshot: { skip: true }, -}; +type Story = StoryObj; -export const Default: StoryFn = () => ( - +const children = ({ group }) => ( + <> - - - + + + ); -export const Disabled: StoryFn = () => ( - - - - - - - -); +export const Playground: Story = { + args: { + group: 'playground', + children: children({ group: 'playground' }), + }, + parameters: { + screenshot: { skip: true }, + }, +}; -export const StateManagement: StoryFn = () => { - const [selected, setSelected] = useState('1'); +export const Default: Story = { + args: { children: children({ group: 'default' }), group: 'default' }, +}; - const handleChange = (e) => { - setSelected(e.target.value); - }; +export const Disabled: Story = { + args: { + children: children({ group: 'disabled' }), + group: 'disabled', + isDisabled: true, + }, +}; - return ( - - - - - - - Selected value: {selected} - - ); +export const DarkMode: Story = { + args: { children: children({ group: 'darkMode' }), group: 'darkMode' }, + parameters: { + themes: { + themeOverride: 'Dark', + }, + }, }; -StateManagement.parameters = { - screenshot: { skip: true }, +export const StateManagement: Story = { + args: { + children: children({ group: 'stateManagement' }), + group: 'stateManagement', + }, + render: function Render(args) { + const [selected, setSelected] = useState('1'); + + return ( + + { + setSelected(e.target.value); + }} + /> + Selected value: {selected} + + ); + }, + parameters: { + screenshot: { skip: true }, + }, }; diff --git a/src/components/forms/SegmentedToggle/SegmentedToggle.test.tsx b/src/components/forms/SegmentedToggle/SegmentedToggle.test.tsx index 158f05ac3..78eafd91d 100644 --- a/src/components/forms/SegmentedToggle/SegmentedToggle.test.tsx +++ b/src/components/forms/SegmentedToggle/SegmentedToggle.test.tsx @@ -2,7 +2,7 @@ import { createRef } from 'react'; import { screen } from '@testing-library/react'; import { map } from 'ramda'; -import { SegmentedToggle, SegmentedToggleItem } from '.'; +import { SegmentedToggle, SegmentedToggleItem } from './SegmentedToggle'; import { renderWithProviders } from '../../../utils/tests/renderWithProviders'; describe('SegmentedToggle', () => { diff --git a/src/components/forms/SegmentedToggle/SegmentedToggle.tsx b/src/components/forms/SegmentedToggle/SegmentedToggle.tsx index 999e7a59e..7d4b6e682 100644 --- a/src/components/forms/SegmentedToggle/SegmentedToggle.tsx +++ b/src/components/forms/SegmentedToggle/SegmentedToggle.tsx @@ -1,65 +1,188 @@ import { - Children, - type PropsWithChildren, - type ReactElement, - cloneElement, + type ChangeEventHandler, + type ComponentPropsWithRef, + type ReactNode, forwardRef, - isValidElement, + useMemo, } from 'react'; -import { noop } from 'ramda-adjunct'; import cls from 'classnames'; +import styled, { css } from 'styled-components'; -import type { - SegmentedToggleItemProps, - SegmentedToggleProps, -} from './SegmentedToggle.types'; -import { BaseTabsWrapper } from '../../_internal/BaseTabs/BaseTabsWrapper'; -import { SpaceSizes } from '../../../theme/space.enums'; -import { Inline } from '../../layout'; -import { BaseTabsEnums } from '../../_internal/BaseTabs'; +import { Inline, Padbox } from '../../layout'; import { CLX_COMPONENT } from '../../../theme/constants'; +import { createCtx } from '../../../managers/common/createCtx'; +import ElementLabel from '../../ElementLabel/ElementLabel'; -const SegmentedToggle = forwardRef< - HTMLDivElement, - PropsWithChildren ->( +export interface SegmentedToggleItemProps + extends Omit, 'size'> { + label: string; + value: string | number; + itemId: string; +} + +export interface SegmentedToggleProps { + children: ReactNode; + /** + * The group is used to identify the SegmentedToggle within the form + */ + group: string; + /** + * Indicates if the SegmentedToggle is disabled or not. + */ + isDisabled?: boolean; + /** + * Should the SegmentedToggle be expanded to full available width. + */ + isExpanded?: boolean; + /** + * Callback when the SegmentedToggle has changed. + */ + onChange?: ChangeEventHandler; + className?: string; +} + +type SegmentedToggleContext = { + name: string; + disabled?: boolean; + onChange?: ChangeEventHandler; +}; + +const { useContext, Provider } = createCtx( + 'SegmentedToggle', + 'The SegmentedToggleItem has to be a child of the SegmentedToggle component.', +); + +const SegmentedToggleRoot = styled(Padbox)` + display: inline-block; + background-color: var(--sscds-color-neutral-alpha-3); + box-shadow: inset 0 0 0 1px var(--sscds-color-neutral-alpha-3); + border-radius: var(--sscds-radii-input); + ${({ $isExpanded }) => + $isExpanded && + css` + width: 100%; + flex-grow: 1; + `}; +`; + +export const SegmentedToggle = forwardRef( ( { group, isDisabled = false, children, - onChange = noop, + onChange, className, + isExpanded = false, ...props }, ref, - ) => ( - - - {Children.map(children, (segmentedToggleItem) => { - if (!isValidElement(segmentedToggleItem)) { - return null; - } - - return cloneElement( - segmentedToggleItem as ReactElement, - { - key: segmentedToggleItem.props.value, - name: group, - disabled: isDisabled, - onChange, - ...props, - }, - ); - })} - - - ), + ) => { + const value = useMemo( + () => ({ + name: group, + disabled: isDisabled, + onChange, + }), + [group, isDisabled, onChange], + ); + + return ( + + + + {children} + + + + ); + }, ); -export default SegmentedToggle; +const SegmentedToggleItemRoot = styled.div` + display: flex; +`; + +const SegmentedToggleLabel = styled.label` + display: block; + width: 100%; + padding: var(--sscds-space-1x) var(--sscds-space-2x); + text-align: center; + transition: var(--sscds-action-transition); +`; + +const Radio = styled.input` + /* transform: translateX(-100%); */ + position: absolute; + pointer-events: none; + opacity: 0; + margin: 0; + + + ${SegmentedToggleLabel} { + color: var(--sscds-color-text-default); + border-radius: var(--sscds-radii-default); + } + + :hover + ${SegmentedToggleLabel} { + background-color: var(--sscds-color-neutral-alpha-3); + } + :focus-visible + ${SegmentedToggleLabel} { + outline: 2px solid var(--sscds-color-border-action-focused); + } + + :checked + ${SegmentedToggleLabel} { + background-color: var(--sscds-color-neutral-0); + box-shadow: var(--sscds-shadow-1x); + } + + :checked:disabled + ${SegmentedToggleLabel} { + background-color: var(--sscds-color-background-action-base-disabled); + color: var(--sscds-color-text-disabled); + box-shadow: none; + } + + :disabled + ${SegmentedToggleLabel} { + color: var(--sscds-color-text-disabled); + &:hover { + color: var(--sscds-color-text-disabled); + background-color: transparent; + } + } +`; + +export const SegmentedToggleItem = forwardRef< + HTMLInputElement, + SegmentedToggleItemProps +>(({ label, value, itemId, ...props }, ref) => { + const { name, disabled, onChange } = useContext(); + + return ( + + + + + {label} + + + + ); +}); diff --git a/src/components/forms/SegmentedToggle/SegmentedToggle.types.ts b/src/components/forms/SegmentedToggle/SegmentedToggle.types.ts deleted file mode 100644 index 6b8fa7cb5..000000000 --- a/src/components/forms/SegmentedToggle/SegmentedToggle.types.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { ChangeEventHandler, ComponentPropsWithoutRef } from 'react'; - -export interface SegmentedToggleItemProps - extends Omit, 'size'> { - label: string; - value: string | number; - itemId: string; - group?: string; -} - -export interface SegmentedToggleProps { - /** - * The group is used to identify the SegmentedToggle within the form - */ - group: string; - /** - * Indicates if the SegmentedToggle is disabled or not. - */ - isDisabled?: boolean; - - /** - * Callback when the SegmentedToggle has changed - */ - onChange?: ChangeEventHandler; - className?: string; -} diff --git a/src/components/forms/SegmentedToggle/SegmentedToggleItem.tsx b/src/components/forms/SegmentedToggle/SegmentedToggleItem.tsx deleted file mode 100644 index 32cc18c36..000000000 --- a/src/components/forms/SegmentedToggle/SegmentedToggleItem.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { forwardRef } from 'react'; -import styled from 'styled-components'; - -import { SegmentedToggleItemProps } from './SegmentedToggle.types'; -import { ColorTypes } from '../../../theme/colors.enums'; -import BaseTabLabel, { - segmentedTabSelected, -} from '../../_internal/BaseTabs/BaseTabLabel'; -import { SpaceSizes } from '../../../theme/space.enums'; -import { PaddingTypes } from '../../layout/Padbox/Padbox.enums'; -import { getFormStyle } from '../../../utils/helpers'; -import { BaseTabsEnums } from '../../_internal/BaseTabs'; - -const Radio = styled.input` - display: none; - - :checked + ${BaseTabLabel} { - ${segmentedTabSelected} - } - - :checked:disabled + ${BaseTabLabel} { - background: ${getFormStyle('disabledBgColor')}; - border-color: ${getFormStyle('disabledBorderColor')}; - } - - :disabled + ${BaseTabLabel} { - color: ${getFormStyle('disabledColor')}; - &:hover { - color: ${getFormStyle('disabledColor')}; - } - } -`; - -const SegmentedToggleItemWrapper = styled.span` - display: flex; -`; - -const SegmentedToggleItem = forwardRef< - HTMLInputElement, - SegmentedToggleItemProps ->(({ label, value, group, itemId, ...props }, ref) => { - const paddingSize = SpaceSizes.sm; - - return ( - - - - {label} - - - ); -}); - -export default SegmentedToggleItem; diff --git a/src/components/forms/SegmentedToggle/index.ts b/src/components/forms/SegmentedToggle/index.ts deleted file mode 100644 index b349df4a7..000000000 --- a/src/components/forms/SegmentedToggle/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as SegmentedToggle } from './SegmentedToggle'; -export { default as SegmentedToggleItem } from './SegmentedToggleItem'; -export * from './SegmentedToggle.types'; diff --git a/src/components/forms/index.ts b/src/components/forms/index.ts index 972ffdb63..dd3a53055 100644 --- a/src/components/forms/index.ts +++ b/src/components/forms/index.ts @@ -10,6 +10,11 @@ export * from './Range'; export * from './Switch'; export * from './TextArea'; export * from './SearchBar'; -export * from './SegmentedToggle'; +export { + SegmentedToggle, + SegmentedToggleItem, + type SegmentedToggleProps, + type SegmentedToggleItemProps, +} from './SegmentedToggle/SegmentedToggle'; export * from './InputGroup'; export * from './types/forms.types';