@liguelead/design-system 0.0.37 → 0.0.38

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.
@@ -15,13 +15,14 @@ Campo de texto para entrada de dados pelo usuário.
15
15
 
16
16
  **Tamanhos:** \`sm\` · \`md\` · \`lg\`
17
17
 
18
- **Tipos suportados:** \`text\` · \`password\` · \`email\` · \`number\` · \`file\`
18
+ **Tipos suportados:** \`text\` · \`password\` · \`email\` · \`number\` · \`file\` · \`textarea\`
19
19
 
20
20
  **Boas práticas:**
21
21
  - Sempre use \`label\` para identificar o campo
22
22
  - Use \`helperText\` para instruções ou validações
23
23
  - Use \`error\` para comunicar erros de validação
24
24
  - Ícones devem reforçar o tipo de dado esperado
25
+ - No \`textarea\`, use \`maxLength\` para o contador visual e \`htmlMaxLength\` para bloquear digitação pelo browser
25
26
  `,
26
27
  },
27
28
  },
@@ -48,7 +49,7 @@ Campo de texto para entrada de dados pelo usuário.
48
49
  },
49
50
  type: {
50
51
  control: 'select',
51
- options: ['text', 'password', 'email', 'number', 'file'],
52
+ options: ['text', 'password', 'email', 'number', 'file', 'textarea'],
52
53
  description: 'Tipo do input HTML',
53
54
  table: { defaultValue: { summary: 'text' } },
54
55
  },
@@ -180,6 +181,112 @@ export const Obrigatorio: Story = {
180
181
  },
181
182
  }
182
183
 
184
+ export const TextArea: Story = {
185
+ name: 'TextArea',
186
+ parameters: {
187
+ docs: {
188
+ description: {
189
+ story: 'Variante textarea com contador de caracteres, botão de variável e controle de maxLength.',
190
+ },
191
+ },
192
+ },
193
+ render: () => (
194
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 24, width: 480 }}>
195
+ <TextField
196
+ label="Mensagem simples"
197
+ type="textarea"
198
+ placeholder="Digite sua mensagem..."
199
+ maxLength={160}
200
+ />
201
+ <TextField
202
+ label="Com botão de variável"
203
+ type="textarea"
204
+ placeholder="Digite sua mensagem..."
205
+ maxLength={160}
206
+ showVariableButton
207
+ variableButtonLabel="Inserir variável"
208
+ />
209
+ <TextField
210
+ label="Com bloqueio nativo (htmlMaxLength)"
211
+ type="textarea"
212
+ placeholder="Máximo 60 caracteres — browser bloqueia ao atingir o limite"
213
+ maxLength={60}
214
+ htmlMaxLength
215
+ showVariableButton
216
+ variableButtonLabel="Inserir variável"
217
+ />
218
+ <TextField
219
+ label="Desabilitado"
220
+ type="textarea"
221
+ placeholder="Campo desabilitado"
222
+ maxLength={160}
223
+ showVariableButton
224
+ disabled
225
+ />
226
+ </div>
227
+ ),
228
+ }
229
+
230
+ export const TextAreaHtmlMaxLength: Story = {
231
+ name: 'TextArea — htmlMaxLength',
232
+ parameters: {
233
+ docs: {
234
+ description: {
235
+ story: `
236
+ Por padrão \`maxLength\` só exibe o contador visual — o usuário pode digitar além do limite (estado de erro).
237
+
238
+ Com \`htmlMaxLength\` o browser bloqueia a digitação ao atingir o limite. O botão "Inserir variável" também respeita o limite.
239
+
240
+ | Prop | Comportamento |
241
+ |------|--------------|
242
+ | \`maxLength={n}\` | Contador visual, sem bloqueio |
243
+ | \`maxLength={n} htmlMaxLength\` | Contador + bloqueio nativo |
244
+ `,
245
+ },
246
+ },
247
+ },
248
+ render: () => (
249
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 24, width: 480 }}>
250
+ <TextField
251
+ label="Só contador (sem bloqueio)"
252
+ type="textarea"
253
+ placeholder="Pode digitar além de 30..."
254
+ maxLength={30}
255
+ showVariableButton
256
+ variableButtonLabel="Inserir variável"
257
+ />
258
+ <TextField
259
+ label="Contador + bloqueio nativo"
260
+ type="textarea"
261
+ placeholder="Bloqueado em 30 caracteres"
262
+ maxLength={30}
263
+ htmlMaxLength
264
+ showVariableButton
265
+ variableButtonLabel="Inserir variável"
266
+ />
267
+ </div>
268
+ ),
269
+ }
270
+
271
+ export const TextAreaComErro: Story = {
272
+ name: 'TextArea com erro',
273
+ parameters: {
274
+ docs: { description: { story: 'Estado de erro no textarea.' } },
275
+ },
276
+ render: () => (
277
+ <div style={{ width: 480 }}>
278
+ <TextField
279
+ label="Mensagem"
280
+ type="textarea"
281
+ placeholder="Digite sua mensagem..."
282
+ maxLength={160}
283
+ showVariableButton
284
+ error={{ message: 'Campo obrigatório.' }}
285
+ />
286
+ </div>
287
+ ),
288
+ }
289
+
183
290
  export const ExemploLogin: Story = {
184
291
  name: 'Exemplo: Formulário de login',
185
292
  parameters: {
@@ -4,6 +4,7 @@ import {
4
4
  fontSize,
5
5
  fontWeight,
6
6
  lineHeight,
7
+ radius,
7
8
  shadow,
8
9
  spacing
9
10
  } from '@liguelead/foundation'
@@ -76,6 +77,9 @@ export const Wrapper = styled.div<StyledInputProps>`
76
77
  ${$themefication.input}
77
78
  ${size.input}
78
79
  }
80
+ ${StyledTextArea} {
81
+ ${$themefication.input}
82
+ }
79
83
  ${Label} {
80
84
  ${$themefication.label}
81
85
  ${size.label}
@@ -124,3 +128,43 @@ export const FileInputContainer = styled.div`
124
128
  width: 100%;
125
129
  position: relative;
126
130
  `
131
+
132
+ export const StyledTextArea = styled.textarea.withConfig({
133
+ shouldForwardProp: prop => !['control', 'errors', 'rules'].includes(prop)
134
+ })`
135
+ width: 100%;
136
+ border-radius: ${radius.radius4}px;
137
+ outline: none;
138
+ border: 1px solid ${parseColor('neutral400')};
139
+ transition: border-color 0.2s ease;
140
+ background: transparent;
141
+ resize: vertical;
142
+ font-family: inherit;
143
+ font-size: ${fontSize.fontSize14}px;
144
+ line-height: ${lineHeight.lineHeight22}px;
145
+ padding: ${spacing.spacing12}px ${spacing.spacing16}px;
146
+ color: inherit;
147
+ `
148
+
149
+ export const TextAreaWrapper = styled.div`
150
+ position: relative;
151
+ width: 100%;
152
+ border-radius: ${radius.radius4}px;
153
+ background: transparent;
154
+ `
155
+
156
+ export const TextAreaFooter = styled.div`
157
+ display: flex;
158
+ align-items: center;
159
+ justify-content: space-between;
160
+ margin-top: -6px;
161
+ `
162
+
163
+ export const CharCount = styled.span<{ $isOver?: boolean }>`
164
+ font-size: ${fontSize.fontSize12}px;
165
+ line-height: ${lineHeight.lineHeight16}px;
166
+ color: ${({ theme, $isOver }) =>
167
+ $isOver
168
+ ? parseColor(theme.colors.danger200)
169
+ : parseColor(theme.colors.textMedium)};
170
+ `
@@ -1,7 +1,8 @@
1
- import React, { forwardRef, useState } from 'react'
1
+ import React, { forwardRef, useEffect, useRef, useState } from 'react'
2
2
  import { TextFieldProps } from './TextField.types'
3
3
  import { StateInterface, TextFieldStates } from './TextField.states'
4
4
  import {
5
+ CharCount,
5
6
  FileButton,
6
7
  FileInputContainer,
7
8
  FileName,
@@ -10,6 +11,9 @@ import {
10
11
  InputWrapper,
11
12
  Label,
12
13
  StyledInput,
14
+ StyledTextArea,
15
+ TextAreaFooter,
16
+ TextAreaWrapper,
13
17
  Wrapper
14
18
  } from './TextField.styles'
15
19
 
@@ -17,6 +21,7 @@ import { textFieldSizes } from './TextField.sizes'
17
21
  import { EyeIcon, EyeClosedIcon } from '@phosphor-icons/react'
18
22
  import getState from './utils/getState'
19
23
  import RequiredAsterisk from '../RequiredAsterisk'
24
+ import Button from '../Button'
20
25
 
21
26
  const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
22
27
  (
@@ -39,12 +44,22 @@ const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
39
44
  register,
40
45
  requiredSymbol = false,
41
46
  placeholder,
47
+ showVariableButton = false,
48
+ variableButtonLabel = 'Variável',
49
+ maxLength,
50
+ htmlMaxLength = false,
51
+ onTextAreaChange,
42
52
  ...props
43
53
  },
44
54
  ref
45
55
  ) => {
46
56
  const [passwordVisible, setPasswordVisible] = useState(false)
47
57
  const [selectedFileName, setSelectedFileName] = useState('')
58
+ const [textAreaValue, setTextAreaValue] = useState(
59
+ (value as string) ?? ''
60
+ )
61
+ const textAreaRef = useRef<HTMLTextAreaElement>(null)
62
+ const varCountRef = useRef(0)
48
63
  const state = getState(disabled, !!error)
49
64
  const textFieldState: StateInterface = TextFieldStates(state)
50
65
  const textFieldSize = textFieldSizes(size, !!leftIcon, !!rightIcon)
@@ -131,6 +146,108 @@ const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
131
146
  e.stopPropagation()
132
147
  setIsDragging(false)
133
148
  }
149
+ useEffect(() => {
150
+ if (value !== undefined) {
151
+ setTextAreaValue(value as string)
152
+ }
153
+ }, [value])
154
+
155
+ const handleInsertVariable = () => {
156
+ const el = textAreaRef.current
157
+ if (!el) return
158
+
159
+ const matches = textAreaValue.match(/\{\{(\d+)\}\}/g) ?? []
160
+ const usedNumbers = matches.map(m => parseInt(m.replace(/\D/g, ''), 10))
161
+ let next = 1
162
+ while (usedNumbers.includes(next)) next++
163
+ varCountRef.current = next
164
+ const variable = `{{${next}}}`
165
+ const start = el.selectionStart ?? textAreaValue.length
166
+ const end = el.selectionEnd ?? textAreaValue.length
167
+ const newValue =
168
+ textAreaValue.slice(0, start) +
169
+ variable +
170
+ textAreaValue.slice(end)
171
+
172
+ if (maxLength !== undefined && newValue.length > maxLength) return
173
+
174
+ setTextAreaValue(newValue)
175
+
176
+ requestAnimationFrame(() => {
177
+ el.focus()
178
+ el.setSelectionRange(
179
+ start + variable.length,
180
+ start + variable.length
181
+ )
182
+ })
183
+ }
184
+
185
+ const handleTextAreaChange = (
186
+ e: React.ChangeEvent<HTMLTextAreaElement>
187
+ ) => {
188
+ setTextAreaValue(e.target.value)
189
+ onTextAreaChange?.(e)
190
+ }
191
+
192
+ if (type === 'textarea') {
193
+ const charCount = textAreaValue.length
194
+ const isOver = maxLength !== undefined && charCount > maxLength
195
+
196
+ return (
197
+ <Wrapper
198
+ className={className}
199
+ size={textFieldSize}
200
+ $themefication={textFieldState}
201
+ >
202
+ {label && (
203
+ <Label>
204
+ {label}{' '}
205
+ {requiredSymbol && <RequiredAsterisk />}
206
+ </Label>
207
+ )}
208
+ <TextAreaWrapper>
209
+ <StyledTextArea
210
+ ref={textAreaRef}
211
+ value={textAreaValue}
212
+ disabled={disabled}
213
+ placeholder={placeholder}
214
+ maxLength={htmlMaxLength ? maxLength : undefined}
215
+ onChange={handleTextAreaChange}
216
+ aria-invalid={!!error}
217
+ aria-describedby={
218
+ helperText || error
219
+ ? `${label}-helper`
220
+ : undefined
221
+ }
222
+ />
223
+ </TextAreaWrapper>
224
+ <TextAreaFooter>
225
+ {maxLength !== undefined && (
226
+ <CharCount $isOver={isOver}>
227
+ {charCount}/{maxLength}
228
+ </CharCount>
229
+ )}
230
+ {showVariableButton && (
231
+ <Button
232
+ size="sm"
233
+ variant="ghost"
234
+ type="button"
235
+ disabled={disabled}
236
+ onClick={handleInsertVariable}
237
+ aria-label={variableButtonLabel}
238
+ >
239
+ {variableButtonLabel}
240
+ </Button>
241
+ )}
242
+ </TextAreaFooter>
243
+ {(helperText || error) && (
244
+ <HelperText id={`${label}-helper`}>
245
+ {error?.message || helperText}
246
+ </HelperText>
247
+ )}
248
+ </Wrapper>
249
+ )
250
+ }
134
251
 
135
252
  if (type === 'file') {
136
253
  return (
@@ -20,6 +20,16 @@ export interface TextFieldProps<TFieldValues extends FieldValues = FieldValues>
20
20
  rightIcon?: React.ReactNode
21
21
  error?: TFieldValues
22
22
  requiredSymbol?: boolean
23
- type?: 'text' | 'password' | 'email' | 'number' | 'file'
23
+ type?: 'text' | 'password' | 'email' | 'number' | 'file' | 'textarea'
24
24
  register?: UseFormRegisterReturn<string>
25
+ /** Habilita o botão de inserir variável (apenas para type="textarea") */
26
+ showVariableButton?: boolean
27
+ /** Label do botão de variável */
28
+ variableButtonLabel?: string
29
+ /** Limite máximo de caracteres (exibido no rodapé quando type="textarea") */
30
+ maxLength?: number
31
+ /** Passa o maxLength nativo ao <textarea>, bloqueando digitação pelo browser. Default: false */
32
+ htmlMaxLength?: boolean
33
+ /** Callback chamado quando o valor do textarea muda */
34
+ onTextAreaChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
25
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguelead/design-system",
3
- "version": "0.0.37",
3
+ "version": "0.0.38",
4
4
  "type": "module",
5
5
  "main": "components/index.ts",
6
6
  "publishConfig": {