@edc-motor/ui 0.5.6 → 0.5.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edc-motor/ui",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "EdC Motor — componentes públicos Vue 3 + tokens SCSS para webs de juegos de mesa (paquete fuente: lo compila el consumidor con Vite)",
5
5
  "license": "GPL-3.0-only",
6
6
  "type": "module",
@@ -377,18 +377,25 @@
377
377
  }
378
378
  }
379
379
 
380
- // Cita: sin adorno de borde, en GRANDE ($fs-32, el mismo tamaño que un
381
- // título de bloque) y del color de acento.
380
+ // Cita: sin adorno de borde, en grande ($fs-28, el mismo cuerpo que un
381
+ // título de bloque; antes $fs-32 — un ~10% menos) y del color de acento,
382
+ // con interlineado propio (1.25: las citas de varias líneas respiraban
383
+ // de más con el line-height del cuerpo). En estrecho baja a $fs-24.
382
384
  &--quote .block__quote {
383
385
  margin: 0;
384
386
  // Fuente "especial" de la configuración de la web (doc 10); las fuentes
385
387
  // decorativas piden un cuerpo mayor.
386
388
  font-family: var(--font-special, inherit);
387
- font-size: $fs-32;
389
+ font-size: $fs-28;
390
+ line-height: 1.25;
388
391
  font-style: italic;
389
392
  font-weight: $k-fw-semibold;
390
393
  color: $accent-500;
391
394
 
395
+ @container content (max-width: #{$bp-sm - 1px}) {
396
+ font-size: $fs-24;
397
+ }
398
+
392
399
  // La cita llega como richtext y .rich-content fija su propio cuerpo
393
400
  // ($fs-16): aquí debe heredar el tamaño grande de la cita.
394
401
  .rich-content { font-size: inherit; }
@@ -0,0 +1,125 @@
1
+ @use "tokens" as *;
2
+
3
+ // =========================================================
4
+ // Sistema COMPARTIDO de filas y grupos de formulario
5
+ // =========================================================
6
+ // Un único lenguaje para TODOS los formularios modales del admin:
7
+ // sustituye a los layouts ad hoc que había repartidos (el `__common-row` de
8
+ // PageBlocks y las filas `--pair`/`--row`/`--image-group` de SchemaFields).
9
+ //
10
+ // PROHIBIDO auto-fit/auto-fill en todo el sistema: columnas FIJAS + cortes
11
+ // EXPLÍCITOS de container query. Con auto-fit las pistas se re-resolvían
12
+ // contra el ancho de `modal-body` (que depende de si hay scrollbar),
13
+ // quedaban pistas fantasma a 0px y los `1fr` resultaban fraccionales
14
+ // desiguales: al repintar con hover a DPI 125 %/150 % los campos vecinos
15
+ // temblaban («baile» en modales largos). Con pistas fijas el layout es
16
+ // idéntico en cada repintado.
17
+ //
18
+ // El contenedor `modal-body` lo define _modal.scss sobre el cuerpo del
19
+ // modal: el patrón funciona en CUALQUIER modal y los cortes miden el ancho
20
+ // REAL del formulario, no el del viewport.
21
+ .form-row {
22
+ display: grid;
23
+ // Base: dos columnas fijas iguales.
24
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
25
+ gap: $space-3;
26
+ align-items: start;
27
+
28
+ > * { min-width: 0; }
29
+
30
+ // Un checkbox como celda: flex para que se comporte como bloque de su
31
+ // celda junto a sus compañeros de fila.
32
+ > .checkbox { display: flex; }
33
+
34
+ // Tres columnas fijas iguales, para filas de 3 campos pequeños.
35
+ &--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
36
+
37
+ // Columna izquierda MUCHO más ancha que la derecha (2:1): un textarea o
38
+ // wysiwyg a la izquierda con su campo pequeño natural al lado (p. ej. el
39
+ // select de alineación). 2fr y no 2.5fr: en el modal `md` (~520px útiles)
40
+ // 2:1 deja ~170px a la derecha — lo justo para un select con su etiqueta
41
+ // («Alineación del subtítulo»…) sin cortarse; con 2.5fr quedaría corto.
42
+ &--wide-left { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
43
+
44
+ // Fila de imagen (especial): la celda izquierda (el input de imagen, con
45
+ // su etiqueta) es UNA sola celda que llena TODO el alto de la fila; la
46
+ // derecha es una pila de filas (.form-row__stack) con los ajustes
47
+ // (posición, escalado, reparto de columnas…). El cuadro de arrastre NO se
48
+ // estira con la celda: conserva su tamaño natural y se centra en el
49
+ // espacio (margin-block auto en _image-upload.scss).
50
+ &--media {
51
+ align-items: stretch;
52
+
53
+ > :first-child {
54
+ display: flex;
55
+ flex-direction: column;
56
+ gap: $space-1;
57
+ min-width: 0;
58
+ min-height: 0;
59
+
60
+ // El input crece para llenar la celda (envoltorios de etiqueta
61
+ // incluidos: .form-field de TranslatableImage, etc.).
62
+ .image-upload {
63
+ flex: 1;
64
+ min-height: 0;
65
+ }
66
+ }
67
+ }
68
+
69
+ // Pila de filas apiladas dentro de una celda (la columna derecha de
70
+ // --media): mismo ritmo que la propia fila.
71
+ &__stack {
72
+ display: grid;
73
+ gap: $space-3;
74
+ align-content: start;
75
+ }
76
+
77
+ // Cortes explícitos (en vez del auto-fit):
78
+ // — la fila de 3 apila ANTES que el resto: por debajo de ~564px de
79
+ // modal-body (3 campos de 180px mínimos cómodos + 2 gaps) ya no caben
80
+ // tres columnas. Pasa DIRECTA a una columna, sin estadio intermedio
81
+ // 2+1 que dejaría una celda huérfana descolgada.
82
+ @container modal-body (max-width: 563px) {
83
+ &--3 { grid-template-columns: minmax(0, 1fr); }
84
+ }
85
+
86
+ // — TODAS las variantes apilan a una columna en el modal angosto.
87
+ @container modal-body (max-width: #{$bp-sm - 1px}) {
88
+ &,
89
+ &--3,
90
+ &--wide-left,
91
+ &--media {
92
+ grid-template-columns: minmax(0, 1fr);
93
+ }
94
+ }
95
+ }
96
+
97
+ // Separador de grupos de campos relacionados dentro de un formulario:
98
+ // <fieldset class="form-fieldset"><legend>…</legend>…</fieldset>
99
+ // Raya superior dashed (el mismo lenguaje que tenía la divisoria de la
100
+ // sección General del form de bloque); el navegador corta la raya donde
101
+ // asienta el legend (comportamiento nativo de fieldset+legend).
102
+ .form-fieldset {
103
+ margin: 0;
104
+ padding: $space-3 0 0; // aire entre la raya y el primer campo
105
+ border: 0;
106
+ border-top: 1px dashed $border;
107
+ min-width: 0; // los fieldset traen min-width min-content; aquí estorba
108
+ display: grid;
109
+ gap: $space-4; // el mismo ritmo que .edit-modal__body
110
+
111
+ // Legend discreto asentado sobre la raya: pegado al borde izquierdo y
112
+ // con un poco de aire antes de que sigan los guiones.
113
+ > legend {
114
+ padding-inline: 0 $space-2;
115
+ font-size: $fs-13;
116
+ color: $text-2;
117
+ }
118
+
119
+ // El PRIMER grupo del formulario no lleva raya superior (el formulario
120
+ // empieza directamente con él; su legend hace de título).
121
+ &:first-child {
122
+ border-top: 0;
123
+ padding-top: 0;
124
+ }
125
+ }
@@ -3,6 +3,7 @@
3
3
  @use "icon-button";
4
4
  @use "motor-badge";
5
5
  @use "form-field";
6
+ @use "form-row";
6
7
  @use "base-select";
7
8
  @use "base-pagination";
8
9
  @use "search-select";
@@ -6,11 +6,16 @@
6
6
  // evita que compita con el contenido.
7
7
  .page-background {
8
8
  position: fixed;
9
- top: 0;
9
+ // El TOP de la imagen queda justo debajo del header de la página: el
10
+ // LAYOUT (cascarón) define --page-background-top con la altura real de su
11
+ // site-header (si es estable por CSS, en scss; si no, medida con JS).
12
+ // Sin definirla vale 0px: comportamiento de siempre (toda la ventana).
13
+ top: var(--page-background-top, 0px);
10
14
  left: 0;
11
15
  width: 100%;
12
- height: 100vh; // fallback
13
- height: 100lvh; // altura SIN la barra del navegador móvil
16
+ height: calc(100vh - var(--page-background-top, 0px)); // fallback
17
+ // altura SIN la barra del navegador móvil
18
+ height: calc(100lvh - var(--page-background-top, 0px));
14
19
  background-size: cover;
15
20
  background-position: top;
16
21
  background-repeat: no-repeat;
@@ -52,6 +52,21 @@
52
52
 
53
53
  &--selected { box-shadow: 0 0 0 2px $surface, 0 0 0 4px var(--swatch-color); }
54
54
 
55
+ // Preset DINÁMICO del tema (token:* → var(--token), resuelto con el
56
+ // tema ACTUAL): borde siempre visible (su color puede confundirse con
57
+ // la superficie del formulario) y check en color de texto del tema (el
58
+ // blanco fijo desaparecía sobre las superficies claras). El anillo de
59
+ // selección en color de texto, como el custom: con el swatch del color
60
+ // del fondo, el anillo del propio color no se veía.
61
+ &--dynamic {
62
+ border-color: $border-strong;
63
+ color: $text-1;
64
+ }
65
+
66
+ &--dynamic#{&}--selected {
67
+ box-shadow: 0 0 0 2px $surface, 0 0 0 4px $text-1;
68
+ }
69
+
55
70
  &--custom { overflow: hidden; }
56
71
 
57
72
  &--custom-idle {
@@ -29,10 +29,11 @@
29
29
  min-width: 0;
30
30
  justify-content: center;
31
31
 
32
- // Estrecho: las tabs con icono se apilan (icono + etiqueta pequeña)
32
+ // Estrecho: las tabs con icono se quedan SOLO con el icono el texto
33
+ // desaparece (la etiqueta, visually hidden más abajo, sigue en el DOM
34
+ // accesible y el botón lleva title; antes se apilaban icono + etiqueta
35
+ // pequeña).
33
36
  &--has-icon {
34
- flex-direction: column;
35
- gap: 2px;
36
37
  padding: $space-1;
37
38
  }
38
39
 
@@ -42,7 +43,6 @@
42
43
  padding: $space-2;
43
44
 
44
45
  &--has-icon {
45
- flex-direction: row;
46
46
  gap: $space-1;
47
47
  padding: $space-2;
48
48
  }
@@ -78,17 +78,24 @@
78
78
  text-overflow: ellipsis;
79
79
  white-space: nowrap;
80
80
 
81
+ // En estrecho, la etiqueta de una tab CON icono desaparece VISUALMENTE
82
+ // (patrón visually-hidden, no display:none: sigue en el árbol de
83
+ // accesibilidad y da nombre al botón; el title del botón cubre el
84
+ // hover). De $bp-sm para arriba vuelve al flujo, en línea con el icono.
81
85
  .tabs__tab--has-icon & {
82
- font-size: 10px;
83
- line-height: 1;
86
+ position: absolute;
87
+ width: 1px;
88
+ height: 1px;
89
+ margin: -1px;
90
+ padding: 0;
91
+ clip-path: inset(50%);
84
92
 
85
93
  @container content (min-width: #{$bp-sm}) {
86
- font-size: $fs-12;
87
- line-height: inherit;
88
- }
89
-
90
- @container content (min-width: #{$bp-md}) {
91
- font-size: $fs-14;
94
+ position: static;
95
+ width: auto;
96
+ height: auto;
97
+ margin: 0;
98
+ clip-path: none;
92
99
  }
93
100
  }
94
101
  }
@@ -19,6 +19,17 @@ const headingAlign = (key: 'title_align' | 'subtitle_align', prefix: string) =>
19
19
  return value && value !== 'inherit' ? `block--${prefix}-${value}` : ''
20
20
  }
21
21
 
22
+ // El color de fondo puede ser un hex fijo o un preset DINÁMICO del tema
23
+ // serializado como `token:<nombre>` (p. ej. token:surface): se resuelve a
24
+ // su custom property (var(--surface)) — color-mix admite vars — y así el
25
+ // tinte sigue al tema claro/oscuro. El nombre se sanea a [a-z0-9-] (el
26
+ // value viene del admin, pero un token roto no debe inyectar CSS).
27
+ const TOKEN_PREFIX = 'token:'
28
+ function resolveColor(value: string): string {
29
+ if (!value.startsWith(TOKEN_PREFIX)) return value
30
+ return `var(--${value.slice(TOKEN_PREFIX.length).replace(/[^a-z0-9-]/gi, '')})`
31
+ }
32
+
22
33
  // La alineación va por CLASE (block--align-*, _blocks.scss), no por style en
23
34
  // línea: un style inline no se puede pisar desde CSS (bloqueaba el cambio a
24
35
  // izquierda del justificado en estrecho, DC-03 ampliado).
@@ -26,7 +37,7 @@ const style = computed<CSSProperties>(() => {
26
37
  const background = props.settings.background as string | undefined
27
38
  return {
28
39
  '--block-bg': background
29
- ? `color-mix(in srgb, ${background} var(--block-tint, 15%), transparent)`
40
+ ? `color-mix(in srgb, ${resolveColor(background)} var(--block-tint, 15%), transparent)`
30
41
  : 'transparent',
31
42
  }
32
43
  })
@@ -1,7 +1,9 @@
1
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.
2
+ // Imagen de fondo de la página (patrón CDL): capa FIJA detrás del
3
+ // contenido desde justo debajo del header (--page-background-top, la
4
+ // define el layout del cascarón; 0 si no existe) hasta abajo —, atenuada
5
+ // según el tema (--page-bg-opacity) y con un desaturado suave para que el
6
+ // texto siga siendo legible. Decorativa.
5
7
  defineProps<{ image?: string | null }>()
6
8
  </script>
7
9
 
@@ -1,6 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  import type { Component } from 'vue'
3
3
 
4
+ // Una tab CON icono lleva además title con su etiqueta: en estrecho el
5
+ // texto desaparece y queda solo el icono (la etiqueta sigue en el DOM
6
+ // accesible, visually hidden — ver _tabs.scss).
4
7
  interface Tab {
5
8
  key: string
6
9
  label: string
@@ -20,6 +23,7 @@ defineEmits<{ 'update:modelValue': [string] }>()
20
23
  type="button"
21
24
  class="tabs__tab"
22
25
  :class="{ 'tabs__tab--active': modelValue === tab.key, 'tabs__tab--has-icon': !!tab.icon }"
26
+ :title="tab.icon ? tab.label : undefined"
23
27
  @click="$emit('update:modelValue', tab.key)"
24
28
  >
25
29
  <component :is="tab.icon" v-if="tab.icon" class="tabs__icon" :size="16" />
@@ -2,16 +2,29 @@
2
2
  import { computed, ref, watch } from 'vue'
3
3
  import { Check, Pipette } from '@lucide/vue'
4
4
 
5
+ /** Preset DINÁMICO del tema: value `token:<nombre>` (p. ej. token:surface),
6
+ * que se resuelve a la custom property `var(--<nombre>)` del tema ACTIVO
7
+ * (los tokens del tema son CSS vars también en el admin). */
8
+ export interface ColorPreset {
9
+ value: string
10
+ label: string
11
+ }
12
+
5
13
  // Selector de color (portado/adaptado de kontuan). A diferencia de kontuan
6
14
  // (que guarda la clave de la paleta), aquí emite el HEX directamente, para
7
- // campos de color guardados como cadena hexadecimal.
15
+ // campos de color guardados como cadena hexadecimal. Además de la paleta
16
+ // fija puede ofrecer presets DINÁMICOS (`presets`, valores token:* — el
17
+ // fondo de bloque del CRM los usa): se pintan resueltos con el tema actual,
18
+ // con title descriptivo, y se seleccionan/deseleccionan igual que un hex.
8
19
  const props = withDefaults(
9
20
  defineProps<{
10
21
  modelValue: string | null
11
22
  label?: string
12
23
  allowCustom?: boolean
24
+ /** Presets dinámicos del tema, delante de la paleta (vacío = como siempre). */
25
+ presets?: ColorPreset[]
13
26
  }>(),
14
- { allowCustom: true },
27
+ { allowCustom: true, presets: () => [] },
15
28
  )
16
29
 
17
30
  const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
@@ -31,15 +44,32 @@ const PALETTE = [
31
44
  { name: 'Gris', hex: '#64748B' },
32
45
  ]
33
46
 
47
+ const TOKEN_PREFIX = 'token:'
48
+
49
+ /** CSS del swatch de un preset: token:x => var(--x); un hex pasa tal cual. */
50
+ function swatchColor(value: string): string {
51
+ if (!value.startsWith(TOKEN_PREFIX)) return value
52
+ return `var(--${value.slice(TOKEN_PREFIX.length).replace(/[^a-z0-9-]/gi, '')})`
53
+ }
54
+
34
55
  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)
56
+ const isPreset = computed(
57
+ () =>
58
+ PALETTE.some((c) => norm(c.hex) === norm(props.modelValue)) ||
59
+ props.presets.some((p) => norm(p.value) === norm(props.modelValue)),
60
+ )
61
+ // Un token desconocido tampoco es "custom": el input color solo entiende hex.
62
+ const isCustom = computed(
63
+ () => !!props.modelValue && !isPreset.value && !props.modelValue.startsWith(TOKEN_PREFIX),
64
+ )
37
65
 
38
66
  const customHex = ref(isCustom.value ? (props.modelValue as string) : '#FF7A00')
39
67
  watch(
40
68
  () => props.modelValue,
41
69
  (v) => {
42
- if (v && !PALETTE.some((c) => norm(c.hex) === norm(v))) customHex.value = v
70
+ if (v && !v.startsWith(TOKEN_PREFIX) && !PALETTE.some((c) => norm(c.hex) === norm(v))) {
71
+ customHex.value = v
72
+ }
43
73
  },
44
74
  )
45
75
 
@@ -54,6 +84,26 @@ function onCustomInput(event: Event) {
54
84
  <div class="palette-color-picker">
55
85
  <label v-if="label" class="palette-color-picker__label">{{ label }}</label>
56
86
  <div class="palette-color-picker__grid">
87
+ <!-- Presets dinámicos del tema (token:*), resueltos con el tema actual -->
88
+ <button
89
+ v-for="preset in presets"
90
+ :key="preset.value"
91
+ type="button"
92
+ class="palette-color-picker__swatch palette-color-picker__swatch--dynamic"
93
+ :class="{
94
+ 'palette-color-picker__swatch--selected': norm(modelValue) === norm(preset.value),
95
+ }"
96
+ :style="{ '--swatch-color': swatchColor(preset.value) }"
97
+ :title="preset.label"
98
+ @click="emit('update:modelValue', preset.value)"
99
+ >
100
+ <Check
101
+ v-if="norm(modelValue) === norm(preset.value)"
102
+ class="palette-color-picker__swatch-check"
103
+ :size="14"
104
+ />
105
+ </button>
106
+
57
107
  <button
58
108
  v-for="opt in PALETTE"
59
109
  :key="opt.hex"
package/src/index.ts CHANGED
@@ -12,7 +12,10 @@ export { default as BaseSelect, type SelectOption } from './components/BaseSelec
12
12
  export { default as SearchSelect, type SearchSelectOption } from './components/SearchSelect.vue'
13
13
  export { default as BaseCheckbox } from './components/BaseCheckbox.vue'
14
14
  export { default as NumericInput } from './components/NumericInput.vue'
15
- export { default as PaletteColorPicker } from './components/PaletteColorPicker.vue'
15
+ export {
16
+ default as PaletteColorPicker,
17
+ type ColorPreset,
18
+ } from './components/PaletteColorPicker.vue'
16
19
  export { default as TranslatableInput } from './components/TranslatableInput.vue'
17
20
  export { default as TranslatableImage } from './components/TranslatableImage.vue'
18
21
  // Locale global de un formulario: EditModal lo provee solo (selector compacto