@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.
- package/README.md +48 -0
- package/package.json +50 -0
- package/src/a11y/announcer.ts +43 -0
- package/src/a11y/apply.ts +83 -0
- package/src/a11y/index.ts +31 -0
- package/src/a11y/state.ts +40 -0
- package/src/a11y/storage.ts +106 -0
- package/src/a11y/types.ts +187 -0
- package/src/api.ts +89 -0
- package/src/assets/softdin-logo.png +0 -0
- package/src/auth.ts +134 -0
- package/src/components/a11y/A11yLiveRegion.vue +11 -0
- package/src/components/a11y/AccessibilityMenuControls.vue +426 -0
- package/src/components/a11y/AccessibilityPreferencesPanel.vue +331 -0
- package/src/components/a11y/ColorSchemeQuickToggle.vue +105 -0
- package/src/components/a11y/SkipLink.vue +41 -0
- package/src/components/auth/AuthBrandHeader.vue +129 -0
- package/src/components/auth/AuthShell.vue +52 -0
- package/src/components/auth/DataProcessingConsentCheckbox.vue +303 -0
- package/src/components/auth/DataProcessingConsentForm.vue +111 -0
- package/src/components/forms/ErrorAlert.vue +170 -0
- package/src/components/forms/FormCancelButton.vue +87 -0
- package/src/components/forms/FormCloseButton.vue +114 -0
- package/src/components/forms/FormHelpButton.vue +109 -0
- package/src/components/forms/FormHelpModal.vue +193 -0
- package/src/components/forms/FormMaximizeButton.vue +143 -0
- package/src/components/forms/FormRefreshButton.vue +152 -0
- package/src/components/forms/FormSaveButton.vue +113 -0
- package/src/components/forms/PasswordInput.vue +154 -0
- package/src/components/navigation/CatalogListPagination.vue +113 -0
- package/src/config/apiBase.ts +37 -0
- package/src/lib/catalog-pagination.ts +27 -0
- package/src/lib/pagination.ts +86 -0
- package/src/lib/softdin-libreria.ts +50 -0
- package/src/style.css +278 -0
- package/src/types/auth.ts +146 -0
- package/src/types/softdin-libreria.d.ts +41 -0
- package/src/utils/apiError.ts +55 -0
- package/src/utils/connectivity.ts +33 -0
- package/src/utils/tenantLogo.ts +6 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { watch } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
accessibilityPreferencesState,
|
|
5
|
+
applyAccessibilityPreferences,
|
|
6
|
+
resetAccessibilityPreferences,
|
|
7
|
+
saveAccessibilityPreferences,
|
|
8
|
+
type A11yBrandAccent,
|
|
9
|
+
type A11yColorScheme,
|
|
10
|
+
type A11yContrast,
|
|
11
|
+
type A11yFontFamily,
|
|
12
|
+
type A11yLineSpacing,
|
|
13
|
+
type A11yMotion,
|
|
14
|
+
type A11yTextScale,
|
|
15
|
+
} from '../../a11y'
|
|
16
|
+
|
|
17
|
+
const prefs = accessibilityPreferencesState
|
|
18
|
+
|
|
19
|
+
const textScaleOptions: Array<{ value: A11yTextScale; label: string }> = [
|
|
20
|
+
{ value: 'xs', label: 'Muy pequeño' },
|
|
21
|
+
{ value: 'sm', label: 'Pequeño' },
|
|
22
|
+
{ value: 'md', label: 'Medio' },
|
|
23
|
+
{ value: 'lg', label: 'Grande' },
|
|
24
|
+
{ value: 'xl', label: 'Extra' },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
const fontFamilyOptions: Array<{ value: A11yFontFamily; label: string }> = [
|
|
28
|
+
{ value: 'system', label: 'Sistema' },
|
|
29
|
+
{ value: 'serif', label: 'Serif' },
|
|
30
|
+
{ value: 'dyslexia', label: 'Lectura fácil' },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
const lineSpacingOptions: Array<{ value: A11yLineSpacing; label: string }> = [
|
|
34
|
+
{ value: 'normal', label: 'Normal' },
|
|
35
|
+
{ value: 'relaxed', label: 'Amplio' },
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const colorSchemeOptions: Array<{ value: A11yColorScheme; label: string }> = [
|
|
39
|
+
{ value: 'light', label: 'Claro' },
|
|
40
|
+
{ value: 'dark', label: 'Oscuro' },
|
|
41
|
+
{ value: 'system', label: 'Sistema' },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
const brandAccentOptions: Array<{ value: A11yBrandAccent; label: string }> = [
|
|
45
|
+
{ value: 'green', label: 'Verde' },
|
|
46
|
+
{ value: 'blue', label: 'Azul' },
|
|
47
|
+
{ value: 'red', label: 'Rojo' },
|
|
48
|
+
{ value: 'purple', label: 'Morado' },
|
|
49
|
+
{ value: 'orange', label: 'Naranja' },
|
|
50
|
+
{ value: 'teal', label: 'Turquesa' },
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
const contrastOptions: Array<{ value: A11yContrast; label: string }> = [
|
|
54
|
+
{ value: 'default', label: 'Normal' },
|
|
55
|
+
{ value: 'high', label: 'Alto' },
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
const motionOptions: Array<{ value: A11yMotion; label: string }> = [
|
|
59
|
+
{ value: 'system', label: 'Según el sistema' },
|
|
60
|
+
{ value: 'reduce', label: 'Reducir animaciones' },
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
watch(
|
|
64
|
+
prefs,
|
|
65
|
+
(next) => {
|
|
66
|
+
applyAccessibilityPreferences({ ...next })
|
|
67
|
+
saveAccessibilityPreferences({ ...next })
|
|
68
|
+
},
|
|
69
|
+
{ deep: true },
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
function handleReset(): void {
|
|
73
|
+
resetAccessibilityPreferences()
|
|
74
|
+
}
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<template>
|
|
78
|
+
<section id="accesibilidad" class="a11y-panel" aria-labelledby="a11y-panel-title" tabindex="-1">
|
|
79
|
+
<div>
|
|
80
|
+
<p id="a11y-panel-title" class="a11y-panel__section-label">Accesibilidad</p>
|
|
81
|
+
<p class="a11y-panel__hint">
|
|
82
|
+
Ajuste tipografía, colores y movimiento. Los cambios se aplican de inmediato y se guardan en
|
|
83
|
+
este navegador.
|
|
84
|
+
</p>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<p class="a11y-panel__preview" aria-live="polite">
|
|
88
|
+
Vista previa: el tamaño y el color de la interfaz cambian al instante.
|
|
89
|
+
</p>
|
|
90
|
+
|
|
91
|
+
<fieldset class="a11y-panel__fieldset">
|
|
92
|
+
<legend class="a11y-panel__legend">Tamaño del texto</legend>
|
|
93
|
+
<div class="a11y-panel__options" role="radiogroup" aria-label="Tamaño del texto">
|
|
94
|
+
<label
|
|
95
|
+
v-for="option in textScaleOptions"
|
|
96
|
+
:key="option.value"
|
|
97
|
+
class="a11y-panel__option"
|
|
98
|
+
:class="{ 'is-active': prefs.textScale === option.value }"
|
|
99
|
+
>
|
|
100
|
+
<input
|
|
101
|
+
v-model="prefs.textScale"
|
|
102
|
+
type="radio"
|
|
103
|
+
name="a11y-text-scale"
|
|
104
|
+
:value="option.value"
|
|
105
|
+
/>
|
|
106
|
+
<span>{{ option.label }}</span>
|
|
107
|
+
</label>
|
|
108
|
+
</div>
|
|
109
|
+
</fieldset>
|
|
110
|
+
|
|
111
|
+
<fieldset class="a11y-panel__fieldset">
|
|
112
|
+
<legend class="a11y-panel__legend">Tipo de fuente</legend>
|
|
113
|
+
<div class="a11y-panel__options" role="radiogroup" aria-label="Tipo de fuente">
|
|
114
|
+
<label
|
|
115
|
+
v-for="option in fontFamilyOptions"
|
|
116
|
+
:key="option.value"
|
|
117
|
+
class="a11y-panel__option"
|
|
118
|
+
:class="{ 'is-active': prefs.fontFamily === option.value }"
|
|
119
|
+
>
|
|
120
|
+
<input
|
|
121
|
+
v-model="prefs.fontFamily"
|
|
122
|
+
type="radio"
|
|
123
|
+
name="a11y-font-family"
|
|
124
|
+
:value="option.value"
|
|
125
|
+
/>
|
|
126
|
+
<span>{{ option.label }}</span>
|
|
127
|
+
</label>
|
|
128
|
+
</div>
|
|
129
|
+
</fieldset>
|
|
130
|
+
|
|
131
|
+
<fieldset class="a11y-panel__fieldset">
|
|
132
|
+
<legend class="a11y-panel__legend">Espaciado</legend>
|
|
133
|
+
<div class="a11y-panel__options" role="radiogroup" aria-label="Espaciado del texto">
|
|
134
|
+
<label
|
|
135
|
+
v-for="option in lineSpacingOptions"
|
|
136
|
+
:key="option.value"
|
|
137
|
+
class="a11y-panel__option"
|
|
138
|
+
:class="{ 'is-active': prefs.lineSpacing === option.value }"
|
|
139
|
+
>
|
|
140
|
+
<input
|
|
141
|
+
v-model="prefs.lineSpacing"
|
|
142
|
+
type="radio"
|
|
143
|
+
name="a11y-line-spacing"
|
|
144
|
+
:value="option.value"
|
|
145
|
+
/>
|
|
146
|
+
<span>{{ option.label }}</span>
|
|
147
|
+
</label>
|
|
148
|
+
</div>
|
|
149
|
+
</fieldset>
|
|
150
|
+
|
|
151
|
+
<fieldset class="a11y-panel__fieldset">
|
|
152
|
+
<legend class="a11y-panel__legend">Color de la interfaz</legend>
|
|
153
|
+
<div class="a11y-panel__options" role="radiogroup" aria-label="Color de la interfaz">
|
|
154
|
+
<label
|
|
155
|
+
v-for="option in colorSchemeOptions"
|
|
156
|
+
:key="option.value"
|
|
157
|
+
class="a11y-panel__option"
|
|
158
|
+
:class="{ 'is-active': prefs.colorScheme === option.value }"
|
|
159
|
+
>
|
|
160
|
+
<input
|
|
161
|
+
v-model="prefs.colorScheme"
|
|
162
|
+
type="radio"
|
|
163
|
+
name="a11y-color-scheme"
|
|
164
|
+
:value="option.value"
|
|
165
|
+
/>
|
|
166
|
+
<span>{{ option.label }}</span>
|
|
167
|
+
</label>
|
|
168
|
+
</div>
|
|
169
|
+
</fieldset>
|
|
170
|
+
|
|
171
|
+
<fieldset class="a11y-panel__fieldset">
|
|
172
|
+
<legend class="a11y-panel__legend">Color de acento (botones y formularios)</legend>
|
|
173
|
+
<div class="a11y-panel__options" role="radiogroup" aria-label="Color de acento">
|
|
174
|
+
<label
|
|
175
|
+
v-for="option in brandAccentOptions"
|
|
176
|
+
:key="option.value"
|
|
177
|
+
class="a11y-panel__option"
|
|
178
|
+
:class="{ 'is-active': prefs.brandAccent === option.value }"
|
|
179
|
+
>
|
|
180
|
+
<input
|
|
181
|
+
v-model="prefs.brandAccent"
|
|
182
|
+
type="radio"
|
|
183
|
+
name="a11y-brand-accent"
|
|
184
|
+
:value="option.value"
|
|
185
|
+
/>
|
|
186
|
+
<span>{{ option.label }}</span>
|
|
187
|
+
</label>
|
|
188
|
+
</div>
|
|
189
|
+
</fieldset>
|
|
190
|
+
|
|
191
|
+
<fieldset class="a11y-panel__fieldset">
|
|
192
|
+
<legend class="a11y-panel__legend">Contraste</legend>
|
|
193
|
+
<div class="a11y-panel__options" role="radiogroup" aria-label="Contraste">
|
|
194
|
+
<label
|
|
195
|
+
v-for="option in contrastOptions"
|
|
196
|
+
:key="option.value"
|
|
197
|
+
class="a11y-panel__option"
|
|
198
|
+
:class="{ 'is-active': prefs.contrast === option.value }"
|
|
199
|
+
>
|
|
200
|
+
<input
|
|
201
|
+
v-model="prefs.contrast"
|
|
202
|
+
type="radio"
|
|
203
|
+
name="a11y-contrast"
|
|
204
|
+
:value="option.value"
|
|
205
|
+
/>
|
|
206
|
+
<span>{{ option.label }}</span>
|
|
207
|
+
</label>
|
|
208
|
+
</div>
|
|
209
|
+
</fieldset>
|
|
210
|
+
|
|
211
|
+
<fieldset class="a11y-panel__fieldset">
|
|
212
|
+
<legend class="a11y-panel__legend">Movimiento</legend>
|
|
213
|
+
<div class="a11y-panel__options" role="radiogroup" aria-label="Movimiento">
|
|
214
|
+
<label
|
|
215
|
+
v-for="option in motionOptions"
|
|
216
|
+
:key="option.value"
|
|
217
|
+
class="a11y-panel__option"
|
|
218
|
+
:class="{ 'is-active': prefs.motion === option.value }"
|
|
219
|
+
>
|
|
220
|
+
<input v-model="prefs.motion" type="radio" name="a11y-motion" :value="option.value" />
|
|
221
|
+
<span>{{ option.label }}</span>
|
|
222
|
+
</label>
|
|
223
|
+
</div>
|
|
224
|
+
</fieldset>
|
|
225
|
+
|
|
226
|
+
<div class="a11y-panel__actions">
|
|
227
|
+
<button type="button" class="a11y-panel__reset" @click="handleReset">
|
|
228
|
+
Restablecer accesibilidad
|
|
229
|
+
</button>
|
|
230
|
+
</div>
|
|
231
|
+
</section>
|
|
232
|
+
</template>
|
|
233
|
+
|
|
234
|
+
<style scoped>
|
|
235
|
+
.a11y-panel {
|
|
236
|
+
display: flex;
|
|
237
|
+
flex-direction: column;
|
|
238
|
+
gap: 1.25rem;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.a11y-panel__section-label {
|
|
242
|
+
margin: 0;
|
|
243
|
+
font-size: 0.875rem;
|
|
244
|
+
font-weight: 600;
|
|
245
|
+
color: var(--color-text, #0f172a);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.a11y-panel__hint {
|
|
249
|
+
margin: 0.25rem 0 0;
|
|
250
|
+
font-size: 0.75rem;
|
|
251
|
+
color: var(--color-text-muted, #64748b);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.a11y-panel__preview {
|
|
255
|
+
margin: 0;
|
|
256
|
+
border-radius: 0.5rem;
|
|
257
|
+
border: 1px solid var(--color-border, #e2e8f0);
|
|
258
|
+
background: var(--color-surface, #f8fafc);
|
|
259
|
+
padding: 0.75rem 1rem;
|
|
260
|
+
font-size: 0.875rem;
|
|
261
|
+
color: var(--color-text, #0f172a);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.a11y-panel__fieldset {
|
|
265
|
+
margin: 0;
|
|
266
|
+
border: 0;
|
|
267
|
+
padding: 0;
|
|
268
|
+
min-inline-size: 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.a11y-panel__legend {
|
|
272
|
+
padding: 0;
|
|
273
|
+
margin-bottom: 0.5rem;
|
|
274
|
+
font-size: 0.875rem;
|
|
275
|
+
font-weight: 500;
|
|
276
|
+
color: var(--color-text, #334155);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.a11y-panel__options {
|
|
280
|
+
display: flex;
|
|
281
|
+
flex-wrap: wrap;
|
|
282
|
+
gap: 0.5rem;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.a11y-panel__option {
|
|
286
|
+
display: inline-flex;
|
|
287
|
+
align-items: center;
|
|
288
|
+
gap: 0.375rem;
|
|
289
|
+
border-radius: 0.5rem;
|
|
290
|
+
border: 1px solid var(--color-border, #cbd5e1);
|
|
291
|
+
background: var(--color-panel, #fff);
|
|
292
|
+
padding: 0.5rem 0.75rem;
|
|
293
|
+
font-size: 0.875rem;
|
|
294
|
+
color: var(--color-text, #334155);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.a11y-panel__option.is-active {
|
|
298
|
+
border-color: var(--color-brand-600);
|
|
299
|
+
background: var(--color-brand-50);
|
|
300
|
+
color: var(--color-brand-800);
|
|
301
|
+
box-shadow: 0 0 0 1px var(--color-brand-600);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.a11y-panel__option input {
|
|
305
|
+
margin: 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.a11y-panel__actions {
|
|
309
|
+
display: flex;
|
|
310
|
+
flex-wrap: wrap;
|
|
311
|
+
gap: 0.75rem;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.a11y-panel__reset {
|
|
315
|
+
border-radius: 0.5rem;
|
|
316
|
+
border: 1px solid var(--color-border, #cbd5e1);
|
|
317
|
+
background: var(--color-panel, #fff);
|
|
318
|
+
padding: 0.5rem 0.875rem;
|
|
319
|
+
font-size: 0.875rem;
|
|
320
|
+
color: var(--color-text, #334155);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.a11y-panel__reset:hover {
|
|
324
|
+
background: var(--color-surface, #f8fafc);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.a11y-panel__reset:focus-visible {
|
|
328
|
+
outline: 2px solid var(--color-brand-600);
|
|
329
|
+
outline-offset: 2px;
|
|
330
|
+
}
|
|
331
|
+
</style>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
accessibilityPreferencesState,
|
|
5
|
+
patchAccessibilityPreferences,
|
|
6
|
+
type A11yColorScheme,
|
|
7
|
+
} from '../../a11y'
|
|
8
|
+
|
|
9
|
+
const nextScheme = computed<A11yColorScheme>(() => {
|
|
10
|
+
if (accessibilityPreferencesState.colorScheme === 'light') {
|
|
11
|
+
return 'dark'
|
|
12
|
+
}
|
|
13
|
+
if (accessibilityPreferencesState.colorScheme === 'dark') {
|
|
14
|
+
return 'system'
|
|
15
|
+
}
|
|
16
|
+
return 'light'
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const label = computed(() => {
|
|
20
|
+
if (accessibilityPreferencesState.colorScheme === 'light') {
|
|
21
|
+
return 'Tema claro. Cambiar a oscuro'
|
|
22
|
+
}
|
|
23
|
+
if (accessibilityPreferencesState.colorScheme === 'dark') {
|
|
24
|
+
return 'Tema oscuro. Cambiar a sistema'
|
|
25
|
+
}
|
|
26
|
+
return 'Tema del sistema. Cambiar a claro'
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const icon = computed(() => {
|
|
30
|
+
if (accessibilityPreferencesState.colorScheme === 'light') {
|
|
31
|
+
return '☀'
|
|
32
|
+
}
|
|
33
|
+
if (accessibilityPreferencesState.colorScheme === 'dark') {
|
|
34
|
+
return '☾'
|
|
35
|
+
}
|
|
36
|
+
return '◐'
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const shortLabel = computed(() => {
|
|
40
|
+
if (accessibilityPreferencesState.colorScheme === 'light') {
|
|
41
|
+
return 'Claro'
|
|
42
|
+
}
|
|
43
|
+
if (accessibilityPreferencesState.colorScheme === 'dark') {
|
|
44
|
+
return 'Oscuro'
|
|
45
|
+
}
|
|
46
|
+
return 'Auto'
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
function cycleColorScheme(): void {
|
|
50
|
+
patchAccessibilityPreferences({ colorScheme: nextScheme.value })
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<template>
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
class="color-scheme-toggle"
|
|
58
|
+
:aria-label="label"
|
|
59
|
+
:title="label"
|
|
60
|
+
@click="cycleColorScheme"
|
|
61
|
+
>
|
|
62
|
+
<span class="color-scheme-toggle__icon" aria-hidden="true">{{ icon }}</span>
|
|
63
|
+
<span class="color-scheme-toggle__text">{{ shortLabel }}</span>
|
|
64
|
+
</button>
|
|
65
|
+
</template>
|
|
66
|
+
|
|
67
|
+
<style scoped>
|
|
68
|
+
.color-scheme-toggle {
|
|
69
|
+
display: inline-flex;
|
|
70
|
+
height: 2.25rem;
|
|
71
|
+
align-items: center;
|
|
72
|
+
gap: 0.375rem;
|
|
73
|
+
border-radius: 0.5rem;
|
|
74
|
+
border: 1px solid var(--color-border, #e2e8f0);
|
|
75
|
+
background: var(--color-panel, #fff);
|
|
76
|
+
padding: 0 0.625rem;
|
|
77
|
+
font-size: 0.75rem;
|
|
78
|
+
font-weight: 600;
|
|
79
|
+
color: var(--color-text, #334155);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.color-scheme-toggle:hover {
|
|
83
|
+
background: var(--color-surface, #f8fafc);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.color-scheme-toggle:focus-visible {
|
|
87
|
+
outline: 2px solid var(--color-brand-600, #16a34a);
|
|
88
|
+
outline-offset: 2px;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.color-scheme-toggle__icon {
|
|
92
|
+
font-size: 0.875rem;
|
|
93
|
+
line-height: 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.color-scheme-toggle__text {
|
|
97
|
+
display: none;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@media (min-width: 640px) {
|
|
101
|
+
.color-scheme-toggle__text {
|
|
102
|
+
display: inline;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
</style>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Enlace visible solo al recibir foco por teclado.
|
|
4
|
+
* Debe ir al inicio del layout autenticado.
|
|
5
|
+
*/
|
|
6
|
+
defineProps<{
|
|
7
|
+
targetId?: string
|
|
8
|
+
label?: string
|
|
9
|
+
}>()
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<a class="skip-link" :href="`#${targetId ?? 'main-content'}`">
|
|
14
|
+
{{ label ?? 'Saltar al contenido' }}
|
|
15
|
+
</a>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<style scoped>
|
|
19
|
+
.skip-link {
|
|
20
|
+
position: absolute;
|
|
21
|
+
left: 0.75rem;
|
|
22
|
+
top: 0.75rem;
|
|
23
|
+
z-index: 100;
|
|
24
|
+
transform: translateY(-200%);
|
|
25
|
+
border-radius: 0.5rem;
|
|
26
|
+
background: var(--color-brand-700, #15803d);
|
|
27
|
+
padding: 0.625rem 1rem;
|
|
28
|
+
font-size: 0.875rem;
|
|
29
|
+
font-weight: 600;
|
|
30
|
+
color: #fff;
|
|
31
|
+
text-decoration: none;
|
|
32
|
+
box-shadow: 0 4px 14px rgb(0 0 0 / 0.2);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.skip-link:focus,
|
|
36
|
+
.skip-link:focus-visible {
|
|
37
|
+
transform: translateY(0);
|
|
38
|
+
outline: 3px solid #fff;
|
|
39
|
+
outline-offset: 2px;
|
|
40
|
+
}
|
|
41
|
+
</style>
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import softdinLogo from '../../assets/softdin-logo.png'
|
|
4
|
+
import { tenantLogoPublicUrl } from '../../utils/tenantLogo'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
tenantName?: string
|
|
8
|
+
tenantSlug?: string
|
|
9
|
+
tenantHasLogo?: boolean
|
|
10
|
+
previewLogoUrl?: string | null
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const showTenantBrand = computed(
|
|
14
|
+
() => Boolean(props.tenantName?.trim() && props.tenantSlug?.trim()),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const logoSrc = computed(() => {
|
|
18
|
+
if (props.previewLogoUrl) {
|
|
19
|
+
return props.previewLogoUrl
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (props.tenantHasLogo && props.tenantSlug) {
|
|
23
|
+
return tenantLogoPublicUrl(props.tenantSlug)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return null
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const initials = computed(() => {
|
|
30
|
+
const name = props.tenantName?.trim() ?? ''
|
|
31
|
+
if (!name) {
|
|
32
|
+
return '?'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const parts = name.split(/\s+/).slice(0, 2)
|
|
36
|
+
return parts.map((part) => part[0]?.toUpperCase() ?? '').join('')
|
|
37
|
+
})
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<div v-if="showTenantBrand" class="brand-row">
|
|
42
|
+
<span class="brand-logo-wrap">
|
|
43
|
+
<img
|
|
44
|
+
v-if="logoSrc"
|
|
45
|
+
:src="logoSrc"
|
|
46
|
+
:alt="tenantName"
|
|
47
|
+
class="brand-logo"
|
|
48
|
+
/>
|
|
49
|
+
<span v-else class="brand-logo-fallback">{{ initials }}</span>
|
|
50
|
+
</span>
|
|
51
|
+
<div class="brand-text">
|
|
52
|
+
<span class="brand-title">{{ tenantName }}</span>
|
|
53
|
+
<span class="brand-subtitle">{{ tenantSlug }}</span>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div v-else class="brand-row">
|
|
58
|
+
<img :src="softdinLogo" alt="SoftDIN Central" class="brand-logo" />
|
|
59
|
+
<div class="brand-text">
|
|
60
|
+
<span class="brand-title">SoftDIN Central</span>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</template>
|
|
64
|
+
|
|
65
|
+
<style scoped>
|
|
66
|
+
.brand-row {
|
|
67
|
+
display: flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
gap: 0.875rem;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.brand-text {
|
|
73
|
+
display: flex;
|
|
74
|
+
flex-direction: column;
|
|
75
|
+
align-items: flex-start;
|
|
76
|
+
text-align: left;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.brand-title {
|
|
80
|
+
font-size: 1.375rem;
|
|
81
|
+
font-weight: 700;
|
|
82
|
+
line-height: 1.2;
|
|
83
|
+
color: var(--color-brand-800);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.brand-subtitle {
|
|
87
|
+
font-size: 0.8125rem;
|
|
88
|
+
font-weight: 600;
|
|
89
|
+
letter-spacing: 0.04em;
|
|
90
|
+
color: var(--color-brand-600);
|
|
91
|
+
text-transform: lowercase;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.brand-logo-wrap {
|
|
95
|
+
display: flex;
|
|
96
|
+
height: 5.5rem;
|
|
97
|
+
width: 5.5rem;
|
|
98
|
+
flex-shrink: 0;
|
|
99
|
+
align-items: center;
|
|
100
|
+
justify-content: center;
|
|
101
|
+
overflow: hidden;
|
|
102
|
+
border-radius: 9999px;
|
|
103
|
+
border: 3px solid var(--color-brand-100);
|
|
104
|
+
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.12);
|
|
105
|
+
background: #f8fafc;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.brand-logo {
|
|
109
|
+
height: 100%;
|
|
110
|
+
width: 100%;
|
|
111
|
+
object-fit: contain;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.brand-logo-fallback {
|
|
115
|
+
font-size: 1.25rem;
|
|
116
|
+
font-weight: 700;
|
|
117
|
+
color: var(--color-brand-700);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.brand-row > .brand-logo {
|
|
121
|
+
width: 5.5rem;
|
|
122
|
+
height: 5.5rem;
|
|
123
|
+
border-radius: 9999px;
|
|
124
|
+
object-fit: cover;
|
|
125
|
+
object-position: center 18%;
|
|
126
|
+
border: 3px solid var(--color-brand-100);
|
|
127
|
+
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.12);
|
|
128
|
+
}
|
|
129
|
+
</style>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import AuthBrandHeader from './AuthBrandHeader.vue'
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
title: string
|
|
6
|
+
subtitle?: string
|
|
7
|
+
wide?: boolean
|
|
8
|
+
extraWide?: boolean
|
|
9
|
+
tenantName?: string
|
|
10
|
+
tenantSlug?: string
|
|
11
|
+
tenantHasLogo?: boolean
|
|
12
|
+
tenantPreviewLogoUrl?: string | null
|
|
13
|
+
}>()
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<div class="auth-bg flex min-h-full items-center justify-center px-4 py-10">
|
|
18
|
+
<div
|
|
19
|
+
class="w-full rounded-2xl bg-white p-8 shadow-xl"
|
|
20
|
+
:class="extraWide ? 'max-w-2xl' : wide ? 'max-w-lg' : 'max-w-md'"
|
|
21
|
+
>
|
|
22
|
+
<div class="mb-8 flex flex-col items-center text-center">
|
|
23
|
+
<AuthBrandHeader
|
|
24
|
+
class="mb-3"
|
|
25
|
+
:tenant-name="tenantName"
|
|
26
|
+
:tenant-slug="tenantSlug"
|
|
27
|
+
:tenant-has-logo="tenantHasLogo"
|
|
28
|
+
:preview-logo-url="tenantPreviewLogoUrl"
|
|
29
|
+
/>
|
|
30
|
+
<h1 class="text-xl font-bold text-slate-900">{{ title }}</h1>
|
|
31
|
+
<p v-if="subtitle" class="mt-2 text-sm text-slate-500">{{ subtitle }}</p>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<slot />
|
|
35
|
+
|
|
36
|
+
<div v-if="$slots.footer" class="mt-6 border-t border-slate-100 pt-4 text-center text-sm">
|
|
37
|
+
<slot name="footer" />
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</template>
|
|
42
|
+
|
|
43
|
+
<style scoped>
|
|
44
|
+
.auth-bg {
|
|
45
|
+
background: linear-gradient(
|
|
46
|
+
135deg,
|
|
47
|
+
var(--color-brand-800) 0%,
|
|
48
|
+
var(--color-brand-700) 45%,
|
|
49
|
+
#052e16 100%
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
</style>
|