@innertia-solutions/ui 0.1.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.
@@ -0,0 +1,259 @@
1
+ import { reactive, toRefs, onMounted } from 'vue';
2
+
3
+ // Constantes de colores para errores
4
+ const ERROR_COLORS = {
5
+ border: {
6
+ light: 'border-red-400',
7
+ dark: 'dark:border-red-500/50'
8
+ },
9
+ text: {
10
+ light: 'text-slate-900', // Keep text readable
11
+ dark: 'dark:text-white'
12
+ },
13
+ placeholder: {
14
+ light: 'placeholder-red-300',
15
+ dark: 'dark:placeholder-red-500/50'
16
+ },
17
+ ring: {
18
+ light: 'focus:ring-red-500/20', // Subtle glow instead of thick ring
19
+ dark: 'dark:focus:ring-red-500/20'
20
+ },
21
+ focusBorder: {
22
+ light: 'focus:border-red-500',
23
+ dark: 'dark:focus:border-red-500'
24
+ },
25
+ message: {
26
+ light: 'text-red-500',
27
+ dark: 'dark:text-red-400'
28
+ }
29
+ };
30
+
31
+ const rules = {
32
+ required: (value) => {
33
+ // inválido si: null/undefined, string vacío o solo espacios, array vacío
34
+ if (value === null || value === undefined) return 'Este campo es obligatorio';
35
+ if (typeof value === 'string' && value.trim() === '') return 'Este campo es obligatorio';
36
+ if (Array.isArray(value) && value.length === 0) return 'Este campo es obligatorio';
37
+ return true;
38
+ },
39
+ email: (value) => /.+@.+\..+/.test(value) || 'El correo no es válido',
40
+ min: (value, arg) => value.length >= arg || `Debe tener al menos ${arg} caracteres`,
41
+ int: (value) => Number.isInteger(+value) || 'Debe ser un número entero',
42
+ rut: (value) => validateRut(value) || 'El RUT no es válido',
43
+ same: (value, arg, form) => value === form[arg] || 'Los campos no coinciden',
44
+ };
45
+
46
+ const dictionary = {
47
+ unique: 'Ya está registrado',
48
+ required: 'Este campo es obligatorio',
49
+ invalid: 'Dato inválido',
50
+ };
51
+
52
+ export function useForm(containerId, formDefinition, options = {}) {
53
+ const zodSchema = options.zodSchema;
54
+ const form = reactive({});
55
+ const errors = reactive({});
56
+
57
+ for (const field in formDefinition) {
58
+ form[field] = formDefinition[field]?.value ?? '';
59
+ errors[field] = [];
60
+ }
61
+
62
+ const reset = () => {
63
+ for (const field in formDefinition) {
64
+ form[field] = '';
65
+ errors[field] = [];
66
+ clearValidation(field);
67
+ }
68
+ };
69
+
70
+ const resetErrors = () => {
71
+ for (const field in formDefinition) {
72
+ errors[field] = [];
73
+ clearValidation(field);
74
+ }
75
+ };
76
+
77
+ const validateField = (field) => {
78
+ const def = formDefinition[field];
79
+ const value = form[field];
80
+ errors[field] = [];
81
+
82
+ if (!def.rules) return true;
83
+
84
+ def.rules.forEach(rule => {
85
+ let ruleName = typeof rule === 'string' ? rule : rule.name;
86
+ let arg = typeof rule === 'object' ? rule.arg : undefined;
87
+ const result = rules[ruleName](value, arg, form);
88
+ if (result !== true) {
89
+ const custom = def.messages?.[ruleName];
90
+ errors[field].push(custom || result);
91
+ }
92
+ });
93
+
94
+ updateValidation(field);
95
+ return errors[field].length === 0;
96
+ };
97
+
98
+ const validateForm = () => {
99
+ if (zodSchema) {
100
+ const result = zodSchema.safeParse(form);
101
+ resetErrors();
102
+
103
+ if (!result.success) {
104
+ for (const issue of result.error.errors) {
105
+ const field = issue.path[0];
106
+ if (errors[field]) {
107
+ errors[field].push(issue.message);
108
+ updateValidation(field);
109
+ }
110
+ }
111
+ return false;
112
+ }
113
+
114
+ return true;
115
+ }
116
+
117
+ for (const field in formDefinition) validateField(field);
118
+
119
+ return Object.values(errors).every(e => e.length === 0);
120
+ };
121
+
122
+ const updateValidation = (field) => {
123
+ const el = document.querySelector(`#${containerId} [name="${field}"]`);
124
+ if (!el) return;
125
+
126
+ if (errors[field].length > 0) {
127
+ // Limpiar validación anterior solo para reemplazarla
128
+ clearValidation(field);
129
+
130
+ // Aplicar clases de error al elemento usando las constantes
131
+ el.classList.add(
132
+ ERROR_COLORS.border.light,
133
+ ERROR_COLORS.border.dark,
134
+ ERROR_COLORS.text.light,
135
+ ERROR_COLORS.text.dark,
136
+ ERROR_COLORS.placeholder.light,
137
+ ERROR_COLORS.placeholder.dark,
138
+ ERROR_COLORS.ring.light,
139
+ ERROR_COLORS.ring.dark,
140
+ ERROR_COLORS.focusBorder.light,
141
+ ERROR_COLORS.focusBorder.dark
142
+ );
143
+
144
+ const errorEl = document.createElement('p');
145
+ errorEl.className = `mt-1 text-sm ${ERROR_COLORS.message.light} ${ERROR_COLORS.message.dark} form-error-message form-error-${field}`;
146
+ errorEl.textContent = errors[field][0];
147
+
148
+ // Buscar el contenedor correcto para insertar el mensaje de error
149
+ let container = el.parentNode;
150
+
151
+ // Si el input está dentro de un div relativo (como en el caso del password),
152
+ // el mensaje debe ir después de ese div
153
+ if (container && container.classList.contains('relative')) {
154
+ container.parentNode?.appendChild(errorEl);
155
+ } else {
156
+ // Para inputs normales y selects, agregar después del contenedor
157
+ container?.appendChild(errorEl);
158
+ }
159
+ } else {
160
+ // Solo limpiar si no hay errores
161
+ clearValidation(field);
162
+ }
163
+ };
164
+
165
+ const clearValidation = (field) => {
166
+ const el = document.querySelector(`#${containerId} [name="${field}"]`);
167
+ if (!el) return;
168
+
169
+ // Remover clases de error usando las constantes
170
+ el.classList.remove(
171
+ ERROR_COLORS.border.light,
172
+ ERROR_COLORS.border.dark,
173
+ ERROR_COLORS.text.light,
174
+ ERROR_COLORS.text.dark,
175
+ ERROR_COLORS.placeholder.light,
176
+ ERROR_COLORS.placeholder.dark,
177
+ ERROR_COLORS.ring.light,
178
+ ERROR_COLORS.ring.dark,
179
+ ERROR_COLORS.focusBorder.light,
180
+ ERROR_COLORS.focusBorder.dark
181
+ );
182
+
183
+ // Buscar y eliminar solo los mensajes de error específicos de este campo
184
+ const formContainer = document.querySelector(`#${containerId}`);
185
+ if (formContainer) {
186
+ const fieldErrorMessages = formContainer.querySelectorAll(`.form-error-${field}`);
187
+ fieldErrorMessages.forEach(errorEl => errorEl.remove());
188
+ }
189
+ };
190
+
191
+ const addError = (field, message) => {
192
+ if (message.startsWith('validation.')) {
193
+ const key = message.split('.')[1];
194
+ message = dictionary[key] || key;
195
+ }
196
+
197
+ errors[field].push(message);
198
+ updateValidation(field);
199
+ };
200
+
201
+ const attachEvents = () => {
202
+ // Solo adjuntar eventos si se especifican explícitamente
203
+ // Si options.events es undefined o no se proporciona, no adjuntar eventos automáticos
204
+ if (!options?.events || options.events.length === 0) {
205
+ return;
206
+ }
207
+
208
+ const events = options.events;
209
+
210
+ for (const field in formDefinition) {
211
+ const el = document.querySelector(`#${containerId} [name="${field}"]`);
212
+ if (el) {
213
+ events.forEach(evt => el.addEventListener(evt, () => validateField(field)));
214
+ }
215
+ }
216
+ };
217
+
218
+ onMounted(() => {
219
+ if (containerId) attachEvents();
220
+ });
221
+
222
+ const loadFromObject = (obj) => {
223
+ for (const field in formDefinition) {
224
+ if (obj[field] !== undefined) {
225
+ console.log(`Loading field ${field} with value:`, obj[field]);
226
+ form[field] = obj[field];
227
+ clearValidation(field);
228
+ }
229
+ }
230
+ }
231
+
232
+ return {
233
+ ...toRefs(form),
234
+ values: form,
235
+ errors,
236
+ validate: (field) => field ? validateField(field) : validateForm(),
237
+ reset,
238
+ resetErrors,
239
+ addError,
240
+ loadFromObject,
241
+ config: formDefinition
242
+ };
243
+ }
244
+
245
+ function validateRut(rut) {
246
+ if (!rut || typeof rut !== 'string') return false;
247
+ rut = rut.replace(/^0+|[^0-9kK]+/g, '').toUpperCase();
248
+ if (rut.length < 8) return false;
249
+ const body = rut.slice(0, -1);
250
+ const dv = rut.slice(-1);
251
+ let sum = 0, multiplier = 2;
252
+ for (let i = body.length - 1; i >= 0; i--) {
253
+ sum += parseInt(body[i]) * multiplier;
254
+ multiplier = multiplier < 7 ? multiplier + 1 : 2;
255
+ }
256
+ const expected = 11 - (sum % 11);
257
+ const expectedDV = expected === 11 ? '0' : expected === 10 ? 'K' : expected.toString();
258
+ return dv === expectedDV;
259
+ }
@@ -0,0 +1,20 @@
1
+
2
+ export const useRutFormatter = (inputElement) => {
3
+ const formatRut = (rut) => {
4
+ const cleanRut = rut.replace(/[^\dKk]/g, '').toUpperCase(); // Limpiar caracteres no numéricos
5
+
6
+ if (cleanRut.length <= 1) return cleanRut; // Si solo hay un dígito, no hacer formato
7
+
8
+ const rutBody = cleanRut.slice(0, -1); // Cuerpo del RUT
9
+ const dv = cleanRut.slice(-1); // Dígito verificador
10
+
11
+ const formattedRut = rutBody.replace(/\B(?=(\d{3})+(?!\d))/g, '.'); // Formatear cuerpo
12
+
13
+ return `${formattedRut}-${dv}`; // Retornar RUT formateado
14
+ };
15
+
16
+ inputElement.addEventListener('input', (e) => {
17
+ let formattedRut = formatRut(e.target.value);
18
+ e.target.value = formattedRut;
19
+ });
20
+ };
@@ -0,0 +1,124 @@
1
+ // composables/useTable.ts
2
+
3
+ export function useTable() {
4
+ // Función para invalidar caché de una tabla específica
5
+ const invalidateCache = (tableName: string) => {
6
+ if (!tableName) {
7
+ console.warn('[useTable] No table name provided');
8
+ return;
9
+ }
10
+
11
+ const fullCacheKey = `table_cache_${tableName}`;
12
+
13
+ try {
14
+ sessionStorage.removeItem(fullCacheKey);
15
+ console.log(`🗑️ Cache invalidated for table: ${tableName}`);
16
+ } catch (error) {
17
+ console.warn('[useTable] Error invalidating cache:', error);
18
+ }
19
+ };
20
+
21
+ // Función para invalidar múltiples cachés
22
+ const invalidateMultiple = (tableNames: string[]) => {
23
+ tableNames.forEach(name => invalidateCache(name));
24
+ };
25
+
26
+ // Función para limpiar todo el caché de tablas
27
+ const clearAllCache = () => {
28
+ try {
29
+ // Obtener todas las claves del sessionStorage
30
+ const keys = Object.keys(sessionStorage);
31
+
32
+ // Filtrar solo las claves de tablas
33
+ const tableCacheKeys = keys.filter(key => key.startsWith('table_cache_'));
34
+
35
+ // Eliminar todas
36
+ tableCacheKeys.forEach(key => {
37
+ sessionStorage.removeItem(key);
38
+ });
39
+
40
+ console.log(`🗑️ Cleared ${tableCacheKeys.length} table caches`);
41
+ } catch (error) {
42
+ console.warn('[useTable] Error clearing all cache:', error);
43
+ }
44
+ };
45
+
46
+ // Función para manejar búsquedas persistentes con invalidación automática de caché
47
+ const useSearch = (tableName: string) => {
48
+ if (!tableName) {
49
+ throw new Error('[useTable] Table name is required for useSearch');
50
+ }
51
+
52
+ // Estado global compartido para todas las búsquedas de tablas
53
+ const searchCache = useState<Record<string, string>>("table-search-cache", () => ({}));
54
+
55
+ // Referencia reactiva para la búsqueda de esta tabla específica
56
+ const search = ref(searchCache.value[tableName] || "");
57
+
58
+ // Watcher para sincronizar automáticamente con el estado global
59
+ // e invalidar caché cuando cambia la búsqueda
60
+ watch(search, (newSearch, oldSearch) => {
61
+ // Guardar en estado global
62
+ searchCache.value[tableName] = newSearch;
63
+ console.log(`[useTable] Saved search for "${tableName}":`, newSearch);
64
+
65
+ // Invalidar caché si la búsqueda cambió (excepto en la carga inicial)
66
+ if (oldSearch !== undefined && newSearch !== oldSearch) {
67
+ console.log(`[useTable] Search changed for "${tableName}", invalidating cache`);
68
+ invalidateCache(tableName);
69
+ }
70
+ }, { immediate: true });
71
+
72
+ // Función para limpiar la búsqueda específica
73
+ const clearSearch = () => {
74
+ search.value = "";
75
+ };
76
+
77
+ return {
78
+ search,
79
+ clearSearch
80
+ };
81
+ };
82
+
83
+ // Función para limpiar todas las búsquedas guardadas
84
+ const clearAllSearches = () => {
85
+ const searchCache = useState<Record<string, string>>("table-search-cache", () => ({}));
86
+ searchCache.value = {};
87
+ console.log('[useTable] Cleared all saved searches');
88
+ };
89
+
90
+ // Función para obtener el estado del caché de búsquedas
91
+ const getSearchCache = () => {
92
+ const searchCache = useState<Record<string, string>>("table-search-cache", () => ({}));
93
+ return searchCache.value;
94
+ };
95
+
96
+ // Manager for persistent filters
97
+ const useFilters = <T extends Record<string, any>>(tableName: string, initialFilters: T) => {
98
+ if (!tableName) {
99
+ throw new Error('[useTable] Table name is required for useFilters');
100
+ }
101
+
102
+ // Use Nuxt useState to persist filters across client-side navigation
103
+ const filters = useState<T>(`table_filters_${tableName}`, () => ({ ...initialFilters }));
104
+
105
+ const resetFilters = () => {
106
+ filters.value = { ...initialFilters };
107
+ };
108
+
109
+ return {
110
+ filters,
111
+ resetFilters
112
+ };
113
+ };
114
+
115
+ return {
116
+ invalidateCache,
117
+ invalidateMultiple,
118
+ clearAllCache,
119
+ useSearch,
120
+ useFilters,
121
+ clearAllSearches,
122
+ getSearchCache
123
+ };
124
+ }
@@ -0,0 +1,25 @@
1
+ // composables/useTimeAgo.ts
2
+ import { ref, onMounted, onBeforeUnmount } from 'vue'
3
+ import dayjs from 'dayjs'
4
+
5
+ export const useTimeAgo = (utcDatetime) => {
6
+ const now = ref(new Date())
7
+ let interval
8
+
9
+ onMounted(() => {
10
+ interval = setInterval(() => {
11
+ now.value = new Date()
12
+ }, 60000)
13
+ })
14
+
15
+ onBeforeUnmount(() => {
16
+ clearInterval(interval)
17
+ })
18
+
19
+ const timeAgo = () => {
20
+ // Convertir desde UTC a la hora local del navegador
21
+ return dayjs.utc(utcDatetime).local().from(now.value)
22
+ }
23
+
24
+ return { timeAgo }
25
+ }
@@ -0,0 +1,69 @@
1
+ // app/composables/useToast.js
2
+ import { useToastStore } from '@/stores/toast'
3
+
4
+ export function useToast() {
5
+ const toast = useToastStore()
6
+
7
+ return {
8
+ // Métodos originales
9
+ success: toast.success,
10
+ info: toast.info,
11
+ error: toast.error,
12
+ show: toast.show,
13
+ remove: toast.remove,
14
+ update: toast.update,
15
+ updateProgress: toast.updateProgress,
16
+ completeProcess: toast.completeProcess,
17
+
18
+ // Métodos rápidos para diferentes tipos de toast
19
+ alert: {
20
+ success: (message, config = {}) => toast.success({
21
+ type: 'alert',
22
+ message,
23
+ duration: 3000,
24
+ ...config
25
+ }),
26
+
27
+ error: (message, config = {}) => toast.error({
28
+ type: 'alert',
29
+ message,
30
+ duration: 5000,
31
+ ...config
32
+ }),
33
+
34
+ warning: (message, config = {}) => toast.show({
35
+ type: 'alert',
36
+ severity: 'warning',
37
+ icon: 'ti ti-alert-triangle',
38
+ title: 'Warning',
39
+ message,
40
+ duration: 4000,
41
+ ...config
42
+ }),
43
+
44
+ info: (message, config = {}) => toast.info({
45
+ type: 'alert',
46
+ message,
47
+ duration: 3000,
48
+ ...config
49
+ })
50
+ },
51
+
52
+ notification: (title, message, config = {}) => toast.show({
53
+ type: 'notification',
54
+ title,
55
+ message,
56
+ duration: 0, // Las notificaciones no se auto-dismiss por defecto
57
+ ...config
58
+ }),
59
+
60
+ process: (title, config = {}) => toast.show({
61
+ type: 'process',
62
+ title,
63
+ progress: 0,
64
+ progressLabel: 'Iniciando...',
65
+ duration: 0, // Los procesos no se auto-dismiss
66
+ ...config
67
+ })
68
+ }
69
+ }
package/nuxt.config.ts ADDED
@@ -0,0 +1,8 @@
1
+ export default defineNuxtConfig({
2
+ components: [
3
+ { path: './components', pathPrefix: false }
4
+ ],
5
+ imports: {
6
+ dirs: ['composables']
7
+ },
8
+ })
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@innertia-solutions/ui",
3
+ "version": "0.1.0",
4
+ "description": "Innertia Solutions — Nuxt UI layer: components and composables",
5
+ "keywords": ["nuxt", "vue", "components", "design-system"],
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "main": "./nuxt.config.ts",
11
+ "exports": {
12
+ ".": "./nuxt.config.ts"
13
+ },
14
+ "peerDependencies": {
15
+ "nuxt": ">=3.0.0",
16
+ "vue": ">=3.0.0"
17
+ },
18
+ "devDependencies": {
19
+ "nuxt": "^3.16.0",
20
+ "vue": "^3.5.0"
21
+ }
22
+ }
@@ -0,0 +1,131 @@
1
+ // stores/toast.js
2
+ import { defineStore } from 'pinia'
3
+ import { v4 as uuid } from 'uuid'
4
+
5
+ const toastConfig = {
6
+ severity: 'info',
7
+ title: null,
8
+ message: null,
9
+ duration: 5000,
10
+ progress: true,
11
+ position: 'top-right',
12
+ icon: null,
13
+ }
14
+
15
+ export const useToastStore = defineStore('toast', {
16
+ state: () => ({
17
+ toasts: {
18
+ 'top-left': [],
19
+ 'top-center': [],
20
+ 'top-right': [],
21
+ 'bottom-left': [],
22
+ 'bottom-center': [],
23
+ 'bottom-right': [],
24
+ },
25
+ timeouts: new Map(), // Para trackear los timeouts activos
26
+ }),
27
+ actions: {
28
+ success(config) {
29
+ const normalizedConfig = typeof config === 'string' ? { message: config } : config
30
+ const toast = {
31
+ ...toastConfig,
32
+ id: uuid(),
33
+ severity: 'success',
34
+ icon: 'ti ti-circle-check text-green-500',
35
+ position: 'top-center',
36
+ ...normalizedConfig,
37
+ }
38
+ this.toasts[toast.position].push(toast)
39
+ this._scheduleRemoval(toast)
40
+ },
41
+ info(config) {
42
+ const normalizedConfig = typeof config === 'string' ? { message: config } : config
43
+ const toast = {
44
+ ...toastConfig,
45
+ id: uuid(),
46
+ severity: 'info',
47
+ icon: 'ti ti-info-circle',
48
+ title: 'Info',
49
+ position: 'top-right',
50
+ ...normalizedConfig,
51
+ }
52
+ this.toasts[toast.position].push(toast)
53
+ this._scheduleRemoval(toast)
54
+ },
55
+ error(config) {
56
+ const normalizedConfig = typeof config === 'string' ? { message: config } : config
57
+ const toast = {
58
+ ...toastConfig,
59
+ id: uuid(),
60
+ severity: 'danger',
61
+ icon: 'ti ti-exclamation-circle',
62
+ title: 'Atención',
63
+ position: 'top-right',
64
+ ...normalizedConfig,
65
+ }
66
+ this.toasts[toast.position].push(toast)
67
+ this._scheduleRemoval(toast)
68
+ },
69
+ show(config) {
70
+ const toast = {
71
+ id: uuid(),
72
+ severity: 'info',
73
+ position: 'top-right',
74
+ ...toastConfig,
75
+ ...config,
76
+ }
77
+ this.toasts[toast.position].push(toast)
78
+ this._scheduleRemoval(toast)
79
+ },
80
+ update(id, data) {
81
+ for (const key in this.toasts) {
82
+ const idx = this.toasts[key].findIndex(t => t.id === id)
83
+ if (idx !== -1) {
84
+ this.toasts[key][idx] = { ...this.toasts[key][idx], ...data }
85
+ break
86
+ }
87
+ }
88
+ },
89
+ remove(id) {
90
+ // Limpiar timeout si existe
91
+ if (this.timeouts.has(id)) {
92
+ clearTimeout(this.timeouts.get(id))
93
+ this.timeouts.delete(id)
94
+ }
95
+
96
+ for (const key in this.toasts) {
97
+ this.toasts[key] = this.toasts[key].filter(t => t.id !== id)
98
+ }
99
+ },
100
+ _scheduleRemoval(toast) {
101
+ // Solo programar auto-remove si duration > 0
102
+ if (toast.duration && toast.duration > 0) {
103
+ const timeoutId = setTimeout(() => {
104
+ this.remove(toast.id)
105
+ }, toast.duration)
106
+
107
+ this.timeouts.set(toast.id, timeoutId)
108
+ }
109
+ },
110
+
111
+ // Método específico para actualizar progreso de un toast tipo process
112
+ updateProgress(id, progress, progressLabel = null) {
113
+ const updateData = { progress }
114
+ if (progressLabel) {
115
+ updateData.progressLabel = progressLabel
116
+ }
117
+ this.update(id, updateData)
118
+ },
119
+
120
+ // Método para completar un proceso
121
+ completeProcess(id, message = 'Completado') {
122
+ this.update(id, {
123
+ progress: 100,
124
+ progressLabel: message,
125
+ duration: 3000, // Auto-remove después de completar
126
+ })
127
+ // Programar removal para el proceso completado
128
+ this._scheduleRemoval({ id, duration: 3000 })
129
+ },
130
+ },
131
+ })