@edc-motor/ui 0.2.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.
- package/LICENSE +674 -0
- package/README.md +45 -0
- package/package.json +47 -0
- package/scss/_base.scss +33 -0
- package/scss/_forms.scss +33 -0
- package/scss/_shared-components.scss +1 -0
- package/scss/_theme.scss +74 -0
- package/scss/_tokens.scss +114 -0
- package/scss/components/_base-button.scss +113 -0
- package/scss/components/_blocks.scss +211 -0
- package/scss/components/_breadcrumbs.scss +31 -0
- package/scss/components/_checkbox.scss +42 -0
- package/scss/components/_chip.scss +23 -0
- package/scss/components/_confirm.scss +3 -0
- package/scss/components/_edit-modal.scss +11 -0
- package/scss/components/_font-upload.scss +91 -0
- package/scss/components/_form-field.scss +85 -0
- package/scss/components/_icon-button.scss +23 -0
- package/scss/components/_image-upload.scss +85 -0
- package/scss/components/_index.scss +24 -0
- package/scss/components/_locale-selector.scss +89 -0
- package/scss/components/_modal.scss +68 -0
- package/scss/components/_motor-badge.scss +21 -0
- package/scss/components/_numeric-input.scss +53 -0
- package/scss/components/_page-background.scss +22 -0
- package/scss/components/_palette-color-picker.scss +69 -0
- package/scss/components/_rich-text.scss +144 -0
- package/scss/components/_search-select.scss +138 -0
- package/scss/components/_tabs.scss +120 -0
- package/scss/components/_theme-selector.scss +34 -0
- package/scss/components/_toast-container.scss +16 -0
- package/scss/components/_toast.scss +25 -0
- package/scss/components/_translatable-input.scss +73 -0
- package/src/blocks/BlockCta.vue +32 -0
- package/src/blocks/BlockFaq.vue +25 -0
- package/src/blocks/BlockHeader.vue +13 -0
- package/src/blocks/BlockIndex.vue +20 -0
- package/src/blocks/BlockQuote.vue +16 -0
- package/src/blocks/BlockShell.vue +30 -0
- package/src/blocks/BlockText.vue +20 -0
- package/src/blocks/BlockTextCard.vue +23 -0
- package/src/blocks/PageBackground.vue +15 -0
- package/src/blocks/index.ts +25 -0
- package/src/components/AppBreadcrumbs.vue +44 -0
- package/src/components/BaseButton.vue +24 -0
- package/src/components/BaseCheckbox.vue +37 -0
- package/src/components/BaseInput.vue +53 -0
- package/src/components/BaseModal.vue +98 -0
- package/src/components/BaseSelect.vue +53 -0
- package/src/components/BaseTabs.vue +30 -0
- package/src/components/BaseTextarea.vue +45 -0
- package/src/components/BaseToast.vue +31 -0
- package/src/components/ConfirmDialog.vue +35 -0
- package/src/components/EditModal.vue +57 -0
- package/src/components/FontUpload.vue +123 -0
- package/src/components/IconButton.vue +24 -0
- package/src/components/ImageUpload.vue +142 -0
- package/src/components/LocaleSelector.vue +59 -0
- package/src/components/MotorBadge.vue +17 -0
- package/src/components/NumericInput.vue +115 -0
- package/src/components/PaletteColorPicker.vue +94 -0
- package/src/components/RichTextInput.vue +273 -0
- package/src/components/SearchSelect.vue +172 -0
- package/src/components/ThemeSelector.vue +28 -0
- package/src/components/ToastContainer.vue +23 -0
- package/src/components/TranslatableImage.vue +120 -0
- package/src/components/TranslatableInput.vue +135 -0
- package/src/composables/useConfirm.ts +49 -0
- package/src/composables/useHead.ts +54 -0
- package/src/composables/useTheme.ts +50 -0
- package/src/composables/useToast.ts +26 -0
- package/src/index.ts +39 -0
- package/src/lib/createApi.ts +42 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import BlockShell from './BlockShell.vue'
|
|
3
|
+
|
|
4
|
+
// Índice automático: enlaces de ancla a los bloques posteriores de la página.
|
|
5
|
+
defineProps<{
|
|
6
|
+
settings: Record<string, unknown>
|
|
7
|
+
data: { items?: { id: number; label: string }[] }
|
|
8
|
+
}>()
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<BlockShell :settings="settings" class="block--index">
|
|
13
|
+
<h2 v-if="settings.title" class="block__title">{{ settings.title }}</h2>
|
|
14
|
+
<component :is="settings.numbered ? 'ol' : 'ul'" class="block__index">
|
|
15
|
+
<li v-for="item in data.items ?? []" :key="item.id">
|
|
16
|
+
<a :href="`#block-${item.id}`">{{ item.label }}</a>
|
|
17
|
+
</li>
|
|
18
|
+
</component>
|
|
19
|
+
</BlockShell>
|
|
20
|
+
</template>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import BlockShell from './BlockShell.vue'
|
|
3
|
+
|
|
4
|
+
defineProps<{ settings: Record<string, unknown>; data?: Record<string, unknown> }>()
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<!-- eslint-disable vue/no-v-html -- HTML saneado en servidor (DC-09) -->
|
|
8
|
+
<template>
|
|
9
|
+
<BlockShell :settings="settings" class="block--quote">
|
|
10
|
+
<blockquote class="block__quote">
|
|
11
|
+
<img v-if="settings.image" class="block__avatar" :src="String(settings.image)" alt="" />
|
|
12
|
+
<div class="rich-content" v-html="settings.quote" />
|
|
13
|
+
<footer v-if="settings.author" class="block__author">— {{ settings.author }}</footer>
|
|
14
|
+
</blockquote>
|
|
15
|
+
</BlockShell>
|
|
16
|
+
</template>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, type CSSProperties } from 'vue'
|
|
3
|
+
|
|
4
|
+
// Envoltorio común de todos los bloques públicos: aplica los campos comunes
|
|
5
|
+
// (align, width, background) que el motor añade a cada tipo. El color de
|
|
6
|
+
// fondo NO se aplica opaco: es un tinte semitransparente (--block-tint,
|
|
7
|
+
// distinto por tema, patrón CDL) para que la imagen de fondo de la página se
|
|
8
|
+
// vea a través. La anchura del CONTENIDO (full/wide/narrow) va por clase.
|
|
9
|
+
const props = defineProps<{ settings: Record<string, unknown> }>()
|
|
10
|
+
|
|
11
|
+
const width = computed(() => `block--w-${(props.settings.width as string) || 'wide'}`)
|
|
12
|
+
|
|
13
|
+
const style = computed<CSSProperties>(() => {
|
|
14
|
+
const background = props.settings.background as string | undefined
|
|
15
|
+
return {
|
|
16
|
+
'--block-bg': background
|
|
17
|
+
? `color-mix(in srgb, ${background} var(--block-tint, 15%), transparent)`
|
|
18
|
+
: 'transparent',
|
|
19
|
+
textAlign: ((props.settings.align as string) || 'left') as CSSProperties['textAlign'],
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<section class="block" :class="width" :style="style">
|
|
26
|
+
<div class="block__inner">
|
|
27
|
+
<slot />
|
|
28
|
+
</div>
|
|
29
|
+
</section>
|
|
30
|
+
</template>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import BlockShell from './BlockShell.vue'
|
|
3
|
+
|
|
4
|
+
defineProps<{ settings: Record<string, unknown>; data?: Record<string, unknown> }>()
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<!-- eslint-disable vue/no-v-html -- HTML saneado en servidor (DC-09) -->
|
|
8
|
+
<template>
|
|
9
|
+
<BlockShell
|
|
10
|
+
:settings="settings"
|
|
11
|
+
class="block--text"
|
|
12
|
+
:class="settings.image ? `block--image-${settings.image_position || 'top'}` : ''"
|
|
13
|
+
>
|
|
14
|
+
<h2 v-if="settings.title" class="block__title">{{ settings.title }}</h2>
|
|
15
|
+
<div class="block__media-layout">
|
|
16
|
+
<img v-if="settings.image" class="block__image" :src="String(settings.image)" alt="" />
|
|
17
|
+
<div class="block__text rich-content" v-html="settings.body" />
|
|
18
|
+
</div>
|
|
19
|
+
</BlockShell>
|
|
20
|
+
</template>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import BlockShell from './BlockShell.vue'
|
|
3
|
+
|
|
4
|
+
defineProps<{ settings: Record<string, unknown>; data?: Record<string, unknown> }>()
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<!-- eslint-disable vue/no-v-html -- HTML saneado en servidor (DC-09) -->
|
|
8
|
+
<template>
|
|
9
|
+
<BlockShell
|
|
10
|
+
:settings="settings"
|
|
11
|
+
class="block--text-card"
|
|
12
|
+
:class="settings.image ? `block--image-${settings.image_position || 'top'}` : ''"
|
|
13
|
+
>
|
|
14
|
+
<div class="block__card">
|
|
15
|
+
<span v-if="settings.label" class="block__label">{{ settings.label }}</span>
|
|
16
|
+
<h2 v-if="settings.title" class="block__title">{{ settings.title }}</h2>
|
|
17
|
+
<div class="block__media-layout">
|
|
18
|
+
<img v-if="settings.image" class="block__image" :src="String(settings.image)" alt="" />
|
|
19
|
+
<div class="block__text rich-content" v-html="settings.body" />
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</BlockShell>
|
|
23
|
+
</template>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Imagen de fondo de la página (patrón CDL): capa FIJA a toda la ventana,
|
|
3
|
+
// detrás del contenido, atenuada según el tema (--page-bg-opacity) y con un
|
|
4
|
+
// desaturado suave para que el texto siga siendo legible. Decorativa.
|
|
5
|
+
defineProps<{ image?: string | null }>()
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<div
|
|
10
|
+
v-if="image"
|
|
11
|
+
class="page-background"
|
|
12
|
+
:style="{ backgroundImage: `url('${image}')` }"
|
|
13
|
+
aria-hidden="true"
|
|
14
|
+
/>
|
|
15
|
+
</template>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Componentes de render de los bloques de PRESENTACIÓN del motor (doc 03).
|
|
2
|
+
// La app del juego los mezcla con los suyos en su blockRegistry.
|
|
3
|
+
import type { Component } from 'vue'
|
|
4
|
+
import BlockCta from './BlockCta.vue'
|
|
5
|
+
import BlockFaq from './BlockFaq.vue'
|
|
6
|
+
import BlockHeader from './BlockHeader.vue'
|
|
7
|
+
import BlockIndex from './BlockIndex.vue'
|
|
8
|
+
import BlockQuote from './BlockQuote.vue'
|
|
9
|
+
import BlockText from './BlockText.vue'
|
|
10
|
+
import BlockTextCard from './BlockTextCard.vue'
|
|
11
|
+
|
|
12
|
+
export { BlockCta, BlockFaq, BlockHeader, BlockIndex, BlockQuote, BlockText, BlockTextCard }
|
|
13
|
+
export { default as BlockShell } from './BlockShell.vue'
|
|
14
|
+
export { default as PageBackground } from './PageBackground.vue'
|
|
15
|
+
|
|
16
|
+
/** Clave del BlockType => componente (los cinco de presentación). */
|
|
17
|
+
export const motorBlockComponents: Record<string, Component> = {
|
|
18
|
+
header: BlockHeader,
|
|
19
|
+
text: BlockText,
|
|
20
|
+
'text-card': BlockTextCard,
|
|
21
|
+
quote: BlockQuote,
|
|
22
|
+
index: BlockIndex,
|
|
23
|
+
cta: BlockCta,
|
|
24
|
+
faq: BlockFaq,
|
|
25
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { useRoute, RouterLink, type RouteLocationRaw } from 'vue-router'
|
|
4
|
+
import { ChevronRight } from '@lucide/vue'
|
|
5
|
+
|
|
6
|
+
// Migas de pan. Adaptado de kontuan sin vue-i18n: cada ruta declara en su
|
|
7
|
+
// `meta.breadcrumbs` un array de { label, to? }. Se antepone una miga "home".
|
|
8
|
+
export interface Crumb {
|
|
9
|
+
label: string
|
|
10
|
+
to?: RouteLocationRaw
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const props = withDefaults(
|
|
14
|
+
defineProps<{
|
|
15
|
+
home?: Crumb | null
|
|
16
|
+
/**
|
|
17
|
+
* Migas ya resueltas/traducidas. Si se pasan, mandan sobre
|
|
18
|
+
* `route.meta.breadcrumbs` (útil para i18n desde la app).
|
|
19
|
+
*/
|
|
20
|
+
crumbs?: Crumb[] | null
|
|
21
|
+
}>(),
|
|
22
|
+
{ home: () => ({ label: 'Inicio', to: { name: 'dashboard' } }), crumbs: null },
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const route = useRoute()
|
|
26
|
+
|
|
27
|
+
const items = computed<Crumb[]>(() => {
|
|
28
|
+
const source = props.crumbs ?? (route.meta.breadcrumbs as Crumb[] | undefined) ?? []
|
|
29
|
+
if (!source.length) return []
|
|
30
|
+
const head = props.home ? [props.home] : []
|
|
31
|
+
// La última miga nunca es navegable (es la página actual).
|
|
32
|
+
return [...head, ...source].map((c, i, arr) => (i === arr.length - 1 ? { label: c.label } : c))
|
|
33
|
+
})
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<nav v-if="items.length" class="breadcrumbs" aria-label="Breadcrumbs">
|
|
38
|
+
<template v-for="(item, i) in items" :key="i">
|
|
39
|
+
<ChevronRight v-if="i > 0" class="breadcrumb-separator" :size="14" />
|
|
40
|
+
<RouterLink v-if="item.to" :to="item.to" class="breadcrumb-link">{{ item.label }}</RouterLink>
|
|
41
|
+
<span v-else class="breadcrumb-current">{{ item.label }}</span>
|
|
42
|
+
</template>
|
|
43
|
+
</nav>
|
|
44
|
+
</template>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Botón base. El slot `icon` (opcional, patrón kontuan) coloca un icono a la
|
|
3
|
+
// izquierda; en contenedores estrechos el texto pasa a mini-etiqueta bajo el
|
|
4
|
+
// icono (ver _base-button.scss) y en anchos vuelve a la fila.
|
|
5
|
+
withDefaults(
|
|
6
|
+
defineProps<{
|
|
7
|
+
variant?:
|
|
8
|
+
'primary' | 'secondary' | 'danger' | 'success' | 'info' | 'warning' | 'text' | 'text-danger'
|
|
9
|
+
type?: 'button' | 'submit'
|
|
10
|
+
}>(),
|
|
11
|
+
{ variant: 'primary', type: 'button' },
|
|
12
|
+
)
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<button
|
|
17
|
+
:type="type"
|
|
18
|
+
class="edc-button"
|
|
19
|
+
:class="[`edc-button--${variant}`, { 'edc-button--has-icon': !!$slots.icon }]"
|
|
20
|
+
>
|
|
21
|
+
<span v-if="$slots.icon" class="edc-button__icon"><slot name="icon" /></span>
|
|
22
|
+
<span v-if="$slots.default" class="edc-button__text"><slot /></span>
|
|
23
|
+
</button>
|
|
24
|
+
</template>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Check } from '@lucide/vue'
|
|
3
|
+
|
|
4
|
+
// Checkbox de formulario (portado de kontuan).
|
|
5
|
+
const props = withDefaults(
|
|
6
|
+
defineProps<{
|
|
7
|
+
modelValue?: boolean
|
|
8
|
+
label?: string
|
|
9
|
+
disabled?: boolean
|
|
10
|
+
id?: string
|
|
11
|
+
}>(),
|
|
12
|
+
{ modelValue: false, disabled: false },
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
|
16
|
+
|
|
17
|
+
const checkboxId = props.id || `checkbox-${Math.random().toString(36).slice(2, 9)}`
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<label :for="checkboxId" class="checkbox" :class="{ 'checkbox--disabled': disabled }">
|
|
22
|
+
<input
|
|
23
|
+
:id="checkboxId"
|
|
24
|
+
type="checkbox"
|
|
25
|
+
:checked="modelValue"
|
|
26
|
+
:disabled="disabled"
|
|
27
|
+
class="checkbox__input"
|
|
28
|
+
@change="emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
|
|
29
|
+
/>
|
|
30
|
+
<span class="checkbox__box">
|
|
31
|
+
<Check v-if="modelValue" class="checkbox__icon" :size="14" />
|
|
32
|
+
</span>
|
|
33
|
+
<span v-if="$slots.default || label" class="checkbox__label"
|
|
34
|
+
><slot>{{ label }}</slot></span
|
|
35
|
+
>
|
|
36
|
+
</label>
|
|
37
|
+
</template>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Input de formulario (portado de kontuan). Estilo compartido `.form-field`.
|
|
3
|
+
const props = withDefaults(
|
|
4
|
+
defineProps<{
|
|
5
|
+
modelValue?: string | number | null
|
|
6
|
+
label?: string
|
|
7
|
+
placeholder?: string
|
|
8
|
+
type?:
|
|
9
|
+
| 'text'
|
|
10
|
+
| 'email'
|
|
11
|
+
| 'password'
|
|
12
|
+
| 'number'
|
|
13
|
+
| 'tel'
|
|
14
|
+
| 'url'
|
|
15
|
+
| 'search'
|
|
16
|
+
| 'date'
|
|
17
|
+
| 'datetime-local'
|
|
18
|
+
| 'time'
|
|
19
|
+
error?: string
|
|
20
|
+
hint?: string
|
|
21
|
+
disabled?: boolean
|
|
22
|
+
required?: boolean
|
|
23
|
+
id?: string
|
|
24
|
+
}>(),
|
|
25
|
+
{ modelValue: '', type: 'text', disabled: false, required: false },
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
// El DOM siempre entrega string: no se declara `number` para no mentir al
|
|
29
|
+
// consumidor (para números está NumericInput).
|
|
30
|
+
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
|
31
|
+
|
|
32
|
+
const inputId = props.id || `input-${Math.random().toString(36).slice(2, 9)}`
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<div class="form-field" :class="{ 'form-field--error': error }">
|
|
37
|
+
<label v-if="label" :for="inputId" class="form-field__label">
|
|
38
|
+
{{ label }}<span v-if="required" class="form-field__required">*</span>
|
|
39
|
+
</label>
|
|
40
|
+
<input
|
|
41
|
+
:id="inputId"
|
|
42
|
+
:type="type"
|
|
43
|
+
:value="modelValue ?? ''"
|
|
44
|
+
:placeholder="placeholder"
|
|
45
|
+
:disabled="disabled"
|
|
46
|
+
:required="required"
|
|
47
|
+
class="form-field__input"
|
|
48
|
+
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
|
49
|
+
/>
|
|
50
|
+
<p v-if="error" class="form-field__error">{{ error }}</p>
|
|
51
|
+
<p v-else-if="hint" class="form-field__hint">{{ hint }}</p>
|
|
52
|
+
</div>
|
|
53
|
+
</template>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, onBeforeUnmount, watch, ref } from 'vue'
|
|
3
|
+
import { X } from '@lucide/vue'
|
|
4
|
+
|
|
5
|
+
// Pila de modales abiertos compartida entre instancias: el bloqueo del scroll
|
|
6
|
+
// del body dura mientras haya ALGÚN modal abierto, y Escape solo cierra el de
|
|
7
|
+
// arriba (no todos a la vez cuando hay modal + confirmación superpuestos).
|
|
8
|
+
const openStack: symbol[] = []
|
|
9
|
+
|
|
10
|
+
const props = withDefaults(
|
|
11
|
+
defineProps<{
|
|
12
|
+
modelValue: boolean
|
|
13
|
+
title?: string
|
|
14
|
+
size?: 'sm' | 'md' | 'lg'
|
|
15
|
+
closeLabel?: string
|
|
16
|
+
}>(),
|
|
17
|
+
{ size: 'md', closeLabel: 'Cerrar' },
|
|
18
|
+
)
|
|
19
|
+
const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
|
|
20
|
+
|
|
21
|
+
const stackId = Symbol('modal')
|
|
22
|
+
|
|
23
|
+
function pushOpen() {
|
|
24
|
+
openStack.push(stackId)
|
|
25
|
+
document.body.style.overflow = 'hidden'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function popOpen() {
|
|
29
|
+
const i = openStack.indexOf(stackId)
|
|
30
|
+
if (i > -1) openStack.splice(i, 1)
|
|
31
|
+
if (openStack.length === 0) document.body.style.overflow = ''
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function close() {
|
|
35
|
+
emit('update:modelValue', false)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Cierre por overlay robusto: solo si el pointer bajó Y subió sobre el propio
|
|
39
|
+
// overlay. Evita el bug de que, al pulsar un botón interno que se desmonta
|
|
40
|
+
// (quitar imagen, borrar contenido…), el click se re-dirija al overlay y cierre.
|
|
41
|
+
const downOnOverlay = ref(false)
|
|
42
|
+
function onOverlayDown(e: MouseEvent) {
|
|
43
|
+
downOnOverlay.value = e.target === e.currentTarget
|
|
44
|
+
}
|
|
45
|
+
function onOverlayClick(e: MouseEvent) {
|
|
46
|
+
if (downOnOverlay.value && e.target === e.currentTarget) close()
|
|
47
|
+
downOnOverlay.value = false
|
|
48
|
+
}
|
|
49
|
+
function onKeydown(e: KeyboardEvent) {
|
|
50
|
+
if (e.key === 'Escape' && props.modelValue && openStack[openStack.length - 1] === stackId) {
|
|
51
|
+
close()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
onMounted(() => {
|
|
56
|
+
window.addEventListener('keydown', onKeydown)
|
|
57
|
+
if (props.modelValue) pushOpen()
|
|
58
|
+
})
|
|
59
|
+
onBeforeUnmount(() => {
|
|
60
|
+
window.removeEventListener('keydown', onKeydown)
|
|
61
|
+
// Si se desmonta con el modal abierto (navegación atrás…), libera el scroll.
|
|
62
|
+
popOpen()
|
|
63
|
+
})
|
|
64
|
+
watch(
|
|
65
|
+
() => props.modelValue,
|
|
66
|
+
(open) => {
|
|
67
|
+
if (typeof document === 'undefined') return
|
|
68
|
+
if (open) pushOpen()
|
|
69
|
+
else popOpen()
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<template>
|
|
75
|
+
<Teleport to="body">
|
|
76
|
+
<Transition name="modal">
|
|
77
|
+
<div
|
|
78
|
+
v-if="modelValue"
|
|
79
|
+
class="modal-overlay"
|
|
80
|
+
@mousedown="onOverlayDown"
|
|
81
|
+
@click="onOverlayClick"
|
|
82
|
+
>
|
|
83
|
+
<div class="modal" :class="`modal--${props.size}`" role="dialog" aria-modal="true">
|
|
84
|
+
<div v-if="title || $slots.header" class="modal__header">
|
|
85
|
+
<slot name="header"
|
|
86
|
+
><h3 class="modal__title">{{ title }}</h3></slot
|
|
87
|
+
>
|
|
88
|
+
<button class="modal__close" :aria-label="closeLabel" @click="close">
|
|
89
|
+
<X :size="18" />
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="modal__body"><slot /></div>
|
|
93
|
+
<div v-if="$slots.footer" class="modal__footer"><slot name="footer" /></div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</Transition>
|
|
97
|
+
</Teleport>
|
|
98
|
+
</template>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Select de formulario (portado de kontuan).
|
|
3
|
+
export interface SelectOption {
|
|
4
|
+
value: string | number
|
|
5
|
+
label: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const props = withDefaults(
|
|
9
|
+
defineProps<{
|
|
10
|
+
modelValue?: string | number
|
|
11
|
+
label?: string
|
|
12
|
+
options: SelectOption[]
|
|
13
|
+
placeholder?: string
|
|
14
|
+
error?: string
|
|
15
|
+
hint?: string
|
|
16
|
+
disabled?: boolean
|
|
17
|
+
required?: boolean
|
|
18
|
+
id?: string
|
|
19
|
+
}>(),
|
|
20
|
+
{ modelValue: '', disabled: false, required: false },
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
// El DOM siempre entrega string: no se declara `number` para no mentir al
|
|
24
|
+
// consumidor (que convierta él si guarda ids numéricos).
|
|
25
|
+
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
|
26
|
+
|
|
27
|
+
const selectId = props.id || `select-${Math.random().toString(36).slice(2, 9)}`
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<div class="form-field" :class="{ 'form-field--error': error }">
|
|
32
|
+
<label v-if="label" :for="selectId" class="form-field__label">
|
|
33
|
+
{{ label }}<span v-if="required" class="form-field__required">*</span>
|
|
34
|
+
</label>
|
|
35
|
+
<div class="form-field__select-wrapper">
|
|
36
|
+
<select
|
|
37
|
+
:id="selectId"
|
|
38
|
+
:value="modelValue"
|
|
39
|
+
:disabled="disabled"
|
|
40
|
+
:required="required"
|
|
41
|
+
class="form-field__select"
|
|
42
|
+
@change="emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
|
|
43
|
+
>
|
|
44
|
+
<option v-if="placeholder" value="" disabled>{{ placeholder }}</option>
|
|
45
|
+
<option v-for="option in options" :key="option.value" :value="option.value">
|
|
46
|
+
{{ option.label }}
|
|
47
|
+
</option>
|
|
48
|
+
</select>
|
|
49
|
+
</div>
|
|
50
|
+
<p v-if="error" class="form-field__error">{{ error }}</p>
|
|
51
|
+
<p v-else-if="hint" class="form-field__hint">{{ hint }}</p>
|
|
52
|
+
</div>
|
|
53
|
+
</template>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Component } from 'vue'
|
|
3
|
+
|
|
4
|
+
interface Tab {
|
|
5
|
+
key: string
|
|
6
|
+
label: string
|
|
7
|
+
count?: number
|
|
8
|
+
icon?: Component
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
defineProps<{ tabs: Tab[]; modelValue: string }>()
|
|
12
|
+
defineEmits<{ 'update:modelValue': [string] }>()
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<div class="tabs">
|
|
17
|
+
<button
|
|
18
|
+
v-for="tab in tabs"
|
|
19
|
+
:key="tab.key"
|
|
20
|
+
type="button"
|
|
21
|
+
class="tabs__tab"
|
|
22
|
+
:class="{ 'tabs__tab--active': modelValue === tab.key, 'tabs__tab--has-icon': !!tab.icon }"
|
|
23
|
+
@click="$emit('update:modelValue', tab.key)"
|
|
24
|
+
>
|
|
25
|
+
<component :is="tab.icon" v-if="tab.icon" class="tabs__icon" :size="16" />
|
|
26
|
+
<span class="tabs__label">{{ tab.label }}</span>
|
|
27
|
+
<span v-if="tab.count !== undefined" class="tabs__count">{{ tab.count }}</span>
|
|
28
|
+
</button>
|
|
29
|
+
</div>
|
|
30
|
+
</template>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Textarea de formulario (portado de kontuan).
|
|
3
|
+
const props = withDefaults(
|
|
4
|
+
defineProps<{
|
|
5
|
+
modelValue?: string
|
|
6
|
+
label?: string
|
|
7
|
+
placeholder?: string
|
|
8
|
+
rows?: number
|
|
9
|
+
error?: string
|
|
10
|
+
hint?: string
|
|
11
|
+
disabled?: boolean
|
|
12
|
+
required?: boolean
|
|
13
|
+
id?: string
|
|
14
|
+
}>(),
|
|
15
|
+
{ modelValue: '', rows: 4, disabled: false, required: false },
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits<{
|
|
19
|
+
'update:modelValue': [value: string]
|
|
20
|
+
keydown: [event: KeyboardEvent]
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
const textareaId = props.id || `textarea-${Math.random().toString(36).slice(2, 9)}`
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<div class="form-field" :class="{ 'form-field--error': error }">
|
|
28
|
+
<label v-if="label" :for="textareaId" class="form-field__label">
|
|
29
|
+
{{ label }}<span v-if="required" class="form-field__required">*</span>
|
|
30
|
+
</label>
|
|
31
|
+
<textarea
|
|
32
|
+
:id="textareaId"
|
|
33
|
+
:value="modelValue"
|
|
34
|
+
:placeholder="placeholder"
|
|
35
|
+
:rows="rows"
|
|
36
|
+
:disabled="disabled"
|
|
37
|
+
:required="required"
|
|
38
|
+
class="form-field__textarea"
|
|
39
|
+
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
|
40
|
+
@keydown="emit('keydown', $event)"
|
|
41
|
+
/>
|
|
42
|
+
<p v-if="error" class="form-field__error">{{ error }}</p>
|
|
43
|
+
<p v-else-if="hint" class="form-field__hint">{{ hint }}</p>
|
|
44
|
+
</div>
|
|
45
|
+
</template>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onBeforeUnmount, onMounted } from 'vue'
|
|
3
|
+
import { X } from '@lucide/vue'
|
|
4
|
+
|
|
5
|
+
const props = withDefaults(
|
|
6
|
+
defineProps<{
|
|
7
|
+
variant?: 'success' | 'warning' | 'danger' | 'info'
|
|
8
|
+
message: string
|
|
9
|
+
duration?: number
|
|
10
|
+
closeLabel?: string
|
|
11
|
+
}>(),
|
|
12
|
+
{ variant: 'info', duration: 4000, closeLabel: 'Cerrar' },
|
|
13
|
+
)
|
|
14
|
+
const emit = defineEmits<{ close: [] }>()
|
|
15
|
+
|
|
16
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
17
|
+
|
|
18
|
+
onMounted(() => {
|
|
19
|
+
if (props.duration > 0) timer = setTimeout(() => emit('close'), props.duration)
|
|
20
|
+
})
|
|
21
|
+
onBeforeUnmount(() => clearTimeout(timer))
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<div :class="['toast', `toast--${variant}`]" role="alert">
|
|
26
|
+
<span class="toast__message">{{ message }}</span>
|
|
27
|
+
<button class="toast__close" :aria-label="closeLabel" @click="emit('close')">
|
|
28
|
+
<X :size="16" />
|
|
29
|
+
</button>
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import BaseModal from './BaseModal.vue'
|
|
3
|
+
import { Check, X } from '@lucide/vue'
|
|
4
|
+
import BaseButton from './BaseButton.vue'
|
|
5
|
+
import { useConfirm } from '../composables/useConfirm'
|
|
6
|
+
|
|
7
|
+
// Diálogo global de confirmación. Se monta una vez (junto a ToastContainer) y
|
|
8
|
+
// responde a useConfirm().confirm({...}). Sustituye al confirm() nativo.
|
|
9
|
+
const { state, resolve } = useConfirm()
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<BaseModal
|
|
14
|
+
:model-value="state.open"
|
|
15
|
+
:title="state.title || 'Confirmar'"
|
|
16
|
+
size="sm"
|
|
17
|
+
@update:model-value="
|
|
18
|
+
(v: boolean) => {
|
|
19
|
+
if (!v) resolve(false)
|
|
20
|
+
}
|
|
21
|
+
"
|
|
22
|
+
>
|
|
23
|
+
<p class="confirm__message">{{ state.message }}</p>
|
|
24
|
+
<template #footer>
|
|
25
|
+
<BaseButton variant="secondary" @click="resolve(false)">
|
|
26
|
+
<template #icon><X :size="16" /></template>
|
|
27
|
+
{{ state.cancelLabel }}
|
|
28
|
+
</BaseButton>
|
|
29
|
+
<BaseButton :variant="state.variant" @click="resolve(true)">
|
|
30
|
+
<template #icon><Check :size="16" /></template>
|
|
31
|
+
{{ state.confirmLabel }}
|
|
32
|
+
</BaseButton>
|
|
33
|
+
</template>
|
|
34
|
+
</BaseModal>
|
|
35
|
+
</template>
|