@softdin/shared 0.1.3

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.
Files changed (40) hide show
  1. package/README.md +48 -0
  2. package/package.json +50 -0
  3. package/src/a11y/announcer.ts +43 -0
  4. package/src/a11y/apply.ts +83 -0
  5. package/src/a11y/index.ts +31 -0
  6. package/src/a11y/state.ts +40 -0
  7. package/src/a11y/storage.ts +106 -0
  8. package/src/a11y/types.ts +187 -0
  9. package/src/api.ts +89 -0
  10. package/src/assets/softdin-logo.png +0 -0
  11. package/src/auth.ts +134 -0
  12. package/src/components/a11y/A11yLiveRegion.vue +11 -0
  13. package/src/components/a11y/AccessibilityMenuControls.vue +426 -0
  14. package/src/components/a11y/AccessibilityPreferencesPanel.vue +331 -0
  15. package/src/components/a11y/ColorSchemeQuickToggle.vue +105 -0
  16. package/src/components/a11y/SkipLink.vue +41 -0
  17. package/src/components/auth/AuthBrandHeader.vue +129 -0
  18. package/src/components/auth/AuthShell.vue +52 -0
  19. package/src/components/auth/DataProcessingConsentCheckbox.vue +303 -0
  20. package/src/components/auth/DataProcessingConsentForm.vue +111 -0
  21. package/src/components/forms/ErrorAlert.vue +170 -0
  22. package/src/components/forms/FormCancelButton.vue +87 -0
  23. package/src/components/forms/FormCloseButton.vue +114 -0
  24. package/src/components/forms/FormHelpButton.vue +109 -0
  25. package/src/components/forms/FormHelpModal.vue +193 -0
  26. package/src/components/forms/FormMaximizeButton.vue +143 -0
  27. package/src/components/forms/FormRefreshButton.vue +152 -0
  28. package/src/components/forms/FormSaveButton.vue +113 -0
  29. package/src/components/forms/PasswordInput.vue +154 -0
  30. package/src/components/navigation/CatalogListPagination.vue +113 -0
  31. package/src/config/apiBase.ts +37 -0
  32. package/src/lib/catalog-pagination.ts +27 -0
  33. package/src/lib/pagination.ts +86 -0
  34. package/src/lib/softdin-libreria.ts +50 -0
  35. package/src/style.css +278 -0
  36. package/src/types/auth.ts +146 -0
  37. package/src/types/softdin-libreria.d.ts +41 -0
  38. package/src/utils/apiError.ts +55 -0
  39. package/src/utils/connectivity.ts +33 -0
  40. package/src/utils/tenantLogo.ts +6 -0
package/src/auth.ts ADDED
@@ -0,0 +1,134 @@
1
+ import { api } from './api'
2
+ import type {
3
+ AuthResponse,
4
+ CentralLoginResponse,
5
+ CentralAdminLoginCredentials,
6
+ DataProcessingConsentPolicy,
7
+ DataProcessingConsentResponse,
8
+ LoginCredentials,
9
+ MeResponse,
10
+ ProfilePhotoResponse,
11
+ ProfileUpdateResponse,
12
+ PublicTenantsResponse,
13
+ RegisterPayload,
14
+ RegisterResponse,
15
+ ForgotPasswordResponse,
16
+ ResetPasswordPayload,
17
+ MessageResponse,
18
+ UpdateProfilePayload,
19
+ } from './types/auth'
20
+
21
+ export const authService = {
22
+ async fetchPublicTenants(): Promise<PublicTenantsResponse> {
23
+ const { data } = await api.get<PublicTenantsResponse>('/auth/tenants')
24
+ return data
25
+ },
26
+
27
+ async register(payload: RegisterPayload, logo?: File | null): Promise<RegisterResponse> {
28
+ if (logo && payload.tenant_mode === 'new') {
29
+ const formData = new FormData()
30
+ for (const [key, value] of Object.entries(payload)) {
31
+ if (value !== undefined && value !== null) {
32
+ formData.append(key, typeof value === 'boolean' ? (value ? '1' : '0') : String(value))
33
+ }
34
+ }
35
+ formData.append('logo', logo)
36
+
37
+ const { data } = await api.post<RegisterResponse>('/auth/register', formData, {
38
+ timeout: 180_000,
39
+ })
40
+ return data
41
+ }
42
+
43
+ const { data } = await api.post<RegisterResponse>('/auth/register', payload, {
44
+ timeout: 180_000,
45
+ })
46
+ return data
47
+ },
48
+
49
+ async forgotPassword(email: string): Promise<ForgotPasswordResponse> {
50
+ const { data } = await api.post<ForgotPasswordResponse>('/auth/forgot-password', { email })
51
+ return data
52
+ },
53
+
54
+ async resetPassword(payload: ResetPasswordPayload): Promise<MessageResponse> {
55
+ const { data } = await api.post<MessageResponse>('/auth/reset-password', payload)
56
+ return data
57
+ },
58
+
59
+ async centralLogin(email: string, password: string): Promise<CentralLoginResponse> {
60
+ const { data } = await api.post<CentralLoginResponse>('/auth/central-login', {
61
+ email,
62
+ password,
63
+ })
64
+ return data
65
+ },
66
+
67
+ async centralAdminLogin(credentials: CentralAdminLoginCredentials): Promise<AuthResponse> {
68
+ const { data } = await api.post<AuthResponse>('/auth/central-admin-login', credentials)
69
+ return data
70
+ },
71
+
72
+ async login(credentials: LoginCredentials): Promise<AuthResponse> {
73
+ const { data } = await api.post<AuthResponse>('/auth/login', credentials)
74
+ return data
75
+ },
76
+
77
+ async switchTenant(tenant: string): Promise<AuthResponse> {
78
+ const { data } = await api.post<AuthResponse>('/auth/switch-tenant', { tenant })
79
+ return data
80
+ },
81
+
82
+ async updateProfile(payload: UpdateProfilePayload): Promise<ProfileUpdateResponse> {
83
+ const { data } = await api.patch<ProfileUpdateResponse>('/auth/profile', payload)
84
+ return data
85
+ },
86
+
87
+ async uploadProfilePhoto(file: File): Promise<ProfilePhotoResponse> {
88
+ const formData = new FormData()
89
+ formData.append('photo', file)
90
+ const { data } = await api.post<ProfilePhotoResponse>('/auth/profile/photo', formData, {
91
+ headers: { 'Content-Type': 'multipart/form-data' },
92
+ })
93
+ return data
94
+ },
95
+
96
+ async fetchProfilePhoto(): Promise<Blob> {
97
+ const { data } = await api.get<Blob>('/auth/profile/photo', {
98
+ responseType: 'blob',
99
+ headers: { Accept: 'image/*,*/*' },
100
+ })
101
+ return data
102
+ },
103
+
104
+ async deleteProfilePhoto(): Promise<ProfilePhotoResponse> {
105
+ const { data } = await api.delete<ProfilePhotoResponse>('/auth/profile/photo')
106
+ return data
107
+ },
108
+
109
+ async me(): Promise<MeResponse> {
110
+ const { data } = await api.get<MeResponse>('/auth/me')
111
+ return data
112
+ },
113
+
114
+ async fetchDataProcessingConsentPolicy(): Promise<DataProcessingConsentPolicy> {
115
+ const { data } = await api.get<DataProcessingConsentPolicy>('/auth/data-processing-consent/policy')
116
+ return data
117
+ },
118
+
119
+ async fetchDataProcessingConsent(): Promise<DataProcessingConsentPolicy> {
120
+ const { data } = await api.get<DataProcessingConsentPolicy>('/auth/data-processing-consent')
121
+ return data
122
+ },
123
+
124
+ async submitDataProcessingConsent(accepted: boolean): Promise<DataProcessingConsentResponse> {
125
+ const { data } = await api.post<DataProcessingConsentResponse>('/auth/data-processing-consent', {
126
+ accepted,
127
+ })
128
+ return data
129
+ },
130
+
131
+ async logout(): Promise<void> {
132
+ await api.post('/auth/logout')
133
+ },
134
+ }
@@ -0,0 +1,11 @@
1
+ <script setup lang="ts">
2
+ import { useA11yAnnouncer } from '../../a11y/announcer'
3
+
4
+ const { message, politeness } = useA11yAnnouncer()
5
+ </script>
6
+
7
+ <template>
8
+ <div class="sr-only" :aria-live="politeness" aria-atomic="true" role="status">
9
+ {{ message }}
10
+ </div>
11
+ </template>
@@ -0,0 +1,426 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import {
4
+ accessibilityPreferencesState,
5
+ patchAccessibilityPreferences,
6
+ type A11yBrandAccent,
7
+ type A11yColorScheme,
8
+ type A11yFontFamily,
9
+ type A11yTextScale,
10
+ } from '../../a11y'
11
+
12
+ type SubmenuKey = 'theme' | 'accent' | 'font' | 'scale'
13
+
14
+ const prefs = accessibilityPreferencesState
15
+ const accessibilityOpen = ref(false)
16
+ const openSubmenu = ref<SubmenuKey | null>(null)
17
+
18
+ const themeOptions: Array<{ value: A11yColorScheme; label: string }> = [
19
+ { value: 'light', label: 'Claro' },
20
+ { value: 'dark', label: 'Oscuro' },
21
+ { value: 'system', label: 'Auto' },
22
+ ]
23
+
24
+ const accentOptions: Array<{ value: A11yBrandAccent; label: string; swatch: string }> = [
25
+ { value: 'green', label: 'Verde', swatch: '#16a34a' },
26
+ { value: 'blue', label: 'Azul', swatch: '#2563eb' },
27
+ { value: 'red', label: 'Rojo', swatch: '#dc2626' },
28
+ { value: 'purple', label: 'Morado', swatch: '#9333ea' },
29
+ { value: 'orange', label: 'Naranja', swatch: '#ea580c' },
30
+ { value: 'teal', label: 'Turquesa', swatch: '#0d9488' },
31
+ ]
32
+
33
+ const fontOptions: Array<{ value: A11yFontFamily; label: string }> = [
34
+ { value: 'system', label: 'Sistema' },
35
+ { value: 'serif', label: 'Serif' },
36
+ { value: 'dyslexia', label: 'Lectura' },
37
+ ]
38
+
39
+ const scaleOptions: Array<{ value: A11yTextScale; label: string }> = [
40
+ { value: 'xs', label: 'Muy pequeño' },
41
+ { value: 'sm', label: 'Pequeño' },
42
+ { value: 'md', label: 'Medio' },
43
+ { value: 'lg', label: 'Grande' },
44
+ { value: 'xl', label: 'Extra' },
45
+ ]
46
+
47
+ const themeLabel = () =>
48
+ themeOptions.find((option) => option.value === prefs.colorScheme)?.label ?? 'Claro'
49
+
50
+ const accentLabel = () =>
51
+ accentOptions.find((option) => option.value === prefs.brandAccent)?.label ?? 'Verde'
52
+
53
+ const fontLabel = () =>
54
+ fontOptions.find((option) => option.value === prefs.fontFamily)?.label ?? 'Sistema'
55
+
56
+ const scaleLabel = () =>
57
+ scaleOptions.find((option) => option.value === prefs.textScale)?.label ?? 'Medio'
58
+
59
+ const summaryLabel = () =>
60
+ `${themeLabel()} · ${accentLabel()} · ${fontLabel()} · ${scaleLabel()}`
61
+
62
+ function toggleAccessibility(): void {
63
+ accessibilityOpen.value = !accessibilityOpen.value
64
+ if (!accessibilityOpen.value) {
65
+ openSubmenu.value = null
66
+ }
67
+ }
68
+
69
+ function toggleSubmenu(key: SubmenuKey): void {
70
+ openSubmenu.value = openSubmenu.value === key ? null : key
71
+ }
72
+
73
+ function setColorScheme(value: A11yColorScheme): void {
74
+ patchAccessibilityPreferences({ colorScheme: value })
75
+ openSubmenu.value = null
76
+ }
77
+
78
+ function setBrandAccent(value: A11yBrandAccent): void {
79
+ patchAccessibilityPreferences({ brandAccent: value })
80
+ openSubmenu.value = null
81
+ }
82
+
83
+ function setFontFamily(value: A11yFontFamily): void {
84
+ patchAccessibilityPreferences({ fontFamily: value })
85
+ openSubmenu.value = null
86
+ }
87
+
88
+ function setTextScale(value: A11yTextScale): void {
89
+ patchAccessibilityPreferences({ textScale: value })
90
+ openSubmenu.value = null
91
+ }
92
+ </script>
93
+
94
+ <template>
95
+ <div class="a11y-menu" role="group" aria-label="Accesibilidad">
96
+ <button
97
+ type="button"
98
+ class="a11y-menu__root"
99
+ :aria-expanded="accessibilityOpen"
100
+ aria-controls="a11y-menu-panel"
101
+ @click.stop="toggleAccessibility"
102
+ >
103
+ <span class="a11y-menu__root-main">
104
+ <span class="a11y-menu__root-label">Accesibilidad</span>
105
+ <span class="a11y-menu__root-value">{{ summaryLabel() }}</span>
106
+ </span>
107
+ <span class="a11y-menu__chevron" aria-hidden="true">
108
+ {{ accessibilityOpen ? '▴' : '▾' }}
109
+ </span>
110
+ </button>
111
+
112
+ <div v-if="accessibilityOpen" id="a11y-menu-panel" class="a11y-menu__panel">
113
+ <div class="a11y-menu__item">
114
+ <button
115
+ type="button"
116
+ class="a11y-menu__trigger"
117
+ :aria-expanded="openSubmenu === 'theme'"
118
+ aria-controls="a11y-submenu-theme"
119
+ @click.stop="toggleSubmenu('theme')"
120
+ >
121
+ <span class="a11y-menu__trigger-main">
122
+ <span class="a11y-menu__trigger-label">Tema</span>
123
+ <span class="a11y-menu__trigger-value">{{ themeLabel() }}</span>
124
+ </span>
125
+ <span class="a11y-menu__chevron" aria-hidden="true">
126
+ {{ openSubmenu === 'theme' ? '▴' : '▾' }}
127
+ </span>
128
+ </button>
129
+ <div
130
+ v-if="openSubmenu === 'theme'"
131
+ id="a11y-submenu-theme"
132
+ class="a11y-menu__submenu"
133
+ role="radiogroup"
134
+ aria-label="Tema de la interfaz"
135
+ >
136
+ <button
137
+ v-for="option in themeOptions"
138
+ :key="option.value"
139
+ type="button"
140
+ class="a11y-menu__option"
141
+ :class="{ 'is-active': prefs.colorScheme === option.value }"
142
+ role="radio"
143
+ :aria-checked="prefs.colorScheme === option.value"
144
+ @click.stop="setColorScheme(option.value)"
145
+ >
146
+ <span>{{ option.label }}</span>
147
+ <span v-if="prefs.colorScheme === option.value" aria-hidden="true">✓</span>
148
+ </button>
149
+ </div>
150
+ </div>
151
+
152
+ <div class="a11y-menu__item">
153
+ <button
154
+ type="button"
155
+ class="a11y-menu__trigger"
156
+ :aria-expanded="openSubmenu === 'accent'"
157
+ aria-controls="a11y-submenu-accent"
158
+ @click.stop="toggleSubmenu('accent')"
159
+ >
160
+ <span class="a11y-menu__trigger-main">
161
+ <span class="a11y-menu__trigger-label">Acento</span>
162
+ <span class="a11y-menu__trigger-value">{{ accentLabel() }}</span>
163
+ </span>
164
+ <span class="a11y-menu__chevron" aria-hidden="true">
165
+ {{ openSubmenu === 'accent' ? '▴' : '▾' }}
166
+ </span>
167
+ </button>
168
+ <div
169
+ v-if="openSubmenu === 'accent'"
170
+ id="a11y-submenu-accent"
171
+ class="a11y-menu__submenu"
172
+ role="radiogroup"
173
+ aria-label="Color de acento"
174
+ >
175
+ <button
176
+ v-for="option in accentOptions"
177
+ :key="option.value"
178
+ type="button"
179
+ class="a11y-menu__option"
180
+ :class="{ 'is-active': prefs.brandAccent === option.value }"
181
+ role="radio"
182
+ :aria-checked="prefs.brandAccent === option.value"
183
+ @click.stop="setBrandAccent(option.value)"
184
+ >
185
+ <span class="a11y-menu__option-accent">
186
+ <span
187
+ class="a11y-menu__swatch"
188
+ :style="{ backgroundColor: option.swatch }"
189
+ aria-hidden="true"
190
+ />
191
+ {{ option.label }}
192
+ </span>
193
+ <span v-if="prefs.brandAccent === option.value" aria-hidden="true">✓</span>
194
+ </button>
195
+ </div>
196
+ </div>
197
+
198
+ <div class="a11y-menu__item">
199
+ <button
200
+ type="button"
201
+ class="a11y-menu__trigger"
202
+ :aria-expanded="openSubmenu === 'font'"
203
+ aria-controls="a11y-submenu-font"
204
+ @click.stop="toggleSubmenu('font')"
205
+ >
206
+ <span class="a11y-menu__trigger-main">
207
+ <span class="a11y-menu__trigger-label">Fuente</span>
208
+ <span class="a11y-menu__trigger-value">{{ fontLabel() }}</span>
209
+ </span>
210
+ <span class="a11y-menu__chevron" aria-hidden="true">
211
+ {{ openSubmenu === 'font' ? '▴' : '▾' }}
212
+ </span>
213
+ </button>
214
+ <div
215
+ v-if="openSubmenu === 'font'"
216
+ id="a11y-submenu-font"
217
+ class="a11y-menu__submenu"
218
+ role="radiogroup"
219
+ aria-label="Tipo de fuente"
220
+ >
221
+ <button
222
+ v-for="option in fontOptions"
223
+ :key="option.value"
224
+ type="button"
225
+ class="a11y-menu__option"
226
+ :class="{ 'is-active': prefs.fontFamily === option.value }"
227
+ role="radio"
228
+ :aria-checked="prefs.fontFamily === option.value"
229
+ @click.stop="setFontFamily(option.value)"
230
+ >
231
+ <span>{{ option.label }}</span>
232
+ <span v-if="prefs.fontFamily === option.value" aria-hidden="true">✓</span>
233
+ </button>
234
+ </div>
235
+ </div>
236
+
237
+ <div class="a11y-menu__item">
238
+ <button
239
+ type="button"
240
+ class="a11y-menu__trigger"
241
+ :aria-expanded="openSubmenu === 'scale'"
242
+ aria-controls="a11y-submenu-scale"
243
+ @click.stop="toggleSubmenu('scale')"
244
+ >
245
+ <span class="a11y-menu__trigger-main">
246
+ <span class="a11y-menu__trigger-label">Tamaño</span>
247
+ <span class="a11y-menu__trigger-value">{{ scaleLabel() }}</span>
248
+ </span>
249
+ <span class="a11y-menu__chevron" aria-hidden="true">
250
+ {{ openSubmenu === 'scale' ? '▴' : '▾' }}
251
+ </span>
252
+ </button>
253
+ <div
254
+ v-if="openSubmenu === 'scale'"
255
+ id="a11y-submenu-scale"
256
+ class="a11y-menu__submenu"
257
+ role="radiogroup"
258
+ aria-label="Tamaño del texto"
259
+ >
260
+ <button
261
+ v-for="option in scaleOptions"
262
+ :key="option.value"
263
+ type="button"
264
+ class="a11y-menu__option"
265
+ :class="{ 'is-active': prefs.textScale === option.value }"
266
+ role="radio"
267
+ :aria-checked="prefs.textScale === option.value"
268
+ @click.stop="setTextScale(option.value)"
269
+ >
270
+ <span>{{ option.label }}</span>
271
+ <span v-if="prefs.textScale === option.value" aria-hidden="true">✓</span>
272
+ </button>
273
+ </div>
274
+ </div>
275
+ </div>
276
+ </div>
277
+ </template>
278
+
279
+ <style scoped>
280
+ .a11y-menu {
281
+ border-top: 1px solid var(--color-border, #e2e8f0);
282
+ border-bottom: 1px solid var(--color-border, #e2e8f0);
283
+ background: var(--color-panel, #fff);
284
+ }
285
+
286
+ .a11y-menu__root {
287
+ display: flex;
288
+ width: 100%;
289
+ align-items: center;
290
+ justify-content: space-between;
291
+ gap: 0.75rem;
292
+ padding: 0.625rem 1rem;
293
+ text-align: left;
294
+ color: var(--color-text, #334155);
295
+ }
296
+
297
+ .a11y-menu__root:hover {
298
+ background: var(--color-surface, #f8fafc);
299
+ }
300
+
301
+ .a11y-menu__root:focus-visible {
302
+ outline: 2px solid var(--color-brand-600);
303
+ outline-offset: -2px;
304
+ }
305
+
306
+ .a11y-menu__root-main {
307
+ display: flex;
308
+ min-width: 0;
309
+ flex-direction: column;
310
+ gap: 0.125rem;
311
+ }
312
+
313
+ .a11y-menu__root-label {
314
+ font-size: 0.875rem;
315
+ font-weight: 500;
316
+ }
317
+
318
+ .a11y-menu__root-value {
319
+ overflow: hidden;
320
+ text-overflow: ellipsis;
321
+ white-space: nowrap;
322
+ font-size: 0.75rem;
323
+ color: var(--color-text-muted, #64748b);
324
+ }
325
+
326
+ .a11y-menu__panel {
327
+ border-top: 1px solid var(--color-border, #f1f5f9);
328
+ background: var(--color-surface, #f8fafc);
329
+ }
330
+
331
+ .a11y-menu__item + .a11y-menu__item {
332
+ border-top: 1px solid var(--color-border, #e2e8f0);
333
+ }
334
+
335
+ .a11y-menu__trigger {
336
+ display: flex;
337
+ width: 100%;
338
+ align-items: center;
339
+ justify-content: space-between;
340
+ gap: 0.75rem;
341
+ padding: 0.5625rem 1rem 0.5625rem 1.25rem;
342
+ text-align: left;
343
+ color: var(--color-text, #334155);
344
+ }
345
+
346
+ .a11y-menu__trigger:hover {
347
+ background: var(--color-panel, #fff);
348
+ }
349
+
350
+ .a11y-menu__trigger:focus-visible {
351
+ outline: 2px solid var(--color-brand-600);
352
+ outline-offset: -2px;
353
+ }
354
+
355
+ .a11y-menu__trigger-main {
356
+ display: flex;
357
+ min-width: 0;
358
+ flex-direction: column;
359
+ gap: 0.125rem;
360
+ }
361
+
362
+ .a11y-menu__trigger-label {
363
+ font-size: 0.8125rem;
364
+ font-weight: 500;
365
+ }
366
+
367
+ .a11y-menu__trigger-value {
368
+ font-size: 0.6875rem;
369
+ color: var(--color-text-muted, #64748b);
370
+ }
371
+
372
+ .a11y-menu__chevron {
373
+ flex-shrink: 0;
374
+ font-size: 0.75rem;
375
+ color: var(--color-text-muted, #94a3b8);
376
+ }
377
+
378
+ .a11y-menu__submenu {
379
+ display: flex;
380
+ flex-direction: column;
381
+ padding: 0.25rem 0.5rem 0.625rem 0.75rem;
382
+ background: var(--color-panel, #fff);
383
+ }
384
+
385
+ .a11y-menu__option {
386
+ display: flex;
387
+ width: 100%;
388
+ align-items: center;
389
+ justify-content: space-between;
390
+ gap: 0.75rem;
391
+ border-radius: 0.375rem;
392
+ padding: 0.5rem 0.625rem;
393
+ text-align: left;
394
+ font-size: 0.8125rem;
395
+ color: var(--color-text, #334155);
396
+ }
397
+
398
+ .a11y-menu__option:hover {
399
+ background: var(--color-surface, #f8fafc);
400
+ }
401
+
402
+ .a11y-menu__option.is-active {
403
+ background: var(--color-brand-50);
404
+ color: var(--color-brand-800);
405
+ font-weight: 600;
406
+ }
407
+
408
+ .a11y-menu__option:focus-visible {
409
+ outline: 2px solid var(--color-brand-600);
410
+ outline-offset: 1px;
411
+ }
412
+
413
+ .a11y-menu__option-accent {
414
+ display: inline-flex;
415
+ align-items: center;
416
+ gap: 0.5rem;
417
+ }
418
+
419
+ .a11y-menu__swatch {
420
+ display: inline-block;
421
+ height: 0.875rem;
422
+ width: 0.875rem;
423
+ border-radius: 9999px;
424
+ border: 1px solid rgb(0 0 0 / 0.15);
425
+ }
426
+ </style>