@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.
@@ -0,0 +1,244 @@
1
+ import React, { useRef, useState, useMemo } from "react";
2
+ import clsx from "clsx";
3
+ import ITInput from "@/components/atoms/input/input";
4
+ import { ITMaskedInputProps } from "./masked-input.props";
5
+
6
+ type Token = "9" | "A" | "a" | "x" | "*";
7
+
8
+ type MaskRule =
9
+ | { type: "token"; token: Token }
10
+ | { type: "literal"; char: string };
11
+
12
+ const TOKEN_SET = /[9Aax*]/;
13
+
14
+ function parseMask(mask: string): MaskRule[] {
15
+ return mask.split("").map((ch) =>
16
+ TOKEN_SET.test(ch)
17
+ ? { type: "token", token: ch as Token }
18
+ : { type: "literal", char: ch }
19
+ );
20
+ }
21
+
22
+ function matchToken(token: Token, ch: string): boolean {
23
+ switch (token) {
24
+ case "9":
25
+ return /\d/.test(ch);
26
+ case "A":
27
+ case "a":
28
+ return /[A-Za-z]/.test(ch);
29
+ case "x":
30
+ return /[A-Za-z0-9]/.test(ch);
31
+ case "*":
32
+ return ch.length > 0;
33
+ default:
34
+ return false;
35
+ }
36
+ }
37
+
38
+ function extract(input: string, rules: MaskRule[], maskChar: string): string {
39
+ let raw = "";
40
+ let slot = 0;
41
+ for (const ch of input) {
42
+ while (slot < rules.length && rules[slot].type === "literal") slot++;
43
+ if (slot >= rules.length) break;
44
+ const rule = rules[slot];
45
+ if (
46
+ rule.type === "token" &&
47
+ ch !== maskChar &&
48
+ matchToken(rule.token, ch)
49
+ ) {
50
+ raw += rule.token === "A" ? ch.toUpperCase() : ch;
51
+ slot++;
52
+ }
53
+ }
54
+ return raw;
55
+ }
56
+
57
+ function format(raw: string, rules: MaskRule[], maskChar: string): string {
58
+ let out = "";
59
+ let idx = 0;
60
+ for (const rule of rules) {
61
+ if (rule.type === "literal") {
62
+ out += rule.char;
63
+ } else {
64
+ const ch = raw[idx];
65
+ out += ch !== undefined ? ch : maskChar;
66
+ idx++;
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ function countConsumed(input: string, rules: MaskRule[], upto: number, maskChar: string): number {
73
+ let slot = 0;
74
+ let n = 0;
75
+ for (let i = 0; i < upto && i < input.length; i++) {
76
+ const ch = input[i];
77
+ while (slot < rules.length && rules[slot].type === "literal") slot++;
78
+ if (slot >= rules.length) break;
79
+ const rule = rules[slot];
80
+ if (rule.type === "token" && ch !== maskChar && matchToken(rule.token, ch)) {
81
+ n++;
82
+ slot++;
83
+ }
84
+ }
85
+ return n;
86
+ }
87
+
88
+ function caretAfter(n: number, rules: MaskRule[]): number {
89
+ let pos = 0;
90
+ let consumed = 0;
91
+ for (const rule of rules) {
92
+ if (rule.type === "token") {
93
+ consumed++;
94
+ if (consumed > n) break;
95
+ }
96
+ pos++;
97
+ }
98
+ return pos;
99
+ }
100
+
101
+ /**
102
+ * Text input that applies a formatting mask while the user types, exposing only
103
+ * the clean (raw) value through `onChange`.
104
+ *
105
+ * The mask is defined by a pattern string. Tokens are filled by the user and
106
+ * any other character acts as an auto-inserted literal separator. Empty slots
107
+ * are painted with `placeholderChar`, so the mask is always visible.
108
+ *
109
+ * @example
110
+ * ```tsx
111
+ * <ITMaskedInput
112
+ * name="code"
113
+ * mask="xxxx-xxxx-xxxx"
114
+ * label="Código"
115
+ * onChange={(e) => setCode(e.target.value)}
116
+ * />
117
+ * ```
118
+ *
119
+ * @example
120
+ * ```tsx
121
+ * <ITMaskedInput
122
+ * name="phone"
123
+ * mask="(999) 999-9999"
124
+ * pattern={/^\d{10}$/}
125
+ * onChange={(e) => setPhone(e.target.value)}
126
+ * />
127
+ * ```
128
+ *
129
+ * @example
130
+ * ```tsx
131
+ * <ITMaskedInput
132
+ * name="card"
133
+ * mask="9999-9999-9999-9999"
134
+ * onComplete={(card) => validateCard(card)}
135
+ * />
136
+ * ```
137
+ */
138
+ export default function ITMaskedInput({
139
+ name,
140
+ mask,
141
+ value,
142
+ onChange,
143
+ onBlur,
144
+ onComplete,
145
+ pattern,
146
+ placeholderChar = "_",
147
+ variant = "primary",
148
+ size = "md",
149
+ label,
150
+ disabled = false,
151
+ required,
152
+ touched,
153
+ error,
154
+ className,
155
+ containerClassName,
156
+ iconLeft,
157
+ iconRight,
158
+ autoFocus = false,
159
+ }: ITMaskedInputProps) {
160
+ const containerRef = useRef<HTMLDivElement>(null);
161
+ const rawRef = useRef<string>(value ?? "");
162
+ const isControlled = value !== undefined;
163
+
164
+ const [internalRaw, setInternalRaw] = useState<string>(value ?? "");
165
+ const [invalidPattern, setInvalidPattern] = useState(false);
166
+
167
+ const rules = useMemo(() => parseMask(mask), [mask]);
168
+ const tokenCount = useMemo(() => rules.filter((r) => r.type === "token").length, [rules]);
169
+
170
+ const raw = isControlled ? (value ?? "") : internalRaw;
171
+ const display = useMemo(() => format(raw, rules, placeholderChar), [raw, rules, placeholderChar]);
172
+
173
+ const scheduleCaret = (caret: number) => {
174
+ const inputEl = containerRef.current?.querySelector("input");
175
+ if (!inputEl) return;
176
+ requestAnimationFrame(() => {
177
+ if (document.activeElement === inputEl) {
178
+ const pos = Math.min(caret, inputEl.value.length);
179
+ inputEl.setSelectionRange(pos, pos);
180
+ }
181
+ });
182
+ };
183
+
184
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
185
+ const el = e.target as HTMLInputElement;
186
+ const inputVal = el.value ?? "";
187
+ const caretBefore = el.selectionStart ?? inputVal.length;
188
+
189
+ const nextRaw = extract(inputVal, rules, placeholderChar);
190
+ const targetCaret = caretAfter(
191
+ countConsumed(inputVal, rules, caretBefore, placeholderChar),
192
+ rules
193
+ );
194
+
195
+ if (nextRaw !== rawRef.current) {
196
+ rawRef.current = nextRaw;
197
+ setInternalRaw(nextRaw);
198
+ onChange?.({ target: { name, value: nextRaw } });
199
+ if (nextRaw.length === tokenCount && nextRaw.length > 0) {
200
+ onComplete?.(nextRaw);
201
+ }
202
+ if (invalidPattern) setInvalidPattern(false);
203
+ }
204
+
205
+ scheduleCaret(targetCaret);
206
+ };
207
+
208
+ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
209
+ if (pattern && rawRef.current.length > 0 && !pattern.test(rawRef.current)) {
210
+ setInvalidPattern(true);
211
+ }
212
+ onBlur?.(e);
213
+ };
214
+
215
+ const resolvedError = invalidPattern
216
+ ? typeof error === "string"
217
+ ? error
218
+ : "Formato inválido"
219
+ : error;
220
+
221
+ return (
222
+ <div ref={containerRef} className={clsx("w-full", className)}>
223
+ <ITInput
224
+ name={name}
225
+ type="text"
226
+ label={label}
227
+ value={display}
228
+ onChange={handleChange}
229
+ onBlur={(e) => handleBlur(e as React.FocusEvent<HTMLInputElement>)}
230
+ maxLength={mask.length}
231
+ variant={variant}
232
+ size={size}
233
+ disabled={disabled}
234
+ required={required}
235
+ touched={touched}
236
+ error={resolvedError}
237
+ containerClassName={containerClassName}
238
+ iconLeft={iconLeft}
239
+ iconRight={iconRight}
240
+ autoFocus={autoFocus}
241
+ />
242
+ </div>
243
+ );
244
+ }
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ import ITChip from "./components/atoms/chip/chip";
21
21
  import ITChipInput from "./components/molecules/chip-input/chip-input";
22
22
  import ITDropdownMenu from "./components/molecules/dropdown-menu/dropdown-menu";
23
23
  import ITField from "./components/molecules/field/field";
24
+ import ITMaskedInput from "./components/molecules/masked-input/masked-input";
24
25
  import ITMultiSelect from "./components/molecules/multi-select/multi-select";
25
26
  import ITConfirmDialog from "./components/organisms/confirm-dialog/confirm-dialog";
26
27
  import ITDataTable from "./components/organisms/data-table/dataTable";
@@ -75,6 +76,7 @@ import type { ITConfirmDialogProps } from "./components/organisms/confirm-dialog
75
76
  import type { ITAccordionProps, ITAccordionItem } from "./components/molecules/accordion/accordion.props";
76
77
  import type { ITDropdownMenuProps, ITDropdownMenuItem } from "./components/molecules/dropdown-menu/dropdown-menu.props";
77
78
  import type { ITFieldProps } from "./components/molecules/field/field.props";
79
+ import type { ITMaskedInputProps } from "./components/molecules/masked-input/masked-input.props";
78
80
  import type { ITMultiSelectProps, ITMultiSelectOption } from "./components/molecules/multi-select/multi-select.props";
79
81
  import type { ITDataTableFetchParams, ITDataTableProps, ITDataTableResponse } from "./components/organisms/data-table/dataTable.props";
80
82
  import type { ITDatePickerProps } from "./components/molecules/date-picker/date-picker.props";
@@ -140,7 +142,7 @@ export {
140
142
  ITAlert, ITAvatar, ITBadget, ITBreadcrumbs, ITButton,
141
143
  ITCalendar,
142
144
  ITCard, ITCheckbox, ITChip, ITChipInput, ITConfirmDialog, ITDataTable, ITDatePicker,
143
- ITDropdownMenu, ITField, ITMultiSelect,
145
+ ITDropdownMenu, ITField, ITMaskedInput, ITMultiSelect,
144
146
  ITDialog, ITDivider, ITDrawer,
145
147
  ITDropfile, ITEmptyState, ITFlex, ITFormBuilder, ITFormHeader, ITGrid, ITImage, ITInput, ITLayout,
146
148
  ITLoader, ITNavbar, ITPage, ITPageHeader, ITPagination, ITPopover, ITProgress,
@@ -161,7 +163,7 @@ export {
161
163
  ITBreadcrumbItem, ITBreadcrumbsProps, ITButtonProps,
162
164
  ITCalendarProps,
163
165
  ITCardProps, ITCheckboxProps, ITChipProps, ITChipInputProps, ITConfirmDialogProps,
164
- ITDropdownMenuItem, ITDropdownMenuProps, ITFieldProps, ITMultiSelectOption, ITMultiSelectProps,
166
+ ITDropdownMenuItem, ITDropdownMenuProps, ITFieldProps, ITMaskedInputProps, ITMultiSelectOption, ITMultiSelectProps,
165
167
  ITDataTableFetchParams, ITDataTableProps, ITDataTableResponse, ITDatePickerProps,
166
168
  ITDialogProps, ITDividerProps, ITDrawerProps,
167
169
  ITEmptyStateProps, ITFlexProps,
@@ -13,6 +13,7 @@ import {
13
13
  ITFormBuilder,
14
14
  ITWysiwyg,
15
15
  ITField,
16
+ ITMaskedInput,
16
17
  ITMultiSelect,
17
18
  ITChipInput,
18
19
  ITBadget,
@@ -1913,3 +1914,180 @@ export const ChipInputShowcase = () => {
1913
1914
  />
1914
1915
  );
1915
1916
  };
1917
+
1918
+ // 15. ITMaskedInput Showcase
1919
+ const MaskedDemo = ({
1920
+ title,
1921
+ mask,
1922
+ pattern,
1923
+ hint,
1924
+ initial,
1925
+ }: {
1926
+ title: string;
1927
+ mask: string;
1928
+ pattern?: RegExp;
1929
+ hint?: string;
1930
+ initial?: string;
1931
+ }) => {
1932
+ const [raw, setRaw] = useState<string>(initial ?? "");
1933
+ const [complete, setComplete] = useState<string | null>(null);
1934
+ return (
1935
+ <div>
1936
+ <h4 className="text-sm font-bold text-slate-700 mb-2">{title}</h4>
1937
+ <ITMaskedInput
1938
+ name={title.toLowerCase().replace(/\s+/g, "_")}
1939
+ label={mask}
1940
+ mask={mask}
1941
+ pattern={pattern}
1942
+ value={raw}
1943
+ onChange={(e) => {
1944
+ setRaw(e.target.value);
1945
+ setComplete(null);
1946
+ }}
1947
+ onComplete={(v) => setComplete(v)}
1948
+ />
1949
+ <p className="mt-1.5 text-xs font-mono text-slate-500">
1950
+ raw: {raw || "(vacío)"}
1951
+ {complete && <span className="text-emerald-600 ml-2">✓ completo</span>}
1952
+ {hint && <span className="text-slate-400 ml-2">· {hint}</span>}
1953
+ </p>
1954
+ </div>
1955
+ );
1956
+ };
1957
+
1958
+ export const MaskedInputShowcase = () => {
1959
+ const [code, setCode] = useState<string>("");
1960
+ const [variant, setVariant] = useState<any>("primary");
1961
+ const [size, setSize] = useState<any>("md");
1962
+ const [disabled, setDisabled] = useState(false);
1963
+ const [withError, setWithError] = useState(false);
1964
+
1965
+ const codeExample = `<ITMaskedInput\n name="code"\n label="Código de activación"\n mask="xxxx-xxxx-xxxx"\n variant="${variant}"\n size="${size}"\n disabled={${disabled}}\n onChange={(e) => setCode(e.target.value)}\n/>`;
1966
+
1967
+ return (
1968
+ <ShowcaseLayout
1969
+ title="ITMaskedInput"
1970
+ description="Input de texto con máscara de formato: teclea solo caracteres útiles y los separadores se insertan solos (xxxx-xxxx-xxxx, (999) 999-9999, etc.)."
1971
+ code={codeExample}
1972
+ demo={
1973
+ <div className="w-full max-w-sm">
1974
+ <ITMaskedInput
1975
+ name="showcase_masked"
1976
+ label="Código de activación"
1977
+ mask="xxxx-xxxx-xxxx"
1978
+ value={code}
1979
+ variant={variant}
1980
+ size={size}
1981
+ disabled={disabled}
1982
+ error={withError}
1983
+ onChange={(e) => setCode(e.target.value)}
1984
+ />
1985
+ {code && (
1986
+ <p className="mt-2 text-xs text-slate-500 font-mono">
1987
+ Valor actual (raw): {code || "—"}
1988
+ </p>
1989
+ )}
1990
+ </div>
1991
+ }
1992
+ controls={
1993
+ <>
1994
+ <ITSelect
1995
+ name="masked_variant_ctrl"
1996
+ label="Tema de Color"
1997
+ value={variant}
1998
+ onChange={(e: any) => setVariant(e.target.value)}
1999
+ options={[
2000
+ { label: "Primary", value: "primary" },
2001
+ { label: "Secondary", value: "secondary" },
2002
+ { label: "Success", value: "success" },
2003
+ { label: "Danger", value: "danger" },
2004
+ { label: "Warning", value: "warning" },
2005
+ { label: "Info", value: "info" },
2006
+ ]}
2007
+ />
2008
+ <ITSelect
2009
+ name="masked_size_ctrl"
2010
+ label="Tamaño"
2011
+ value={size}
2012
+ onChange={(e: any) => setSize(e.target.value)}
2013
+ options={[
2014
+ { label: "Small", value: "sm" },
2015
+ { label: "Medium", value: "md" },
2016
+ { label: "Large", value: "lg" },
2017
+ ]}
2018
+ />
2019
+ <div className="flex items-center justify-between pt-2">
2020
+ <span className="text-sm font-semibold text-gray-700">Deshabilitado</span>
2021
+ <ITSlideToggle isOn={disabled} onToggle={setDisabled} activeColor="danger" size="sm" />
2022
+ </div>
2023
+ <div className="flex items-center justify-between">
2024
+ <span className="text-sm font-semibold text-gray-700">Error</span>
2025
+ <ITSlideToggle isOn={withError} onToggle={setWithError} activeColor="danger" size="sm" />
2026
+ </div>
2027
+ </>
2028
+ }
2029
+ gallery={
2030
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
2031
+ <MaskedDemo
2032
+ title="Código alfanumérico"
2033
+ mask="xxxx-xxxx-xxxx"
2034
+ hint="Escribe: ABC1-2345"
2035
+ />
2036
+ <MaskedDemo
2037
+ title="Número de tarjeta"
2038
+ mask="9999-9999-9999-9999"
2039
+ hint="Escribe 16 dígitos"
2040
+ />
2041
+ <MaskedDemo
2042
+ title="Teléfono"
2043
+ mask="(999) 999-9999"
2044
+ pattern={/^\d{10}$/}
2045
+ hint="Regex /^\\d{10}$/"
2046
+ />
2047
+ <MaskedDemo
2048
+ title="Código postal"
2049
+ mask="99999"
2050
+ hint="Ejemplo: 44100"
2051
+ />
2052
+ <MaskedDemo
2053
+ title="CVV / OTP"
2054
+ mask="999"
2055
+ hint="3 dígitos, onComplete"
2056
+ />
2057
+ <MaskedDemo
2058
+ title="Clave con letras en MAYÚSCULA"
2059
+ mask="A-A-A-999"
2060
+ hint="Token A auto-mayúscula"
2061
+ initial="ABC123"
2062
+ />
2063
+ </div>
2064
+ }
2065
+ doc={{
2066
+ summary: "Input con máscara: value/onChange entregan solo los caracteres útiles y los separadores se auto-insertan.",
2067
+ description:
2068
+ "Define el patrón con tokens (9=dígito, A=letra mayúscula, a=letra, x=alfanumérico, *=cualquiera) y literales (todo lo demás). Los huecos vacíos se pintan con placeholderChar ('_').",
2069
+ examples: [
2070
+ '<ITMaskedInput name="code" mask="xxxx-xxxx-xxxx" onChange={(e) => setCode(e.target.value)} />',
2071
+ '<ITMaskedInput name="phone" mask="(999) 999-9999" pattern={/^\\d{10}$/} onChange={setPhone} />',
2072
+ '<ITMaskedInput name="card" mask="9999-9999-9999-9999" onComplete={validateCard} />',
2073
+ ],
2074
+ props: [
2075
+ { name: "mask", type: "string", required: true, description: "Patrón de la máscara (tokens + literales)." },
2076
+ { name: "value", type: "string", description: "Valor limpio (raw): solo caracteres útiles." },
2077
+ { name: "onChange", type: "(e) => void", description: "e.target.value = raw sin separadores." },
2078
+ { name: "onComplete", type: "(value) => void", description: "Dispara al llenar toda la máscara." },
2079
+ { name: "pattern", type: "RegExp", description: "Validación full-match del raw en blur." },
2080
+ { name: "placeholderChar", type: "string", default: '"_"', description: "Relleno de slots vacíos." },
2081
+ { name: "variant", type: "ColorsTypes", default: '"primary"', description: "Color de acento." },
2082
+ { name: "size", type: '"sm" | "md" | "lg"', default: '"md"', description: "Tamaño del input." },
2083
+ { name: "error", type: "string | boolean", description: "Estado de error." },
2084
+ ],
2085
+ notes: [
2086
+ "Los tokens A guardan letras en mayúsculas automáticamente.",
2087
+ "Borrar un separador lo vuelve a insertar; el cursor se conserva en la posición correcta.",
2088
+ "El cursor se preserva también al editar en medio de la máscara.",
2089
+ ],
2090
+ }}
2091
+ />
2092
+ );
2093
+ };