@liguelead/design-system 0.0.37 → 0.0.39

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.
@@ -0,0 +1,300 @@
1
+ import { useState } from 'react'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { Stepper } from './Stepper'
4
+ import Button from '../Button'
5
+
6
+ const steps = [
7
+ { label: 'Seus dados', description: 'Nome e e-mail' },
8
+ { label: 'Empresa', description: 'Dados da empresa' },
9
+ { label: 'Convidar time', description: 'Colabore com sua equipe' },
10
+ ]
11
+
12
+ const meta: Meta<typeof Stepper> = {
13
+ title: 'Navigation/Stepper',
14
+ component: Stepper,
15
+ parameters: {
16
+ layout: 'padded',
17
+ docs: {
18
+ description: {
19
+ component: `
20
+ Componente Stepper para indicar progresso em processos multi-etapas.
21
+
22
+ **Orientações:**
23
+ - \`horizontal\` — padrão, ideal para fluxos com poucas etapas
24
+ - \`vertical\` — ideal para etapas com descrições longas ou em painéis laterais
25
+
26
+ **Controle de estado:**
27
+ - O prop \`currentStep\` (0-indexed) define qual etapa está ativa
28
+ - Etapas anteriores ao \`currentStep\` são marcadas como concluídas automaticamente
29
+ `,
30
+ },
31
+ },
32
+ },
33
+ tags: ['autodocs'],
34
+ argTypes: {
35
+ orientation: {
36
+ control: 'select',
37
+ options: ['horizontal', 'vertical'],
38
+ description: 'Orientação do stepper',
39
+ table: { defaultValue: { summary: 'horizontal' } },
40
+ },
41
+ currentStep: {
42
+ control: { type: 'number', min: 0, max: 2 },
43
+ description: 'Índice da etapa atual (0-indexed)',
44
+ },
45
+ steps: {
46
+ control: false,
47
+ description: 'Array de etapas com label e description opcional',
48
+ },
49
+ },
50
+ }
51
+
52
+ export default meta
53
+ type Story = StoryObj<typeof meta>
54
+
55
+ export const Default: Story = {
56
+ args: {
57
+ steps,
58
+ currentStep: 1,
59
+ orientation: 'horizontal',
60
+ },
61
+ }
62
+
63
+ export const Horizontal: Story = {
64
+ name: 'Horizontal',
65
+ parameters: {
66
+ docs: {
67
+ description: { story: 'Orientação padrão, ideal para fluxos lineares.' },
68
+ },
69
+ },
70
+ render: () => {
71
+ const [current, setCurrent] = useState(0)
72
+ return (
73
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
74
+ <Stepper steps={steps} currentStep={current} orientation="horizontal" />
75
+ <div style={{ display: 'flex', gap: 8 }}>
76
+ <Button
77
+ variant="outline"
78
+ size="sm"
79
+ onClick={() => setCurrent(c => Math.max(0, c - 1))}
80
+ disabled={current === 0}
81
+ >
82
+ Anterior
83
+ </Button>
84
+ <Button
85
+ size="sm"
86
+ onClick={() => setCurrent(c => Math.min(steps.length - 1, c + 1))}
87
+ disabled={current === steps.length - 1}
88
+ >
89
+ Próximo
90
+ </Button>
91
+ </div>
92
+ </div>
93
+ )
94
+ },
95
+ }
96
+
97
+ export const Vertical: Story = {
98
+ name: 'Vertical',
99
+ parameters: {
100
+ docs: {
101
+ description: { story: 'Orientação vertical, ideal para painéis laterais ou etapas com descrições longas.' },
102
+ },
103
+ },
104
+ render: () => {
105
+ const [current, setCurrent] = useState(0)
106
+ return (
107
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 24, maxWidth: 320 }}>
108
+ <Stepper steps={steps} currentStep={current} orientation="vertical" />
109
+ <div style={{ display: 'flex', gap: 8 }}>
110
+ <Button
111
+ variant="outline"
112
+ size="sm"
113
+ onClick={() => setCurrent(c => Math.max(0, c - 1))}
114
+ disabled={current === 0}
115
+ >
116
+ Anterior
117
+ </Button>
118
+ <Button
119
+ size="sm"
120
+ onClick={() => setCurrent(c => Math.min(steps.length - 1, c + 1))}
121
+ disabled={current === steps.length - 1}
122
+ >
123
+ Próximo
124
+ </Button>
125
+ </div>
126
+ </div>
127
+ )
128
+ },
129
+ }
130
+
131
+ export const SemDescricao: Story = {
132
+ name: 'Sem descrição',
133
+ parameters: {
134
+ docs: {
135
+ description: { story: 'Stepper apenas com labels, sem descrições.' },
136
+ },
137
+ },
138
+ args: {
139
+ steps: [
140
+ { label: 'Etapa 1' },
141
+ { label: 'Etapa 2' },
142
+ { label: 'Etapa 3' },
143
+ { label: 'Etapa 4' },
144
+ ],
145
+ currentStep: 2,
146
+ orientation: 'horizontal',
147
+ },
148
+ }
149
+
150
+ export const PrimeiraEtapa: Story = {
151
+ name: 'Primeira etapa',
152
+ args: {
153
+ steps,
154
+ currentStep: 0,
155
+ orientation: 'horizontal',
156
+ },
157
+ }
158
+
159
+ export const UltimaEtapa: Story = {
160
+ name: 'Última etapa',
161
+ args: {
162
+ steps,
163
+ currentStep: steps.length - 1,
164
+ orientation: 'horizontal',
165
+ },
166
+ }
167
+
168
+ // ─── Form multi-step example ────────────────────────────────────────────────
169
+
170
+ import { useForm } from 'react-hook-form'
171
+ import { zodResolver } from '@hookform/resolvers/zod'
172
+ import { z } from 'zod'
173
+ import TextField from '../TextField'
174
+
175
+ const formSteps = [
176
+ { label: 'Seus dados', description: 'Nome e e-mail' },
177
+ { label: 'Empresa', description: 'Dados da empresa' },
178
+ { label: 'Senha', description: 'Crie sua senha' },
179
+ ]
180
+
181
+ const formSchema = z
182
+ .object({
183
+ name: z.string().min(3, 'Nome deve ter ao menos 3 caracteres'),
184
+ email: z.string().email('E-mail inválido'),
185
+ company: z.string().min(2, 'Nome da empresa obrigatório'),
186
+ role: z.string().min(2, 'Cargo obrigatório'),
187
+ password: z.string().min(6, 'Mínimo 6 caracteres'),
188
+ confirm: z.string(),
189
+ })
190
+ .refine(d => d.password === d.confirm, {
191
+ message: 'As senhas não coincidem',
192
+ path: ['confirm'],
193
+ })
194
+
195
+ type FormData = z.infer<typeof formSchema>
196
+
197
+ const stepFields: (keyof FormData)[][] = [
198
+ ['name', 'email'],
199
+ ['company', 'role'],
200
+ ['password', 'confirm'],
201
+ ]
202
+
203
+ type StepFormProps = {
204
+ form: ReturnType<typeof useForm<FormData>>
205
+ onNext: () => void
206
+ onBack?: () => void
207
+ }
208
+
209
+ const StepForm0: React.FC<StepFormProps> = ({ form, onNext }) => {
210
+ const { register, formState: { errors }, trigger } = form
211
+ const handleNext = async () => {
212
+ if (await trigger(stepFields[0])) onNext()
213
+ }
214
+ return (
215
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
216
+ <TextField label="Nome completo" register={register('name')} error={errors.name} requiredSymbol />
217
+ <TextField label="E-mail" type="email" register={register('email')} error={errors.email} requiredSymbol />
218
+ <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
219
+ <Button size="sm" onClick={handleNext}>Próximo</Button>
220
+ </div>
221
+ </div>
222
+ )
223
+ }
224
+
225
+ const StepForm1: React.FC<StepFormProps> = ({ form, onNext, onBack }) => {
226
+ const { register, formState: { errors }, trigger } = form
227
+ const handleNext = async () => {
228
+ if (await trigger(stepFields[1])) onNext()
229
+ }
230
+ return (
231
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
232
+ <TextField label="Empresa" register={register('company')} error={errors.company} requiredSymbol />
233
+ <TextField label="Cargo" register={register('role')} error={errors.role} requiredSymbol />
234
+ <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
235
+ <Button size="sm" variant="outline" onClick={onBack}>Anterior</Button>
236
+ <Button size="sm" onClick={handleNext}>Próximo</Button>
237
+ </div>
238
+ </div>
239
+ )
240
+ }
241
+
242
+ const StepForm2: React.FC<StepFormProps> = ({ form, onNext, onBack }) => {
243
+ const { register, formState: { errors }, trigger } = form
244
+ const handleNext = async () => {
245
+ if (await trigger(stepFields[2])) onNext()
246
+ }
247
+ return (
248
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
249
+ <TextField label="Senha" type="password" register={register('password')} error={errors.password} requiredSymbol />
250
+ <TextField label="Confirmar senha" type="password" register={register('confirm')} error={errors.confirm} requiredSymbol />
251
+ <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
252
+ <Button size="sm" variant="outline" onClick={onBack}>Anterior</Button>
253
+ <Button size="sm" onClick={handleNext}>Finalizar</Button>
254
+ </div>
255
+ </div>
256
+ )
257
+ }
258
+
259
+ export const FormMultiStep: Story = {
260
+ name: 'Formulário multi-step',
261
+ parameters: {
262
+ docs: {
263
+ description: {
264
+ story: 'Dados preservados ao voltar entre etapas. Um único `useForm` no pai com `trigger` parcial por step.',
265
+ },
266
+ },
267
+ },
268
+ render: () => {
269
+ const [current, setCurrent] = useState(0)
270
+ const [done, setDone] = useState(false)
271
+
272
+ const form = useForm<FormData>({
273
+ resolver: zodResolver(formSchema),
274
+ mode: 'onTouched',
275
+ })
276
+
277
+ const next = () => setCurrent(c => c + 1)
278
+ const back = () => setCurrent(c => c - 1)
279
+ const finish = () => setDone(true)
280
+ const reset = () => { setCurrent(0); setDone(false); form.reset() }
281
+
282
+ return (
283
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 24, maxWidth: 480 }}>
284
+ <Stepper steps={formSteps} currentStep={done ? formSteps.length : current} orientation="horizontal" />
285
+ {done ? (
286
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12, alignItems: 'center', padding: 24 }}>
287
+ <p style={{ margin: 0, fontWeight: 600 }}>Cadastro concluído!</p>
288
+ <Button size="sm" variant="outline" onClick={reset}>Recomeçar</Button>
289
+ </div>
290
+ ) : (
291
+ <>
292
+ {current === 0 && <StepForm0 form={form} onNext={next} />}
293
+ {current === 1 && <StepForm1 form={form} onNext={next} onBack={back} />}
294
+ {current === 2 && <StepForm2 form={form} onNext={finish} onBack={back} />}
295
+ </>
296
+ )}
297
+ </div>
298
+ )
299
+ },
300
+ }
@@ -0,0 +1,179 @@
1
+ import styled, { css, keyframes } from 'styled-components'
2
+ import { spacing, fontSize, fontWeight, lineHeight, radius } from '@liguelead/foundation'
3
+ import { parseColor } from '../../utils'
4
+ import {
5
+ TStepIndicatorProps,
6
+ TStepSeparatorProps,
7
+ TStepperContainerProps,
8
+ } from './Stepper.types'
9
+ import { StepIndicatorAppearance } from './Stepper.appearance'
10
+
11
+ const popIn = keyframes`
12
+ 0% { transform: scale(0.5); opacity: 0; }
13
+ 60% { transform: scale(1.2); }
14
+ 100% { transform: scale(1); opacity: 1; }
15
+ `
16
+
17
+ const fillLine = keyframes`
18
+ from { width: 0%; }
19
+ to { width: 100%; }
20
+ `
21
+
22
+ const fillLineV = keyframes`
23
+ from { height: 0%; }
24
+ to { height: 100%; }
25
+ `
26
+ export const StepperWrapper = styled.div`
27
+ display: flex;
28
+ flex-direction: row;
29
+ align-items: flex-start;
30
+ width: 100%;
31
+ `
32
+
33
+ export const StepColumn = styled.div`
34
+ display: flex;
35
+ flex-direction: column;
36
+ align-items: center;
37
+ flex-shrink: 0;
38
+ `
39
+
40
+ export const StepperLabelsRow = styled.div`
41
+ display: flex;
42
+ flex-direction: row;
43
+ align-items: flex-start;
44
+ width: 100%;
45
+ `
46
+
47
+ export const StepLabelCell = styled.div`
48
+ display: flex;
49
+ flex-direction: column;
50
+ align-items: center;
51
+ text-align: center;
52
+ margin-top: ${spacing.spacing8}px;
53
+ `
54
+
55
+ export const StepperContainer = styled.ol<TStepperContainerProps>`
56
+ display: flex;
57
+ list-style: none;
58
+ margin: 0;
59
+ padding: 0;
60
+ width: 100%;
61
+ flex-direction: column;
62
+ `
63
+
64
+ export const StepperItem = styled.li<{ $orientation: 'horizontal' | 'vertical' }>`
65
+ display: flex;
66
+ position: relative;
67
+ flex-direction: column;
68
+ align-items: flex-start;
69
+ gap: ${spacing.spacing8}px;
70
+ `
71
+
72
+ export const StepBody = styled.div<{ $orientation: 'horizontal' | 'vertical' }>`
73
+ display: flex;
74
+ flex-direction: row;
75
+ align-items: center;
76
+ gap: ${spacing.spacing8}px;
77
+ `
78
+
79
+ export const StepTrigger = styled.div<{ $orientation: 'horizontal' | 'vertical' }>`
80
+ display: flex;
81
+ align-items: center;
82
+ cursor: default;
83
+ gap: ${spacing.spacing8}px;
84
+ `
85
+
86
+ export const StepIndicator = styled.div<TStepIndicatorProps>`
87
+ display: flex;
88
+ align-items: center;
89
+ justify-content: center;
90
+ border-radius: 50%;
91
+ flex-shrink: 0;
92
+ font-size: ${fontSize.fontSize14}px;
93
+ font-weight: ${fontWeight.fontWeight600};
94
+ line-height: 1;
95
+ animation: ${popIn} 0.35s ease both;
96
+ transition:
97
+ width 0.3s ease,
98
+ height 0.3s ease,
99
+ background-color 0.3s ease,
100
+ border-color 0.3s ease,
101
+ box-shadow 0.3s ease;
102
+
103
+ ${({ $state }) => StepIndicatorAppearance($state)}
104
+ `
105
+
106
+ export const StepSeparator = styled.div<TStepSeparatorProps>`
107
+ border-radius: ${radius.radius4}px;
108
+ overflow: hidden;
109
+ position: relative;
110
+ background-color: ${({ theme }) => parseColor(theme.colors.neutral400)};
111
+
112
+ ${({ $orientation }) =>
113
+ $orientation === 'vertical'
114
+ ? css`
115
+ width: 2px;
116
+ min-height: ${spacing.spacing8}px;
117
+ margin-left: 13px;
118
+ `
119
+ : css`
120
+ height: 2px;
121
+ flex: 1;
122
+ min-width: ${spacing.spacing16}px;
123
+ margin: 14px ${spacing.spacing4}px 0;
124
+ `}
125
+
126
+ &::after {
127
+ content: '';
128
+ position: absolute;
129
+ inset: 0;
130
+ background-color: ${({ theme }) => parseColor(theme.colors.primary)};
131
+ border-radius: ${radius.radius4}px;
132
+
133
+ ${({ $completed, $orientation }) =>
134
+ $completed
135
+ ? $orientation === 'vertical'
136
+ ? css`
137
+ animation: ${fillLineV} 0.4s ease forwards;
138
+ `
139
+ : css`
140
+ animation: ${fillLine} 0.4s ease forwards;
141
+ `
142
+ : css`
143
+ width: 0%;
144
+ height: 0%;
145
+ `}
146
+ }
147
+ `
148
+
149
+ export const StepContent = styled.div<{ $orientation: 'horizontal' | 'vertical' }>`
150
+ display: flex;
151
+ flex-direction: column;
152
+ align-items: flex-start;
153
+ `
154
+
155
+ export const StepLabel = styled.strong<{ $active: boolean }>`
156
+ font-size: ${fontSize.fontSize14}px;
157
+ font-weight: ${({ $active }) =>
158
+ $active ? fontWeight.fontWeight600 : fontWeight.fontWeight400};
159
+ line-height: ${lineHeight.lineHeight20}px;
160
+ color: ${({ theme, $active }) =>
161
+ $active
162
+ ? parseColor(theme.colors.textDark)
163
+ : parseColor(theme.colors.textMedium)};
164
+ transition: color 0.3s ease;
165
+ `
166
+
167
+ export const StepDescription = styled.p`
168
+ font-size: ${fontSize.fontSize12}px;
169
+ font-weight: ${fontWeight.fontWeight400};
170
+ line-height: ${lineHeight.lineHeight20}px;
171
+ color: ${({ theme }) => parseColor(theme.colors.textMedium)};
172
+ margin: 0;
173
+ `
174
+
175
+ export const StepNumber = styled.span`
176
+ font-size: ${fontSize.fontSize14}px;
177
+ font-weight: ${fontWeight.fontWeight600};
178
+ line-height: 1;
179
+ `
@@ -0,0 +1,118 @@
1
+ import React from 'react'
2
+ import { CheckFatIcon, WarningCircleIcon } from '@phosphor-icons/react'
3
+ import * as S from './Stepper.styles'
4
+ import { TStepperProps, StepState } from './Stepper.types'
5
+
6
+ const getStepState = (index: number, currentStep: number): StepState => {
7
+ if (index < currentStep) return 'completed'
8
+ if (index === currentStep) return 'active'
9
+ return 'pending'
10
+ }
11
+
12
+ const StepIndicatorContent: React.FC<{ state: StepState; index: number }> = ({
13
+ state,
14
+ index,
15
+ }) => {
16
+ if (state === 'completed') return <CheckFatIcon size={14} weight="fill" />
17
+ if (state === 'error') return <WarningCircleIcon size={14} weight="fill" />
18
+ if (state === 'active') return <S.StepNumber>{index + 1}</S.StepNumber>
19
+ return null
20
+ }
21
+
22
+ export const Stepper: React.FC<TStepperProps> = ({
23
+ steps,
24
+ currentStep,
25
+ orientation = 'horizontal',
26
+ className,
27
+ }) => {
28
+ if (orientation === 'horizontal') {
29
+ return (
30
+ <S.StepperWrapper className={className} aria-label="stepper">
31
+ {steps.map((step, index) => {
32
+ const state = getStepState(index, currentStep)
33
+ const isLast = index === steps.length - 1
34
+ return (
35
+ <React.Fragment key={index}>
36
+ <S.StepColumn>
37
+ <S.StepIndicator
38
+ $state={state}
39
+ aria-label={`Etapa ${index + 1}: ${step.label}`}
40
+ aria-current={state === 'active' ? 'step' : undefined}
41
+ >
42
+ <StepIndicatorContent state={state} index={index} />
43
+ </S.StepIndicator>
44
+ <S.StepLabelCell role="listitem" aria-current={state === 'active' ? 'step' : undefined}>
45
+ <S.StepLabel $active={state === 'active'}>
46
+ {step.label}
47
+ </S.StepLabel>
48
+ {step.description && (
49
+ <S.StepDescription>
50
+ {step.description}
51
+ </S.StepDescription>
52
+ )}
53
+ </S.StepLabelCell>
54
+ </S.StepColumn>
55
+ {!isLast && (
56
+ <S.StepSeparator
57
+ role="separator"
58
+ aria-hidden="true"
59
+ $completed={state === 'completed'}
60
+ $orientation="horizontal"
61
+ />
62
+ )}
63
+ </React.Fragment>
64
+ )
65
+ })}
66
+ </S.StepperWrapper>
67
+ )
68
+ }
69
+
70
+ return (
71
+ <S.StepperContainer
72
+ $orientation="vertical"
73
+ className={className}
74
+ role="list"
75
+ aria-label="stepper"
76
+ >
77
+ {steps.map((step, index) => {
78
+ const state = getStepState(index, currentStep)
79
+ const isLast = index === steps.length - 1
80
+ return (
81
+ <S.StepperItem
82
+ key={index}
83
+ $orientation="vertical"
84
+ role="listitem"
85
+ aria-current={state === 'active' ? 'step' : undefined}
86
+ >
87
+ <S.StepBody $orientation="vertical">
88
+ <S.StepIndicator
89
+ $state={state}
90
+ aria-label={`Etapa ${index + 1}: ${step.label}`}
91
+ >
92
+ <StepIndicatorContent state={state} index={index} />
93
+ </S.StepIndicator>
94
+ <S.StepContent $orientation="vertical">
95
+ <S.StepLabel $active={state === 'active'}>
96
+ {step.label}
97
+ </S.StepLabel>
98
+ {step.description && (
99
+ <S.StepDescription>
100
+ {step.description}
101
+ </S.StepDescription>
102
+ )}
103
+ </S.StepContent>
104
+ </S.StepBody>
105
+ {!isLast && (
106
+ <S.StepSeparator
107
+ role="separator"
108
+ aria-hidden="true"
109
+ $completed={state === 'completed'}
110
+ $orientation="vertical"
111
+ />
112
+ )}
113
+ </S.StepperItem>
114
+ )
115
+ })}
116
+ </S.StepperContainer>
117
+ )
118
+ }
@@ -0,0 +1,27 @@
1
+ export type StepperOrientation = 'horizontal' | 'vertical'
2
+ export type StepState = 'completed' | 'active' | 'pending' | 'error'
3
+
4
+ export type TStepItem = {
5
+ label: string
6
+ description?: string
7
+ }
8
+
9
+ export type TStepperProps = {
10
+ steps: TStepItem[]
11
+ currentStep: number
12
+ orientation?: StepperOrientation
13
+ className?: string
14
+ }
15
+
16
+ export type TStepIndicatorProps = {
17
+ $state: StepState
18
+ }
19
+
20
+ export type TStepSeparatorProps = {
21
+ $completed: boolean
22
+ $orientation: StepperOrientation
23
+ }
24
+
25
+ export type TStepperContainerProps = {
26
+ $orientation: StepperOrientation
27
+ }
@@ -0,0 +1,7 @@
1
+ export { Stepper } from './Stepper'
2
+ export type {
3
+ TStepperProps,
4
+ TStepItem,
5
+ StepperOrientation,
6
+ StepState
7
+ } from './Stepper.types'