@innertia-solutions/innertia-nuxt 0.1.12 → 0.1.13

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 (51) hide show
  1. package/components/Attention.vue +269 -0
  2. package/components/Chart/index.vue +193 -0
  3. package/components/Container.vue +116 -0
  4. package/components/CopyButton.vue +98 -0
  5. package/components/Files/Card.vue +215 -0
  6. package/components/Files/Preview.vue +675 -0
  7. package/components/Files/Upload.vue +408 -0
  8. package/components/Files/UploadModal.vue +83 -0
  9. package/components/Forms/InputPin.vue +162 -0
  10. package/components/InfoToolbar.vue +54 -0
  11. package/components/Kanban/Standard.vue +588 -0
  12. package/components/Loader.vue +358 -0
  13. package/components/Modal/Base.vue +6 -0
  14. package/components/Modal/Confirm.vue +89 -0
  15. package/components/Modal/DeleteConfirm.vue +14 -31
  16. package/components/Modal/Form.vue +76 -0
  17. package/components/Modal/Host.vue +58 -0
  18. package/components/Modal/Prompt.vue +74 -0
  19. package/components/Modal.vue +119 -34
  20. package/components/Table/Standard.vue +198 -25
  21. package/components/Table/index.vue +169 -369
  22. package/components/Table/views/ViewCards.vue +74 -0
  23. package/components/Table/views/ViewDatabase.vue +236 -0
  24. package/components/Table/views/ViewTable.vue +299 -0
  25. package/components/Toast/Alert.vue +16 -111
  26. package/components/Toast/Container.vue +4 -29
  27. package/components/Toast/Notification.vue +8 -43
  28. package/components/Toast/Process.vue +8 -86
  29. package/components/Toast/Stack.vue +59 -0
  30. package/components/Toast/Standard.vue +115 -0
  31. package/components/Tree/Standard.vue +49 -4
  32. package/components/Workflow/Board.vue +356 -0
  33. package/components/Workflow/Builder.vue +454 -0
  34. package/components/Workflow/Form.vue +526 -0
  35. package/components/Workflow/Viewer.vue +225 -0
  36. package/components/Workflow/edges/TransitionEdge.vue +84 -0
  37. package/components/Workflow/nodes/StepNode.vue +88 -0
  38. package/composables/useClipboard.js +79 -0
  39. package/composables/useConfetti.js +194 -0
  40. package/composables/useFile.js +189 -0
  41. package/composables/useModal.js +71 -0
  42. package/composables/useToast.js +38 -65
  43. package/composables/useWorkflow/form.js +162 -0
  44. package/composables/useWorkflow/index.js +18 -0
  45. package/composables/useWorkflow/view.js +220 -0
  46. package/composables/useWorkflowLayout.js +162 -0
  47. package/css/tokens.css +10 -10
  48. package/nuxt.config.ts +1 -1
  49. package/package.json +13 -8
  50. package/stores/modal.js +48 -0
  51. package/stores/toast.js +156 -117
@@ -0,0 +1,269 @@
1
+ <script setup>
2
+ /**
3
+ * <Attention> — Wrapper que aplica efectos de "llamar la atención" a su slot.
4
+ *
5
+ * Efectos:
6
+ * - 'ping' → ondas de radar concéntricas saliendo del elemento
7
+ * - 'pulse' → respiración (scale 1 → 1.05)
8
+ * - 'shake' → vibración tipo campana (rotate)
9
+ * - 'bounce' → rebote vertical
10
+ * - 'glow' → halo de color difuminado
11
+ * - 'shimmer-border' → borde con gradiente cónico multicolor girando
12
+ *
13
+ * Props clave:
14
+ * - active → encender/apagar la animación
15
+ * - color → semántico ('primary','success','danger','warning'...) o CSS color
16
+ * - colors → array para shimmer-border (multicolor)
17
+ * - intensity → 'subtle' | 'normal' | 'strong' (escala los keyframes)
18
+ * - duration → ms (por defecto depende del efecto)
19
+ * - border → grosor del shimmer-border en px
20
+ * - radius → radio del wrapper (para que rings/glow lo respeten)
21
+ * - count → número de iteraciones (default Infinity)
22
+ *
23
+ * ```vue
24
+ * <Attention effect="ping" color="danger" radius="full">
25
+ * <button class="bg-red-500 size-10 rounded-full"><IconBell /></button>
26
+ * </Attention>
27
+ *
28
+ * <Attention effect="shake" :duration="1500">
29
+ * <IconBell class="size-6 text-amber-500" />
30
+ * </Attention>
31
+ *
32
+ * <Attention effect="shimmer-border" :colors="['#3b82f6','#8b5cf6','#ec4899']" radius="md">
33
+ * <button class="px-4 py-2 bg-card">Probar PRO</button>
34
+ * </Attention>
35
+ * ```
36
+ */
37
+
38
+ const props = defineProps({
39
+ effect: { type: String, default: 'ping', validator: v => ['ping','pulse','shake','bounce','glow','shimmer-border'].includes(v) },
40
+ active: { type: Boolean, default: true },
41
+ color: { type: String, default: 'primary' },
42
+ colors: { type: Array, default: () => ['#3b82f6', '#8b5cf6', '#ec4899'] }, // para shimmer-border
43
+ intensity: { type: String, default: 'normal', validator: v => ['subtle','normal','strong'].includes(v) },
44
+ duration: { type: Number, default: 0 }, // 0 = default por efecto
45
+ border: { type: Number, default: 2 },
46
+ /** Radio del wrapper: 'inherit' | 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full' o un valor CSS. */
47
+ radius: { type: String, default: 'inherit' },
48
+ /** Iteraciones de la animación. Default 'infinite'. Pasá un número para limitar. */
49
+ count: { type: [Number, String], default: 'infinite' },
50
+ })
51
+
52
+ const COLOR_MAP = {
53
+ primary: 'var(--color-primary)',
54
+ foreground: 'var(--color-foreground)',
55
+ muted: 'var(--color-muted-foreground)',
56
+ success: '#10b981',
57
+ danger: '#ef4444',
58
+ warning: '#f59e0b',
59
+ info: '#3b82f6',
60
+ emerald: '#10b981',
61
+ red: '#ef4444',
62
+ amber: '#f59e0b',
63
+ blue: '#3b82f6',
64
+ violet: '#8b5cf6',
65
+ pink: '#ec4899',
66
+ }
67
+
68
+ const resolvedColor = computed(() => COLOR_MAP[props.color] ?? props.color)
69
+
70
+ const radiusMap = {
71
+ inherit: 'inherit',
72
+ none: '0',
73
+ sm: 'var(--radius-sm, 0.25rem)',
74
+ md: 'var(--radius-control, 0.5rem)',
75
+ lg: 'var(--radius-card, 0.75rem)',
76
+ xl: 'var(--radius-xl, 1rem)',
77
+ full: '9999px',
78
+ }
79
+ const resolvedRadius = computed(() => radiusMap[props.radius] ?? props.radius)
80
+
81
+ // Duración default por efecto — más lentas = más sofisticadas.
82
+ const defaultDuration = {
83
+ ping: 2400,
84
+ pulse: 2400,
85
+ shake: 1400,
86
+ bounce: 1400,
87
+ glow: 2800,
88
+ 'shimmer-border': 4500,
89
+ }
90
+ const resolvedDuration = computed(() => props.duration > 0 ? props.duration : (defaultDuration[props.effect] ?? 1800))
91
+
92
+ const intensityScale = computed(() => ({ subtle: 0.6, normal: 1, strong: 1.5 }[props.intensity] ?? 1))
93
+
94
+ const cssVars = computed(() => ({
95
+ '--att-color': resolvedColor.value,
96
+ '--att-c1': props.colors[0] ?? resolvedColor.value,
97
+ '--att-c2': props.colors[1] ?? resolvedColor.value,
98
+ '--att-c3': props.colors[2] ?? resolvedColor.value,
99
+ '--att-c4': props.colors[3] ?? props.colors[0] ?? resolvedColor.value,
100
+ '--att-dur': resolvedDuration.value + 'ms',
101
+ '--att-radius': resolvedRadius.value,
102
+ '--att-border': props.border + 'px',
103
+ '--att-scale': intensityScale.value,
104
+ '--att-count': String(props.count),
105
+ }))
106
+
107
+ const hasRings = computed(() => props.active && props.effect === 'ping')
108
+ const hasGlow = computed(() => props.active && props.effect === 'glow')
109
+ const hasShimmer = computed(() => props.active && props.effect === 'shimmer-border')
110
+ const hasInlineEffect = computed(() => props.active && ['pulse','shake','bounce'].includes(props.effect))
111
+ </script>
112
+
113
+ <template>
114
+ <span
115
+ class="att-wrap"
116
+ :class="active ? `att-mode-${effect}` : ''"
117
+ :style="cssVars"
118
+ >
119
+ <!-- Ondas de radar (ping) -->
120
+ <span v-if="hasRings" class="att-ring att-ring-a" aria-hidden="true" />
121
+ <span v-if="hasRings" class="att-ring att-ring-b" aria-hidden="true" />
122
+
123
+ <!-- Halo (glow) -->
124
+ <span v-if="hasGlow" class="att-glow" aria-hidden="true" />
125
+
126
+ <!-- Borde gradiente cónico (shimmer-border) -->
127
+ <span v-if="hasShimmer" class="att-shimmer" aria-hidden="true" />
128
+
129
+ <!-- Contenido — recibe la transformación cuando aplica -->
130
+ <span
131
+ class="att-content"
132
+ :class="hasInlineEffect ? `att-anim-${effect}` : ''"
133
+ >
134
+ <slot />
135
+ </span>
136
+ </span>
137
+ </template>
138
+
139
+ <style scoped>
140
+ /* Custom property registrada para que el ángulo del conic-gradient pueda
141
+ interpolar via animation (sin esto, no anima). */
142
+ @property --att-angle {
143
+ syntax: '<angle>';
144
+ inherits: false;
145
+ initial-value: 0deg;
146
+ }
147
+
148
+ .att-wrap {
149
+ position: relative;
150
+ display: inline-flex;
151
+ isolation: isolate;
152
+ border-radius: var(--att-radius);
153
+ }
154
+ .att-content {
155
+ position: relative;
156
+ display: inline-flex;
157
+ z-index: 1;
158
+ border-radius: inherit;
159
+ }
160
+
161
+ /* ── PING — onda discreta ───────────────────────────────────────────── */
162
+ .att-ring {
163
+ position: absolute;
164
+ inset: 0;
165
+ border-radius: inherit;
166
+ border: 1px solid var(--att-color);
167
+ pointer-events: none;
168
+ z-index: 0;
169
+ opacity: 0;
170
+ animation: att-ping var(--att-dur) cubic-bezier(0, 0, 0.2, 1) var(--att-count);
171
+ }
172
+ .att-ring-b { animation-delay: calc(var(--att-dur) / 2); }
173
+ @keyframes att-ping {
174
+ 0% { transform: scale(1); opacity: calc(0.5 * var(--att-scale)); }
175
+ 100% { transform: scale(calc(1.35 + 0.25 * var(--att-scale))); opacity: 0; }
176
+ }
177
+
178
+ /* ── PULSE — respiración casi imperceptible ─────────────────────────── */
179
+ .att-anim-pulse {
180
+ animation: att-pulse var(--att-dur) ease-in-out var(--att-count);
181
+ }
182
+ @keyframes att-pulse {
183
+ 0%, 100% { transform: scale(1); }
184
+ 50% { transform: scale(calc(1 + 0.025 * var(--att-scale))); }
185
+ }
186
+
187
+ /* ── SHAKE — leve oscilación ────────────────────────────────────────── */
188
+ .att-anim-shake {
189
+ animation: att-shake var(--att-dur) ease-in-out var(--att-count);
190
+ transform-origin: top center;
191
+ }
192
+ @keyframes att-shake {
193
+ 0%, 60%, 100% { transform: rotate(0); }
194
+ 10%, 30% { transform: rotate(calc(-7deg * var(--att-scale))); }
195
+ 20%, 40% { transform: rotate(calc(7deg * var(--att-scale))); }
196
+ 50% { transform: rotate(0); }
197
+ }
198
+
199
+ /* ── BOUNCE — rebote sutil ──────────────────────────────────────────── */
200
+ .att-anim-bounce {
201
+ animation: att-bounce var(--att-dur) cubic-bezier(.5,.05,.5,1) var(--att-count);
202
+ }
203
+ @keyframes att-bounce {
204
+ 0%, 100% { transform: translateY(0); }
205
+ 50% { transform: translateY(calc(-3px * var(--att-scale))); }
206
+ }
207
+
208
+ /* ── GLOW — halo difuso muy tenue ───────────────────────────────────── */
209
+ .att-glow {
210
+ position: absolute;
211
+ inset: calc(-2px * var(--att-scale));
212
+ border-radius: inherit;
213
+ background: var(--att-color);
214
+ filter: blur(calc(10px * var(--att-scale)));
215
+ pointer-events: none;
216
+ z-index: 0;
217
+ opacity: 0;
218
+ animation: att-glow var(--att-dur) ease-in-out var(--att-count);
219
+ }
220
+ @keyframes att-glow {
221
+ 0%, 100% { opacity: calc(0.10 * var(--att-scale)); }
222
+ 50% { opacity: calc(0.28 * var(--att-scale)); }
223
+ }
224
+
225
+ /* ── SHIMMER-BORDER — gradiente fluyendo por el borde ───────────────── */
226
+ /* Técnica: contenedor con conic-gradient + mask hueco para que sólo se
227
+ renderice el borde. El ángulo del gradiente se anima (no el elemento)
228
+ para que el flujo sea suave y los corners no se vean. */
229
+ .att-shimmer {
230
+ position: absolute;
231
+ inset: 0;
232
+ border-radius: inherit;
233
+ padding: var(--att-border);
234
+ background:
235
+ conic-gradient(
236
+ from var(--att-angle),
237
+ var(--att-c1),
238
+ var(--att-c2),
239
+ var(--att-c3),
240
+ var(--att-c4),
241
+ var(--att-c1)
242
+ );
243
+ /* Hueco: pinta sólo el padding (el borde). */
244
+ -webkit-mask:
245
+ linear-gradient(#000 0 0) content-box,
246
+ linear-gradient(#000 0 0);
247
+ -webkit-mask-composite: xor;
248
+ mask:
249
+ linear-gradient(#000 0 0) content-box,
250
+ linear-gradient(#000 0 0);
251
+ mask-composite: exclude;
252
+ pointer-events: none;
253
+ z-index: 2; /* Por encima del contenido para que se vea como borde. */
254
+ animation: att-angle var(--att-dur) linear var(--att-count);
255
+ }
256
+ @keyframes att-angle {
257
+ to { --att-angle: 360deg; }
258
+ }
259
+
260
+ /* Reducción de animaciones por accesibilidad */
261
+ @media (prefers-reduced-motion: reduce) {
262
+ .att-ring,
263
+ .att-glow,
264
+ .att-shimmer,
265
+ .att-anim-pulse,
266
+ .att-anim-shake,
267
+ .att-anim-bounce { animation: none !important; }
268
+ }
269
+ </style>
@@ -0,0 +1,193 @@
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+ import VueApexCharts from 'vue3-apexcharts'
4
+
5
+ const props = defineProps({
6
+ /** Tipo de gráfico: line | bar | area | donut | pie | radialBar | scatter | heatmap */
7
+ type: { type: String, default: 'line' },
8
+ /** Series de datos — formato ApexCharts */
9
+ series: { type: Array, required: true },
10
+ /** Categorías para el eje X */
11
+ categories: { type: Array, default: () => [] },
12
+ /** Altura del gráfico en px o string CSS */
13
+ height: { type: [Number, String], default: 320 },
14
+ /** Opciones ApexCharts adicionales — se fusionan sobre las defaults */
15
+ options: { type: Object, default: () => ({}) },
16
+ /** Colores personalizados. Si se omite, usa los tokens --chart-* */
17
+ colors: { type: Array, default: null },
18
+ /** Muestra barra de herramientas */
19
+ toolbar: { type: Boolean, default: false },
20
+ /** Animación al montar */
21
+ animated: { type: Boolean, default: true },
22
+ /** Curva de líneas: smooth | straight | stepline */
23
+ curve: { type: String, default: 'smooth' },
24
+ /** Grosor de líneas (solo line/area) */
25
+ strokeWidth: { type: [Number, Array], default: 2 },
26
+ /** Mostrar dataLabels */
27
+ dataLabels: { type: Boolean, default: false },
28
+ /** Color de fondo de la zona — para area charts */
29
+ fillOpacity: { type: [Number, Array], default: undefined },
30
+ })
31
+
32
+ // ── Resolución de CSS vars a colores reales ───────────────────────────────────
33
+ const resolveCSSColor = (varName) => {
34
+ if (!process.client) return null
35
+ const el = document.createElement('span')
36
+ el.style.cssText = `display:none;color:var(${varName})`
37
+ document.body.appendChild(el)
38
+ const computed = getComputedStyle(el).color // siempre devuelve rgb(r,g,b)
39
+ document.body.removeChild(el)
40
+ // rgb(r, g, b) → #rrggbb
41
+ const match = computed.match(/\d+/g)
42
+ if (!match || match.length < 3) return null
43
+ return '#' + match.slice(0, 3).map(n => parseInt(n).toString(16).padStart(2, '0')).join('')
44
+ }
45
+
46
+ const resolveCSSVar = (varName) => {
47
+ if (!process.client) return ''
48
+ return getComputedStyle(document.documentElement).getPropertyValue(varName).trim()
49
+ }
50
+
51
+ // ── Dark mode reactive ────────────────────────────────────────────────────────
52
+ const isDark = useState('isDark', () => false)
53
+
54
+ // ── Paleta dinámica ───────────────────────────────────────────────────────────
55
+ const CHART_VARS = [
56
+ '--chart-primary',
57
+ '--chart-2', '--chart-3', '--chart-4', '--chart-5',
58
+ '--chart-6', '--chart-7',
59
+ ]
60
+
61
+ const resolvedColors = computed(() => {
62
+ // isDark.value registra reactividad; los colores se re-resuelven al cambiar el tema
63
+ void isDark.value
64
+ if (props.colors) return props.colors
65
+ return CHART_VARS.map(v => resolveCSSColor(v)).filter(Boolean)
66
+ })
67
+
68
+ // ── Opciones base ─────────────────────────────────────────────────────────────
69
+ const baseOptions = computed(() => {
70
+ void isDark.value // trigger reactivity on dark mode toggle
71
+ const mutedFg = resolveCSSColor('--color-muted-foreground') ?? '#9ca3af'
72
+ const cardLine = resolveCSSColor('--color-card-line') ?? '#e5e7eb'
73
+ const tooltipBg = resolveCSSColor('--color-card') ?? '#ffffff'
74
+ const tooltipFg = resolveCSSColor('--color-foreground') ?? '#111827'
75
+
76
+ const isDonutLike = ['donut', 'pie', 'radialBar'].includes(props.type)
77
+
78
+ const opts = {
79
+ chart: {
80
+ background: 'transparent',
81
+ toolbar: { show: props.toolbar },
82
+ fontFamily: 'inherit',
83
+ foreColor: mutedFg,
84
+ animations: { enabled: props.animated },
85
+ redrawOnWindowResize: true,
86
+ },
87
+ colors: resolvedColors.value,
88
+
89
+ stroke: isDonutLike ? undefined : {
90
+ curve: props.curve,
91
+ width: props.strokeWidth,
92
+ },
93
+
94
+ dataLabels: {
95
+ enabled: props.dataLabels,
96
+ style: { fontFamily: 'inherit', fontSize: '11px' },
97
+ },
98
+
99
+ ...(props.fillOpacity !== undefined ? {
100
+ fill: { opacity: props.fillOpacity },
101
+ } : {}),
102
+
103
+ xaxis: isDonutLike ? undefined : {
104
+ categories: props.categories,
105
+ axisBorder: { show: false },
106
+ axisTicks: { show: false },
107
+ labels: {
108
+ style: { colors: mutedFg, fontFamily: 'inherit', fontSize: '12px' },
109
+ },
110
+ },
111
+
112
+ yaxis: isDonutLike ? undefined : {
113
+ labels: {
114
+ style: { colors: mutedFg, fontFamily: 'inherit', fontSize: '12px' },
115
+ },
116
+ },
117
+
118
+ grid: isDonutLike ? { show: false } : {
119
+ borderColor: cardLine,
120
+ strokeDashArray: 4,
121
+ xaxis: { lines: { show: false } },
122
+ },
123
+
124
+ legend: {
125
+ fontFamily: 'inherit',
126
+ fontSize: '13px',
127
+ labels: { colors: tooltipFg },
128
+ },
129
+
130
+ tooltip: {
131
+ theme: isDark.value ? 'dark' : 'light',
132
+ style: { fontFamily: 'inherit', fontSize: '12px' },
133
+ x: { show: !isDonutLike },
134
+ },
135
+
136
+ plotOptions: props.type === 'bar' ? {
137
+ bar: { borderRadius: 4, columnWidth: '60%' },
138
+ } : props.type === 'radialBar' ? {
139
+ radialBar: {
140
+ hollow: { size: '60%' },
141
+ dataLabels: {
142
+ name: { color: tooltipFg, fontFamily: 'inherit' },
143
+ value: { color: tooltipFg, fontFamily: 'inherit', fontSize: '20px' },
144
+ },
145
+ },
146
+ } : undefined,
147
+ }
148
+
149
+ // Limpiar undefined para que ApexCharts no se confunda
150
+ return JSON.parse(JSON.stringify(opts))
151
+ })
152
+
153
+ // ── Merge profundo de opciones ────────────────────────────────────────────────
154
+ const mergedOptions = computed(() => deepMerge(baseOptions.value, props.options))
155
+
156
+ function deepMerge(base, override) {
157
+ const result = { ...base }
158
+ for (const key of Object.keys(override ?? {})) {
159
+ if (
160
+ override[key] !== null &&
161
+ typeof override[key] === 'object' &&
162
+ !Array.isArray(override[key]) &&
163
+ base[key] !== null &&
164
+ typeof base[key] === 'object' &&
165
+ !Array.isArray(base[key])
166
+ ) {
167
+ result[key] = deepMerge(base[key], override[key])
168
+ } else {
169
+ result[key] = override[key]
170
+ }
171
+ }
172
+ return result
173
+ }
174
+ </script>
175
+
176
+ <template>
177
+ <ClientOnly>
178
+ <VueApexCharts
179
+ :type="type"
180
+ :height="height"
181
+ :series="series"
182
+ :options="mergedOptions"
183
+ />
184
+ <template #fallback>
185
+ <div
186
+ :style="{ height: typeof height === 'number' ? height + 'px' : height }"
187
+ class="flex items-center justify-center bg-surface rounded-card"
188
+ >
189
+ <div class="w-6 h-6 rounded-full border-2 border-primary border-t-transparent animate-spin" />
190
+ </div>
191
+ </template>
192
+ </ClientOnly>
193
+ </template>
@@ -0,0 +1,116 @@
1
+ <script setup>
2
+ /**
3
+ * <Container> — Card / panel reutilizable que sigue los tokens del DS.
4
+ *
5
+ * Usado para contenido estático: settings, dashboards, KPI tiles, info boxes,
6
+ * formularios agrupados. NO para Tree/Table/Kanban (esos manejan su propio
7
+ * layout interno complejo).
8
+ *
9
+ * Slots:
10
+ * - default — body
11
+ * - header — reemplaza el header entero (si necesitás algo custom)
12
+ * - actions — botones a la derecha del título
13
+ * - footer — pie con border-top
14
+ *
15
+ * Props:
16
+ * - title / subtitle — texto del header automático
17
+ * - bordered — true = border + rounded-card (default)
18
+ * - padding — 'none' | 'sm' | 'md' (default) | 'lg' del body
19
+ * - size — 'sm' | 'md' | 'lg' | 'fit' (default) — min-height baseline
20
+ * - tone — 'default' | 'muted' | 'primary' | 'destructive' | 'emerald' | 'amber'
21
+ */
22
+
23
+ const props = defineProps({
24
+ title: { type: String, default: '' },
25
+ subtitle: { type: String, default: '' },
26
+ bordered: { type: Boolean, default: true },
27
+ padding: { type: String, default: 'md' },
28
+ size: { type: String, default: 'fit' },
29
+ tone: { type: String, default: 'default' },
30
+ })
31
+
32
+ const slots = useSlots()
33
+
34
+ const hasAutoHeader = computed(() => !!(props.title || props.subtitle || slots.actions))
35
+ const hasHeaderSlot = computed(() => !!slots.header)
36
+ const showHeader = computed(() => hasHeaderSlot.value || hasAutoHeader.value)
37
+
38
+ // ─── Padding del body ────────────────────────────────────────────────────────
39
+ const paddingClass = computed(() => ({
40
+ none: '',
41
+ sm: 'p-3',
42
+ md: 'p-5',
43
+ lg: 'p-7',
44
+ }[props.padding] ?? 'p-5'))
45
+
46
+ // ─── Size (min-height baseline) ──────────────────────────────────────────────
47
+ const sizeStyle = computed(() => {
48
+ switch (props.size) {
49
+ case 'sm': return { minHeight: '8rem' }
50
+ case 'md': return { minHeight: '14rem' }
51
+ case 'lg': return { minHeight: '24rem' }
52
+ case 'fit':
53
+ default: return {}
54
+ }
55
+ })
56
+
57
+ // ─── Tone (color del border + bg sutil) ──────────────────────────────────────
58
+ const toneClasses = computed(() => {
59
+ if (!props.bordered) return 'bg-card'
60
+ switch (props.tone) {
61
+ case 'muted': return 'bg-muted/30 border-card-line'
62
+ case 'primary': return 'bg-primary/5 border-primary/20'
63
+ case 'destructive': return 'bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-900/50'
64
+ case 'emerald': return 'bg-emerald-50 dark:bg-emerald-950/30 border-emerald-200 dark:border-emerald-900/50'
65
+ case 'amber': return 'bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-900/50'
66
+ case 'default':
67
+ default: return 'bg-card border-card-line'
68
+ }
69
+ })
70
+ </script>
71
+
72
+ <template>
73
+ <div
74
+ :class="[
75
+ 'flex flex-col',
76
+ toneClasses,
77
+ bordered ? 'border rounded-card overflow-hidden' : '',
78
+ ]"
79
+ :style="sizeStyle"
80
+ >
81
+ <!-- Header -->
82
+ <div
83
+ v-if="showHeader"
84
+ :class="[
85
+ 'shrink-0 flex items-center gap-3 px-5 py-4',
86
+ bordered ? 'border-b border-card-line' : '',
87
+ ]"
88
+ >
89
+ <slot name="header">
90
+ <div class="flex-1 min-w-0">
91
+ <h3 v-if="title" class="text-sm font-semibold text-foreground truncate">{{ title }}</h3>
92
+ <p v-if="subtitle" class="text-xs text-muted-foreground mt-0.5 truncate">{{ subtitle }}</p>
93
+ </div>
94
+ <div v-if="$slots.actions" class="shrink-0 flex items-center gap-1">
95
+ <slot name="actions" />
96
+ </div>
97
+ </slot>
98
+ </div>
99
+
100
+ <!-- Body -->
101
+ <div :class="['flex-1 min-h-0', paddingClass]">
102
+ <slot />
103
+ </div>
104
+
105
+ <!-- Footer -->
106
+ <div
107
+ v-if="$slots.footer"
108
+ :class="[
109
+ 'shrink-0 px-5 py-3',
110
+ bordered ? 'border-t border-card-line' : '',
111
+ ]"
112
+ >
113
+ <slot name="footer" />
114
+ </div>
115
+ </div>
116
+ </template>
@@ -0,0 +1,98 @@
1
+ <script setup>
2
+ import { IconCopy, IconCheck } from '@tabler/icons-vue'
3
+
4
+ /**
5
+ * <CopyButton :value="..." /> — Botón con feedback visual de copia.
6
+ *
7
+ * Variants:
8
+ * - 'icon' (default) — solo el icono
9
+ * - 'inline' — input readonly con icono al lado (estilo Preline clipboard)
10
+ * - 'button' — botón ancho con label "Copiar"
11
+ *
12
+ * ```vue
13
+ * <CopyButton :value="user.id" />
14
+ * <CopyButton :value="apiKey" variant="inline" />
15
+ * <CopyButton :value="link" variant="button" label="Copiar enlace" />
16
+ * ```
17
+ */
18
+ const props = defineProps({
19
+ value: { type: [String, Number], default: '' },
20
+ variant: { type: String, default: 'icon', validator: v => ['icon','inline','button'].includes(v) },
21
+ label: { type: String, default: 'Copiar' },
22
+ labelCopied: { type: String, default: '¡Copiado!' },
23
+ size: { type: String, default: 'sm', validator: v => ['xs','sm','md'].includes(v) },
24
+ /** Solo para variant="inline" — placeholder cuando el value está vacío. */
25
+ placeholder: { type: String, default: '' },
26
+ /** Solo para variant="inline" — oculta el value (útil para tokens/passwords). */
27
+ masked: { type: Boolean, default: false },
28
+ })
29
+
30
+ const emit = defineEmits(['copied'])
31
+
32
+ const { copy, copied } = useClipboard({ timeout: 1500 })
33
+
34
+ const onCopy = async () => {
35
+ if (await copy(props.value)) emit('copied', props.value)
36
+ }
37
+
38
+ const iconSize = computed(() => ({ xs: 12, sm: 14, md: 16 }[props.size]))
39
+ const btnSize = computed(() => ({ xs: 'size-6', sm: 'size-7', md: 'size-8' }[props.size]))
40
+
41
+ const displayValue = computed(() => {
42
+ if (!props.value) return ''
43
+ if (!props.masked) return String(props.value)
44
+ return String(props.value).replace(/./g, '•').slice(0, 32)
45
+ })
46
+ </script>
47
+
48
+ <template>
49
+ <!-- Icon-only -->
50
+ <button
51
+ v-if="variant === 'icon'"
52
+ type="button"
53
+ :title="copied ? labelCopied : label"
54
+ :class="[btnSize, 'inline-flex items-center justify-center rounded-control transition-colors',
55
+ copied ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
56
+ : 'text-muted-foreground hover:text-foreground hover:bg-muted-hover']"
57
+ @click="onCopy"
58
+ >
59
+ <IconCheck v-if="copied" :size="iconSize" :stroke="2" />
60
+ <IconCopy v-else :size="iconSize" :stroke="1.75" />
61
+ </button>
62
+
63
+ <!-- Inline input + icon (Preline clipboard style) -->
64
+ <div
65
+ v-else-if="variant === 'inline'"
66
+ class="inline-flex items-center gap-2 w-full innertia-field !pr-1 !py-1 cursor-pointer"
67
+ @click="onCopy"
68
+ >
69
+ <span class="flex-1 truncate text-sm text-foreground font-mono">
70
+ {{ displayValue || placeholder }}
71
+ </span>
72
+ <button
73
+ type="button"
74
+ :class="['shrink-0 inline-flex items-center justify-center size-7 rounded-control transition-colors',
75
+ copied ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
76
+ : 'text-muted-foreground hover:text-foreground hover:bg-muted-hover']"
77
+ @click.stop="onCopy"
78
+ >
79
+ <IconCheck v-if="copied" :size="14" :stroke="2" />
80
+ <IconCopy v-else :size="14" :stroke="1.75" />
81
+ </button>
82
+ </div>
83
+
84
+ <!-- Full button -->
85
+ <button
86
+ v-else
87
+ type="button"
88
+ :class="['inline-flex items-center gap-1.5 px-3 py-1.5 rounded-control text-xs font-medium transition-colors border',
89
+ copied
90
+ ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
91
+ : 'bg-card text-foreground border-card-line hover:bg-muted-hover']"
92
+ @click="onCopy"
93
+ >
94
+ <IconCheck v-if="copied" :size="14" :stroke="2" />
95
+ <IconCopy v-else :size="14" :stroke="1.75" />
96
+ {{ copied ? labelCopied : label }}
97
+ </button>
98
+ </template>