@cloud-ru/ds-stepper 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +201 -0
  3. package/README.md +214 -0
  4. package/dist/cjs/components/Stepper/Stepper.d.ts +10 -0
  5. package/dist/cjs/components/Stepper/Stepper.js +17 -0
  6. package/dist/cjs/components/Stepper/index.d.ts +1 -0
  7. package/dist/cjs/components/Stepper/index.js +17 -0
  8. package/dist/cjs/components/index.d.ts +1 -0
  9. package/dist/cjs/components/index.js +20 -0
  10. package/dist/cjs/constants.d.ts +16 -0
  11. package/dist/cjs/constants.js +25 -0
  12. package/dist/cjs/context.d.ts +3 -0
  13. package/dist/cjs/context.js +18 -0
  14. package/dist/cjs/helperComponents/DesktopStep/DesktopStep.d.ts +12 -0
  15. package/dist/cjs/helperComponents/DesktopStep/DesktopStep.js +25 -0
  16. package/dist/cjs/helperComponents/DesktopStep/index.d.ts +1 -0
  17. package/dist/cjs/helperComponents/DesktopStep/index.js +17 -0
  18. package/dist/cjs/helperComponents/DesktopStep/styles.module.css +169 -0
  19. package/dist/cjs/helperComponents/DesktopStepper/DesktopStepper.d.ts +5 -0
  20. package/dist/cjs/helperComponents/DesktopStepper/DesktopStepper.js +35 -0
  21. package/dist/cjs/helperComponents/DesktopStepper/index.d.ts +1 -0
  22. package/dist/cjs/helperComponents/DesktopStepper/index.js +17 -0
  23. package/dist/cjs/helperComponents/DesktopStepper/styles.module.css +6 -0
  24. package/dist/cjs/helperComponents/MobileStep/MobileStep.d.ts +8 -0
  25. package/dist/cjs/helperComponents/MobileStep/MobileStep.js +28 -0
  26. package/dist/cjs/helperComponents/MobileStep/index.d.ts +1 -0
  27. package/dist/cjs/helperComponents/MobileStep/index.js +17 -0
  28. package/dist/cjs/helperComponents/MobileStep/styles.module.css +114 -0
  29. package/dist/cjs/helperComponents/MobileStepper/MobileStepper.d.ts +5 -0
  30. package/dist/cjs/helperComponents/MobileStepper/MobileStepper.js +37 -0
  31. package/dist/cjs/helperComponents/MobileStepper/index.d.ts +1 -0
  32. package/dist/cjs/helperComponents/MobileStepper/index.js +17 -0
  33. package/dist/cjs/helperComponents/MobileStepper/styles.module.css +32 -0
  34. package/dist/cjs/helperComponents/StepIcon/StepIcon.d.ts +10 -0
  35. package/dist/cjs/helperComponents/StepIcon/StepIcon.js +30 -0
  36. package/dist/cjs/helperComponents/StepIcon/index.d.ts +1 -0
  37. package/dist/cjs/helperComponents/StepIcon/index.js +17 -0
  38. package/dist/cjs/helperComponents/StepIcon/styles.module.css +35 -0
  39. package/dist/cjs/helperComponents/index.d.ts +5 -0
  40. package/dist/cjs/helperComponents/index.js +21 -0
  41. package/dist/cjs/hooks/useStepperController.d.ts +25 -0
  42. package/dist/cjs/hooks/useStepperController.js +124 -0
  43. package/dist/cjs/index.d.ts +5 -0
  44. package/dist/cjs/index.js +21 -0
  45. package/dist/cjs/package.json +3 -0
  46. package/dist/cjs/style.css +356 -0
  47. package/dist/cjs/types.d.ts +51 -0
  48. package/dist/cjs/types.js +2 -0
  49. package/dist/cjs/utils.d.ts +6 -0
  50. package/dist/cjs/utils.js +10 -0
  51. package/dist/esm/components/Stepper/Stepper.d.ts +10 -0
  52. package/dist/esm/components/Stepper/Stepper.js +14 -0
  53. package/dist/esm/components/Stepper/index.d.ts +1 -0
  54. package/dist/esm/components/Stepper/index.js +1 -0
  55. package/dist/esm/components/index.d.ts +1 -0
  56. package/dist/esm/components/index.js +4 -0
  57. package/dist/esm/constants.d.ts +16 -0
  58. package/dist/esm/constants.js +20 -0
  59. package/dist/esm/context.d.ts +3 -0
  60. package/dist/esm/context.js +14 -0
  61. package/dist/esm/helperComponents/DesktopStep/DesktopStep.d.ts +12 -0
  62. package/dist/esm/helperComponents/DesktopStep/DesktopStep.js +19 -0
  63. package/dist/esm/helperComponents/DesktopStep/index.d.ts +1 -0
  64. package/dist/esm/helperComponents/DesktopStep/index.js +1 -0
  65. package/dist/esm/helperComponents/DesktopStep/styles.module.css +169 -0
  66. package/dist/esm/helperComponents/DesktopStepper/DesktopStepper.d.ts +5 -0
  67. package/dist/esm/helperComponents/DesktopStepper/DesktopStepper.js +29 -0
  68. package/dist/esm/helperComponents/DesktopStepper/index.d.ts +1 -0
  69. package/dist/esm/helperComponents/DesktopStepper/index.js +1 -0
  70. package/dist/esm/helperComponents/DesktopStepper/styles.module.css +6 -0
  71. package/dist/esm/helperComponents/MobileStep/MobileStep.d.ts +8 -0
  72. package/dist/esm/helperComponents/MobileStep/MobileStep.js +22 -0
  73. package/dist/esm/helperComponents/MobileStep/index.d.ts +1 -0
  74. package/dist/esm/helperComponents/MobileStep/index.js +1 -0
  75. package/dist/esm/helperComponents/MobileStep/styles.module.css +114 -0
  76. package/dist/esm/helperComponents/MobileStepper/MobileStepper.d.ts +5 -0
  77. package/dist/esm/helperComponents/MobileStepper/MobileStepper.js +31 -0
  78. package/dist/esm/helperComponents/MobileStepper/index.d.ts +1 -0
  79. package/dist/esm/helperComponents/MobileStepper/index.js +1 -0
  80. package/dist/esm/helperComponents/MobileStepper/styles.module.css +32 -0
  81. package/dist/esm/helperComponents/StepIcon/StepIcon.d.ts +10 -0
  82. package/dist/esm/helperComponents/StepIcon/StepIcon.js +24 -0
  83. package/dist/esm/helperComponents/StepIcon/index.d.ts +1 -0
  84. package/dist/esm/helperComponents/StepIcon/index.js +1 -0
  85. package/dist/esm/helperComponents/StepIcon/styles.module.css +35 -0
  86. package/dist/esm/helperComponents/index.d.ts +5 -0
  87. package/dist/esm/helperComponents/index.js +5 -0
  88. package/dist/esm/hooks/useStepperController.d.ts +25 -0
  89. package/dist/esm/hooks/useStepperController.js +121 -0
  90. package/dist/esm/index.d.ts +5 -0
  91. package/dist/esm/index.js +5 -0
  92. package/dist/esm/style.css +356 -0
  93. package/dist/esm/types.d.ts +51 -0
  94. package/dist/esm/types.js +1 -0
  95. package/dist/esm/utils.d.ts +6 -0
  96. package/dist/esm/utils.js +6 -0
  97. package/dist/tsconfig.cjs.tsbuildinfo +1 -0
  98. package/dist/tsconfig.esm.tsbuildinfo +1 -0
  99. package/package.json +47 -0
  100. package/src/components/Stepper/Stepper.tsx +18 -0
  101. package/src/components/Stepper/index.ts +1 -0
  102. package/src/components/index.ts +4 -0
  103. package/src/constants.ts +23 -0
  104. package/src/context.tsx +18 -0
  105. package/src/helperComponents/DesktopStep/DesktopStep.tsx +73 -0
  106. package/src/helperComponents/DesktopStep/index.ts +1 -0
  107. package/src/helperComponents/DesktopStep/styles.module.scss +103 -0
  108. package/src/helperComponents/DesktopStepper/DesktopStepper.tsx +52 -0
  109. package/src/helperComponents/DesktopStepper/index.ts +1 -0
  110. package/src/helperComponents/DesktopStepper/styles.module.scss +6 -0
  111. package/src/helperComponents/MobileStep/MobileStep.tsx +46 -0
  112. package/src/helperComponents/MobileStep/index.ts +1 -0
  113. package/src/helperComponents/MobileStep/styles.module.scss +156 -0
  114. package/src/helperComponents/MobileStepper/MobileStepper.tsx +71 -0
  115. package/src/helperComponents/MobileStepper/index.ts +1 -0
  116. package/src/helperComponents/MobileStepper/styles.module.scss +36 -0
  117. package/src/helperComponents/StepIcon/StepIcon.tsx +47 -0
  118. package/src/helperComponents/StepIcon/index.ts +1 -0
  119. package/src/helperComponents/StepIcon/styles.module.scss +54 -0
  120. package/src/helperComponents/index.ts +5 -0
  121. package/src/hooks/useStepperController.ts +201 -0
  122. package/src/index.ts +5 -0
  123. package/src/types.ts +59 -0
  124. package/src/utils.ts +9 -0
@@ -0,0 +1,52 @@
1
+ import cn from 'classnames';
2
+
3
+ import { StepperContext } from '../../context';
4
+ import { useStepperController } from '../../hooks/useStepperController';
5
+ import { StepperApi, StepperProps } from '../../types';
6
+ import { DesktopStep } from '../DesktopStep';
7
+ import styles from './styles.module.scss';
8
+
9
+ export function DesktopStepper({
10
+ children,
11
+ steps,
12
+ className,
13
+ onChangeCurrentStep,
14
+ onCompleteChange,
15
+ defaultCurrentStepIndex = 0,
16
+ validator,
17
+ 'data-test-id': testId,
18
+ allowFreeNavigation = false,
19
+ }: StepperProps) {
20
+ const { stepsView, currentStepIndex, isCompleted, stepCount, goNext, goPrev, resetValidation, setValidator } =
21
+ useStepperController({
22
+ steps,
23
+ defaultCurrentStepIndex,
24
+ validator,
25
+ onChangeCurrentStep,
26
+ onCompleteChange,
27
+ allowFreeNavigation,
28
+ });
29
+
30
+ const stepper = (
31
+ <div className={cn(styles.stepper, className)} data-test-id={testId}>
32
+ {stepsView.map((step, index) => (
33
+ <DesktopStep key={step.title + index} step={step} data-test-id={testId} />
34
+ ))}
35
+ </div>
36
+ );
37
+
38
+ const stepperApi: StepperApi = {
39
+ goNext,
40
+ goPrev,
41
+ currentStepIndex,
42
+ isCompleted,
43
+ resetValidation,
44
+ stepCount,
45
+ stepper,
46
+ setValidator,
47
+ };
48
+
49
+ return <StepperContext.Provider value={stepperApi}>{children(stepperApi)}</StepperContext.Provider>;
50
+ }
51
+
52
+ DesktopStepper.displayName = 'DesktopStepper';
@@ -0,0 +1 @@
1
+ export * from './DesktopStepper';
@@ -0,0 +1,6 @@
1
+ .stepper {
2
+ display: flex;
3
+ flex-direction: row;
4
+ align-items: flex-start;
5
+ width: 100%;
6
+ }
@@ -0,0 +1,46 @@
1
+ import { CheckSVG, CrossSVG } from '@cloud-ru/ds-icons/interface/system';
2
+ import { Sun } from '@cloud-ru/ds-loader';
3
+ import { ReactNode } from 'react';
4
+
5
+ import { STEP_STATE } from '../../constants';
6
+ import { StepState, StepViewData } from '../../types';
7
+ import { getTestIdBuilder } from '../../utils';
8
+ import styles from './styles.module.scss';
9
+
10
+ export type MobileStepProps = {
11
+ /** Данные шага для отображения */
12
+ step: StepViewData;
13
+ /** data-test-id */
14
+ 'data-test-id'?: string;
15
+ };
16
+
17
+ const getStepTestId = getTestIdBuilder('_element-step');
18
+
19
+ function getContent(state: StepState): ReactNode {
20
+ switch (state) {
21
+ case STEP_STATE.Completed:
22
+ return <CheckSVG size={24} />;
23
+ case STEP_STATE.Rejected:
24
+ return <CrossSVG size={24} />;
25
+ case STEP_STATE.Loading:
26
+ return <Sun size='s' />;
27
+ default:
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export function MobileStep({ step, 'data-test-id': testId }: MobileStepProps) {
33
+ return (
34
+ <button
35
+ type='button'
36
+ className={styles.step}
37
+ onClick={step.onClick}
38
+ disabled={!step.onClick}
39
+ data-test-id={getStepTestId(testId)}
40
+ data-state={step.state}
41
+ >
42
+ <div className={styles.track} />
43
+ <div className={styles.status}>{getContent(step.state)}</div>
44
+ </button>
45
+ );
46
+ }
@@ -0,0 +1 @@
1
+ export * from './MobileStep';
@@ -0,0 +1,156 @@
1
+ @use '@cloud-ru/ds-figma-variables/build/scss/components/stepper.module' as base;
2
+
3
+ .step {
4
+ all: unset;
5
+
6
+ cursor: pointer;
7
+
8
+ position: relative;
9
+
10
+ display: flex;
11
+ align-items: center;
12
+ justify-content: center;
13
+
14
+ width: 100%;
15
+ min-height: 24px;
16
+
17
+ outline: none;
18
+
19
+ &:disabled {
20
+ cursor: default;
21
+ }
22
+
23
+ &:focus-visible {
24
+ border-radius: 2px;
25
+ outline: base.$sn-primitive-strokeWeight-strokeSemiBold solid base.$sn-theme-color-available-complementary;
26
+ outline-offset: base.$sn-primitive-strokeWeight-strokeSemiBold;
27
+ }
28
+ }
29
+
30
+ // Track — горизонтальная полоса шага. По умолчанию waiting (серая).
31
+ .track {
32
+ position: relative;
33
+
34
+ width: 100%;
35
+ height: base.$sn-stepper-anatomy-size-s-progressWrapper-progressLine-height;
36
+ border-radius: base.$sn-stepper-anatomy-size-s-progressWrapper-progressLine-borderRadius;
37
+
38
+ background-color: base.$sn-theme-color-neutral-decor;
39
+
40
+ transition: background-color 150ms ease;
41
+ }
42
+
43
+ // Status — круглый индикатор поверх полосы, центрируется.
44
+ // Виден только для completed / loading / rejected.
45
+ .status {
46
+ pointer-events: none;
47
+
48
+ position: absolute;
49
+ top: 50%;
50
+ left: 50%;
51
+ transform: translate(-50%, -50%);
52
+
53
+ display: none;
54
+ align-items: center;
55
+ justify-content: center;
56
+
57
+ width: base.$sn-stepper-anatomy-size-s-stepStatus-width;
58
+ height: base.$sn-stepper-anatomy-size-s-stepStatus-height;
59
+ border-radius: 50%;
60
+ }
61
+
62
+ // State-layer: hover/pressed красят ТОЛЬКО полосу и круг (если он есть на этом
63
+ // шаге), но триггерятся по всей кликабельной области `.step`. Оверлей клипится
64
+ // формой родителя через `border-radius: inherit` (пилюля полосы / круг статуса),
65
+ // поэтому большой прямоугольной плашки на всю кнопку больше нет.
66
+ .track::after,
67
+ .status::after {
68
+ pointer-events: none;
69
+ content: '';
70
+
71
+ position: absolute;
72
+ inset: 0;
73
+
74
+ border-radius: inherit;
75
+
76
+ background-color: base.$sn-theme-color-material-stateLayer-neutral-onBackground-defaultEmpty-filled;
77
+
78
+ transition: background-color 150ms ease;
79
+ }
80
+
81
+ .step[data-state='completed'] {
82
+ .track::after,
83
+ .status::after {
84
+ background-color: base.$sn-theme-color-material-stateLayer-darkOnAccent-defaultEmpty-filled;
85
+ }
86
+ }
87
+
88
+ @media (hover: hover) {
89
+ .step:not(:disabled):hover {
90
+ .track::after,
91
+ .status::after {
92
+ background-color: base.$sn-theme-color-material-stateLayer-neutral-onBackground-hovered-filled;
93
+ }
94
+ }
95
+ }
96
+
97
+ .step:not(:disabled):active {
98
+ .track::after,
99
+ .status::after {
100
+ background-color: base.$sn-theme-color-material-stateLayer-neutral-onBackground-pressed-filled;
101
+ }
102
+ }
103
+
104
+ @media (hover: hover) {
105
+ .step[data-state='completed']:not(:disabled):hover {
106
+ .track::after,
107
+ .status::after {
108
+ background-color: base.$sn-theme-color-material-stateLayer-darkOnAccent-hovered-filled;
109
+ }
110
+ }
111
+ }
112
+
113
+ .step[data-state='completed']:not(:disabled):active {
114
+ .track::after,
115
+ .status::after {
116
+ background-color: base.$sn-theme-color-material-stateLayer-darkOnAccent-pressed-filled;
117
+ }
118
+ }
119
+
120
+ // Current — сплошная тёмно-зелёная полоса. Индикатор-круг не показывается.
121
+ .step[data-state='current'] .track {
122
+ background-color: base.$sn-theme-color-primary-accent;
123
+ }
124
+
125
+ // Completed — светло-зелёная полоса + круг с галочкой в центре.
126
+ .step[data-state='completed'] .track {
127
+ background-color: base.$sn-theme-color-primary-decor;
128
+ }
129
+
130
+ .step[data-state='completed'] .status {
131
+ display: flex;
132
+ color: base.$sn-theme-color-primary-accent;
133
+ background-color: base.$sn-theme-color-primary-decor;
134
+ }
135
+
136
+ // Loading — нейтральная серая полоса + серый круг со спиннером («в процессе»).
137
+ .step[data-state='loading'] .track {
138
+ background-color: base.$sn-theme-color-neutral-decor;
139
+ }
140
+
141
+ .step[data-state='loading'] .status {
142
+ display: flex;
143
+ color: base.$sn-theme-color-neutral-text;
144
+ background-color: base.$sn-theme-color-neutral-background;
145
+ }
146
+
147
+ // Rejected — сплошная красная полоса + круг с крестом (как current, активное состояние).
148
+ .step[data-state='rejected'] .track {
149
+ background-color: base.$sn-theme-color-red-accent;
150
+ }
151
+
152
+ .step[data-state='rejected'] .status {
153
+ display: flex;
154
+ color: base.$sn-theme-color-red-onAccent;
155
+ background-color: base.$sn-theme-color-red-accent;
156
+ }
@@ -0,0 +1,71 @@
1
+ import { Typography } from '@cloud-ru/ds-typography';
2
+ import cn from 'classnames';
3
+
4
+ import { StepperContext } from '../../context';
5
+ import { useStepperController } from '../../hooks/useStepperController';
6
+ import { StepperApi, StepperProps } from '../../types';
7
+ import { MobileStep } from '../MobileStep';
8
+ import styles from './styles.module.scss';
9
+
10
+ export function MobileStepper({
11
+ children,
12
+ steps,
13
+ className,
14
+ onChangeCurrentStep,
15
+ onCompleteChange,
16
+ defaultCurrentStepIndex = 0,
17
+ validator,
18
+ allowFreeNavigation = false,
19
+ 'data-test-id': testId,
20
+ }: StepperProps) {
21
+ const { stepsView, currentStepIndex, isCompleted, stepCount, goNext, goPrev, resetValidation, setValidator } =
22
+ useStepperController({
23
+ steps,
24
+ defaultCurrentStepIndex,
25
+ validator,
26
+ onChangeCurrentStep,
27
+ onCompleteChange,
28
+ allowFreeNavigation,
29
+ });
30
+
31
+ const currentStep = stepsView[currentStepIndex];
32
+
33
+ const stepper = (
34
+ <div className={cn(styles.stepper, className)} data-test-id={testId}>
35
+ <div className={styles.steps} style={{ gridTemplateColumns: `repeat(${stepsView.length}, 1fr)` }}>
36
+ {stepsView.map((step, index) => (
37
+ <MobileStep key={step.title + index} step={step} data-test-id={testId} />
38
+ ))}
39
+ </div>
40
+
41
+ {currentStep && (
42
+ <div className={styles.content}>
43
+ <Typography variant='body' size='m' as='div' className={styles.title}>
44
+ {currentStep.title}
45
+ </Typography>
46
+
47
+ {currentStep.description && (
48
+ <Typography variant='body' size='s' as='div' className={styles.description}>
49
+ {currentStep.description}
50
+ </Typography>
51
+ )}
52
+ </div>
53
+ )}
54
+ </div>
55
+ );
56
+
57
+ const stepperApi: StepperApi = {
58
+ goNext,
59
+ goPrev,
60
+ currentStepIndex,
61
+ isCompleted,
62
+ resetValidation,
63
+ stepCount,
64
+ stepper,
65
+ setValidator,
66
+ };
67
+
68
+ return <StepperContext.Provider value={stepperApi}>{children(stepperApi)}</StepperContext.Provider>;
69
+ }
70
+
71
+ MobileStepper.displayName = 'MobileStepper';
@@ -0,0 +1 @@
1
+ export * from './MobileStepper';
@@ -0,0 +1,36 @@
1
+ @use '@cloud-ru/ds-figma-variables/build/scss/styles/styles.module' as base;
2
+ @use '@cloud-ru/ds-figma-variables/build/scss/components/stepper.module' as stepper;
3
+
4
+ .stepper {
5
+ display: flex;
6
+ flex-direction: column;
7
+ gap: stepper.$sn-stepper-anatomy-stepRow-gap;
8
+ width: 100%;
9
+ }
10
+
11
+ .steps {
12
+ display: grid;
13
+ gap: stepper.$sn-stepper-anatomy-step-gap;
14
+ width: 100%;
15
+ }
16
+
17
+ .content {
18
+ display: flex;
19
+ flex-direction: column;
20
+ }
21
+
22
+ .title {
23
+ overflow: hidden;
24
+ color: base.$sn-theme-color-available-version-textMain;
25
+ text-overflow: ellipsis;
26
+ white-space: nowrap;
27
+ }
28
+
29
+ .description {
30
+ overflow: hidden;
31
+ display: -webkit-box;
32
+ -webkit-box-orient: vertical;
33
+ -webkit-line-clamp: 2;
34
+
35
+ color: base.$sn-theme-color-available-version-textTertiary;
36
+ }
@@ -0,0 +1,47 @@
1
+ import { CheckSVG, CrossSVG } from '@cloud-ru/ds-icons/interface/system';
2
+ import { Sun } from '@cloud-ru/ds-loader';
3
+ import { Typography } from '@cloud-ru/ds-typography';
4
+ import cn from 'classnames';
5
+ import { ReactNode, useMemo } from 'react';
6
+
7
+ import { STEP_STATE } from '../../constants';
8
+ import { StepState } from '../../types';
9
+ import styles from './styles.module.scss';
10
+
11
+ export type StepIconProps = {
12
+ /** Состояние шага */
13
+ state: StepState;
14
+ /** Порядковый номер шага (1-based) */
15
+ number: number;
16
+ /** CSS-класс */
17
+ className?: string;
18
+ };
19
+
20
+ function getContent(state: StepState, number: number): ReactNode {
21
+ switch (state) {
22
+ case STEP_STATE.Completed:
23
+ return <CheckSVG size={24} />;
24
+ case STEP_STATE.Rejected:
25
+ return <CrossSVG size={24} />;
26
+ case STEP_STATE.Loading:
27
+ return <Sun size='s' />;
28
+ default:
29
+ return number;
30
+ }
31
+ }
32
+
33
+ export function StepIcon({ state, number, className }: StepIconProps) {
34
+ const content = useMemo(() => getContent(state, number), [number, state]);
35
+
36
+ return (
37
+ <div data-state={state} className={cn(styles.icon, className)}>
38
+ {typeof content === 'number' ? (
39
+ <Typography variant='label' size='l' as='span'>
40
+ {content}
41
+ </Typography>
42
+ ) : (
43
+ content
44
+ )}
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1 @@
1
+ export * from './StepIcon';
@@ -0,0 +1,54 @@
1
+ @use '@cloud-ru/ds-figma-variables/build/scss/components/stepper.module' as base;
2
+
3
+ .icon {
4
+ display: flex;
5
+ align-items: center;
6
+ justify-content: center;
7
+
8
+ box-sizing: border-box;
9
+ width: base.$sn-stepper-anatomy-size-m-stepStatus-width;
10
+ height: base.$sn-stepper-anatomy-size-m-stepStatus-height;
11
+ border: base.$sn-stepper-anatomy-size-m-stepStatus-borderWidth solid transparent;
12
+ border-radius: 50%;
13
+
14
+ color: base.$sn-theme-color-available-version-textTertiary;
15
+
16
+ background-color: base.$sn-theme-color-neutral-background1Level;
17
+
18
+ transition:
19
+ background-color 150ms ease,
20
+ color 150ms ease,
21
+ border-color 150ms ease;
22
+
23
+ // Waiting — белый фон, еле-заметный бордер, приглушённый номер.
24
+ &[data-state='waiting'] {
25
+ border-color: base.$sn-theme-color-available-borderColor;
26
+ color: base.$sn-theme-color-available-version-textTertiary;
27
+ background-color: base.$sn-theme-color-neutral-background1Level;
28
+ }
29
+
30
+ // Current — сплошной тёмно-зелёный фон, светлый номер, без бордера.
31
+ &[data-state='current'] {
32
+ color: base.$sn-theme-color-primary-onAccent;
33
+ background-color: base.$sn-theme-color-primary-accent;
34
+ }
35
+
36
+ // Loading — нейтральный серый фон, еле-заметный бордер, серый спиннер («в процессе»).
37
+ &[data-state='loading'] {
38
+ border-color: base.$sn-theme-color-available-borderColor;
39
+ color: base.$sn-theme-color-neutral-text;
40
+ background-color: base.$sn-theme-color-neutral-background;
41
+ }
42
+
43
+ // Completed — светло-зелёный фон, тёмно-зелёная галочка.
44
+ &[data-state='completed'] {
45
+ color: base.$sn-theme-color-primary-accent;
46
+ background-color: base.$sn-theme-color-primary-decor;
47
+ }
48
+
49
+ // Rejected — сплошной красный фон, светлый крест.
50
+ &[data-state='rejected'] {
51
+ color: base.$sn-theme-color-red-onAccent;
52
+ background-color: base.$sn-theme-color-red-accent;
53
+ }
54
+ }
@@ -0,0 +1,5 @@
1
+ export * from './DesktopStep';
2
+ export * from './DesktopStepper';
3
+ export * from './MobileStep';
4
+ export * from './MobileStepper';
5
+ export * from './StepIcon';
@@ -0,0 +1,201 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react';
2
+
3
+ import { STEP_STATE } from '../constants';
4
+ import { StepData, StepState, StepsValidator, StepViewData } from '../types';
5
+
6
+ const DEFAULT_VALIDATOR: StepsValidator = async () => true;
7
+
8
+ export type UseStepperControllerParams = {
9
+ steps: StepData[];
10
+ defaultCurrentStepIndex?: number;
11
+ validator?: StepsValidator;
12
+ onChangeCurrentStep?: (newValue: number, prevValue: number) => void;
13
+ onCompleteChange?: (isCompleted: boolean) => void;
14
+ allowFreeNavigation: boolean;
15
+ };
16
+
17
+ export type UseStepperControllerResult = {
18
+ stepsView: StepViewData[];
19
+ currentStepIndex: number;
20
+ currentStepState: StepState;
21
+ isCompleted: boolean;
22
+ stepCount: number;
23
+ goNext: (stepIndex?: number) => void;
24
+ goPrev: (stepIndex?: number) => void;
25
+ resetValidation: () => void;
26
+ setValidator: (validator: StepsValidator) => void;
27
+ };
28
+
29
+ /**
30
+ * Общий контроллер состояния шагов — используется одинаково в Stepper и
31
+ * MobileStepper. Порт из @snack-uikit/stepper без изменений поведения.
32
+ */
33
+ export function useStepperController({
34
+ steps,
35
+ defaultCurrentStepIndex = 0,
36
+ validator: validatorProp = DEFAULT_VALIDATOR,
37
+ onChangeCurrentStep,
38
+ onCompleteChange,
39
+ allowFreeNavigation,
40
+ }: UseStepperControllerParams): UseStepperControllerResult {
41
+ const isCompletedByDefault = defaultCurrentStepIndex === steps.length - 1;
42
+ const [currentStepState, setCurrentStepState] = useState<StepState>(
43
+ isCompletedByDefault ? STEP_STATE.Completed : STEP_STATE.Current,
44
+ );
45
+ const [currentStepIndex, setCurrentStepIndexValue] = useState(defaultCurrentStepIndex);
46
+ const [isCompleted, setIsCompleted] = useState(isCompletedByDefault);
47
+ const [stepsValidator, setStepsValidator] = useState<{ value: StepsValidator }>();
48
+
49
+ useEffect(() => {
50
+ onCompleteChange?.(isCompleted);
51
+ }, [isCompleted, onCompleteChange]);
52
+
53
+ const setCurrentStepIndex = useCallback(
54
+ (newValue: number) => {
55
+ setCurrentStepIndexValue(prevValue => {
56
+ if (prevValue !== newValue) {
57
+ onChangeCurrentStep?.(newValue, prevValue);
58
+ }
59
+ return newValue;
60
+ });
61
+ },
62
+ [onChangeCurrentStep],
63
+ );
64
+
65
+ const goNext = useCallback(
66
+ (newStepIndexUnsafety: number = currentStepIndex + 1) => {
67
+ if (currentStepIndex >= steps.length || isCompleted || newStepIndexUnsafety <= currentStepIndex) {
68
+ return;
69
+ }
70
+
71
+ const newStepIndex = Math.min(steps.length - 1, newStepIndexUnsafety);
72
+
73
+ setCurrentStepState(STEP_STATE.Loading);
74
+
75
+ const validator = stepsValidator?.value || validatorProp;
76
+
77
+ validator(currentStepIndex, newStepIndex)
78
+ .catch(() => false)
79
+ .then(isValid => {
80
+ if (!isValid) {
81
+ setCurrentStepState(STEP_STATE.Rejected);
82
+ return;
83
+ }
84
+
85
+ if (currentStepIndex === steps.length - 1) {
86
+ setCurrentStepState(STEP_STATE.Completed);
87
+ setIsCompleted(true);
88
+ } else {
89
+ setCurrentStepIndex(newStepIndex);
90
+ setCurrentStepState(STEP_STATE.Current);
91
+ }
92
+ });
93
+ },
94
+ [currentStepIndex, isCompleted, setCurrentStepIndex, steps.length, stepsValidator?.value, validatorProp],
95
+ );
96
+
97
+ const moveToStep = useCallback(
98
+ (stepIndex: number) => {
99
+ if (isCompleted) {
100
+ setIsCompleted(false);
101
+ }
102
+
103
+ setCurrentStepIndex(stepIndex);
104
+ setCurrentStepState(STEP_STATE.Current);
105
+ },
106
+ [isCompleted, setCurrentStepIndex],
107
+ );
108
+
109
+ const goToStep = useCallback(
110
+ (newStepIndex: number) => {
111
+ if (newStepIndex < 0 || newStepIndex >= steps.length) {
112
+ return;
113
+ }
114
+
115
+ if (newStepIndex === currentStepIndex) {
116
+ return;
117
+ }
118
+
119
+ moveToStep(newStepIndex);
120
+ },
121
+ [currentStepIndex, steps, moveToStep],
122
+ );
123
+
124
+ const goPrev = useCallback(
125
+ (index: number = currentStepIndex - 1) => {
126
+ if (currentStepIndex === 0 || index < 0 || index > currentStepIndex) {
127
+ return;
128
+ }
129
+
130
+ if (index === currentStepIndex && !isCompleted) {
131
+ return;
132
+ }
133
+
134
+ moveToStep(index);
135
+ },
136
+ [currentStepIndex, isCompleted, moveToStep],
137
+ );
138
+
139
+ const getStepOnClick = useCallback(
140
+ (stepIndex: number) => {
141
+ if (allowFreeNavigation) {
142
+ return () => goToStep(stepIndex);
143
+ }
144
+
145
+ if (stepIndex < currentStepIndex || (stepIndex === currentStepIndex && isCompleted)) {
146
+ return () => goPrev(stepIndex);
147
+ }
148
+
149
+ if (stepIndex === currentStepIndex + 1) {
150
+ return () => goNext();
151
+ }
152
+
153
+ return undefined;
154
+ },
155
+ [allowFreeNavigation, currentStepIndex, goNext, goPrev, goToStep, isCompleted],
156
+ );
157
+
158
+ const stepsView: StepViewData[] = useMemo(
159
+ () =>
160
+ steps.map((step, index) => {
161
+ const number = index + 1;
162
+ let state: StepState = STEP_STATE.Waiting;
163
+
164
+ if (index < currentStepIndex) {
165
+ state = STEP_STATE.Completed;
166
+ } else if (index === currentStepIndex) {
167
+ state = currentStepState;
168
+ }
169
+
170
+ return {
171
+ ...step,
172
+ number,
173
+ state,
174
+ onClick: getStepOnClick(index),
175
+ };
176
+ }),
177
+ [steps, currentStepIndex, currentStepState, getStepOnClick],
178
+ );
179
+
180
+ const resetValidation = useCallback(() => {
181
+ if (currentStepState === STEP_STATE.Rejected) {
182
+ setCurrentStepState(STEP_STATE.Current);
183
+ }
184
+ }, [currentStepState]);
185
+
186
+ const setValidator = useCallback((validator: StepsValidator) => {
187
+ setStepsValidator({ value: validator });
188
+ }, []);
189
+
190
+ return {
191
+ stepsView,
192
+ currentStepIndex,
193
+ currentStepState,
194
+ isCompleted,
195
+ stepCount: steps.length,
196
+ goNext,
197
+ goPrev,
198
+ resetValidation,
199
+ setValidator,
200
+ };
201
+ }