@axzydev/axzy_ui_system 1.3.0 → 1.3.1

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.
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "packageManager": "pnpm@10.34.0",
5
5
  "private": false,
6
6
  "license": "MIT",
7
- "version": "1.3.0",
7
+ "version": "1.3.1",
8
8
  "type": "module",
9
9
  "sideEffects": [
10
10
  "*.css"
package/src/App.tsx CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  FormBuilderShowcase,
45
45
  WysiwygShowcase,
46
46
  FieldShowcase,
47
+ MaskedInputShowcase,
47
48
  MultiSelectShowcase,
48
49
  ChipInputShowcase,
49
50
  } from "./showcases/FormShowcases";
@@ -114,6 +115,7 @@ const categories = [
114
115
  { id: "field", label: "ITField" },
115
116
  { id: "datepicker", label: "ITDatePicker" },
116
117
  { id: "timepicker", label: "ITTimePicker" },
118
+ { id: "maskedinput", label: "ITMaskedInput" },
117
119
  { id: "calendar", label: "ITCalendar" },
118
120
  { id: "slidetoggle", label: "ITSlideToggle" },
119
121
  { id: "dropfile", label: "ITDropfile" },
@@ -294,6 +296,8 @@ function App() {
294
296
  return <DatePickerShowcase />;
295
297
  case "timepicker":
296
298
  return <TimePickerShowcase />;
299
+ case "maskedinput":
300
+ return <MaskedInputShowcase />;
297
301
  case "calendar":
298
302
  return <CalendarShowcase />;
299
303
  case "slidetoggle":
@@ -61,7 +61,8 @@ import { ITCalendar } from '@axzydev/axzy_ui_system';
61
61
  - **month** — Traditional month grid with date picker, single and range selection modes.
62
62
  - **week** — 7-column time grid from 6 AM to 10 PM with event overlays and drag-to-select range.
63
63
  - **day** — Single-column time grid for focused daily scheduling.
64
- - **years** — Click the month/year header to switch to a year selection view (12-year grid).
64
+ - **years** — Click the year in the header to switch to a year selection view (12-year grid).
65
+ - **months** — Click the month name in the header (or pick a year) to switch to a month selection view (12-month grid, same style as the year grid). Flow: header → year → month → days.
65
66
  - Uses `date-fns` with Spanish locale (`es`) for month and weekday names.
66
67
  - Month view supports `minDate`/`maxDate` boundaries — out-of-range dates are disabled and dimmed.
67
68
  - Events render as colored bars in week/day views with customizable `color` prop.
@@ -78,7 +78,7 @@ export const ITCalendar: React.FC<ITCalendarProps> = ({
78
78
  const mode = modeProp || (onChange ? 'month' : 'week');
79
79
 
80
80
  const [currentDate, setCurrentDate] = useState(value || new Date());
81
- const [view, setView] = useState<'calendar' | 'years'>('calendar');
81
+ const [view, setView] = useState<'calendar' | 'years' | 'months'>('calendar');
82
82
 
83
83
  // Selection/Accent colors based on variant
84
84
  const getVariantStyles = () => {
@@ -96,6 +96,25 @@ export const ITCalendar: React.FC<ITCalendarProps> = ({
96
96
  if (value) setCurrentDate(value);
97
97
  }, [value]);
98
98
 
99
+ // Shift the view year while keeping the day-of-month safe (clamped to target month).
100
+ const shiftYear = (yearDelta: number) => {
101
+ setCurrentDate((d) => {
102
+ const year = d.getFullYear() + yearDelta;
103
+ const daysInTarget = new Date(year, d.getMonth() + 1, 0).getDate();
104
+ return new Date(year, d.getMonth(), Math.min(d.getDate(), daysInTarget));
105
+ });
106
+ };
107
+
108
+ // Jump to a month of the currently viewed year (day-of-month clamped).
109
+ const pickMonth = (monthIndex: number) => {
110
+ setCurrentDate((d) => {
111
+ const year = d.getFullYear();
112
+ const daysInTarget = new Date(year, monthIndex + 1, 0).getDate();
113
+ return new Date(year, monthIndex, Math.min(d.getDate(), daysInTarget));
114
+ });
115
+ setView('calendar');
116
+ };
117
+
99
118
  // Navigation handlers
100
119
  const handleNext = () => {
101
120
  if (view === 'years') {
@@ -104,6 +123,8 @@ export const ITCalendar: React.FC<ITCalendarProps> = ({
104
123
  newDate.setFullYear(d.getFullYear() + 12);
105
124
  return newDate;
106
125
  });
126
+ } else if (view === 'months') {
127
+ shiftYear(1);
107
128
  } else if (mode === 'month') {
108
129
  setCurrentDate((d) => addMonths(d, 1));
109
130
  } else if (mode === 'day') {
@@ -120,7 +141,9 @@ export const ITCalendar: React.FC<ITCalendarProps> = ({
120
141
  newDate.setFullYear(d.getFullYear() - 12);
121
142
  return newDate;
122
143
  });
123
- } else if (mode === 'month') {
144
+ } else if (view === 'months') {
145
+ shiftYear(-1);
146
+ } else if (mode === 'month') {
124
147
  setCurrentDate((d) => addMonths(d, -1));
125
148
  } else if (mode === 'day') {
126
149
  setCurrentDate((d) => addDays(d, -1));
@@ -249,6 +272,7 @@ export const ITCalendar: React.FC<ITCalendarProps> = ({
249
272
  // ----------------------------------------------------------------------
250
273
  const startYear = currentDate.getFullYear() - 6;
251
274
  const years = Array.from({ length: 12 }, (_, i) => startYear + i);
275
+ const months = Array.from({ length: 12 }, (_, i) => i);
252
276
 
253
277
  // ----------------------------------------------------------------------
254
278
  // Render
@@ -277,25 +301,50 @@ export const ITCalendar: React.FC<ITCalendarProps> = ({
277
301
  borderBottomColor: 'var(--it-calendar-border, #e2e8f0)'
278
302
  }}
279
303
  >
280
- <ITText
281
- as="h2"
282
- className="text-sm font-bold capitalize cursor-pointer transition-colors select-none px-2 py-1 rounded"
283
- style={{
284
- color: 'var(--it-calendar-header-text, #1e293b)',
285
- }}
286
- onClick={() => setView(view === 'calendar' ? 'years' : 'calendar')}
287
- onMouseEnter={(e) => {
288
- e.currentTarget.style.backgroundColor = 'var(--it-calendar-header-hover, #f1f5f9)';
289
- }}
290
- onMouseLeave={(e) => {
291
- e.currentTarget.style.backgroundColor = 'transparent';
292
- }}
293
- >
294
- {view === 'years'
295
- ? `${years[0]} - ${years[years.length - 1]}`
296
- : format(currentDate, 'MMMM yyyy', { locale: es })
297
- }
298
- </ITText>
304
+ {view === 'years' ? (
305
+ <ITText
306
+ as="h2"
307
+ className="text-sm font-bold select-none px-2 py-1"
308
+ style={{
309
+ color: 'var(--it-calendar-header-text, #1e293b)',
310
+ }}
311
+ >
312
+ {`${years[0]} - ${years[years.length - 1]}`}
313
+ </ITText>
314
+ ) : (
315
+ <div className="flex items-center">
316
+ {view !== 'months' && (
317
+ <button
318
+ type="button"
319
+ onClick={() => setView('months')}
320
+ className="text-sm font-bold capitalize select-none px-2 py-1 rounded transition-colors"
321
+ style={{ color: 'var(--it-calendar-header-text, #1e293b)' }}
322
+ onMouseEnter={(e) => {
323
+ e.currentTarget.style.backgroundColor = 'var(--it-calendar-header-hover, #f1f5f9)';
324
+ }}
325
+ onMouseLeave={(e) => {
326
+ e.currentTarget.style.backgroundColor = 'transparent';
327
+ }}
328
+ >
329
+ {format(currentDate, 'MMMM', { locale: es })}
330
+ </button>
331
+ )}
332
+ <button
333
+ type="button"
334
+ onClick={() => setView('years')}
335
+ className="text-sm font-bold select-none px-2 py-1 rounded transition-colors"
336
+ style={{ color: 'var(--it-calendar-header-text, #1e293b)' }}
337
+ onMouseEnter={(e) => {
338
+ e.currentTarget.style.backgroundColor = 'var(--it-calendar-header-hover, #f1f5f9)';
339
+ }}
340
+ onMouseLeave={(e) => {
341
+ e.currentTarget.style.backgroundColor = 'transparent';
342
+ }}
343
+ >
344
+ {format(currentDate, 'yyyy')}
345
+ </button>
346
+ </div>
347
+ )}
299
348
  <div className="flex items-center gap-1">
300
349
  <button
301
350
  onClick={handlePrev}
@@ -359,13 +408,41 @@ export const ITCalendar: React.FC<ITCalendarProps> = ({
359
408
  newDate.setFullYear(year);
360
409
  return newDate;
361
410
  });
362
- setView('calendar');
411
+ setView('months');
363
412
  }}
364
413
  >
365
414
  <ITText as="span">{year}</ITText>
366
415
  </button>
367
416
  ))}
368
417
  </div>
418
+ ) : view === 'months' ? (
419
+ <div className="p-4 grid grid-cols-4 gap-2">
420
+ {months.map(monthIndex => {
421
+ const isActive = monthIndex === currentDate.getMonth();
422
+ return (
423
+ <button
424
+ key={monthIndex}
425
+ type="button"
426
+ className={cn(
427
+ "h-10 rounded-md text-sm font-medium transition-colors border border-transparent",
428
+ isActive
429
+ ? "bg-[var(--it-calendar-selected-bg)] text-[var(--it-calendar-selected-text)]"
430
+ : "hover:bg-[var(--it-calendar-today-bg)] hover:text-[var(--it-calendar-today-text)]"
431
+ )}
432
+ style={{
433
+ color: isActive
434
+ ? 'var(--it-calendar-selected-text, #ffffff)'
435
+ : 'var(--it-calendar-days-text, #334155)'
436
+ }}
437
+ onClick={() => pickMonth(monthIndex)}
438
+ >
439
+ <ITText as="span" className="capitalize">
440
+ {format(new Date(2000, monthIndex, 1), 'MMM', { locale: es })}
441
+ </ITText>
442
+ </button>
443
+ );
444
+ })}
445
+ </div>
369
446
  ) : mode === 'month' ? (
370
447
  <div className="p-4">
371
448
  {/* Weekday Headers */}
@@ -396,6 +473,7 @@ export const ITCalendar: React.FC<ITCalendarProps> = ({
396
473
  onClick={() => onChange && onChange(day)}
397
474
  className={cn(
398
475
  "h-10 w-full flex items-center justify-center rounded-md text-sm transition-colors relative",
476
+ "text-[var(--it-calendar-days-text)] hover:bg-[var(--it-calendar-today-bg)] hover:text-[var(--it-calendar-today-text)]",
399
477
  !isCurrentMonth && "opacity-40",
400
478
  isDisabled && "opacity-20 cursor-not-allowed",
401
479
  )}
@@ -406,12 +484,12 @@ export const ITCalendar: React.FC<ITCalendarProps> = ({
406
484
  ? 'var(--it-calendar-range-bg, #eff6ff)'
407
485
  : isToday(day)
408
486
  ? 'var(--it-calendar-today-bg, #eff6ff)'
409
- : 'transparent',
487
+ : undefined,
410
488
  color: isSelected || isRangeStart || isRangeEnd
411
489
  ? 'var(--it-calendar-selected-text, #ffffff)'
412
490
  : isToday(day)
413
491
  ? 'var(--it-calendar-today-text, #2563eb)'
414
- : 'var(--it-calendar-days-text, #334155)',
492
+ : undefined,
415
493
  fontWeight: isSelected || isRangeStart || isRangeEnd || isToday(day) ? '700' : '400',
416
494
  }}
417
495
  >
@@ -0,0 +1,78 @@
1
+ import { Meta, Title, Description, Primary, Controls, Stories } from '@storybook/blocks';
2
+ import * as StoriesData from './masked-input.stories';
3
+
4
+ <Meta of={StoriesData} />
5
+
6
+ # ITMaskedInput
7
+
8
+ Input con máscara de formato: defines un patrón y el usuario solo escribe los caracteres útiles; los separadores se insertan solos y los huecos se pintan con un carácter de relleno.
9
+
10
+ ## Props
11
+
12
+ | Prop | Type | Default | Description |
13
+ |------|------|---------|-------------|
14
+ | `name` | `string` | required | Nombre del campo para formularios. |
15
+ | `mask` | `string` | required | Patrón: `9`=dígito, `A`=letra (mayúscula), `a`=letra, `x`=alfanumérico, `*`=cualquiera. Cualquier otro carácter es un separador literal auto-insertado. |
16
+ | `value` | `string` | — | Valor limpio (raw): solo caracteres útiles, sin separadores ni relleno. |
17
+ | `onChange` | `(e) => void` | required | `e.target.value` es el valor raw. |
18
+ | `onBlur` | `(e) => void` | — | Blur del input. |
19
+ | `onComplete` | `(value) => void` | — | Dispara cuando la máscara queda llena. |
20
+ | `pattern` | `RegExp` | — | Regex validada con full-match contra el raw en blur; si no cumple muestra error. |
21
+ | `placeholderChar` | `string` | `"_"` | Carácter pintado en slots vacíos. |
22
+ | `variant` | `ColorsTypes` | `"primary"` | Color de acento del tema. |
23
+ | `size` | `"sm" \| "md" \| "lg"` | `"md"` | Tamaño del input. |
24
+ | `label` | `string` | — | Etiqueta sobre el input. |
25
+ | `disabled` | `boolean` | `false` | Desactiva el input. |
26
+ | `required` | `boolean` | `false` | Marca el campo como requerido. |
27
+ | `touched` | `boolean` | `false` | Indica si el campo fue tocado. |
28
+ | `error` | `string \| boolean` | — | Estado de error. |
29
+ | `className` | `string` | — | Clases del wrapper externo. |
30
+ | `containerClassName` | `string` | — | Clases del contenedor del input. |
31
+ | `iconLeft` / `iconRight` | `ReactNode` | — | Iconos laterales. |
32
+ | `autoFocus` | `boolean` | `false` | Enfoca al montar. |
33
+
34
+ ## Tokens del mask
35
+
36
+ | Token | Acepta |
37
+ |-------|--------|
38
+ | `9` | un dígito |
39
+ | `A` | una letra (se guarda en mayúsculas) |
40
+ | `a` | una letra (tal cual) |
41
+ | `x` | un carácter alfanumérico |
42
+ | `*` | cualquier carácter |
43
+ | cualquier otro | literal insertado solo (p.ej. `-`, `/`, `(`, `)`, espacio) |
44
+
45
+ ## Usage
46
+
47
+ ```tsx
48
+ import { ITMaskedInput } from '@axzydev/axzy_ui_system';
49
+
50
+ // Código de activación
51
+ <ITMaskedInput
52
+ name="code"
53
+ mask="xxxx-xxxx-xxxx"
54
+ label="Código"
55
+ onChange={(e) => setCode(e.target.value)}
56
+ />
57
+
58
+ // Teléfono con validación regex sobre el valor limpio
59
+ <ITMaskedInput
60
+ name="phone"
61
+ mask="(999) 999-9999"
62
+ pattern={/^\d{10}$/}
63
+ onChange={(e) => setPhone(e.target.value)}
64
+ />
65
+ ```
66
+
67
+ ## Notes
68
+
69
+ - ¿El valor? **Solo caracteres útiles**: escribir `ABC1-2345-6789` devuelve `ABC123456789` en `onChange`.
70
+ - El cursor salta a huecos vacíos con selección automática; borrar un separador lo vuelve a insertar.
71
+ - `pattern` valida contra el raw completo (full-match); el mensaje de error genérico es "Formato inválido" salvo que pases `error` como string.
72
+ - `onComplete` es ideal para tarjetas, CVV o códigos de un solo uso.
73
+
74
+ ## Stories
75
+
76
+ <Primary />
77
+ <Controls />
78
+ <Stories />
@@ -0,0 +1,58 @@
1
+ import { ColorsTypes } from "@/types/colors.types";
2
+ import { SizesTypes } from "@/types/sizes.types";
3
+ import { ReactNode } from "react";
4
+
5
+ export interface ITMaskedInputProps {
6
+ /** Unique name attribute for the underlying input element. */
7
+ name: string;
8
+ /**
9
+ * Mask pattern. Tokens are filled by the user and everything else is an
10
+ * auto-inserted literal.
11
+ * - `9` — digit
12
+ * - `A` — letter (stored uppercase)
13
+ * - `a` — letter
14
+ * - `x` — alphanumeric
15
+ * - `*` — any character
16
+ * - any other character ( `-`, `/`, `(`, `)`, ` `, `.` ) is a literal separator
17
+ * @example "xxxx-xxxx-xxxx"
18
+ * @example "(999) 999-9999"
19
+ * @example "9999-9999-9999-9999"
20
+ */
21
+ mask: string;
22
+ /** Clean value containing only the useful characters (no separators, no placeholders). */
23
+ value?: string;
24
+ /** Called when the masked value changes. `event.target.value` is the raw value without separators. */
25
+ onChange: (event: { target: { name: string; value: string } }) => void;
26
+ /** Called when the input loses focus. */
27
+ onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
28
+ /** Fired when all token slots are filled with valid characters. */
29
+ onComplete?: (value: string) => void;
30
+ /** Optional regex full-matched against the raw value to validate the field (e.g. `/^\\d{16}$/`). */
31
+ pattern?: RegExp;
32
+ /** Character shown for empty token slots. @default "_" */
33
+ placeholderChar?: string;
34
+ /** Color variant matching the design system (ColorsTypes). @default "primary" */
35
+ variant?: ColorsTypes;
36
+ /** Size preset: "sm" | "md" | "lg". @default "md" */
37
+ size?: SizesTypes;
38
+ /** Label displayed above the input. */
39
+ label?: string;
40
+ /** Disables the input. @default false */
41
+ disabled?: boolean;
42
+ /** Marks the field as required. */
43
+ required?: boolean;
44
+ /** Visually indicates the field has been interacted with. */
45
+ touched?: boolean;
46
+ /** Error state — pass a string message or `true` for default styling. */
47
+ error?: string | boolean;
48
+ /** Additional CSS classes for the outer wrapper element. */
49
+ className?: string;
50
+ /** Additional CSS classes for the underlying input container. */
51
+ containerClassName?: string;
52
+ /** Icon element rendered on the left side of the input. */
53
+ iconLeft?: ReactNode;
54
+ /** Icon element rendered on the right side of the input. */
55
+ iconRight?: ReactNode;
56
+ /** Auto-focus the input on mount. @default false */
57
+ autoFocus?: boolean;
58
+ }
@@ -0,0 +1,126 @@
1
+ import type { Meta } from '@storybook/react';
2
+ import { useState } from 'react';
3
+ import ITMaskedInput from './masked-input';
4
+
5
+ const meta = {
6
+ title: 'Components/Form Elements/ITMaskedInput',
7
+ component: ITMaskedInput,
8
+ parameters: { layout: 'padded' },
9
+ argTypes: {
10
+ mask: { control: 'text' },
11
+ placeholderChar: { control: 'text' },
12
+ onChange: { action: 'changed' },
13
+ onComplete: { action: 'completed' },
14
+ disabled: { control: 'boolean' },
15
+ error: { control: 'text' },
16
+ variant: {
17
+ control: 'select',
18
+ options: ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'purple', 'gray'],
19
+ },
20
+ size: { control: 'select', options: ['sm', 'md', 'lg'] },
21
+ },
22
+ } satisfies Meta<typeof ITMaskedInput>;
23
+
24
+ export default meta;
25
+
26
+ function CodeMaskedWrapper(args: any) {
27
+ const [value, setValue] = useState<string>('');
28
+ return (
29
+ <div className="w-full max-w-xs">
30
+ <ITMaskedInput
31
+ {...args}
32
+ value={value}
33
+ onChange={(e) => {
34
+ setValue(e.target.value);
35
+ args.onChange(e);
36
+ }}
37
+ />
38
+ <p className="mt-2 text-xs font-mono text-slate-500">
39
+ raw: {value || '(vacío)'}
40
+ </p>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ export const Alphanumeric: any = {
46
+ render: CodeMaskedWrapper,
47
+ args: {
48
+ name: 'code',
49
+ label: 'Código único',
50
+ mask: 'xxxx-xxxx-xxxx',
51
+ },
52
+ };
53
+
54
+ export const Phone: any = {
55
+ render: CodeMaskedWrapper,
56
+ args: {
57
+ name: 'phone',
58
+ label: 'Teléfono',
59
+ mask: '(999) 999-9999',
60
+ },
61
+ };
62
+
63
+ export const CreditCard: any = {
64
+ render: CodeMaskedWrapper,
65
+ args: {
66
+ name: 'card',
67
+ label: 'Número de tarjeta',
68
+ mask: '9999-9999-9999-9999',
69
+ onComplete: undefined,
70
+ },
71
+ };
72
+
73
+ const PhoneValidatedWrapper = (args: any) => {
74
+ const [value, setValue] = useState<string>('5512345678');
75
+ const [complete, setComplete] = useState<string | null>(null);
76
+ return (
77
+ <div className="w-full max-w-xs">
78
+ <ITMaskedInput
79
+ {...args}
80
+ value={value}
81
+ onChange={(e) => {
82
+ setValue(e.target.value);
83
+ args.onChange(e);
84
+ setComplete(null);
85
+ }}
86
+ onComplete={(v) => setComplete(v)}
87
+ />
88
+ {complete && (
89
+ <p className="mt-2 text-xs font-mono text-emerald-600">
90
+ ¡Completo! raw: {complete}
91
+ </p>
92
+ )}
93
+ </div>
94
+ );
95
+ };
96
+
97
+ export const WithPattern: any = {
98
+ render: PhoneValidatedWrapper,
99
+ args: {
100
+ name: 'validated_phone',
101
+ label: 'Teléfono válido (regex)',
102
+ mask: '999-999-9999',
103
+ pattern: /^\d{9}$/,
104
+ },
105
+ };
106
+
107
+ export const Sizes: any = {
108
+ render: () => (
109
+ <div className="flex flex-col gap-4 w-full max-w-xs">
110
+ <ITMaskedInput name="sz_sm" label="Small" mask="xxxx-xxxx" size="sm" onChange={() => {}} />
111
+ <ITMaskedInput name="sz_md" label="Medium" mask="xxxx-xxxx" size="md" onChange={() => {}} />
112
+ <ITMaskedInput name="sz_lg" label="Large" mask="xxxx-xxxx" size="lg" onChange={() => {}} />
113
+ </div>
114
+ ),
115
+ };
116
+
117
+ export const Disabled: any = {
118
+ args: {
119
+ name: 'disabled_code',
120
+ label: 'Deshabilitado',
121
+ mask: 'xxxx-xxxx',
122
+ value: 'abcd',
123
+ disabled: true,
124
+ onChange: () => {},
125
+ },
126
+ };