@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,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>
|