@edc-motor/ui 0.4.36 → 0.4.38

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.4.36",
3
+ "version": "0.4.38",
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",
package/scss/_forms.scss CHANGED
@@ -9,13 +9,11 @@
9
9
  .field { display: flex; flex-direction: column; gap: $space-1; }
10
10
  .field__label, .field label { font-size: $fs-13; color: $text-2; }
11
11
 
12
- // El numérico compacto del NumberInput queda FUERA: esta regla genérica le
13
- // ganaba por especificidad y le imponía el 100%/36px de un campo de línea.
14
12
  input[type="text"],
15
13
  input[type="email"],
16
14
  input[type="password"],
17
15
  input[type="search"],
18
- input[type="number"]:not(.number-input__field),
16
+ input[type="number"],
19
17
  select,
20
18
  textarea {
21
19
  font: inherit;
@@ -21,7 +21,6 @@
21
21
  @use "translatable-input";
22
22
  @use "form-locale-switch";
23
23
  @use "image-upload";
24
- @use "number-input";
25
24
  @use "multi-select";
26
25
  @use "font-upload";
27
26
  @use "toast";
@@ -1,7 +1,8 @@
1
1
  @use "tokens" as *;
2
2
 
3
3
  // Select múltiple (MultiSelect.vue): hereda toda la estética de .base-select;
4
- // aquí solo la casilla de check de cada opción y el truncado del trigger.
4
+ // aquí la casilla de check de cada opción y las etiquetas del trigger (las
5
+ // elegidas se quitan desde su aspa sin abrir el panel).
5
6
  .multi-select {
6
7
  .base-select__value {
7
8
  overflow: hidden;
@@ -9,6 +10,47 @@
9
10
  white-space: nowrap;
10
11
  }
11
12
 
13
+ // El trigger crece con las etiquetas (min-height en vez del alto fijo).
14
+ .base-select__trigger {
15
+ height: auto;
16
+ min-height: $input-height;
17
+ padding-block: 4px;
18
+ }
19
+
20
+ &__tags {
21
+ display: flex;
22
+ flex-wrap: wrap;
23
+ align-items: center;
24
+ gap: 4px;
25
+ min-width: 0;
26
+ }
27
+
28
+ &__tag {
29
+ display: inline-flex;
30
+ align-items: center;
31
+ gap: 4px;
32
+ max-width: 100%;
33
+ padding: 1px $space-1 1px $space-2;
34
+ border: 1px solid $border;
35
+ border-radius: $radius-sm;
36
+ background: $surface-2;
37
+ font-size: $fs-13;
38
+ line-height: 1.4;
39
+ }
40
+
41
+ &__tag-remove {
42
+ display: inline-flex;
43
+ align-items: center;
44
+ justify-content: center;
45
+ flex: none;
46
+ color: $text-3;
47
+ cursor: pointer;
48
+
49
+ &:hover {
50
+ color: $danger;
51
+ }
52
+ }
53
+
12
54
  &__option {
13
55
  display: flex;
14
56
  align-items: center;
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
3
- import { Check } from '@lucide/vue'
3
+ import { Check, X } from '@lucide/vue'
4
4
  import { useDropdownPanel } from '../composables/useDropdownPanel'
5
5
  import type { SelectOption } from './BaseSelect.vue'
6
6
 
@@ -10,9 +10,9 @@ import type { SelectOption } from './BaseSelect.vue'
10
10
  // marca de check y el panel SE CIERRA al elegir (como el simple: un valor
11
11
  // por apertura — para añadir otro se reabre y las marcas siguen ahí). El
12
12
  // valor viaja como array de strings (mismo criterio String() que
13
- // BaseSelect). El trigger pinta las etiquetas elegidas unidas por comas
14
- // (el CSS las trunca con elipsis) así no hace falta ningún texto
15
- // "N seleccionadas" que traducir.
13
+ // BaseSelect). El trigger pinta las elegidas como etiquetas con aspa: se
14
+ // quitan desde ahí sin abrir el panel (también con Backspace); así no
15
+ // hace falta ningún texto "N seleccionadas" que traducir.
16
16
  const props = withDefaults(
17
17
  defineProps<{
18
18
  modelValue?: (string | number)[]
@@ -42,12 +42,17 @@ useDropdownPanel(root, panel, open)
42
42
 
43
43
  const selectedValues = computed(() => new Set(props.modelValue.map((v) => String(v))))
44
44
  const isSelected = (option: SelectOption) => selectedValues.value.has(String(option.value))
45
- const triggerLabel = computed(() =>
46
- props.options
47
- .filter((o) => isSelected(o))
48
- .map((o) => o.label)
49
- .join(', '),
50
- )
45
+ const selectedOptions = computed(() => props.options.filter((o) => isSelected(o)))
46
+
47
+ /** Quita una elegida desde su etiqueta del trigger, sin tocar el panel. */
48
+ function removeOption(option: SelectOption) {
49
+ if (props.disabled) return
50
+ const value = String(option.value)
51
+ emit(
52
+ 'update:modelValue',
53
+ props.modelValue.map((v) => String(v)).filter((v) => v !== value),
54
+ )
55
+ }
51
56
 
52
57
  function optionId(index: number) {
53
58
  return `${selectId}-opt-${index}`
@@ -101,6 +106,10 @@ function onKeydown(e: KeyboardEvent) {
101
106
  if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) {
102
107
  e.preventDefault()
103
108
  void openPanel()
109
+ } else if (e.key === 'Backspace' && selectedOptions.value.length) {
110
+ // Quitar la última elegida sin abrir el panel (espejo del aspa).
111
+ e.preventDefault()
112
+ removeOption(selectedOptions.value[selectedOptions.value.length - 1])
104
113
  }
105
114
  return
106
115
  }
@@ -171,9 +180,22 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onOutside))
171
180
  @click="toggle"
172
181
  @keydown="onKeydown"
173
182
  >
174
- <span class="base-select__value" :class="{ 'is-placeholder': !triggerLabel }">
175
- {{ triggerLabel || placeholder }}
183
+ <!-- Elegidas como etiquetas con aspa (el aspa NO abre el panel);
184
+ span con role=button porque un button no puede anidar otro -->
185
+ <span v-if="selectedOptions.length" class="multi-select__tags">
186
+ <span v-for="option in selectedOptions" :key="option.value" class="multi-select__tag">
187
+ {{ option.label }}
188
+ <span
189
+ class="multi-select__tag-remove"
190
+ role="button"
191
+ :aria-label="String(option.label)"
192
+ @click.stop="removeOption(option)"
193
+ >
194
+ <X :size="12" />
195
+ </span>
196
+ </span>
176
197
  </span>
198
+ <span v-else class="base-select__value is-placeholder">{{ placeholder }}</span>
177
199
  </button>
178
200
 
179
201
  <ul
package/src/index.ts CHANGED
@@ -31,7 +31,6 @@ export {
31
31
  export const RichTextInput = defineAsyncComponent(() => import('./components/RichTextInput.vue'))
32
32
  export type { RichIcon, RichTextLabels } from './components/RichTextInput.vue'
33
33
  export { default as ImageUpload } from './components/ImageUpload.vue'
34
- export { default as NumberInput } from './components/NumberInput.vue'
35
34
  export { default as MultiSelect } from './components/MultiSelect.vue'
36
35
  export { default as FontUpload } from './components/FontUpload.vue'
37
36
  export { default as BaseModal } from './components/BaseModal.vue'
@@ -1,48 +0,0 @@
1
- @use "tokens" as *;
2
-
3
- // Input numérico compacto con steppers −/+ (NumberInput.vue): la estética
4
- // del contador de copias del editor de mazos de CDL, generalizada.
5
- .number-input {
6
- display: inline-flex;
7
- align-items: center;
8
- gap: $space-1;
9
-
10
- &__step {
11
- display: inline-flex;
12
- align-items: center;
13
- justify-content: center;
14
- width: 1.6rem;
15
- height: 1.6rem;
16
- padding: 0;
17
- border: 1px solid $border;
18
- border-radius: $radius-sm;
19
- background: $surface-2;
20
- color: $text-2;
21
- cursor: pointer;
22
-
23
- &:hover:not(:disabled) { color: $text-1; border-color: $text-3; }
24
- &:disabled { opacity: $disabled-opacity; cursor: not-allowed; }
25
- }
26
-
27
- &__field {
28
- width: 2.6rem;
29
- padding: 0.1rem 0.2rem;
30
- text-align: center;
31
- font-weight: $k-fw-bold;
32
- font-size: $fs-13;
33
- color: $text-1;
34
- border: 1px solid $border;
35
- border-radius: $radius-sm;
36
- background: $surface;
37
- appearance: textfield;
38
- -moz-appearance: textfield;
39
-
40
- &::-webkit-outer-spin-button,
41
- &::-webkit-inner-spin-button {
42
- appearance: none;
43
- margin: 0;
44
- }
45
- }
46
-
47
- &--invalid &__field { color: $danger; border-color: $danger; }
48
- }
@@ -1,82 +0,0 @@
1
- <script setup lang="ts">
2
- import { computed } from 'vue'
3
- import { Minus, Plus } from '@lucide/vue'
4
-
5
- // Input numérico compacto con steppers −/+ (nacido como el contador de
6
- // copias del editor de mazos de CDL): input centrado sin flechas nativas,
7
- // botones que respetan min/max y clamp al teclear. `invalid` pinta el borde
8
- // en danger (p. ej. copias por encima del límite del modo).
9
- const props = withDefaults(
10
- defineProps<{
11
- modelValue: number
12
- min?: number
13
- max?: number
14
- step?: number
15
- disabled?: boolean
16
- invalid?: boolean
17
- /** aria-label del input (los botones llevan los suyos). */
18
- label?: string
19
- decreaseLabel?: string
20
- increaseLabel?: string
21
- }>(),
22
- { step: 1, disabled: false, invalid: false },
23
- )
24
-
25
- const emit = defineEmits<{ 'update:modelValue': [number] }>()
26
-
27
- function clamp(value: number): number {
28
- if (Number.isNaN(value)) value = props.min ?? 0
29
- if (props.min !== undefined) value = Math.max(props.min, value)
30
- if (props.max !== undefined) value = Math.min(props.max, value)
31
- return value
32
- }
33
-
34
- const atMin = computed(() => props.min !== undefined && props.modelValue <= props.min)
35
- const atMax = computed(() => props.max !== undefined && props.modelValue >= props.max)
36
-
37
- function stepBy(delta: number) {
38
- emit('update:modelValue', clamp(props.modelValue + delta * props.step))
39
- }
40
-
41
- function onChange(event: Event) {
42
- const input = event.target as HTMLInputElement
43
- const value = clamp(Number(input.value))
44
- // El clamp puede dejar el mismo modelValue (sin re-render): se repinta a mano.
45
- input.value = String(value)
46
- emit('update:modelValue', value)
47
- }
48
- </script>
49
-
50
- <template>
51
- <span class="number-input" :class="{ 'number-input--invalid': invalid }">
52
- <button
53
- type="button"
54
- class="number-input__step"
55
- :disabled="disabled || atMin"
56
- :aria-label="decreaseLabel"
57
- @click="stepBy(-1)"
58
- >
59
- <Minus :size="14" />
60
- </button>
61
- <input
62
- class="number-input__field"
63
- type="number"
64
- :value="modelValue"
65
- :min="min"
66
- :max="max"
67
- :step="step"
68
- :disabled="disabled"
69
- :aria-label="label"
70
- @change="onChange"
71
- />
72
- <button
73
- type="button"
74
- class="number-input__step"
75
- :disabled="disabled || atMax"
76
- :aria-label="increaseLabel"
77
- @click="stepBy(1)"
78
- >
79
- <Plus :size="14" />
80
- </button>
81
- </span>
82
- </template>