@vasakgroup/vue-libvasak 0.3.0 → 0.3.1

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 (34) hide show
  1. package/dist/types/cards/DeviceCard.vue.d.ts +32 -0
  2. package/dist/types/cards/ListCard.vue.d.ts +24 -0
  3. package/dist/types/controls/ActionButton.vue.d.ts +35 -0
  4. package/dist/types/controls/ToggleControl.vue.d.ts +23 -0
  5. package/dist/types/forms/FormGroup.vue.d.ts +23 -0
  6. package/dist/types/forms/SliderControl.vue.d.ts +28 -0
  7. package/dist/types/forms/SwitchToggle.vue.d.ts +21 -0
  8. package/{src/index.ts → dist/types/index.d.ts} +4 -40
  9. package/dist/types/layout/ConfigSection.vue.d.ts +21 -0
  10. package/dist/types/sidebar/SideBar.vue.d.ts +51 -0
  11. package/dist/types/sidebar/SideButton.vue.d.ts +21 -0
  12. package/dist/types/sidebar/SideGroup.vue.d.ts +21 -0
  13. package/dist/types/sidebar/tipos.d.ts +15 -0
  14. package/dist/types/tray/TrayIconButton.vue.d.ts +37 -0
  15. package/dist/types/window/TopBar.vue.d.ts +9 -0
  16. package/dist/types/window/WindowFrame.vue.d.ts +20 -0
  17. package/package.json +7 -7
  18. package/src/cards/DeviceCard.vue +0 -82
  19. package/src/cards/ListCard.vue +0 -40
  20. package/src/controls/ActionButton.vue +0 -85
  21. package/src/controls/ToggleControl.vue +0 -55
  22. package/src/forms/FormGroup.vue +0 -27
  23. package/src/forms/SliderControl.vue +0 -100
  24. package/src/forms/SwitchToggle.vue +0 -49
  25. package/src/layout/ConfigSection.vue +0 -28
  26. package/src/shims-vue.d.ts +0 -15
  27. package/src/sidebar/SideBar.vue +0 -183
  28. package/src/sidebar/SideButton.vue +0 -115
  29. package/src/sidebar/SideGroup.vue +0 -46
  30. package/src/sidebar/tipos.ts +0 -16
  31. package/src/tray/TrayIconButton.vue +0 -80
  32. package/src/types/vue-libvasak.d.ts +0 -191
  33. package/src/window/TopBar.vue +0 -46
  34. package/src/window/WindowFrame.vue +0 -21
@@ -1,85 +0,0 @@
1
- <script setup lang="ts">
2
- interface Props {
3
- label: string;
4
- disabled?: boolean;
5
- variant?: 'primary' | 'secondary' | 'danger';
6
- loading?: boolean;
7
- customClass?: string | Record<string, boolean>;
8
- size?: 'sm' | 'md' | 'lg';
9
- fullWidth?: boolean;
10
- iconSrc?: string;
11
- iconAlt?: string;
12
- iconRight?: boolean;
13
- type?: 'button' | 'submit' | 'reset';
14
- stopPropagation?: boolean;
15
- preventDefault?: boolean;
16
- }
17
-
18
- const props = withDefaults(defineProps<Props>(), {
19
- disabled: false,
20
- variant: 'primary',
21
- loading: false,
22
- customClass: () => ({}),
23
- size: 'md',
24
- fullWidth: false,
25
- iconSrc: '',
26
- iconAlt: '',
27
- iconRight: false,
28
- type: 'button',
29
- stopPropagation: false,
30
- preventDefault: false,
31
- });
32
-
33
- const emit = defineEmits<{
34
- click: [];
35
- }>();
36
-
37
- const variantClasses: Record<string, string> = {
38
- primary: 'bg-primary dark:bg-primary-dark text-tx-on-primary dark:text-tx-on-primary-dark hover:bg-primary/90 dark:hover:bg-primary-dark/90',
39
- secondary: 'bg-secondary dark:bg-secondary-dark text-tx-on-primary dark:text-tx-on-primary-dark hover:bg-secondary/80 dark:hover:bg-secondary-dark/80',
40
- danger: 'bg-status-error dark:bg-status-error-dark text-tx-on-primary dark:text-tx-on-primary-dark hover:bg-status-error/90 dark:hover:bg-status-error-dark/90',
41
- };
42
-
43
- const sizeClasses: Record<'sm' | 'md' | 'lg', string> = {
44
- sm: 'px-2 py-1 text-xs',
45
- md: 'px-3 py-1 text-sm',
46
- lg: 'px-4 py-2 text-base',
47
- };
48
-
49
- const handleClick = (event: Event) => {
50
- if (props.stopPropagation) event.stopPropagation();
51
- if (props.preventDefault) event.preventDefault();
52
- if (!props.disabled && !props.loading) {
53
- emit('click');
54
- }
55
- };
56
- </script>
57
-
58
- <template>
59
- <button
60
- :type="props.type"
61
- @click="handleClick"
62
- class="rounded-corner transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
63
- :class="[
64
- variantClasses[props.variant],
65
- sizeClasses[props.size],
66
- props.fullWidth ? 'w-full' : '',
67
- props.iconSrc && !props.label ? 'px-2 py-2' : '',
68
- customClass,
69
- ]"
70
- :disabled="props.disabled || props.loading"
71
- >
72
- <span v-if="loading" class="w-4 h-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
73
- <template v-if="props.iconSrc && !props.iconRight">
74
- <img :src="props.iconSrc" :alt="props.iconAlt || props.label" class="w-4 h-4" />
75
- </template>
76
- <span v-if="props.label">{{ props.label }}</span>
77
- <template v-if="props.iconSrc && props.iconRight">
78
- <img :src="props.iconSrc" :alt="props.iconAlt || props.label" class="w-4 h-4" />
79
- </template>
80
- </button>
81
- </template>
82
-
83
- <style scoped>
84
- /* Ningún estilo adicional requerido */
85
- </style>
@@ -1,55 +0,0 @@
1
- <template>
2
- <button
3
- @click="handleClick"
4
- class="p-2 rounded-corner background hover:opacity-50 transition-all duration-300 h-17.5 w-17.5 group relative overflow-hidden hover:scale-105 hover:shadow-lg active:scale-95"
5
- :class="{
6
- 'animate-pulse': isLoading,
7
- 'ring-2 ring-primary dark:ring-primary-dark': isActive,
8
- 'opacity-60': !isActive,
9
- ...customClass
10
- }"
11
- :disabled="isLoading"
12
- >
13
- <img
14
- :src="icon"
15
- :alt="alt"
16
- :title="tooltip"
17
- class="m-auto w-12.5 h-12.5 transition-all duration-300 group-hover:scale-110 relative z-10"
18
- :class="{
19
- 'animate-spin': isLoading,
20
- 'filter brightness-75': !isActive,
21
- 'drop-shadow-lg': isActive,
22
- ...iconClass
23
- }"
24
- />
25
- </button>
26
- </template>
27
-
28
- <script setup lang="ts">
29
- interface Props {
30
- icon: string;
31
- alt?: string;
32
- tooltip?: string;
33
- isActive?: boolean;
34
- isLoading?: boolean;
35
- iconClass?: Record<string, boolean>;
36
- customClass?: Record<string, boolean>;
37
- }
38
-
39
- withDefaults(defineProps<Props>(), {
40
- alt: '',
41
- tooltip: '',
42
- isActive: false,
43
- isLoading: false,
44
- iconClass: () => ({}),
45
- customClass: () => ({}),
46
- });
47
-
48
- const emit = defineEmits<{
49
- click: [];
50
- }>();
51
-
52
- const handleClick = () => {
53
- emit('click');
54
- };
55
- </script>
@@ -1,27 +0,0 @@
1
- <script setup lang="ts">
2
- interface Props {
3
- label: string;
4
- htmlFor?: string;
5
- customClass?: string | Record<string, boolean>;
6
- labelClass?: string | Record<string, boolean>;
7
- }
8
-
9
- withDefaults(defineProps<Props>(), {
10
- htmlFor: '',
11
- customClass: () => ({}),
12
- labelClass: () => ({}),
13
- });
14
- </script>
15
-
16
- <template>
17
- <div class="flex flex-col gap-2" :class="customClass">
18
- <label v-if="label" :for="htmlFor" class="text-sm font-medium text-primary dark:text-primary-dark" :class="labelClass">
19
- {{ label }}
20
- </label>
21
- <slot />
22
- </div>
23
- </template>
24
-
25
- <style scoped>
26
- /* Ningún estilo adicional requerido */
27
- </style>
@@ -1,100 +0,0 @@
1
- <template>
2
- <div
3
- class="background rounded-corner flex flex-row items-center gap-2 justify-between w-full h-auto p-4 transition-all duration-200 hover:bg-ui-surface/80 dark:hover:bg-ui-surface-dark/80"
4
- >
5
- <button
6
- v-if="showButton"
7
- @click="handleButtonClick"
8
- class="w-8 h-8 flex items-center justify-center rounded-corner transition-all duration-200 hover:bg-ui-surface/80 dark:hover:bg-ui-surface-dark/80 hover:scale-110 active:scale-95"
9
- >
10
- <img
11
- :src="icon"
12
- :alt="alt"
13
- :title="tooltip"
14
- class="w-6 h-6 transition-all duration-200"
15
- :class="iconClass"
16
- />
17
- </button>
18
-
19
- <div
20
- v-else
21
- class="w-8 h-8 flex items-center justify-center"
22
- >
23
- <img
24
- :src="icon"
25
- :alt="alt"
26
- class="w-6 h-6 transition-all duration-200"
27
- />
28
- </div>
29
-
30
- <input
31
- type="range"
32
- :min="min"
33
- :max="max"
34
- :value="modelValue"
35
- @input="handleInput"
36
- class="flex-1 transition-all duration-200 hover:scale-105"
37
- />
38
-
39
- <span
40
- class="w-12 text-right transition-all duration-200 font-medium"
41
- :class="percentageClass"
42
- >
43
- {{ percentage }}%
44
- </span>
45
- </div>
46
- </template>
47
-
48
- <script setup lang="ts">
49
- import { computed } from 'vue';
50
-
51
- interface Props {
52
- icon: string;
53
- alt?: string;
54
- tooltip?: string;
55
- modelValue: number;
56
- min?: number;
57
- max?: number;
58
- showButton?: boolean;
59
- iconClass?: string | Record<string, boolean>;
60
- getPercentageClass?: (percentage: number) => string;
61
- }
62
-
63
- const props = withDefaults(defineProps<Props>(), {
64
- alt: '',
65
- tooltip: '',
66
- min: 0,
67
- max: 100,
68
- showButton: false,
69
- iconClass: () => ({}),
70
- getPercentageClass: () => '',
71
- });
72
-
73
- const emit = defineEmits<{
74
- 'update:modelValue': [value: number];
75
- 'buttonClick': [];
76
- }>();
77
-
78
- const percentage = computed(() => {
79
- if (props.max <= props.min) return 0;
80
- const range = props.max - props.min;
81
- const value = props.modelValue - props.min;
82
- return Math.round((value / range) * 100);
83
- });
84
-
85
- const percentageClass = computed(() => {
86
- if (props.getPercentageClass) {
87
- return props.getPercentageClass(percentage.value);
88
- }
89
- return '';
90
- });
91
-
92
- const handleInput = (event: Event) => {
93
- const target = event.target as HTMLInputElement;
94
- emit('update:modelValue', Number(target.value));
95
- };
96
-
97
- const handleButtonClick = () => {
98
- emit('buttonClick');
99
- };
100
- </script>
@@ -1,49 +0,0 @@
1
- <template>
2
- <button
3
- type="button"
4
- @click="handleClick"
5
- :disabled="disabled"
6
- :class="[
7
- 'relative inline-flex items-center rounded-full transition-colors',
8
- size === 'small' ? 'h-6 w-11' : 'h-7 w-12',
9
- isOn ? activeClass : inactiveClass,
10
- disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
11
- customClass
12
- ]"
13
- >
14
- <span
15
- :class="[
16
- 'inline-block transform rounded-full bg-white shadow transition-transform',
17
- size === 'small' ? 'h-4 w-4' : 'h-6 w-6',
18
- isOn ? (size === 'small' ? 'translate-x-6' : 'translate-x-5') : 'translate-x-1'
19
- ]"
20
- ></span>
21
- </button>
22
- </template>
23
-
24
- <script setup lang="ts">
25
- interface Props {
26
- isOn: boolean;
27
- disabled?: boolean;
28
- size?: 'small' | 'medium';
29
- activeClass?: string;
30
- inactiveClass?: string;
31
- customClass?: string;
32
- }
33
-
34
- const props = withDefaults(defineProps<Props>(), {
35
- disabled: false,
36
- size: 'small',
37
- activeClass: 'bg-primary dark:bg-primary-dark',
38
- inactiveClass: 'background',
39
- customClass: '',
40
- });
41
-
42
- const emit = defineEmits<{
43
- toggle: [value: boolean];
44
- }>();
45
-
46
- const handleClick = () => {
47
- emit('toggle', !props.isOn);
48
- };
49
- </script>
@@ -1,28 +0,0 @@
1
- <script setup lang="ts">
2
- interface Props {
3
- title: string;
4
- icon?: string;
5
- customClass?: string | Record<string, boolean>;
6
- }
7
-
8
- withDefaults(defineProps<Props>(), {
9
- icon: '',
10
- customClass: () => ({}),
11
- });
12
- </script>
13
-
14
- <template>
15
- <div
16
- class="flex flex-col gap-4 p-4 background rounded-corner"
17
- :class="customClass"
18
- >
19
- <h3 class="text-base font-semibold m-0 text-primary dark:text-primary-dark">
20
- {{ icon ? `${icon} ${title}` : title }}
21
- </h3>
22
- <slot />
23
- </div>
24
- </template>
25
-
26
- <style scoped>
27
- /* Ningún estilo adicional requerido */
28
- </style>
@@ -1,15 +0,0 @@
1
- /**
2
- * Que TypeScript entienda los `.vue`.
3
- *
4
- * Acá había un `declare module '*'`, que no es un shim: es apagar el chequeo de
5
- * **todos** los imports del proyecto y dejarlos en `any`. Con eso, los tipos que
6
- * esta librería publica no los comprobaba nadie —ni siquiera contra sus propios
7
- * componentes—, que para un paquete cuya razón de ser es que otras seis
8
- * aplicaciones lo usen es justo lo que no puede pasar.
9
- */
10
- declare module '*.vue' {
11
- import type { DefineComponent } from 'vue';
12
-
13
- const componente: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
14
- export default componente;
15
- }
@@ -1,183 +0,0 @@
1
- <script lang="ts" setup>
2
- /**
3
- * La barra lateral de las aplicaciones de VasakOS.
4
- *
5
- * Es la de Configuración, que es la que todas las demás venían copiando: mismo
6
- * `aside` con borde y esquina redondeada, mismo plegado a 84 píxeles, mismos
7
- * grupos con título. Que estén acá y no copiadas en cada repositorio es lo que
8
- * hace que las ventanas se lean como partes del mismo escritorio en vez de
9
- * parecerse por casualidad.
10
- *
11
- * # Los dos caminos
12
- *
13
- * Con `categories` se arma sola: cada categoría es un grupo plegable y cada
14
- * elemento un botón, con el activo marcado contra `modelValue`. Es el camino de
15
- * Configuración y del monitor, donde la barra **es** la navegación.
16
- *
17
- * Con la ranura por omisión se pone cualquier cosa adentro —discos, pasos de un
18
- * asistente, una línea de tiempo— y la barra aporta nada más que el marco y el
19
- * plegado. La ranura recibe `collapsed`, porque lo que se dibuja adentro casi
20
- * siempre tiene que saberlo.
21
- *
22
- * Los dos caminos conviven: lo declarativo va primero y la ranura después.
23
- *
24
- * # Por qué no hay traducciones acá
25
- *
26
- * Una librería de componentes que traduce obliga a todas las aplicaciones a
27
- * compartir sus claves. Los textos entran por propiedades; el `aria-label` del
28
- * botón de plegar también, que es el único que no se ve pero se oye.
29
- */
30
- import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
31
- import SideGroup from './SideGroup.vue';
32
- import SideButton from './SideButton.vue';
33
- import type { SidebarCategory } from './tipos';
34
-
35
- const props = withDefaults(
36
- defineProps<{
37
- /** El nombre de la ventana. Sin él y sin `subtitle` no hay área de título. */
38
- title?: string;
39
- subtitle?: string;
40
- categories?: SidebarCategory[];
41
- /** El identificador del elemento activo. */
42
- modelValue?: string;
43
- /** Plegada desde afuera. Sin esto, la barra se maneja sola. */
44
- collapsed?: boolean;
45
- /** Lo que oye un lector de pantalla en el botón de plegar. */
46
- collapseLabel?: string;
47
- expandLabel?: string;
48
- }>(),
49
- {
50
- title: '',
51
- subtitle: '',
52
- categories: () => [],
53
- modelValue: '',
54
- collapsed: undefined,
55
- collapseLabel: 'Collapse',
56
- expandLabel: 'Expand',
57
- }
58
- );
59
-
60
- const emit = defineEmits<{
61
- 'update:modelValue': [value: string];
62
- 'update:collapsed': [value: boolean];
63
- change: [value: string];
64
- }>();
65
-
66
- const plegadaAMano = ref(props.collapsed ?? false);
67
- const esAngosta = ref(false);
68
- let consulta: MediaQueryList | null = null;
69
-
70
- /**
71
- * Plegada por decisión o por ancho.
72
- *
73
- * Por debajo de 768 píxeles no hay lugar para el texto de los botones, así que
74
- * la barra se pliega sola y el botón de plegar no se muestra: ofrecer
75
- * desplegarla ahí sería ofrecer algo que no entra.
76
- */
77
- const plegada = computed(() => esAngosta.value || plegadaAMano.value);
78
- const hayTitulo = computed(() => Boolean(props.title || props.subtitle));
79
- const hayCategorias = computed(() => props.categories.length > 0);
80
-
81
- function revisar() {
82
- esAngosta.value = consulta?.matches ?? false;
83
- }
84
-
85
- function alternar() {
86
- plegadaAMano.value = !plegadaAMano.value;
87
- emit('update:collapsed', plegadaAMano.value);
88
- }
89
-
90
- function elegir(id: string) {
91
- emit('update:modelValue', id);
92
- emit('change', id);
93
- }
94
-
95
- // Controlada desde afuera cuando la aplicación pasa `collapsed`: así dos
96
- // ventanas de la misma aplicación pueden recordar cómo la dejó la persona.
97
- watch(
98
- () => props.collapsed,
99
- (valor) => {
100
- if (valor !== undefined) {
101
- plegadaAMano.value = valor;
102
- }
103
- }
104
- );
105
-
106
- onMounted(() => {
107
- consulta = window.matchMedia('(max-width: 767px)');
108
- revisar();
109
- consulta.addEventListener('change', revisar);
110
- });
111
-
112
- onBeforeUnmount(() => consulta?.removeEventListener('change', revisar));
113
-
114
- defineExpose({ collapsed: plegada });
115
- </script>
116
-
117
- <template>
118
- <aside
119
- class="relative z-30 flex h-full shrink-0 flex-col rounded-corner border border-ui-border bg-ui-bg/80 transition-all duration-300"
120
- :class="['w-[84px]', plegada ? 'md:w-[84px]' : 'md:w-72']">
121
- <header
122
- v-if="hayTitulo || $slots.header"
123
- class="flex flex-col gap-2 border-ui-border border-b p-2">
124
- <div class="flex items-center gap-2">
125
- <button
126
- type="button"
127
- class="hidden h-10 w-10 items-center justify-center rounded-corner border border-ui-border bg-ui-surface/70 font-semibold text-sm md:inline-flex"
128
- :aria-label="plegada ? expandLabel : collapseLabel"
129
- :aria-expanded="!plegada"
130
- @click="alternar">
131
- {{ plegada ? '&gt;' : '&lt;' }}
132
- </button>
133
- <!-- El área de título es opcional: hay ventanas donde el nombre ya está
134
- en la barra superior y repetirlo acá gasta la mitad del alto. -->
135
- <div v-if="hayTitulo && !plegada" class="min-w-0 flex-1">
136
- <p v-if="title" class="truncate font-semibold text-sm">{{ title }}</p>
137
- <p v-if="subtitle" class="truncate text-tx-muted text-xs">{{ subtitle }}</p>
138
- </div>
139
- </div>
140
-
141
- <!-- Lo que va antes que cualquier categoría: la búsqueda de la tienda,
142
- por ejemplo. Plegada no entra un campo de texto —84 píxeles es el
143
- ancho del icono— así que se esconde en vez de quedar ilegible. -->
144
- <div v-if="$slots.header && !plegada">
145
- <slot name="header" :collapsed="plegada" />
146
- </div>
147
- </header>
148
-
149
- <!-- Sin área de título el botón de plegar necesita su propio lugar, o la
150
- barra deja de poder plegarse. -->
151
- <div v-else class="flex justify-center border-ui-border border-b p-2">
152
- <button
153
- type="button"
154
- class="hidden h-10 w-10 items-center justify-center rounded-corner border border-ui-border bg-ui-surface/70 font-semibold text-sm md:inline-flex"
155
- :aria-label="plegada ? expandLabel : collapseLabel"
156
- :aria-expanded="!plegada"
157
- @click="alternar">
158
- {{ plegada ? '&gt;' : '&lt;' }}
159
- </button>
160
- </div>
161
-
162
- <div class="flex-1 space-y-3 overflow-y-auto p-2">
163
- <SideGroup
164
- v-for="category in categories"
165
- :key="category.id"
166
- :title="category.title"
167
- :collapsed="plegada">
168
- <SideButton
169
- v-for="item in category.items"
170
- :key="item.id"
171
- :label="item.label"
172
- :icon="item.icon"
173
- :badge="item.badge"
174
- :disabled="item.disabled"
175
- :collapsed="plegada"
176
- :active="modelValue === item.id"
177
- @click="elegir(item.id)" />
178
- </SideGroup>
179
-
180
- <slot :collapsed="plegada" />
181
- </div>
182
- </aside>
183
- </template>
@@ -1,115 +0,0 @@
1
- <script lang="ts" setup>
2
- /**
3
- * Un elemento de la barra lateral.
4
- *
5
- * El icono sale del tema del escritorio y se vuelve a resolver cuando la
6
- * persona cambia de tema: por eso no se recibe una ruta sino un nombre. Plegado
7
- * queda sólo el icono, y el nombre pasa al `title` para que el globo lo diga —
8
- * sin eso, una barra plegada es una columna de dibujos sin explicación.
9
- */
10
- import { getIconSource } from '@vasakgroup/plugin-vicons';
11
- import { listen, type UnlistenFn } from '@tauri-apps/api/event';
12
- import { onMounted, onUnmounted, ref, toRef, watch } from 'vue';
13
-
14
- const props = withDefaults(
15
- defineProps<{
16
- label: string;
17
- icon?: string;
18
- active?: boolean;
19
- collapsed?: boolean;
20
- disabled?: boolean;
21
- badge?: string | number;
22
- }>(),
23
- { icon: '', active: false, collapsed: false, disabled: false, badge: '' }
24
- );
25
-
26
- defineEmits<{ click: [] }>();
27
-
28
- const fuente = ref('');
29
- const icono = toRef(props, 'icon');
30
- let soltar: UnlistenFn | null = null;
31
- let desmontado = false;
32
-
33
- /** Cuántas resoluciones se pidieron. De las que estén en vuelo, sólo vale la última. */
34
- let ultimoPedido = 0;
35
-
36
- async function resolver() {
37
- const mio = ++ultimoPedido;
38
- const nombre = icono.value;
39
- if (!nombre) {
40
- fuente.value = '';
41
- return;
42
- }
43
- const resuelto = await getIconSource(nombre);
44
- // Cambiar de icono y cambiar de tema resuelven en paralelo, y el tema tarda
45
- // lo que tarde el backend. Sin el testigo, la respuesta vieja llega última y
46
- // deja puesto el icono anterior — que es el mismo síntoma que se venía a
47
- // evitar, pero intermitente y según cuál tarde más.
48
- if (mio === ultimoPedido) {
49
- fuente.value = resuelto;
50
- }
51
- }
52
-
53
- onMounted(async () => {
54
- // El oyente **antes** de la primera resolución. El tema de iconos cambia en
55
- // caliente, y resolver el primero tarda: un cambio de tema durante esa
56
- // espera no lo escuchaba nadie, y el botón se quedaba con el icono del tema
57
- // anterior hasta el cambio siguiente.
58
- const dejarDeEscuchar = await listen('vicons:theme-changed', resolver);
59
- // Registrarse tarda, y en una lista que se desplaza un botón puede irse
60
- // antes de que termine. Ahí `onUnmounted` ya pasó y no vio nada que soltar:
61
- // el oyente quedaba registrado para siempre sobre un componente muerto.
62
- if (desmontado) {
63
- dejarDeEscuchar();
64
- return;
65
- }
66
- soltar = dejarDeEscuchar;
67
-
68
- // Las dos resoluciones pueden cruzarse —ésta y la que dispare un cambio de
69
- // tema—, y de eso se encarga el testigo de `resolver`.
70
- await resolver();
71
- });
72
-
73
- onUnmounted(() => {
74
- desmontado = true;
75
- soltar?.();
76
- });
77
-
78
- watch(icono, resolver);
79
- </script>
80
-
81
- <template>
82
- <button
83
- type="button"
84
- :title="collapsed ? label : undefined"
85
- :aria-label="collapsed ? label : undefined"
86
- :disabled="disabled"
87
- :aria-current="active ? 'page' : undefined"
88
- class="group relative flex w-full items-center gap-3 rounded-corner border px-3 py-2 text-left text-sm transition-all duration-200"
89
- :class="[
90
- active
91
- ? 'border-secondary bg-primary/15 text-tx-main shadow-sm'
92
- : 'border-transparent bg-ui-bg/30 hover:border-ui-border hover:bg-ui-surface/70',
93
- disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
94
- collapsed ? 'justify-center px-2' : '',
95
- ]"
96
- @click="$emit('click')">
97
- <span
98
- class="flex h-8 w-8 shrink-0 items-center justify-center rounded-corner font-semibold text-xs uppercase tracking-wide"
99
- :class="active ? 'border-secondary bg-primary/20' : ''"
100
- aria-hidden="true">
101
- <img v-if="fuente" :src="fuente" alt="" class="h-8 w-8 object-contain">
102
- <!-- Sin icono, la inicial: un hueco vacío del mismo tamaño deja la fila
103
- desalineada contra las que sí lo tienen. -->
104
- <span v-else>{{ label.charAt(0).toUpperCase() }}</span>
105
- </span>
106
-
107
- <span v-if="!collapsed" class="min-w-0 flex-1 truncate font-medium">{{ label }}</span>
108
-
109
- <span
110
- v-if="!collapsed && badge !== ''"
111
- class="rounded-corner bg-ui-surface px-2 py-0.5 font-semibold text-tx-muted text-xs">
112
- {{ badge }}
113
- </span>
114
- </button>
115
- </template>