diff --git a/src/components/Stepper/README-ru.md b/src/components/Stepper/README-ru.md new file mode 100644 index 000000000..7167f3277 --- /dev/null +++ b/src/components/Stepper/README-ru.md @@ -0,0 +1,227 @@ + + +# Stepper + + + +```tsx +import {Stepper} from '@gravity-ui/uikit'; +``` + +`Stepper` - это компонент, который отображает прогресс при помощи последовательности пронумерованных шагов. Компонент обеспечивает возможность использования wizard-like процессов работы. + +## Example + + + +```jsx + + Step 1 + Step 2 + Step 3 + Step 4 with very long title + +``` + + + + + + + +### Interactive items + +Используйте `onUpdate` и `value` параметры вместе с кастомным состоянием для управления шагами + + + +```jsx +const [value, setValue] = React.useState(); + +return ( + + Step 1 + Step 2 + Step 3 + Step 4 with very long title + +); +``` + + + + + + + +### Different views + + + +```jsx + + Step 1 + Step 2 + Step 3 + Step 4 + +``` + + + + + + + +### Different sizes + + + +```jsx + + + Step 1 + Step 2 + Step 3 + + + + Step 1 + Step 2 + Step 3 + + + + Step 1 + Step 2 + Step 3 + + +``` + + + + + + + +### Disabled steps + + + +```jsx + + Step 1 + Step 2 + Step 3 + Step 4 with very long title + +``` + + + + + + + +### Custom icons + + + +```jsx + + }>Step 1 + }> + Step 2 + + }> + Step 3 + + Step 4 with very long title + +``` + + + + + + + +### Custom step separator + + + +```jsx +const Separator = () => { + return {'->'}; +}; + +}> + Step 1 + Step 2 + Step 3 + Step 4 with very long title +; +``` + + + + + + + +### Step with floating element + + + +```jsx +}> + + Step 1 + + Step 2 + Step 3 + Step 4 with very long title + +``` + + + + + + + +## Properties + +| Name | Description | Type | Default | +| :--------------- | :---------------------------------------------------------------------------------- | :------------------------------------- | :------ | +| children | Дочерние элементы степера. | `React.ReactElement` | | +| size | Задает размер степа. | `"s"` `"m"` `"l"` | `"s"` | +| value | Текущий выбранный идентификатор степа. | `number` `string` | | +| onUpdate | Функция для обновления текущего выбранного элемента. | `Function` | | +| qa | `data-qa` HTML атрибут, используется для тестирования. | `string` | | +| separator | Кастомная нода-разделитель степов. | `React.ReactNode` | | +| className | CSS имя класса Step контейнера. | `string` | | +| style | Задает инлайн-стили Step контейнера. | `CSSProperties` | | +| aria-label | Определяет строковое значение, используемое в качестве метки для текущего элемента. | `string` | | +| aria-labelledby | Определяет элементы, используемые в качестве метки для текущего элемента. | `string` | | +| aria-describedby | Определяет элементы, описывающие объект. | `string` | | + +### StepperItemProps + +| Name | Description | Type | Default | +| :-------- | :----------------------------------------------------------------------- | :----------------------------- | :------- | +| id | Идентификатора степа. Если не передан, берется значения индекса массива. | `string` `number` | | +| view | Внешний вид степа. | `"idle"` `"error"` `"success"` | `"idle"` | +| children | Внутреннее содержимое степа. | `React.Node` | | +| disabled | Устанавливает заблокированное состояние для степа. | `boolean` | | +| icon | Задает кастомную иконка степа | `SVGIconData` | | +| onClick | Обработчик клика на степ | `React.MouseEventHandler` | | +| className | CSS class name элемента | `string` | | + +### CSS API + +| Name | Description | +| :-------------------------------- | :--------------------------------------------- | +| `--g-stepper-gap` | Расстояние между степами и разделителем. | +| `--g-stepper-item-text-max-width` | Максимальная ширина текстового контента степа. | diff --git a/src/components/Stepper/README.md b/src/components/Stepper/README.md new file mode 100644 index 000000000..141ca3928 --- /dev/null +++ b/src/components/Stepper/README.md @@ -0,0 +1,229 @@ + + +# Stepper + + + +```tsx +import {Stepper} from '@gravity-ui/uikit'; +``` + +`Stepper` convey progress through numbered steps. It provides a wizard-like workflow.Steppers display progress through a sequence of logical and numbered steps. + +## Example + + + +```jsx + + Step 1 + Step 2 + Step 3 + Step 4 with very long title + +``` + + + + + + + +### Interactive items + +Use `onUpdate` and `value` props with custom state to manipulate steps + + + +```jsx +const [value, setValue] = React.useState(); + +return ( + + Step 1 + Step 2 + Step 3 + Step 4 with very long title + +); +``` + + + + + + + +### Different views + + + +```jsx + + Step 1 + Step 2 + Step 3 + Step 4 + +``` + + + + + + + +### Different sizes + + + +```jsx + + + Step 1 + Step 2 + Step 3 + + + + Step 1 + Step 2 + Step 3 + + + + Step 1 + Step 2 + Step 3 + + +``` + + + + + + + +### Disabled steps + + + +```jsx + + Step 1 + Step 2 + Step 3 + Step 4 with very long title + +``` + + + + + + + +### Custom icons + + + +```jsx + + }>Step 1 + }> + Step 2 + + }> + Step 3 + + Step 4 with very long title + +``` + + + + + + + +### Custom step separator + + + +```jsx +const Separator = () => { + return {'->'}; +}; + +}> + Step 1 + Step 2 + Step 3 + Step 4 with very long title +; +``` + + + + + + + + + +### Step with floating element + + + +```jsx +}> + + Step 1 + + Step 2 + Step 3 + Step 4 with very long title + +``` + + + + + + + +## Properties + +| Name | Description | Type | Default | +| :--------------- | :-------------------------------------------------------- | :------------------------------------- | :------ | +| children | Stepper items. | `React.ReactElement` | | +| size | Set the `Step` size. | `"s"` `"m"` `"l"` | `"s"` | +| value | Current selected `Step` id. | `number` `string` | | +| onUpdate | function for change current `Step`. | `Function` | | +| qa | `data-qa` HTML attribute, used for testing. | `string` | | +| separator | Custom separator node. | `React.ReactNode` | | +| className | CSS class name for the Steps container. | `string` | | +| style | Sets the inline style for the Steps container. | `CSSProperties` | | +| aria-label | Defines a string value that labels the current element. | `string` | | +| aria-labelledby | Identifies the element(s) that label the current element. | `string` | | +| aria-describedby | Identifies the element(s) that describe the object. | `string` | | + +### StepperItemProps + +| Name | Description | Type | Default | +| :-------- | :------------------------------------------------ | :----------------------------- | :------- | +| id | Set `Step` id. Index of array element as default. | `string` `number` | | +| view | Set `Step` view. | `"idle"` `"error"` `"success"` | `"idle"` | +| children | `Step` content. | `React.Node` | | +| disabled | Determines whether `Step` is disable. | `boolean` | | +| icon | Custom icon node. | `SVGIconData` | | +| onClick | Step click handler. | `React.MouseEventHandler` | | +| className | CSS class name for the element. | `string` | | + +### CSS API + +| Name | Description | +| :-------------------------------- | :------------------------------------ | +| `--g-stepper-gap` | Gap between step items and separator. | +| `--g-stepper-item-text-max-width` | Step item text max-width. | diff --git a/src/components/Stepper/Stepper.scss b/src/components/Stepper/Stepper.scss new file mode 100644 index 000000000..08f3c1dab --- /dev/null +++ b/src/components/Stepper/Stepper.scss @@ -0,0 +1,70 @@ +@use '../variables'; +@use '../../../styles/mixins'; + +$block: '.#{variables.$ns}stepper'; + +#{$block} { + --_--text-max-width: 150px; + --_--step-gap: var(--g-stepper-gap, var(--g-spacing-2)); + + list-style: none; + display: flex; + gap: var(--_--step-gap); + + &__list-item { + display: flex; + flex-wrap: nowrap; + gap: var(--_--step-gap); + align-items: center; + } + + &__item { + &_selected:not(&_disabled) { + border-color: var(--g-color-line-info); + } + + &_disabled { + cursor: default; + + #{$block}__item-text { + color: var(--g-color-text-hint); + } + } + } + + &__item-text { + display: inline-block; + vertical-align: middle; + + width: 100%; + max-width: var(--g-stepper-item-text-max-width, var(--_--text-max-width)); + color: var(--g-color-text-primary); + + @include mixins.overflow-ellipsis(); + } + + &__item-icon { + width: 16px; + height: 16px; + + &_view { + &_idle { + color: var(--g-color-text-secondary); + } + + &_error { + color: var(--g-color-text-danger); + } + + &_success { + color: var(--g-color-text-positive); + } + } + } + + &__separator { + display: flex; + align-items: center; + color: var(--g-color-text-secondary); + } +} diff --git a/src/components/Stepper/Stepper.tsx b/src/components/Stepper/Stepper.tsx new file mode 100644 index 000000000..8e8037779 --- /dev/null +++ b/src/components/Stepper/Stepper.tsx @@ -0,0 +1,58 @@ +'use client'; + +import * as React from 'react'; + +import type {AriaLabelingProps, DOMProps, QAProps} from '../types'; +import {filterDOMProps} from '../utils/filterDOMProps'; + +import {StepperItem} from './StepperItem'; +import {StepperSeparator} from './StepperSeparator'; +import {StepperContext} from './context'; +import type {StepperSize} from './types'; +import {b} from './utils'; + +import './Stepper.scss'; + +export interface StepperProps extends DOMProps, AriaLabelingProps, QAProps { + children: React.ReactElement | React.ReactElement[]; + value?: number | string; + onUpdate?: (id?: number | string) => void; + size?: StepperSize; + separator?: React.ReactNode; +} + +export const Stepper = (props: StepperProps) => { + const {children, value, size = 's', className, onUpdate, separator} = props; + + const stepItems = React.useMemo(() => { + return React.Children.map(children, (child, index) => { + const itemId = child.props?.id || index; + const clonedChild = React.cloneElement(child, {id: itemId}); + + return ( +
  • + {clonedChild} + {Boolean(index !== React.Children.count(children) - 1) && ( + + )} +
  • + ); + }); + }, [children, separator]); + + return ( + +
      + {stepItems} +
    +
    + ); +}; + +Stepper.Item = StepperItem; +Stepper.displayName = 'Stepper'; diff --git a/src/components/Stepper/StepperItem.tsx b/src/components/Stepper/StepperItem.tsx new file mode 100644 index 000000000..d3b42bece --- /dev/null +++ b/src/components/Stepper/StepperItem.tsx @@ -0,0 +1,85 @@ +import * as React from 'react'; + +import {CircleCheck, CircleDashed, CircleExclamation} from '@gravity-ui/icons'; + +import {Button} from '../Button'; +import type {ButtonButtonProps} from '../Button'; +import {Icon} from '../Icon'; +import type {SVGIconData} from '../Icon/types'; +import {Text} from '../Text'; + +import {useStepperContext} from './context'; +import type {StepperItemView} from './types'; +import {b} from './utils'; + +export type StepperItemProps = Omit & { + id?: string | number; + children: React.ReactNode; + view?: StepperItemView; + disabled?: boolean; + icon?: SVGIconData; + onClick?: (event: React.MouseEvent) => void; + className?: string; +}; + +export const StepperItem = React.forwardRef((props, ref) => { + const { + id, + children, + view = 'idle', + disabled = false, + className, + icon: customIcon, + ...restButtonProps + } = props; + + const {onUpdate, value, size} = useStepperContext(); + + const onClick = (e: React.MouseEvent) => { + props.onClick?.(e); + + onUpdate?.(id); + }; + + const icon = React.useMemo(() => { + if (customIcon) { + return customIcon; + } + + switch (view) { + case 'idle': { + return CircleDashed; + } + case 'error': { + return CircleExclamation; + } + case 'success': { + return CircleCheck; + } + default: { + return CircleDashed; + } + } + }, [view, customIcon]); + + const selectedItem = id === undefined ? false : id === value; + + return ( + + ); +}); + +StepperItem.displayName = 'StepperItem'; diff --git a/src/components/Stepper/StepperSeparator.tsx b/src/components/Stepper/StepperSeparator.tsx new file mode 100644 index 000000000..01c966615 --- /dev/null +++ b/src/components/Stepper/StepperSeparator.tsx @@ -0,0 +1,19 @@ +import {ChevronLeft, ChevronRight} from '@gravity-ui/icons'; + +import {Icon} from '../Icon'; +import {useDirection} from '../theme'; + +import {b} from './utils'; + +type StepperSeparatorProps = { + separator?: React.ReactNode; +}; + +export const StepperSeparator = ({separator}: StepperSeparatorProps) => { + const direction = useDirection(); + return ( +
    + {separator ?? } +
    + ); +}; diff --git a/src/components/Stepper/__stories__/Docs.mdx b/src/components/Stepper/__stories__/Docs.mdx new file mode 100644 index 000000000..ed08c8aef --- /dev/null +++ b/src/components/Stepper/__stories__/Docs.mdx @@ -0,0 +1,47 @@ +import { + Meta, + Markdown, + Canvas, + AnchorMdx, + CodeOrSourceMdx, + HeadersMdx, +} from '@storybook/addon-docs'; +import * as Stories from './Stepper.stories'; +import Readme from '../README.md?raw'; + +export const StepperDefault = () => ; +export const StepperSize = () => ; +export const StepperView = () => ; +export const StepperCustomIcons = () => ; +export const StepperCustomSeparator = () => ( + +); +export const StepperDisabled = () => ; +export const StepperWithFloatingElements = () => ( + +); +export const StepperInteractiveShowcase = () => ( + +); + + + + + {Readme} + diff --git a/src/components/Stepper/__stories__/Stepper.stories.tsx b/src/components/Stepper/__stories__/Stepper.stories.tsx new file mode 100644 index 000000000..1d5773e14 --- /dev/null +++ b/src/components/Stepper/__stories__/Stepper.stories.tsx @@ -0,0 +1,133 @@ +import {Cloud, CreditCard, Rocket} from '@gravity-ui/icons'; +import type {Meta, StoryObj} from '@storybook/react'; + +import {Text} from '../../Text'; +import {Tooltip} from '../../Tooltip'; +import {Stepper} from '../Stepper'; + +import {StepperInteractiveShowcase, StepperSizeShowcase} from './StepperShowcase'; + +export default { + title: 'Components/Navigation/Stepper', + component: Stepper, + parameters: { + a11y: { + element: '#storybook-root', + config: { + rules: [ + { + id: 'color-contrast', + enabled: false, // actual color contrast may differ in particular usage + }, + { + id: 'duplicate-id', + enabled: false, + selector: 'defs', // one may use same id in different + }, + ], + }, + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default = { + render: (args) => { + return ( + + Step 1 + Step 2 + Step 3 + Step 4 with very long title + + ); + }, +} satisfies Story; + +export const View = { + render: (args) => { + return ( + + Step 1 + Step 2 + Step 3 + Step 4 with very long title + + ); + }, +} satisfies Story; + +export const Size = { + render: () => { + return ; + }, +} satisfies Story; + +export const Disabled = { + render: (args) => { + return ( + + Step 1 + Step 2 + Step 3 + Step 4 with very long title + + ); + }, +} satisfies Story; + +export const CustomIcons = { + render: (args) => { + return ( + + Step 1 + + Step 2 + + + Step 3 + + Step 4 with very long title + + ); + }, +} satisfies Story; + +const Separator = () => { + return {'->'}; +}; + +export const CustomSeparator = { + render: (args) => { + return ( + }> + Step 1 + Step 2 + Step 3 + Step 4 with very long title + + ); + }, +} satisfies Story; + +export const InteractiveShowcase = { + render: (args) => { + return ; + }, +} satisfies Story; + +export const WithFloatingElements = { + render: (args) => { + return ( + }> + + Step 1 + + Step 2 + Step 3 + Step 4 with very long title + + ); + }, +} satisfies Story; diff --git a/src/components/Stepper/__stories__/StepperShowcase.tsx b/src/components/Stepper/__stories__/StepperShowcase.tsx new file mode 100644 index 000000000..bd8d8bdbb --- /dev/null +++ b/src/components/Stepper/__stories__/StepperShowcase.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; + +import {Flex} from '../../layout/Flex/Flex'; +import {Stepper} from '../Stepper'; +import type {StepperProps} from '../Stepper'; + +export const StepperInteractiveShowcase = (props: StepperProps) => { + const [value, setValue] = React.useState(0); + + return ( + + Step 1 + Step 2 + Step 3 + Step 4 with very long title + + ); +}; + +export const StepperSizeShowcase = () => { + return ( + + + Step 1 + Step 2 + Step 3 + + + + Step 1 + Step 2 + Step 3 + + + + Step 1 + Step 2 + Step 3 + + + ); +}; diff --git a/src/components/Stepper/context.ts b/src/components/Stepper/context.ts new file mode 100644 index 000000000..7d1f54807 --- /dev/null +++ b/src/components/Stepper/context.ts @@ -0,0 +1,17 @@ +import * as React from 'react'; + +import type {StepperProps} from './Stepper'; + +export type StepperContextProps = Pick; + +export const StepperContext = React.createContext({ + size: 'm', + onUpdate: undefined, + value: undefined, +}); + +export const useStepperContext = () => { + const data = React.useContext(StepperContext); + + return data; +}; diff --git a/src/components/Stepper/index.ts b/src/components/Stepper/index.ts new file mode 100644 index 000000000..f050967a4 --- /dev/null +++ b/src/components/Stepper/index.ts @@ -0,0 +1,3 @@ +export * from './Stepper'; + +export * from './types'; diff --git a/src/components/Stepper/types.ts b/src/components/Stepper/types.ts new file mode 100644 index 000000000..c864a9163 --- /dev/null +++ b/src/components/Stepper/types.ts @@ -0,0 +1,3 @@ +export type StepperItemView = 'idle' | 'error' | 'success'; + +export type StepperSize = 's' | 'm' | 'l'; diff --git a/src/components/Stepper/utils.ts b/src/components/Stepper/utils.ts new file mode 100644 index 000000000..89f1d58b9 --- /dev/null +++ b/src/components/Stepper/utils.ts @@ -0,0 +1,3 @@ +import {block} from '../utils/cn'; + +export const b = block('stepper');