@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,57 @@
1
+ <script setup lang="ts">
2
+ import BaseModal from './BaseModal.vue'
3
+ import { Save, X } from '@lucide/vue'
4
+ import BaseButton from './BaseButton.vue'
5
+
6
+ // Modal de formulario (portado de kontuan): BaseModal + pie con Cancelar/Guardar.
7
+ // Agnóstico de i18n: las etiquetas se pasan por props (la app las traduce).
8
+ const props = withDefaults(
9
+ defineProps<{
10
+ modelValue: boolean
11
+ title: string
12
+ size?: 'sm' | 'md' | 'lg'
13
+ loading?: boolean
14
+ submitLabel?: string
15
+ cancelLabel?: string
16
+ submitVariant?: 'primary' | 'secondary' | 'danger' | 'success'
17
+ }>(),
18
+ {
19
+ size: 'md',
20
+ loading: false,
21
+ submitLabel: 'Guardar',
22
+ cancelLabel: 'Cancelar',
23
+ submitVariant: 'primary',
24
+ },
25
+ )
26
+
27
+ const emit = defineEmits<{ 'update:modelValue': [boolean]; submit: [] }>()
28
+
29
+ function close() {
30
+ if (props.loading) return
31
+ emit('update:modelValue', false)
32
+ }
33
+ </script>
34
+
35
+ <template>
36
+ <BaseModal
37
+ :model-value="modelValue"
38
+ :title="title"
39
+ :size="size"
40
+ @update:model-value="(v) => !loading && emit('update:modelValue', v)"
41
+ >
42
+ <form class="edit-modal__form" @submit.prevent="emit('submit')">
43
+ <div class="edit-modal__body"><slot /></div>
44
+ </form>
45
+
46
+ <template #footer>
47
+ <BaseButton variant="secondary" type="button" @click="close">
48
+ <template #icon><X :size="16" /></template>
49
+ {{ cancelLabel }}
50
+ </BaseButton>
51
+ <BaseButton :variant="submitVariant" type="button" @click="emit('submit')">
52
+ <template #icon><Save :size="16" /></template>
53
+ {{ loading ? '…' : submitLabel }}
54
+ </BaseButton>
55
+ </template>
56
+ </BaseModal>
57
+ </template>
@@ -0,0 +1,123 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import { Type, X } from '@lucide/vue'
4
+
5
+ // Subida de fuente con arrastrar-y-soltar o clic (hermano de ImageUpload):
6
+ // TODA la zona abre el diálogo; el fichero elegido se muestra con nombre y
7
+ // tamaño. Controlado por v-model (File | null). Agnóstico de i18n (DC-29).
8
+ const props = withDefaults(
9
+ defineProps<{
10
+ modelValue: File | null
11
+ label?: string
12
+ accept?: string
13
+ maxSize?: number // MB
14
+ error?: string
15
+ dragText?: string
16
+ hintText?: string
17
+ tooLargeText?: string
18
+ invalidTypeText?: string
19
+ }>(),
20
+ {
21
+ label: '',
22
+ accept: '.woff2,.woff,.ttf,.otf',
23
+ maxSize: 4,
24
+ dragText: 'Arrastra una fuente o haz clic',
25
+ hintText: '',
26
+ tooLargeText: 'El archivo es demasiado grande.',
27
+ invalidTypeText: 'Formato de archivo no válido.',
28
+ },
29
+ )
30
+
31
+ const emit = defineEmits<{ 'update:modelValue': [File | null] }>()
32
+
33
+ const isDragging = ref(false)
34
+ const localError = ref<string | null>(null)
35
+ const fileInputRef = ref<HTMLInputElement>()
36
+
37
+ const shownError = computed(() => props.error || localError.value)
38
+
39
+ const extensions = computed(() =>
40
+ props.accept.split(',').map((ext) => ext.trim().toLowerCase().replace(/^\./, '')),
41
+ )
42
+
43
+ function sizeLabel(file: File): string {
44
+ const kb = file.size / 1024
45
+ return kb >= 1024 ? `${(kb / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(kb))} KB`
46
+ }
47
+
48
+ function handleFile(file: File) {
49
+ const extension = file.name.split('.').pop()?.toLowerCase() ?? ''
50
+ if (!extensions.value.includes(extension)) {
51
+ localError.value = props.invalidTypeText
52
+ return
53
+ }
54
+ if (file.size > props.maxSize * 1024 * 1024) {
55
+ localError.value = props.tooLargeText
56
+ return
57
+ }
58
+ localError.value = null
59
+ emit('update:modelValue', file)
60
+ }
61
+
62
+ function onFileChange(event: Event) {
63
+ const input = event.target as HTMLInputElement
64
+ if (input.files?.[0]) handleFile(input.files[0])
65
+ if (input) input.value = ''
66
+ }
67
+
68
+ function onDrop(event: DragEvent) {
69
+ isDragging.value = false
70
+ if (event.dataTransfer?.files?.[0]) handleFile(event.dataTransfer.files[0])
71
+ }
72
+
73
+ function clear() {
74
+ localError.value = null
75
+ emit('update:modelValue', null)
76
+ }
77
+
78
+ function openDialog() {
79
+ fileInputRef.value?.click()
80
+ }
81
+ </script>
82
+
83
+ <template>
84
+ <div class="font-upload">
85
+ <span v-if="label" class="font-upload__label">{{ label }}</span>
86
+ <div
87
+ class="font-upload__zone"
88
+ :class="{
89
+ 'font-upload__zone--dragging': isDragging,
90
+ 'font-upload__zone--error': shownError,
91
+ 'font-upload__zone--has-file': modelValue,
92
+ }"
93
+ @dragover.prevent="isDragging = true"
94
+ @dragleave="isDragging = false"
95
+ @drop.prevent="onDrop"
96
+ @click="openDialog"
97
+ >
98
+ <input
99
+ ref="fileInputRef"
100
+ type="file"
101
+ :accept="accept"
102
+ class="font-upload__input"
103
+ @change="onFileChange"
104
+ />
105
+
106
+ <Type class="font-upload__icon" :size="20" />
107
+
108
+ <template v-if="modelValue">
109
+ <span class="font-upload__name">{{ modelValue.name }}</span>
110
+ <span class="font-upload__size">{{ sizeLabel(modelValue) }}</span>
111
+ <button class="font-upload__remove" type="button" @click.stop="clear">
112
+ <X :size="14" />
113
+ </button>
114
+ </template>
115
+
116
+ <template v-else>
117
+ <span class="font-upload__text">{{ dragText }}</span>
118
+ <span v-if="hintText" class="font-upload__hint">{{ hintText }}</span>
119
+ </template>
120
+ </div>
121
+ <p v-if="shownError" class="font-upload__error">{{ shownError }}</p>
122
+ </div>
123
+ </template>
@@ -0,0 +1,24 @@
1
+ <script setup lang="ts">
2
+ // Botón de icono. En reposo usa el color del texto; en hover tiñe el icono
3
+ // (stroke) y un fondo semitransparente con el color de la variante.
4
+ withDefaults(
5
+ defineProps<{
6
+ variant?: 'neutral' | 'accent' | 'danger' | 'success' | 'warning' | 'info'
7
+ title?: string
8
+ type?: 'button' | 'submit'
9
+ }>(),
10
+ { variant: 'neutral', type: 'button' },
11
+ )
12
+ </script>
13
+
14
+ <template>
15
+ <button
16
+ :type="type"
17
+ class="icon-btn"
18
+ :class="`icon-btn--${variant}`"
19
+ :title="title"
20
+ :aria-label="title"
21
+ >
22
+ <slot />
23
+ </button>
24
+ </template>
@@ -0,0 +1,142 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, ref } from 'vue'
3
+ import { Trash2 } from '@lucide/vue'
4
+
5
+ // Subida de imagen con arrastrar-y-soltar o clic (portado de kontuan).
6
+ // Agnóstico de i18n: los textos van por props.
7
+ const props = withDefaults(
8
+ defineProps<{
9
+ modelValue: File | null
10
+ currentUrl?: string | null
11
+ label?: string
12
+ accept?: string
13
+ maxSize?: number // MB
14
+ error?: string
15
+ dragText?: string
16
+ hintText?: string
17
+ tooLargeText?: string
18
+ invalidTypeText?: string
19
+ }>(),
20
+ {
21
+ currentUrl: null,
22
+ label: '',
23
+ accept: 'image/*',
24
+ maxSize: 4,
25
+ dragText: 'Arrastra una imagen o haz clic',
26
+ hintText: '',
27
+ tooLargeText: 'El archivo es demasiado grande.',
28
+ invalidTypeText: 'Formato de archivo no válido.',
29
+ },
30
+ )
31
+
32
+ const emit = defineEmits<{ 'update:modelValue': [File | null]; remove: [] }>()
33
+
34
+ const isDragging = ref(false)
35
+ const previewUrl = ref<string | null>(null)
36
+ const removed = ref(false)
37
+ const localError = ref<string | null>(null)
38
+ const fileInputRef = ref<HTMLInputElement>()
39
+
40
+ const displayUrl = computed(() => (removed.value ? null : previewUrl.value || props.currentUrl))
41
+ // El error externo (validación del servidor) manda sobre el interno.
42
+ const shownError = computed(() => props.error || localError.value)
43
+
44
+ function handleFile(file: File) {
45
+ // Validación en cliente: tamaño y tipo, con feedback (no se ignora en silencio).
46
+ if (file.size > props.maxSize * 1024 * 1024) {
47
+ localError.value = props.tooLargeText
48
+ return
49
+ }
50
+ if (!file.type.startsWith('image/')) {
51
+ localError.value = props.invalidTypeText
52
+ return
53
+ }
54
+ localError.value = null
55
+ removed.value = false
56
+ if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
57
+ previewUrl.value = URL.createObjectURL(file)
58
+ emit('update:modelValue', file)
59
+ }
60
+
61
+ function onFileChange(event: Event) {
62
+ const input = event.target as HTMLInputElement
63
+ if (input.files?.[0]) handleFile(input.files[0])
64
+ if (input) input.value = ''
65
+ }
66
+
67
+ function onDrop(event: DragEvent) {
68
+ isDragging.value = false
69
+ if (event.dataTransfer?.files?.[0]) handleFile(event.dataTransfer.files[0])
70
+ }
71
+
72
+ function clear() {
73
+ if (previewUrl.value) {
74
+ URL.revokeObjectURL(previewUrl.value)
75
+ previewUrl.value = null
76
+ }
77
+ removed.value = true
78
+ localError.value = null
79
+ emit('update:modelValue', null)
80
+ emit('remove')
81
+ }
82
+
83
+ function openDialog() {
84
+ fileInputRef.value?.click()
85
+ }
86
+
87
+ onBeforeUnmount(() => {
88
+ if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
89
+ })
90
+ </script>
91
+
92
+ <template>
93
+ <div class="image-upload">
94
+ <label v-if="label" class="image-upload__label">{{ label }}</label>
95
+ <div
96
+ class="image-upload__zone"
97
+ :class="{
98
+ 'image-upload__zone--dragging': isDragging,
99
+ 'image-upload__zone--error': shownError,
100
+ 'image-upload__zone--has-image': displayUrl,
101
+ }"
102
+ @dragover.prevent="isDragging = true"
103
+ @dragleave="isDragging = false"
104
+ @drop.prevent="onDrop"
105
+ @click="!displayUrl && openDialog()"
106
+ >
107
+ <input
108
+ ref="fileInputRef"
109
+ type="file"
110
+ :accept="accept"
111
+ class="image-upload__input"
112
+ @change="onFileChange"
113
+ />
114
+
115
+ <template v-if="displayUrl">
116
+ <img :src="displayUrl" class="image-upload__preview" alt="" />
117
+ <button class="image-upload__remove" type="button" @click.stop="clear">
118
+ <Trash2 :size="16" />
119
+ </button>
120
+ </template>
121
+
122
+ <template v-else>
123
+ <div class="image-upload__placeholder">
124
+ <svg
125
+ class="image-upload__icon"
126
+ viewBox="0 0 24 24"
127
+ fill="none"
128
+ stroke="currentColor"
129
+ stroke-width="1.5"
130
+ >
131
+ <rect x="3" y="3" width="18" height="18" rx="3" />
132
+ <circle cx="8.5" cy="8.5" r="1.5" />
133
+ <path d="m21 15-5-5L5 21" />
134
+ </svg>
135
+ <span class="image-upload__text">{{ dragText }}</span>
136
+ <span v-if="hintText" class="image-upload__hint">{{ hintText }}</span>
137
+ </div>
138
+ </template>
139
+ </div>
140
+ <p v-if="shownError" class="image-upload__error">{{ shownError }}</p>
141
+ </div>
142
+ </template>
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, onBeforeUnmount } from 'vue'
3
+ import { ChevronDown } from '@lucide/vue'
4
+
5
+ interface Locale {
6
+ code: string
7
+ name: string
8
+ }
9
+
10
+ // Selector de idioma de contenido. Controlado por v-model (código de locale).
11
+ // Adaptado de kontuan pero sin vue-i18n: la lista de locales se pasa por props
12
+ // (la sirve la API del motor).
13
+ const props = defineProps<{
14
+ modelValue: string
15
+ locales: Locale[]
16
+ }>()
17
+
18
+ const emit = defineEmits<{ 'update:modelValue': [code: string] }>()
19
+
20
+ const open = ref(false)
21
+ const dropdownRef = ref<HTMLElement>()
22
+
23
+ function selectLocale(code: string) {
24
+ emit('update:modelValue', code)
25
+ open.value = false
26
+ }
27
+
28
+ function handleClickOutside(e: MouseEvent) {
29
+ if (dropdownRef.value && !dropdownRef.value.contains(e.target as Node)) {
30
+ open.value = false
31
+ }
32
+ }
33
+
34
+ onMounted(() => document.addEventListener('click', handleClickOutside))
35
+ onBeforeUnmount(() => document.removeEventListener('click', handleClickOutside))
36
+ </script>
37
+
38
+ <template>
39
+ <div ref="dropdownRef" class="locale-selector">
40
+ <button type="button" class="locale-trigger" @click="open = !open">
41
+ {{ props.modelValue.toUpperCase() }}
42
+ <ChevronDown class="locale-chevron" :size="14" />
43
+ </button>
44
+ <Transition name="dropdown">
45
+ <div v-if="open" class="locale-dropdown">
46
+ <button
47
+ v-for="loc in locales"
48
+ :key="loc.code"
49
+ type="button"
50
+ :class="['locale-option', { active: props.modelValue === loc.code }]"
51
+ @click="selectLocale(loc.code)"
52
+ >
53
+ <span class="locale-code">{{ loc.code.toUpperCase() }}</span>
54
+ <span class="locale-label">{{ loc.name }}</span>
55
+ </button>
56
+ </div>
57
+ </Transition>
58
+ </div>
59
+ </template>
@@ -0,0 +1,17 @@
1
+ <script setup lang="ts">
2
+ // Componente de prueba de cableado: muestra que el front consume @edc-motor/ui.
3
+ withDefaults(
4
+ defineProps<{
5
+ label?: string
6
+ version?: string
7
+ }>(),
8
+ { label: 'EdC', version: '' },
9
+ )
10
+ </script>
11
+
12
+ <template>
13
+ <span class="edc-badge">
14
+ <span class="edc-badge__dot" />
15
+ {{ label }}<template v-if="version"> · v{{ version }}</template>
16
+ </span>
17
+ </template>
@@ -0,0 +1,115 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { Minus, Plus } from '@lucide/vue'
4
+
5
+ // Campo numérico con botones -/+ (portado de kontuan). Por defecto acepta solo
6
+ // enteros ≥ 0 (estadísticas de cartas, costes…). Para decimales: :integer="false".
7
+ const props = withDefaults(
8
+ defineProps<{
9
+ modelValue?: number | null
10
+ label?: string
11
+ placeholder?: string
12
+ error?: string
13
+ hint?: string
14
+ disabled?: boolean
15
+ required?: boolean
16
+ id?: string
17
+ min?: number
18
+ max?: number
19
+ step?: number
20
+ integer?: boolean
21
+ }>(),
22
+ { modelValue: 0, disabled: false, required: false, min: 0, step: 1, integer: true },
23
+ )
24
+
25
+ const emit = defineEmits<{ 'update:modelValue': [value: number] }>()
26
+
27
+ const inputId = props.id || `num-${Math.random().toString(36).slice(2, 9)}`
28
+ const numericValue = computed(() => props.modelValue ?? props.min ?? 0)
29
+ const canDecrement = computed(
30
+ () => !props.disabled && (props.min === undefined || numericValue.value > props.min),
31
+ )
32
+ const canIncrement = computed(
33
+ () => !props.disabled && (props.max === undefined || numericValue.value < props.max),
34
+ )
35
+
36
+ function clamp(value: number): number {
37
+ let v = props.integer ? Math.trunc(value) : value
38
+ if (props.min !== undefined) v = Math.max(props.min, v)
39
+ if (props.max !== undefined) v = Math.min(props.max, v)
40
+ return v
41
+ }
42
+
43
+ function onInput(event: Event) {
44
+ const el = event.target as HTMLInputElement
45
+ const raw = el.value
46
+ if (raw === '') {
47
+ emit('update:modelValue', props.min ?? 0)
48
+ return
49
+ }
50
+ const parsed = Number(raw)
51
+ if (isNaN(parsed)) {
52
+ el.value = String(numericValue.value)
53
+ return
54
+ }
55
+ const next = clamp(parsed)
56
+ emit('update:modelValue', next)
57
+ // Fuerza el campo a mostrar el valor saneado aunque el modelo no cambie
58
+ // (p. ej. teclear "-4" con min 0 -> el modelo sigue en 0 pero el DOM mostraba -4).
59
+ if (el.value !== String(next)) el.value = String(next)
60
+ }
61
+ function onBlur(event: Event) {
62
+ const el = event.target as HTMLInputElement
63
+ const next = clamp(numericValue.value)
64
+ emit('update:modelValue', next)
65
+ el.value = String(next)
66
+ }
67
+ function decrement() {
68
+ if (canDecrement.value) emit('update:modelValue', clamp(numericValue.value - props.step))
69
+ }
70
+ function increment() {
71
+ if (canIncrement.value) emit('update:modelValue', clamp(numericValue.value + props.step))
72
+ }
73
+ </script>
74
+
75
+ <template>
76
+ <div class="form-field" :class="{ 'form-field--error': error }">
77
+ <label v-if="label" :for="inputId" class="form-field__label">
78
+ {{ label }}<span v-if="required" class="form-field__required">*</span>
79
+ </label>
80
+ <div class="numeric-input" :class="{ 'numeric-input--disabled': disabled }">
81
+ <button
82
+ type="button"
83
+ class="numeric-input__btn"
84
+ :disabled="!canDecrement"
85
+ tabindex="-1"
86
+ @click="decrement"
87
+ >
88
+ <Minus :size="14" />
89
+ </button>
90
+ <input
91
+ :id="inputId"
92
+ type="text"
93
+ inputmode="numeric"
94
+ :value="modelValue ?? ''"
95
+ :placeholder="placeholder"
96
+ :disabled="disabled"
97
+ :required="required"
98
+ class="numeric-input__field"
99
+ @input="onInput"
100
+ @blur="onBlur"
101
+ />
102
+ <button
103
+ type="button"
104
+ class="numeric-input__btn"
105
+ :disabled="!canIncrement"
106
+ tabindex="-1"
107
+ @click="increment"
108
+ >
109
+ <Plus :size="14" />
110
+ </button>
111
+ </div>
112
+ <p v-if="error" class="form-field__error">{{ error }}</p>
113
+ <p v-else-if="hint" class="form-field__hint">{{ hint }}</p>
114
+ </div>
115
+ </template>
@@ -0,0 +1,94 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { Check, Pipette } from '@lucide/vue'
4
+
5
+ // Selector de color (portado/adaptado de kontuan). A diferencia de kontuan
6
+ // (que guarda la clave de la paleta), aquí emite el HEX directamente, para
7
+ // campos de color guardados como cadena hexadecimal.
8
+ const props = withDefaults(
9
+ defineProps<{
10
+ modelValue: string | null
11
+ label?: string
12
+ allowCustom?: boolean
13
+ }>(),
14
+ { allowCustom: true },
15
+ )
16
+
17
+ const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
18
+
19
+ // Paleta base (mismos tonos que kontuan).
20
+ const PALETTE = [
21
+ { name: 'Slate', hex: '#64748B' },
22
+ { name: 'Red', hex: '#EF4444' },
23
+ { name: 'Orange', hex: '#F97316' },
24
+ { name: 'Amber', hex: '#F59E0B' },
25
+ { name: 'Yellow', hex: '#EAB308' },
26
+ { name: 'Green', hex: '#22C55E' },
27
+ { name: 'Teal', hex: '#14B8A6' },
28
+ { name: 'Blue', hex: '#3B82F6' },
29
+ { name: 'Indigo', hex: '#6366F1' },
30
+ { name: 'Violet', hex: '#8B5CF6' },
31
+ { name: 'Pink', hex: '#EC4899' },
32
+ ]
33
+
34
+ const norm = (v: string | null) => (v ?? '').toLowerCase()
35
+ const isPreset = computed(() => PALETTE.some((c) => norm(c.hex) === norm(props.modelValue)))
36
+ const isCustom = computed(() => !!props.modelValue && !isPreset.value)
37
+
38
+ const customHex = ref(isCustom.value ? (props.modelValue as string) : '#FF7A00')
39
+ watch(
40
+ () => props.modelValue,
41
+ (v) => {
42
+ if (v && !PALETTE.some((c) => norm(c.hex) === norm(v))) customHex.value = v
43
+ },
44
+ )
45
+
46
+ function onCustomInput(event: Event) {
47
+ const value = (event.target as HTMLInputElement).value
48
+ customHex.value = value
49
+ emit('update:modelValue', value)
50
+ }
51
+ </script>
52
+
53
+ <template>
54
+ <div class="palette-color-picker">
55
+ <label v-if="label" class="palette-color-picker__label">{{ label }}</label>
56
+ <div class="palette-color-picker__grid">
57
+ <button
58
+ v-for="opt in PALETTE"
59
+ :key="opt.hex"
60
+ type="button"
61
+ class="palette-color-picker__swatch"
62
+ :class="{ 'palette-color-picker__swatch--selected': norm(modelValue) === norm(opt.hex) }"
63
+ :style="{ '--swatch-color': opt.hex }"
64
+ :title="opt.name"
65
+ @click="emit('update:modelValue', opt.hex)"
66
+ >
67
+ <Check
68
+ v-if="norm(modelValue) === norm(opt.hex)"
69
+ class="palette-color-picker__swatch-check"
70
+ :size="14"
71
+ />
72
+ </button>
73
+
74
+ <label
75
+ v-if="allowCustom"
76
+ class="palette-color-picker__swatch palette-color-picker__swatch--custom"
77
+ :class="{
78
+ 'palette-color-picker__swatch--selected': isCustom,
79
+ 'palette-color-picker__swatch--custom-idle': !isCustom,
80
+ }"
81
+ :style="isCustom ? { '--swatch-color': customHex } : undefined"
82
+ >
83
+ <input
84
+ type="color"
85
+ class="palette-color-picker__swatch-input"
86
+ :value="customHex"
87
+ @input="onCustomInput"
88
+ />
89
+ <Check v-if="isCustom" class="palette-color-picker__swatch-check" :size="14" />
90
+ <Pipette v-else class="palette-color-picker__swatch-pipette" :size="14" />
91
+ </label>
92
+ </div>
93
+ </div>
94
+ </template>