From c0ca2470173def47f48d3bdcfbd8aad8ac7a5292 Mon Sep 17 00:00:00 2001 From: Karolina Szarek <74671633+karolinaszarek@users.noreply.github.com> Date: Mon, 9 Sep 2024 12:18:52 +0200 Subject: [PATCH 1/2] feat: TET-904 add MetricsCard (#155) * feat: TET-904 add MetricsCard * feat: TET-904 fix MetricsCard * feat: TET-904 fix MetricsCard --- .../InlineMetrics/InlineMetrics.styles.ts | 19 +++-- .../InlineMetrics/InlineMetrics.tsx | 3 + .../MetricsCard/MetricsCard.props.ts | 13 ++++ .../MetricsCard/MetricsCard.stories.tsx | 56 ++++++++++++++ .../MetricsCard/MetricsCard.styles.ts | 75 +++++++++++++++++++ .../MetricsCard/MetricsCard.test.tsx | 57 ++++++++++++++ src/components/MetricsCard/MetricsCard.tsx | 69 +++++++++++++++++ src/components/MetricsCard/index.ts | 3 + src/components/MetricsCard/stylesBuilder.ts | 30 ++++++++ src/docs-components/MetricsCardDocs.tsx | 68 +++++++++++++++++ src/index.ts | 1 + 11 files changed, 386 insertions(+), 8 deletions(-) create mode 100644 src/components/MetricsCard/MetricsCard.props.ts create mode 100644 src/components/MetricsCard/MetricsCard.stories.tsx create mode 100644 src/components/MetricsCard/MetricsCard.styles.ts create mode 100644 src/components/MetricsCard/MetricsCard.test.tsx create mode 100644 src/components/MetricsCard/MetricsCard.tsx create mode 100644 src/components/MetricsCard/index.ts create mode 100644 src/components/MetricsCard/stylesBuilder.ts create mode 100644 src/docs-components/MetricsCardDocs.tsx diff --git a/src/components/InlineMetrics/InlineMetrics.styles.ts b/src/components/InlineMetrics/InlineMetrics.styles.ts index 5210eff1..68040293 100644 --- a/src/components/InlineMetrics/InlineMetrics.styles.ts +++ b/src/components/InlineMetrics/InlineMetrics.styles.ts @@ -5,12 +5,13 @@ import { IconName } from '@/utility-types/IconName'; export type InlineMetricsConfig = { innerElements: { - label: BaseProps; - metric: BaseProps; - trendContainer: BaseProps; - trend: { trend: Partial> } & BaseProps; - icon: BaseProps; - trendValue: BaseProps; + label?: BaseProps; + metric?: BaseProps; + trendContainer?: BaseProps; + trend?: { trend?: Partial> } & BaseProps; + icon?: BaseProps; + trendValue?: BaseProps; + referenceDate?: BaseProps; }; } & BaseProps; @@ -19,6 +20,7 @@ export const defaultConfig = { h: '', display: 'flex', flexDirection: 'column', + gap: '$space-component-gap-medium', innerElements: { trendContainer: { display: 'flex', @@ -28,7 +30,6 @@ export const defaultConfig = { label: { color: '$color-content-secondary', text: '$typo-body-medium', - marginBottom: '$space-component-gap-medium', }, metric: { text: '$typo-header-4xLarge', @@ -39,7 +40,6 @@ export const defaultConfig = { padding: '$space-component-padding-xSmall 0', display: 'flex', alignItems: 'center', - alignSelf: 'flex-end', trend: { None: {}, Positive: { @@ -58,6 +58,9 @@ export const defaultConfig = { display: 'flex', alignItems: 'end', }, + referenceDate: { + display: 'none', + }, }, } satisfies InlineMetricsConfig; diff --git a/src/components/InlineMetrics/InlineMetrics.tsx b/src/components/InlineMetrics/InlineMetrics.tsx index aff774ac..f8c4023f 100644 --- a/src/components/InlineMetrics/InlineMetrics.tsx +++ b/src/components/InlineMetrics/InlineMetrics.tsx @@ -48,6 +48,9 @@ export const InlineMetrics: FC = ({ data-testid="inline-metrics-trend-value" > {trendValue} + + vs. last year + diff --git a/src/components/MetricsCard/MetricsCard.props.ts b/src/components/MetricsCard/MetricsCard.props.ts new file mode 100644 index 00000000..ac152e10 --- /dev/null +++ b/src/components/MetricsCard/MetricsCard.props.ts @@ -0,0 +1,13 @@ +import { MetricsCardConfig } from './MetricsCard.styles'; +import { InlineMetricsProps } from '../InlineMetrics'; + +export type IconPositionType = 'Top' | 'Left'; +export type IntentType = 'Neutral' | 'Positive' | 'Negative'; + +export type MetricsCardProps = { + iconPosition?: IconPositionType; + hasTrend?: boolean; + hasIcon?: boolean; + hasMoreIcon?: boolean; + custom?: MetricsCardConfig; +} & Omit; diff --git a/src/components/MetricsCard/MetricsCard.stories.tsx b/src/components/MetricsCard/MetricsCard.stories.tsx new file mode 100644 index 00000000..fa83d464 --- /dev/null +++ b/src/components/MetricsCard/MetricsCard.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { MetricsCard } from './MetricsCard'; + +import { MetricsCardDocs } from '@/docs-components/MetricsCardDocs'; +import { TetDocs } from '@/docs-components/TetDocs'; + +const meta = { + title: 'Metrics / MetricsCard', + component: MetricsCard, + tags: ['autodocs'], + args: {}, + parameters: { + backgrounds: {}, + docs: { + description: { + component: + 'A set of several grouped components that displays numerical data, such as, for example, key performance indicators (KPIs). Metrics provide users with a clear, visual representation of essential statistics or progress.', + }, + page: () => ( + + + + ), + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + trend: 'Positive', + trendValue: '+24%', + metrics: '$123.12', + label: 'Total Earnings', + hasIcon: true, + hasMoreIcon: true, + hasTrend: true, + iconPosition: 'Top', + }, +}; + +export const IconPositionLeft: Story = { + args: { + trend: 'Negative', + trendValue: '-24%', + metrics: '$123.12', + label: 'Total Earnings', + hasIcon: true, + hasMoreIcon: true, + hasTrend: true, + iconPosition: 'Left', + }, +}; diff --git a/src/components/MetricsCard/MetricsCard.styles.ts b/src/components/MetricsCard/MetricsCard.styles.ts new file mode 100644 index 00000000..723441ad --- /dev/null +++ b/src/components/MetricsCard/MetricsCard.styles.ts @@ -0,0 +1,75 @@ +import type { IconPositionType } from './MetricsCard.props'; + +import type { BaseProps } from '@/types/BaseProps'; + +export type MetricsCardConfig = { + iconPosition?: Record; + innerElements: { + trendContainer?: BaseProps; + circle?: BaseProps; + referenceDate?: BaseProps; + trend?: BaseProps; + icon?: BaseProps; + trendValue?: BaseProps; + moreIcon?: BaseProps; + }; +} & BaseProps; + +export const defaultConfig = { + position: 'relative', + border: '1px solid', + borderColor: '$color-border-defaultA', + borderRadius: '$border-radius-xLarge', + padding: '$space-component-padding-2xLarge', + display: 'flex', + boxShadow: '$elevation-bottom-200', + w: '480px', + iconPosition: { + Top: { + flexDirection: 'column', + }, + Left: { + flexDirection: 'row', + }, + }, + innerElements: { + circle: { + w: '$size-large', + h: '$size-large', + padding: '$space-component-padding-medium', + border: '1px solid', + borderColor: '$color-border-neutral-subtle', + borderRadius: '24px', + }, + trend: {}, + icon: { + display: 'flex', + }, + trendValue: { + text: '$typo-body-strong-medium', + display: 'flex', + alignItems: 'end', + }, + referenceDate: { + display: 'block', + text: '$typo-body-medium', + color: '$color-content-secondary', + marginLeft: '$space-component-padding-xSmall', + }, + trendContainer: { + flexDirection: 'column', + alignSelf: 'flex-start', + gap: '$space-component-gap-xLarge', + }, + moreIcon: { + position: 'absolute', + color: '$color-action-neutral-normal', + top: '$space-component-padding-2xLarge', + right: '$space-component-padding-2xLarge', + }, + }, +} satisfies MetricsCardConfig; + +export const metricsCardStyles = { + defaultConfig, +}; diff --git a/src/components/MetricsCard/MetricsCard.test.tsx b/src/components/MetricsCard/MetricsCard.test.tsx new file mode 100644 index 00000000..5c3e87bb --- /dev/null +++ b/src/components/MetricsCard/MetricsCard.test.tsx @@ -0,0 +1,57 @@ +import { MetricsCard } from './MetricsCard'; +import { render } from '../../tests/render'; + +import { customPropTester } from '@/tests/customPropTester'; + +const getMetricsCard = (jsx: JSX.Element) => { + const { getByTestId, queryByTestId } = render(jsx); + return { + container: getByTestId('metrics-card'), + inlineMetrics: getByTestId('metrics-card-inline-metrics'), + moreIcon: queryByTestId('metrics-card-more-icon'), + walletIcon: queryByTestId('metrics-card-wallet-icon'), + }; +}; + +describe('Metrics Card', () => { + customPropTester( + , + { + containerId: 'metrics-card', + props: { + trend: ['Negative', 'None', 'Positive'], + }, + }, + ); + + it('should render the metrics card', () => { + const { container } = getMetricsCard(); + expect(container).toBeInTheDocument(); + }); + + it('should render the more icon', () => { + const { moreIcon } = getMetricsCard(); + expect(moreIcon).toBeInTheDocument(); + }); + + it('should not render the more icon', () => { + const { moreIcon } = getMetricsCard(); + expect(moreIcon).toBeNull(); + }); + + it('should render the wallet icon', () => { + const { walletIcon } = getMetricsCard(); + expect(walletIcon).toBeInTheDocument(); + }); + + it('should not render the wallet icon', () => { + const { walletIcon } = getMetricsCard(); + expect(walletIcon).toBeNull(); + }); +}); diff --git a/src/components/MetricsCard/MetricsCard.tsx b/src/components/MetricsCard/MetricsCard.tsx new file mode 100644 index 00000000..83fdfa74 --- /dev/null +++ b/src/components/MetricsCard/MetricsCard.tsx @@ -0,0 +1,69 @@ +import { Icon } from '@virtuslab/tetrisly-icons'; +import { MarginProps } from '@xstyled/styled-components'; +import { type FC, useMemo } from 'react'; + +import { MetricsCardProps } from './MetricsCard.props'; +import { stylesBuilder } from './stylesBuilder'; +import { InlineMetrics } from '../InlineMetrics'; +import { InlineMetricsConfig } from '../InlineMetrics/InlineMetrics.styles'; + +import { tet } from '@/tetrisly'; + +export const MetricsCard: FC = ({ + hasIcon = false, + hasMoreIcon = false, + hasTrend, + metrics, + label, + trend = 'None', + trendValue, + iconPosition = 'Top', + custom, + ...restProps +}) => { + const styles = useMemo( + () => stylesBuilder({ iconPosition, custom }), + [custom, iconPosition], + ); + const isLeftPosition = iconPosition === 'Left'; + + const customInlineMetrics: InlineMetricsConfig = { + gap: '$space-component-gap-null', + innerElements: { + trendContainer: { ...styles.trendContainer }, + referenceDate: { ...styles.referenceDate }, + trend: { ...styles.trend, display: hasTrend ? 'flex' : 'none' }, + }, + }; + + return ( + + {hasIcon && ( + + + + )} + + {hasMoreIcon && ( + + + + )} + + ); +}; diff --git a/src/components/MetricsCard/index.ts b/src/components/MetricsCard/index.ts new file mode 100644 index 00000000..ca6a9b2e --- /dev/null +++ b/src/components/MetricsCard/index.ts @@ -0,0 +1,3 @@ +export { MetricsCard } from './MetricsCard'; +export type { MetricsCardProps } from './MetricsCard.props'; +export { metricsCardStyles } from './MetricsCard.styles'; diff --git a/src/components/MetricsCard/stylesBuilder.ts b/src/components/MetricsCard/stylesBuilder.ts new file mode 100644 index 00000000..d8fb4a42 --- /dev/null +++ b/src/components/MetricsCard/stylesBuilder.ts @@ -0,0 +1,30 @@ +import type { IconPositionType, MetricsCardProps } from './MetricsCard.props'; +import { defaultConfig } from './MetricsCard.styles'; + +import { mergeConfigWithCustom } from '@/services'; + +type StylesBuilderParams = { + iconPosition: IconPositionType; + custom: MetricsCardProps['custom']; +}; + +export const stylesBuilder = ({ + iconPosition, + custom, +}: StylesBuilderParams) => { + const { + innerElements, + iconPosition: position, + ...restStyles + } = mergeConfigWithCustom({ + defaultConfig, + custom, + }); + + const containerStyles = { ...restStyles, ...position[iconPosition] }; + + return { + container: containerStyles, + ...innerElements, + }; +}; diff --git a/src/docs-components/MetricsCardDocs.tsx b/src/docs-components/MetricsCardDocs.tsx new file mode 100644 index 00000000..9f25a708 --- /dev/null +++ b/src/docs-components/MetricsCardDocs.tsx @@ -0,0 +1,68 @@ +import { SectionHeader } from './common/SectionHeader'; + +import { TrendType } from '@/components/InlineMetrics/InlineMetrics.props'; +import { MetricsCard } from '@/components/MetricsCard'; +import type { IconPositionType } from '@/components/MetricsCard/MetricsCard.props'; +import { tet } from '@/tetrisly'; + +const trends: TrendType[] = ['None', 'Positive', 'Negative']; +const iconPositions: IconPositionType[] = ['Top', 'Left']; +const intentNames: Record = { + None: 'Neutral', + Positive: 'Positive', + Negative: 'Negative', +}; + +export const MetricsCardDocs = () => ( + + {iconPositions.map((position) => ( + <> + + {position} Icon Position + + {trends.map((trend) => ( + + + Intent: {intentNames[trend]} + + + + + + ))} + + ))} + +); diff --git a/src/index.ts b/src/index.ts index 6440c3ae..e61dc8fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ export * from './components/InlineMetrics'; export * from './components/InlineSearchInput'; export * from './components/Label'; export * from './components/Loader'; +export * from './components/MetricsCard'; export * from './components/NewItemButton'; export * from './components/Popover'; export * from './components/RadioButton'; From 99f8a15ce0e382f573ed1e1cd0bf1f90143b6869 Mon Sep 17 00:00:00 2001 From: Marta Kozina <38346938+MartaKozina010@users.noreply.github.com> Date: Mon, 9 Sep 2024 12:20:02 +0200 Subject: [PATCH 2/2] feat: TET-895 toggle (#136) * feat: TET-895 toggle * feat: TET-895 toggle * feat: TET-895 toggle --------- Co-authored-by: Marta Kozina Co-authored-by: Tomasz P. --- src/components/Checkbox/Checkbox.tsx | 2 +- src/components/Checkbox/hooks/index.ts | 1 - .../Checkbox/hooks/useIconChecked.ts | 11 -- src/components/Toggle/Toggle.props.ts | 22 +++ src/components/Toggle/Toggle.stories.tsx | 104 ++++++++++++ src/components/Toggle/Toggle.styles.ts | 158 ++++++++++++++++++ src/components/Toggle/Toggle.test.tsx | 108 ++++++++++++ src/components/Toggle/Toggle.tsx | 96 +++++++++++ src/components/Toggle/index.ts | 3 + src/components/Toggle/stylesBuilder.ts | 43 +++++ src/docs-components/ToggleDocs.tsx | 154 +++++++++++++++++ src/hooks/index.ts | 1 + .../Checkbox => }/hooks/useIndeterminate.ts | 0 src/index.ts | 1 + src/theme/theme.ts | 2 +- 15 files changed, 692 insertions(+), 14 deletions(-) delete mode 100644 src/components/Checkbox/hooks/index.ts delete mode 100644 src/components/Checkbox/hooks/useIconChecked.ts create mode 100644 src/components/Toggle/Toggle.props.ts create mode 100644 src/components/Toggle/Toggle.stories.tsx create mode 100644 src/components/Toggle/Toggle.styles.ts create mode 100644 src/components/Toggle/Toggle.test.tsx create mode 100644 src/components/Toggle/Toggle.tsx create mode 100644 src/components/Toggle/index.ts create mode 100644 src/components/Toggle/stylesBuilder.ts create mode 100644 src/docs-components/ToggleDocs.tsx rename src/{components/Checkbox => }/hooks/useIndeterminate.ts (100%) diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 981981c3..0e9faf71 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -3,10 +3,10 @@ import styled from '@xstyled/styled-components'; import { forwardRef, useCallback, useId, useMemo } from 'react'; import type { CheckboxProps } from './Checkbox.props'; -import { useIndeterminate } from './hooks'; import { stylesBuilder } from './stylesBuilder'; import { HelperText } from '../HelperText'; +import { useIndeterminate } from '@/hooks'; import { extractInputProps } from '@/services'; import { tet } from '@/tetrisly'; import { MarginProps } from '@/types/MarginProps'; diff --git a/src/components/Checkbox/hooks/index.ts b/src/components/Checkbox/hooks/index.ts deleted file mode 100644 index 496ac61c..00000000 --- a/src/components/Checkbox/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useIndeterminate } from './useIndeterminate'; diff --git a/src/components/Checkbox/hooks/useIconChecked.ts b/src/components/Checkbox/hooks/useIconChecked.ts deleted file mode 100644 index 24ed28a1..00000000 --- a/src/components/Checkbox/hooks/useIconChecked.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useEffect, useState } from 'react'; - -export const useIconChecked = (isChecked: boolean | undefined) => { - const [isIconChecked, setIsIconChecked] = useState(isChecked); - - useEffect(() => { - setIsIconChecked(isChecked); - }, [isChecked]); - - return [isIconChecked, setIsIconChecked] as const; -}; diff --git a/src/components/Toggle/Toggle.props.ts b/src/components/Toggle/Toggle.props.ts new file mode 100644 index 00000000..e9a2c930 --- /dev/null +++ b/src/components/Toggle/Toggle.props.ts @@ -0,0 +1,22 @@ +import { InputHTMLAttributes } from 'react'; + +import type { ToggleConfig } from './Toggle.styles'; +import { HelperTextProps } from '../HelperText'; + +export type ToggleProps = { + isIndeterminate?: boolean; + isChecked?: boolean; + size?: 'small' | 'large'; + state?: 'disabled'; + custom?: ToggleConfig; +} & Omit< + InputHTMLAttributes, + 'checked' | 'disabled' | 'color' | 'type' | 'size' +> & + ( + | { label?: string; helperText?: never } + | { + label: string; + helperText?: Pick; + } + ); diff --git a/src/components/Toggle/Toggle.stories.tsx b/src/components/Toggle/Toggle.stories.tsx new file mode 100644 index 00000000..0cd99742 --- /dev/null +++ b/src/components/Toggle/Toggle.stories.tsx @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useLayoutEffect, useRef, useState } from 'react'; + +import { Toggle } from './Toggle'; + +import { TetDocs } from '@/docs-components/TetDocs'; +import { ToggleDocs } from '@/docs-components/ToggleDocs.tsx'; +import { tet } from '@/tetrisly'; + +const meta = { + title: 'Toggle', + component: Toggle, + tags: ['autodocs'], + argTypes: { + state: { + control: { + type: 'select', + options: [undefined, 'disabled'], + }, + }, + }, + parameters: { + docs: { + description: { + component: + 'A visual representation of the switch that allows the user to choose between two states, such as on and off or enable and disable. Toggles are often used in forms or settings to represent binary options and provide clear visual feedback of the active state.', + }, + page: () => ( + + + + ), + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; +export const Checked: Story = { + args: { + isChecked: true, + }, +}; + +export const Disabled: Story = { + args: { + state: 'disabled', + }, +}; + +export const Indeterminate = () => { + const [mainChecked, setMainChecked] = useState(false); + const [toggle1Value, setToggle1Value] = useState(true); + const [toggle2Value, setToggle2Value] = useState(false); + const isInitialRender = useRef(true); + + useLayoutEffect(() => { + if (isInitialRender.current) { + isInitialRender.current = false; + return; + } + setToggle1Value(mainChecked); + setToggle2Value(mainChecked); + }, [mainChecked]); + + return ( + + setMainChecked((prevValue) => !prevValue)} + label="Main label" + /> + setToggle1Value((prevValue) => !prevValue)} + label="Label 1" + /> + setToggle2Value((prevValue) => !prevValue)} + label="Label 2" + /> + + ); +}; + +export const Label: Story = { + args: { + label: 'Label', + }, +}; + +export const HelperText: Story = { + args: { + label: 'Label', + helperText: { text: 'Helper text' }, + }, +}; diff --git a/src/components/Toggle/Toggle.styles.ts b/src/components/Toggle/Toggle.styles.ts new file mode 100644 index 00000000..f0c84262 --- /dev/null +++ b/src/components/Toggle/Toggle.styles.ts @@ -0,0 +1,158 @@ +import { SystemProps } from '@xstyled/styled-components'; + +import { HelperTextConfig } from '../HelperText/HelperText.styles'; + +import { BaseProps } from '@/types/BaseProps'; + +type ToggleSize = { size?: Record<'small' | 'large', BaseProps> }; +export type ToggleConfig = { + innerElements?: { + toggle?: { + input?: SystemProps; + slider?: BaseProps & ToggleSize; + toggleOval?: BaseProps & ToggleSize; + }; + labelContainer?: BaseProps; + label?: BaseProps; + helperText?: HelperTextConfig; + }; +} & BaseProps; + +export const defaultConfig = { + display: 'inline-flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: '$space-component-gap-xSmall', + opacity: { + _: 1, + disabled: 0.5, + }, + innerElements: { + toggle: { + toggleOval: { + size: { + large: { + w: '36px', + h: '20px', + }, + small: { + w: '28px', + h: '16px', + }, + }, + p: '$space-component-padding-2xSmall', + backgroundColor: { + _: '$color-interaction-disabled-normal', + hover: '$color-interaction-disabled-hover', + focus: '$color-interaction-disabled-focus', + active: '$color-interaction-disabled-active', + disabled: '$color-interaction-disabled-normal', + selected: { + _: '$color-interaction-default-normal', + hover: '$color-interaction-default-hover', + focus: '$color-interaction-default-focus', + active: '$color-interaction-default-active', + disabled: '$color-interaction-default-normal', + }, + indeterminate: { + _: '$color-interaction-default-normal', + hover: '$color-interaction-default-hover', + focus: '$color-interaction-default-focus', + active: '$color-interaction-default-active', + disabled: '$color-interaction-default-normal', + }, + }, + transition: '0.2s', + borderRadius: '100px', + display: 'flex', + position: 'relative', + alignItems: 'center', + outlineColor: { + focusWithin: '$color-interaction-focus-default', + }, + outlineWidth: { + focusWithin: '$border-width-focus', + }, + outlineStyle: { + focusWithin: 'solid', + }, + outlineOffset: { + focusWithin: '$border-width-small', + }, + }, + slider: { + size: { + large: { + w: { + _: '16px', + indeterminate: '15px', + }, + h: { + _: '16px', + indeterminate: '1.5px', + }, + transform: { + selected: 'translateX(16px)', + indeterminate: 'translateX(8px)', + }, + }, + small: { + w: { + _: '12px', + indeterminate: '10px', + }, + h: { + _: '12px', + indeterminate: '1.5px', + }, + transform: { + selected: 'translateX(12px)', + indeterminate: 'translateX(7px)', + }, + }, + }, + transition: 'transform 0.2s ease-in-out', + backgroundColor: '$color-whiteA-0', + borderRadius: '$border-radius-full', + borderWidth: '$border-width-small', + borderStyle: '$border-style-solid', + borderColor: '$color-border-defaultA', + boxShadow: '$elevation-bottom-100', + position: 'absolute', + }, + input: { + borderRadius: '100px', + w: '100%', + h: '100%', + appearance: 'none', + zIndex: 1, + cursor: { + _: 'pointer', + disabled: 'default', + }, + }, + }, + labelContainer: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + gap: '$space-component-gap-medium', + text: '$typo-body-medium', + color: '$color-content-primary', + }, + label: { + cursor: { + _: 'pointer', + disabled: 'default', + }, + }, + helperText: { + paddingLeft: '$space-component-padding-2xLarge', + cursor: 'default', + }, + }, +} satisfies ToggleConfig; + +export const toggleStyles = { + defaultConfig, +}; diff --git a/src/components/Toggle/Toggle.test.tsx b/src/components/Toggle/Toggle.test.tsx new file mode 100644 index 00000000..ea58cb99 --- /dev/null +++ b/src/components/Toggle/Toggle.test.tsx @@ -0,0 +1,108 @@ +import { vi } from 'vitest'; + +import { fireEvent, render } from '../../tests/render'; + +import { Toggle } from '@/components/Toggle/Toggle.tsx'; +import { customPropTester } from '@/tests/customPropTester'; + +const handleEventMock = vi.fn(); + +const getToggle = (jsx: JSX.Element) => { + const { getByTestId, queryByTestId } = render(jsx); + + return { + toggle: getByTestId('toggle'), + toggleOval: getByTestId('toggle-oval'), + input: getByTestId('toggle-input'), + label: queryByTestId('toggle-label'), + labelContainer: getByTestId('toggle-label-container'), + }; +}; + +describe('Toggle', () => { + customPropTester(, { + containerId: 'toggle', + }); + + beforeEach(() => { + handleEventMock.mockReset(); + }); + + it('should render the toggle', () => { + const { toggle } = getToggle(); + expect(toggle).toBeInTheDocument(); + }); + + it('should render the correct label', () => { + const { label } = getToggle(); + expect(label).toHaveTextContent('label'); + }); + + it('should render the correct helper text', () => { + const { toggle } = getToggle( + , + ); + expect(toggle).toHaveTextContent('helper text'); + }); + + it('should emit onChange', () => { + const { input } = getToggle(); + fireEvent.click(input); + expect(handleEventMock).toHaveBeenCalled(); + }); + + it('should not emit onChange when disable', () => { + const { input } = getToggle( + , + ); + fireEvent.click(input); + expect(handleEventMock).not.toHaveBeenCalled(); + }); + + it('should render the correct color when unchecked and disabled', () => { + const { toggleOval } = getToggle(); + expect(toggleOval).toHaveStyle('background-color: rgba(158, 168, 179, 1);'); + }); + + it('should render the correct color when checked and disabled', () => { + const { toggleOval } = getToggle(); + expect(toggleOval).toHaveStyle('background-color: rgba(48, 98, 212, 1);'); + }); + + it('should render the right cursor on disabled toggleOval', () => { + const { input } = getToggle(); + expect(input).toHaveStyle('cursor: default'); + }); + + it('should render the right cursor on disabled label', () => { + const { label } = getToggle(); + expect(label).toHaveStyle('cursor: default'); + }); + + it('should propagate custom props', () => { + const { toggleOval } = getToggle( + , + ); + expect(toggleOval).toHaveStyle('background-color: rgb(254, 245, 245)'); + }); + + it('should render small size toggle', () => { + const { toggleOval } = getToggle(); + expect(toggleOval).toHaveStyle('width: 28px'); + }); + + it('should render large size toggle', () => { + const { toggleOval } = getToggle(); + expect(toggleOval).toHaveStyle('width: 36px'); + }); +}); diff --git a/src/components/Toggle/Toggle.tsx b/src/components/Toggle/Toggle.tsx new file mode 100644 index 00000000..e8e9585f --- /dev/null +++ b/src/components/Toggle/Toggle.tsx @@ -0,0 +1,96 @@ +import { ChangeEventHandler, FC, useCallback, useId, useMemo } from 'react'; + +import { stylesBuilder } from './stylesBuilder'; +import { ToggleProps } from './Toggle.props'; + +import { HelperText } from '@/components/HelperText'; +import { useIndeterminate } from '@/hooks'; +import { tet } from '@/tetrisly'; +import { MarginProps } from '@/types/MarginProps'; + +export const Toggle: FC = ({ + isChecked = false, + helperText, + label, + state, + isIndeterminate = false, + size = 'small', + custom, + onChange, + ...restProps +}) => { + const styles = useMemo(() => stylesBuilder(size, custom), [custom, size]); + const toggleId = useId(); + + const disabled = state === 'disabled'; + const indeterminate = !isChecked && isIndeterminate; + + const inputRef = useIndeterminate(indeterminate); + + const handleToggle: ChangeEventHandler = useCallback( + (e) => { + if (state !== 'disabled') { + onChange?.(e); + } + }, + [onChange, state], + ); + + return ( + + + + + + + {label ? ( + + {label} + + ) : null} + + {!!helperText && ( + + )} + + ); +}; diff --git a/src/components/Toggle/index.ts b/src/components/Toggle/index.ts new file mode 100644 index 00000000..75b0a4dc --- /dev/null +++ b/src/components/Toggle/index.ts @@ -0,0 +1,3 @@ +export { Toggle } from './Toggle.tsx'; +export type { ToggleProps } from './Toggle.props'; +export { toggleStyles } from './Toggle.styles'; diff --git a/src/components/Toggle/stylesBuilder.ts b/src/components/Toggle/stylesBuilder.ts new file mode 100644 index 00000000..9de6eb5c --- /dev/null +++ b/src/components/Toggle/stylesBuilder.ts @@ -0,0 +1,43 @@ +import { ToggleConfig, defaultConfig } from './Toggle.styles.ts'; + +import { HelperTextConfig } from '@/components/HelperText/HelperText.styles.ts'; +import { mergeConfigWithCustom } from '@/services'; +import { BaseProps } from '@/types/BaseProps.ts'; + +type ToggleStylesBuilder = { + container: BaseProps; + toggleOval: BaseProps; + slider: BaseProps; + input: BaseProps; + label: BaseProps; + labelContainer: BaseProps; + helperText: HelperTextConfig; +}; + +export const stylesBuilder = ( + size: 'small' | 'large', + custom?: ToggleConfig, +): ToggleStylesBuilder => { + const { + innerElements: { + toggle: { + input, + slider: { size: sliderSize, ...restSlider }, + toggleOval: { size: toggleOvalSize, ...restToggleOval }, + }, + labelContainer, + label, + helperText, + }, + ...container + } = mergeConfigWithCustom({ defaultConfig, custom }); + return { + container, + input, + slider: { ...sliderSize[size], ...restSlider }, + toggleOval: { ...toggleOvalSize[size], ...restToggleOval }, + labelContainer, + label, + helperText, + }; +}; diff --git a/src/docs-components/ToggleDocs.tsx b/src/docs-components/ToggleDocs.tsx new file mode 100644 index 00000000..d4b9b874 --- /dev/null +++ b/src/docs-components/ToggleDocs.tsx @@ -0,0 +1,154 @@ +import type { FC } from 'react'; + +import { SectionHeader } from './common/SectionHeader'; +import { States } from './common/States'; + +import { Toggle } from '@/components/Toggle'; +import { tet } from '@/tetrisly'; + +const getYesNo = (yes: boolean) => (yes ? 'Yes' : 'No'); + +const getLabels = (label: boolean, helperText: boolean) => [ + `Label: ${getYesNo(label)}`, + `Helper Text: ${getYesNo(helperText)}`, +]; + +export const ToggleDocs: FC = () => ( + + {['Unchecked', 'Checked', 'Indeterminate'].map((state) => ( + + + {state} + + + {[ + { label: false, helperText: false }, + { label: true, helperText: false }, + { label: true, helperText: true }, + ].map(({ label, helperText }) => ( + + + + + + + {label ? ( + + ) : ( + + )} + {label ? ( + + ) : ( + + )} + + + + + {label ? ( + + ) : ( + + )} + {label ? ( + + ) : ( + + )} + + + + + ))} + + ))} + +); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 04511efc..cb10d11d 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1 +1,2 @@ export { useAction } from './useAction'; +export { useIndeterminate } from './useIndeterminate.ts'; diff --git a/src/components/Checkbox/hooks/useIndeterminate.ts b/src/hooks/useIndeterminate.ts similarity index 100% rename from src/components/Checkbox/hooks/useIndeterminate.ts rename to src/hooks/useIndeterminate.ts diff --git a/src/index.ts b/src/index.ts index e61dc8fa..f7258878 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ export * from './components/Tag'; export * from './components/TagInput'; export * from './components/TextInput'; export * from './components/Toast'; +export * from './components/Toggle'; export * from './components/Tooltip'; export * from './tetrisly'; export * from './theme'; diff --git a/src/theme/theme.ts b/src/theme/theme.ts index e512203a..f40ad3da 100644 --- a/src/theme/theme.ts +++ b/src/theme/theme.ts @@ -1352,7 +1352,7 @@ const fixedTokens = { hoverWithoutButton: '&:hover:not(:has(button:hover), &:invalid, &[data-state="alert"])', alert: '&:invalid, &[data-state="alert"]', - indeterminate: '&:indeterminate', + indeterminate: '&:indeterminate, &[data-indeterminate="indeterminate"]', }, // TO DO: utility for xstyled to gather color of shadow and generate rgba based on opacity shadows: {