@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.
Files changed (73) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +45 -0
  3. package/package.json +47 -0
  4. package/scss/_base.scss +33 -0
  5. package/scss/_forms.scss +33 -0
  6. package/scss/_shared-components.scss +1 -0
  7. package/scss/_theme.scss +74 -0
  8. package/scss/_tokens.scss +114 -0
  9. package/scss/components/_base-button.scss +113 -0
  10. package/scss/components/_blocks.scss +211 -0
  11. package/scss/components/_breadcrumbs.scss +31 -0
  12. package/scss/components/_checkbox.scss +42 -0
  13. package/scss/components/_chip.scss +23 -0
  14. package/scss/components/_confirm.scss +3 -0
  15. package/scss/components/_edit-modal.scss +11 -0
  16. package/scss/components/_font-upload.scss +91 -0
  17. package/scss/components/_form-field.scss +85 -0
  18. package/scss/components/_icon-button.scss +23 -0
  19. package/scss/components/_image-upload.scss +85 -0
  20. package/scss/components/_index.scss +24 -0
  21. package/scss/components/_locale-selector.scss +89 -0
  22. package/scss/components/_modal.scss +68 -0
  23. package/scss/components/_motor-badge.scss +21 -0
  24. package/scss/components/_numeric-input.scss +53 -0
  25. package/scss/components/_page-background.scss +22 -0
  26. package/scss/components/_palette-color-picker.scss +69 -0
  27. package/scss/components/_rich-text.scss +144 -0
  28. package/scss/components/_search-select.scss +138 -0
  29. package/scss/components/_tabs.scss +120 -0
  30. package/scss/components/_theme-selector.scss +34 -0
  31. package/scss/components/_toast-container.scss +16 -0
  32. package/scss/components/_toast.scss +25 -0
  33. package/scss/components/_translatable-input.scss +73 -0
  34. package/src/blocks/BlockCta.vue +32 -0
  35. package/src/blocks/BlockFaq.vue +25 -0
  36. package/src/blocks/BlockHeader.vue +13 -0
  37. package/src/blocks/BlockIndex.vue +20 -0
  38. package/src/blocks/BlockQuote.vue +16 -0
  39. package/src/blocks/BlockShell.vue +30 -0
  40. package/src/blocks/BlockText.vue +20 -0
  41. package/src/blocks/BlockTextCard.vue +23 -0
  42. package/src/blocks/PageBackground.vue +15 -0
  43. package/src/blocks/index.ts +25 -0
  44. package/src/components/AppBreadcrumbs.vue +44 -0
  45. package/src/components/BaseButton.vue +24 -0
  46. package/src/components/BaseCheckbox.vue +37 -0
  47. package/src/components/BaseInput.vue +53 -0
  48. package/src/components/BaseModal.vue +98 -0
  49. package/src/components/BaseSelect.vue +53 -0
  50. package/src/components/BaseTabs.vue +30 -0
  51. package/src/components/BaseTextarea.vue +45 -0
  52. package/src/components/BaseToast.vue +31 -0
  53. package/src/components/ConfirmDialog.vue +35 -0
  54. package/src/components/EditModal.vue +57 -0
  55. package/src/components/FontUpload.vue +123 -0
  56. package/src/components/IconButton.vue +24 -0
  57. package/src/components/ImageUpload.vue +142 -0
  58. package/src/components/LocaleSelector.vue +59 -0
  59. package/src/components/MotorBadge.vue +17 -0
  60. package/src/components/NumericInput.vue +115 -0
  61. package/src/components/PaletteColorPicker.vue +94 -0
  62. package/src/components/RichTextInput.vue +273 -0
  63. package/src/components/SearchSelect.vue +172 -0
  64. package/src/components/ThemeSelector.vue +28 -0
  65. package/src/components/ToastContainer.vue +23 -0
  66. package/src/components/TranslatableImage.vue +120 -0
  67. package/src/components/TranslatableInput.vue +135 -0
  68. package/src/composables/useConfirm.ts +49 -0
  69. package/src/composables/useHead.ts +54 -0
  70. package/src/composables/useTheme.ts +50 -0
  71. package/src/composables/useToast.ts +26 -0
  72. package/src/index.ts +39 -0
  73. 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>