.gitignore and v0.2.0

This commit is contained in:
2026-08-12 03:44:07 -06:00
parent 26851fa750
commit 8f009bb1ed
22 changed files with 1175 additions and 25 deletions
+3
View File
@@ -15,3 +15,6 @@ npm-debug.log*
# Material de referencia local, no parte del paquete # Material de referencia local, no parte del paquete
/prompt.md /prompt.md
/Captura de pantalla_*.png /Captura de pantalla_*.png
# Gitea personal
INSTALAR_DESDE_GITEA.md
+3 -3
View File
@@ -20,12 +20,11 @@ El playground queda disponible en la dirección indicada por Vite. Incluye catá
## Validación ## Validación
```bash ```bash
npm run typecheck npm run check
npm test
npm run build
``` ```
`npm run build` genera la librería en `dist/` y el catálogo en `dist-playground/`. `npm run build` genera la librería en `dist/` y el catálogo en `dist-playground/`.
Usa `npm run test:coverage` para revisar la cobertura antes de ampliar el catálogo.
## Consumo ## Consumo
@@ -61,6 +60,7 @@ Se recomienda usar importaciones nombradas para conservar tree-shaking. La perso
- [Catálogo de componentes](docs/components.md) - [Catálogo de componentes](docs/components.md)
- [Accesibilidad](docs/accessibility.md) - [Accesibilidad](docs/accessibility.md)
- [Instalación y theming](docs/installation.md) - [Instalación y theming](docs/installation.md)
- [Herramientas interactivas](docs/interactive-tools.md)
- [Roadmap](docs/roadmap.md) - [Roadmap](docs/roadmap.md)
El paquete se publica en el registro npm de Gitea y no contiene lógica de negocio, router, almacenamiento ni acceso a APIs. El paquete se publica en el registro npm de Gitea y no contiene lógica de negocio, router, almacenamiento ni acceso a APIs.
+2
View File
@@ -17,6 +17,8 @@ Objetivo: WCAG 2.2 nivel AA para los contratos entregados.
- Tabs: Tab entra en el tab activo; flechas cambian a la pestaña anterior/siguiente; Home y End van a extremos; tabs deshabilitadas se omiten. - Tabs: Tab entra en el tab activo; flechas cambian a la pestaña anterior/siguiente; Home y End van a extremos; tabs deshabilitadas se omiten.
- Dialog: `showModal()` proporciona top layer, fondo inerte, contención y Escape nativos. Al cerrar se intenta restaurar el foco previo. - Dialog: `showModal()` proporciona top layer, fondo inerte, contención y Escape nativos. Al cerrar se intenta restaurar el foco previo.
- Todos los botones tienen una altura mínima razonable; `DsIconButton` exige un `label` accesible. - Todos los botones tienen una altura mínima razonable; `DsIconButton` exige un `label` accesible.
- La lista ordenable ofrece botones subir/bajar y anuncia la nueva posición; arrastrar nunca es la única forma de completar la operación.
- El árbol lógico usa controles nativos con nombres accesibles y conserva una jerarquía de listas y grupos comprensible sin presentación visual.
## Estados ## Estados
+25
View File
@@ -81,3 +81,28 @@ Ejemplo:
`DsDialog` recibe `v-model<boolean>`, `title`, `description`, `closeLabel` y `closeOnBackdrop`. Emite `close` y `cancel`; ofrece slots default y `footer`. `DsDialog` recibe `v-model<boolean>`, `title`, `description`, `closeLabel` y `closeOnBackdrop`. Emite `close` y `cancel`; ofrece slots default y `footer`.
`DsConfirmDialog` añade `confirmLabel`, `cancelLabel`, `danger` y `loading`; emite `confirm` y `cancel`. `DsConfirmDialog` añade `confirmLabel`, `cancelLabel`, `danger` y `loading`; emite `confirm` y `cancel`.
## Datos y reglas
`DsSortableList` ordena un `v-model<DsSortableItem[]>` mediante drag-and-drop. Cada elemento requiere `id` y `label`; puede incluir `description` y `disabled`. El evento `reorder` entrega el elemento y sus posiciones `from` y `to`. Los botones subir/bajar ofrecen la misma operación sin depender del puntero.
```vue
<DsSortableList v-model="steps" label="Etapas del proyecto" />
```
`DsLogicTree` edita un `v-model<DsLogicGroup>` serializable. Un grupo combina nodos con `operator: "and" | "or"`; cada condición guarda `field`, `comparison` y `value`. `fields` y `comparisons` reciben opciones configurables, y `readonly` permite mostrar el árbol sin editarlo.
```vue
<DsLogicTree
v-model="rules"
:fields="[
{ label: 'Estado', value: 'status' },
{ label: 'Plan', value: 'plan' },
]"
label="Reglas de audiencia"
/>
```
Los textos del constructor pueden adaptarse mediante la prop `labels`. El componente emite `change` con el árbol completo después de cada edición.
Las decisiones internas, limitaciones conocidas y propuestas de evolución están registradas en [Herramientas interactivas](interactive-tools.md).
+138
View File
@@ -0,0 +1,138 @@
# Herramientas interactivas
Estado: primera versión funcional. Este documento conserva el contexto necesario para extender `DsSortableList` y `DsLogicTree` sin cambiar accidentalmente sus contratos públicos.
## Principios compartidos
- Los modelos son serializables y no contienen referencias DOM ni estado interno de Vue.
- Cada elemento o nodo necesita un `id` estable y único.
- Las actualizaciones reemplazan arreglos y nodos en lugar de mutar los objetos recibidos.
- La interacción con puntero siempre debe tener una alternativa de teclado.
- La lógica de negocio, persistencia y traducción a consultas pertenece a la aplicación consumidora o a adaptadores independientes.
- Cualquier ampliación debe actualizar tipos, API pública, pruebas, playground y documentación.
## `DsSortableList`
### Contrato actual
Recibe `v-model<DsSortableItem[]>`. Cada elemento contiene:
```ts
interface DsSortableItem {
id: string;
label: string;
description?: string;
disabled?: boolean;
}
```
El usuario puede arrastrar un elemento o utilizar los botones subir/bajar. Cada cambio reemplaza el arreglo y emite:
```ts
interface DsSortableChange {
item: DsSortableItem;
from: number;
to: number;
}
```
El slot predeterminado puede personalizar la representación, pero `id` y `label` continúan formando parte del contrato porque se utilizan para identidad y nombres accesibles.
### Decisiones
- Se usa drag-and-drop nativo del navegador para mantener pequeña la primera versión y evitar una dependencia runtime.
- Los botones subir/bajar son la alternativa accesible y también permiten operar la lista en pantallas táctiles.
- La nueva posición se anuncia mediante una región `status`.
- Un elemento `disabled` no puede iniciar un movimiento.
### Limitaciones conocidas
- Solo reordena elementos dentro de una misma lista.
- No tiene gesto táctil de arrastre; en móvil se usan los botones.
- No ofrece clonación, selección múltiple, grupos ni zonas de descarte.
- El destino corresponde al elemento sobre el que se suelta; todavía no existe un indicador entre filas.
- No administra persistencia ni historial para deshacer/rehacer.
### Próximas extensiones
1. Mejorar el indicador de inserción antes/después de una fila.
2. Evaluar Pointer Events para arrastre táctil sin eliminar la alternativa por botones.
3. Diseñar drag-and-drop entre listas. Antes de implementarlo hay que definir `sourceListId`, `targetListId`, copia frente a movimiento y el evento público resultante.
4. Añadir soporte opcional para deshacer/rehacer como composable independiente.
5. Incorporar pruebas visuales y de interacción en navegadores reales.
## `DsLogicTree`
### Contrato actual
Recibe `v-model<DsLogicGroup>`. El árbol utiliza una unión discriminada:
```ts
type DsLogicNode = DsLogicCondition | DsLogicGroup;
interface DsLogicGroup {
id: string;
type: "group";
operator: "and" | "or";
children: DsLogicNode[];
}
interface DsLogicCondition {
id: string;
type: "condition";
field: string;
comparison: string;
value: string;
}
```
`fields` y `comparisons` usan opciones `{ label, value, disabled? }`. La prop `labels` permite adaptar todos los textos del editor y `readonly` conserva la estructura sin permitir cambios. Cada edición actualiza el `v-model` y emite `change` con el árbol completo.
El render recursivo vive en `DsLogicTreeNode.vue`, que es una implementación interna y no forma parte de la API pública.
### Decisiones
- `AND` y `OR` se almacenan como `and` y `or`; los textos visibles son configurables.
- Las condiciones usan cadenas para que la primera versión sea neutral respecto al dominio.
- Los nodos nuevos reciben UUID y los nodos proporcionados por el consumidor deben conservar IDs estables.
- El árbol solamente describe reglas. No evalúa condiciones ni genera SQL, JSON Logic u otra sintaxis.
- Los controles nativos mantienen nombres accesibles y el anidamiento se representa con listas y grupos.
### Limitaciones conocidas
- Todos los valores se editan como texto, sin tipos `number`, `boolean`, fecha o selección múltiple.
- No existe validación de campos vacíos, operadores incompatibles o grupos sin condiciones.
- Los nodos no pueden reordenarse ni moverse entre grupos.
- No hay operadores unarios como `is-empty` ni condiciones que omitan `value`.
- No se muestran resúmenes en lenguaje natural.
- No se incluye un evaluador ni adaptadores para backends.
### Próximas extensiones
1. Añadir validación sin cambiar el modelo serializable; los errores deben asociarse al control correspondiente.
2. Diseñar definiciones de campo tipadas con un `valueType` y opciones de valor. Hay que conservar compatibilidad con el esquema actual o introducir el cambio en una versión mayor.
3. Permitir comparadores que no requieran valor y editores personalizados mediante slots.
4. Reordenar condiciones y mover nodos entre grupos reutilizando un contrato de drag-and-drop estable.
5. Crear adaptadores separados para evaluar el árbol o convertirlo a formatos como JSON Logic. Estos adaptadores no deben acoplar el componente visual a un backend.
6. Añadir colapsado de grupos, resumen legible y deshacer/rehacer cuando el árbol crezca.
## Checklist para futuras sesiones
Antes de modificar estas herramientas:
1. Revisa este documento, `src/types.ts` y las pruebas de `tests/data-tools.test.ts`.
2. Decide si el cambio es compatible con los modelos actuales y documenta cualquier migración.
3. Conserva una alternativa completa al arrastre para teclado y tecnologías asistivas.
4. Añade el caso nuevo al playground en `?section=tools`.
5. Actualiza `docs/components.md` si cambia la API pública.
6. Ejecuta `npm run check` y `npm run test:coverage`.
## Fuera de alcance actual
- Ejecución de reglas contra datos reales.
- Generación directa de SQL o llamadas a APIs.
- Colaboración en tiempo real.
- Persistencia automática.
- Un editor visual de flujos con nodos y conexiones libres.
Estas capacidades requieren contratos y análisis de seguridad propios; no deben incorporarse como comportamiento implícito de los componentes visuales.
+11
View File
@@ -9,6 +9,17 @@
- Mejoras de formularios: grupos de campos, contador de caracteres y estados asíncronos. - Mejoras de formularios: grupos de campos, contador de caracteres y estados asíncronos.
- Versionado semántico, changelog y política de deprecación antes de compartir el paquete ampliamente. - Versionado semántico, changelog y política de deprecación antes de compartir el paquete ampliamente.
## Herramientas interactivas
- Mejorar el indicador de inserción de `DsSortableList` y evaluar arrastre táctil con Pointer Events.
- Diseñar un contrato explícito para mover o copiar elementos entre listas.
- Añadir validación y valores tipados al árbol lógico sin romper su esquema serializable.
- Permitir reordenar condiciones y mover nodos entre grupos.
- Crear adaptadores independientes para evaluar o convertir reglas a formatos externos.
- Añadir pruebas de navegador real para drag-and-drop, teclado y árboles profundamente anidados.
El contrato actual, sus restricciones y el orden recomendado de evolución se detallan en [Herramientas interactivas](interactive-tools.md).
## Deliberadamente pospuesto ## Deliberadamente pospuesto
- Tabla de datos interactiva. - Tabla de datos interactiva.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@omaresquivel/design-system", "name": "@omaresquivel/design-system",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@omaresquivel/design-system", "name": "@omaresquivel/design-system",
"version": "0.1.0", "version": "0.2.0",
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@testing-library/vue": "^8.1.0", "@testing-library/vue": "^8.1.0",
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@omaresquivel/design-system", "name": "@omaresquivel/design-system",
"version": "0.1.0", "version": "0.2.0",
"private": false, "private": false,
"type": "module", "type": "module",
"files": [ "files": [
@@ -24,11 +24,13 @@
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json", "typecheck": "vue-tsc --noEmit -p tsconfig.app.json",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"build:lib": "vite build", "build:lib": "vite build",
"build:playground": "vite build --config vite.playground.config.ts", "build:playground": "vite build --config vite.playground.config.ts",
"build": "npm run build:lib && npm run build:playground", "build": "npm run build:lib && npm run build:playground",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"check": "npm run typecheck && npm test && npm run format:check && npm run build",
"prepublishOnly": "npm run typecheck && npm test && npm run build:lib" "prepublishOnly": "npm run typecheck && npm test && npm run build:lib"
}, },
"peerDependencies": { "peerDependencies": {
+116 -1
View File
@@ -15,6 +15,7 @@ import {
DsInline, DsInline,
DsInlineMessage, DsInlineMessage,
DsLoadingState, DsLoadingState,
DsLogicTree,
DsPageHeader, DsPageHeader,
DsPanel, DsPanel,
DsPasswordInput, DsPasswordInput,
@@ -25,23 +26,34 @@ import {
DsSelect, DsSelect,
DsSettingsSection, DsSettingsSection,
DsSkeleton, DsSkeleton,
DsSortableList,
DsSpinner, DsSpinner,
DsStack, DsStack,
DsSwitch, DsSwitch,
DsTabs, DsTabs,
DsTextarea, DsTextarea,
DsTextInput, DsTextInput,
type DsLogicGroup,
type DsSortableItem,
type DsTabItem, type DsTabItem,
} from "@omaresquivel/design-system"; } from "@omaresquivel/design-system";
const sections: DsTabItem[] = [ const sections: DsTabItem[] = [
{ id: "catalog", label: "Catálogo" }, { id: "catalog", label: "Catálogo" },
{ id: "tools", label: "Herramientas" },
{ id: "profile", label: "Perfil" }, { id: "profile", label: "Perfil" },
{ id: "settings", label: "Configuración" }, { id: "settings", label: "Configuración" },
{ id: "form", label: "Formulario largo" }, { id: "form", label: "Formulario largo" },
{ id: "states", label: "Estados de panel" }, { id: "states", label: "Estados de panel" },
]; ];
const currentSection = ref("catalog"); const requestedSection = new URLSearchParams(window.location.search).get(
"section",
);
const currentSection = ref(
sections.some((section) => section.id === requestedSection)
? (requestedSection ?? "catalog")
: "catalog",
);
const name = ref("Omar Esquivel"); const name = ref("Omar Esquivel");
const email = ref("omar@example.com"); const email = ref("omar@example.com");
const password = ref("correct horse battery staple"); const password = ref("correct horse battery staple");
@@ -55,6 +67,63 @@ const terms = ref(false);
const dialogOpen = ref(false); const dialogOpen = ref(false);
const confirmOpen = ref(false); const confirmOpen = ref(false);
const panelState = ref<"loading" | "error" | "empty" | "success">("success"); const panelState = ref<"loading" | "error" | "empty" | "success">("success");
const sortableItems = ref<DsSortableItem[]>([
{
id: "discovery",
label: "Descubrimiento",
description: "Entender el problema y las personas usuarias.",
},
{
id: "design",
label: "Diseño",
description: "Proponer y validar una solución.",
},
{
id: "delivery",
label: "Entrega",
description: "Construir, medir y aprender.",
},
]);
const logicFields = [
{ label: "Estado", value: "status" },
{ label: "País", value: "country" },
{ label: "Plan", value: "plan" },
];
const logicTree = ref<DsLogicGroup>({
id: "audience",
type: "group",
operator: "and",
children: [
{
id: "active-users",
type: "condition",
field: "status",
comparison: "equals",
value: "active",
},
{
id: "market",
type: "group",
operator: "or",
children: [
{
id: "mexico",
type: "condition",
field: "country",
comparison: "equals",
value: "MX",
},
{
id: "pro-plan",
type: "condition",
field: "plan",
comparison: "equals",
value: "pro",
},
],
},
],
});
const nameError = computed(() => const nameError = computed(() =>
name.value.length > 0 && name.value.length < 3 name.value.length > 0 && name.value.length < 3
? "Escribe al menos tres caracteres." ? "Escribe al menos tres caracteres."
@@ -261,6 +330,52 @@ const planOptions = [
</DsStack> </DsStack>
</template> </template>
<template #tools>
<DsStack :gap="6">
<DsSectionHeader
title="Herramientas interactivas"
description="Primeros contratos para ordenar contenido y construir reglas anidadas."
/>
<DsPanel>
<DsStack :gap="4">
<DsSectionHeader
title="Ordenar etapas"
description="Arrastra los elementos o usa los botones para moverlos."
/>
<DsSortableList
v-model="sortableItems"
label="Etapas del proyecto"
/>
</DsStack>
</DsPanel>
<DsPanel>
<DsStack :gap="4">
<DsSectionHeader
title="Árbol lógico"
description="Combina grupos AND/OR y condiciones editables."
/>
<DsLogicTree
v-model="logicTree"
:fields="logicFields"
label="Reglas de audiencia"
/>
</DsStack>
</DsPanel>
<DsPanel tone="muted">
<DsStack :gap="3">
<DsSectionHeader
title="Modelo serializable"
description="El resultado del árbol puede guardarse o enviarse directamente como JSON."
/>
<pre class="playground-code"><code>{{
JSON.stringify(logicTree, null, 2)
}}</code></pre>
</DsStack>
</DsPanel>
</DsStack>
</template>
<template #profile> <template #profile>
<DsStack :gap="6"> <DsStack :gap="6">
<DsSectionHeader <DsSectionHeader
+66
View File
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed } from "vue";
import DsLogicTreeNode from "./DsLogicTreeNode.vue";
import type {
DsLogicGroup,
DsLogicNode,
DsLogicOption,
DsLogicTreeLabels,
} from "../../types";
const model = defineModel<DsLogicGroup>({ required: true });
const emit = defineEmits<{ change: [tree: DsLogicGroup] }>();
const props = withDefaults(
defineProps<{
fields: DsLogicOption[];
comparisons?: DsLogicOption[];
label?: string;
readonly?: boolean;
labels?: Partial<DsLogicTreeLabels>;
}>(),
{
comparisons: () => [
{ label: "es igual a", value: "equals" },
{ label: "no es igual a", value: "not-equals" },
{ label: "contiene", value: "contains" },
],
label: "Constructor de reglas",
readonly: false,
labels: undefined,
},
);
const resolvedLabels = computed<DsLogicTreeLabels>(() => ({
all: "Todas las condiciones",
any: "Cualquier condición",
operator: "Operador lógico",
field: "Campo",
comparison: "Comparación",
value: "Valor",
addCondition: "Añadir condición",
addGroup: "Añadir grupo",
remove: "Eliminar",
empty: "Este grupo todavía no tiene reglas.",
...props.labels,
}));
function updateTree(node: DsLogicNode) {
if (node.type !== "group") return;
model.value = node;
emit("change", node);
}
</script>
<template>
<div class="ds-logic-tree" role="group" :aria-label="label">
<DsLogicTreeNode
:node="model"
:fields="fields"
:comparisons="comparisons"
:labels="resolvedLabels"
:readonly="readonly"
root
@update:node="updateTree"
/>
</div>
</template>
+208
View File
@@ -0,0 +1,208 @@
<script setup lang="ts">
import { useId } from "vue";
import DsButton from "../actions/DsButton.vue";
import DsIconButton from "../actions/DsIconButton.vue";
import type {
DsLogicCondition,
DsLogicGroup,
DsLogicNode,
DsLogicOption,
DsLogicOperator,
DsLogicTreeLabels,
} from "../../types";
const props = withDefaults(
defineProps<{
node: DsLogicNode;
fields: DsLogicOption[];
comparisons: DsLogicOption[];
labels: DsLogicTreeLabels;
readonly?: boolean;
root?: boolean;
}>(),
{ readonly: false, root: false },
);
const emit = defineEmits<{
"update:node": [node: DsLogicNode];
remove: [];
}>();
const controlId = useId();
function createId(prefix: "condition" | "group") {
return `${prefix}-${globalThis.crypto.randomUUID()}`;
}
function updateOperator(event: Event) {
if (props.node.type !== "group") return;
const operator = (event.target as HTMLSelectElement).value as DsLogicOperator;
emit("update:node", { ...props.node, operator });
}
function updateCondition(key: "field" | "comparison" | "value", event: Event) {
if (props.node.type !== "condition") return;
const value = (event.target as HTMLInputElement | HTMLSelectElement).value;
emit("update:node", { ...props.node, [key]: value });
}
function updateChild(index: number, node: DsLogicNode) {
if (props.node.type !== "group") return;
const children = [...props.node.children];
children[index] = node;
emit("update:node", { ...props.node, children });
}
function removeChild(index: number) {
if (props.node.type !== "group") return;
const children = props.node.children.filter(
(_child, childIndex) => childIndex !== index,
);
emit("update:node", { ...props.node, children });
}
function addCondition() {
if (props.node.type !== "group") return;
const condition: DsLogicCondition = {
id: createId("condition"),
type: "condition",
field: props.fields[0]?.value ?? "",
comparison: props.comparisons[0]?.value ?? "equals",
value: "",
};
emit("update:node", {
...props.node,
children: [...props.node.children, condition],
});
}
function addGroup() {
if (props.node.type !== "group") return;
const group: DsLogicGroup = {
id: createId("group"),
type: "group",
operator: "and",
children: [],
};
emit("update:node", {
...props.node,
children: [...props.node.children, group],
});
}
</script>
<template>
<section
v-if="node.type === 'group'"
class="ds-logic-group"
:class="{ 'ds-logic-group--nested': !root }"
>
<header class="ds-logic-group__header">
<label class="ds-logic-group__operator" :for="controlId">
<span class="sr-only">{{ labels.operator }}</span>
<select
:id="controlId"
class="ds-control ds-logic-group__select"
:value="node.operator"
:disabled="readonly"
@change="updateOperator"
>
<option value="and">{{ labels.all }}</option>
<option value="or">{{ labels.any }}</option>
</select>
</label>
<div v-if="!readonly" class="ds-logic-group__actions">
<DsButton size="sm" variant="secondary" @click="addCondition">
{{ labels.addCondition }}
</DsButton>
<DsButton size="sm" variant="ghost" @click="addGroup">
{{ labels.addGroup }}
</DsButton>
<DsIconButton
v-if="!root"
size="sm"
variant="ghost"
:label="labels.remove"
@click="emit('remove')"
>
×
</DsIconButton>
</div>
</header>
<p v-if="!node.children.length" class="ds-logic-group__empty">
{{ labels.empty }}
</p>
<ol v-else class="ds-logic-group__children">
<li v-for="(child, index) in node.children" :key="child.id">
<DsLogicTreeNode
:node="child"
:fields="fields"
:comparisons="comparisons"
:labels="labels"
:readonly="readonly"
@update:node="updateChild(index, $event)"
@remove="removeChild(index)"
/>
</li>
</ol>
</section>
<div v-else class="ds-logic-condition">
<label>
<span class="sr-only">{{ labels.field }}</span>
<select
class="ds-control"
:aria-label="labels.field"
:value="node.field"
:disabled="readonly"
@change="updateCondition('field', $event)"
>
<option
v-for="field in fields"
:key="field.value"
:value="field.value"
:disabled="field.disabled"
>
{{ field.label }}
</option>
</select>
</label>
<label>
<span class="sr-only">{{ labels.comparison }}</span>
<select
class="ds-control"
:aria-label="labels.comparison"
:value="node.comparison"
:disabled="readonly"
@change="updateCondition('comparison', $event)"
>
<option
v-for="comparison in comparisons"
:key="comparison.value"
:value="comparison.value"
:disabled="comparison.disabled"
>
{{ comparison.label }}
</option>
</select>
</label>
<label>
<span class="sr-only">{{ labels.value }}</span>
<input
class="ds-control"
:aria-label="labels.value"
:value="node.value"
:disabled="readonly"
@input="updateCondition('value', $event)"
/>
</label>
<DsIconButton
v-if="!readonly"
size="sm"
variant="ghost"
:label="labels.remove"
@click="emit('remove')"
>
×
</DsIconButton>
</div>
</template>
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { ref } from "vue";
import DsIconButton from "../actions/DsIconButton.vue";
import type { DsSortableChange, DsSortableItem } from "../../types";
const model = defineModel<DsSortableItem[]>({ required: true });
const emit = defineEmits<{ reorder: [change: DsSortableChange] }>();
const props = withDefaults(
defineProps<{
label: string;
disabled?: boolean;
emptyText?: string;
moveUpLabel?: string;
moveDownLabel?: string;
movedText?: string;
}>(),
{
disabled: false,
emptyText: "No hay elementos para ordenar.",
moveUpLabel: "Mover arriba",
moveDownLabel: "Mover abajo",
movedText: "movido a la posición",
},
);
const draggingIndex = ref<number | null>(null);
const dropIndex = ref<number | null>(null);
const statusMessage = ref("");
function canMove(index: number) {
return !props.disabled && !model.value[index]?.disabled;
}
function move(from: number, to: number) {
if (
from === to ||
from < 0 ||
to < 0 ||
from >= model.value.length ||
to >= model.value.length ||
!canMove(from)
)
return;
const items = [...model.value];
const [item] = items.splice(from, 1);
if (!item) return;
items.splice(to, 0, item);
model.value = items;
statusMessage.value = `${item.label}, ${props.movedText} ${to + 1} de ${items.length}.`;
emit("reorder", { item, from, to });
}
function onDragStart(event: DragEvent, index: number) {
if (!canMove(index)) {
event.preventDefault();
return;
}
draggingIndex.value = index;
dropIndex.value = index;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", model.value[index]?.id ?? "");
}
}
function onDragOver(event: DragEvent, index: number) {
if (draggingIndex.value === null || props.disabled) return;
event.preventDefault();
dropIndex.value = index;
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
}
function onDrop(event: DragEvent, index: number) {
if (draggingIndex.value === null) return;
event.preventDefault();
move(draggingIndex.value, index);
resetDrag();
}
function resetDrag() {
draggingIndex.value = null;
dropIndex.value = null;
}
</script>
<template>
<div class="ds-sortable">
<ol v-if="model.length" class="ds-sortable__list" :aria-label="label">
<li
v-for="(item, index) in model"
:key="item.id"
class="ds-sortable__item"
:class="{
'ds-sortable__item--dragging': draggingIndex === index,
'ds-sortable__item--target':
dropIndex === index && draggingIndex !== index,
'ds-sortable__item--disabled': disabled || item.disabled,
}"
:draggable="canMove(index)"
@dragstart="onDragStart($event, index)"
@dragover="onDragOver($event, index)"
@drop="onDrop($event, index)"
@dragend="resetDrag"
>
<span class="ds-sortable__handle" aria-hidden="true"></span>
<div class="ds-sortable__content">
<slot :item="item" :index="index" :dragging="draggingIndex === index">
<strong class="ds-sortable__label">{{ item.label }}</strong>
<span v-if="item.description" class="ds-sortable__description">
{{ item.description }}
</span>
</slot>
</div>
<div class="ds-sortable__actions">
<DsIconButton
size="sm"
variant="ghost"
:label="`${moveUpLabel}: ${item.label}`"
:disabled="!canMove(index) || index === 0"
@click="move(index, index - 1)"
>
</DsIconButton>
<DsIconButton
size="sm"
variant="ghost"
:label="`${moveDownLabel}: ${item.label}`"
:disabled="!canMove(index) || index === model.length - 1"
@click="move(index, index + 1)"
>
</DsIconButton>
</div>
</li>
</ol>
<div v-else class="ds-sortable__empty">
<slot name="empty">{{ emptyText }}</slot>
</div>
<p class="sr-only" role="status" aria-live="polite">{{ statusMessage }}</p>
</div>
</template>
+11 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue";
import { useFieldIds } from "../../composables/useFieldIds"; import { useFieldIds } from "../../composables/useFieldIds";
const model = defineModel<boolean>({ default: false }); const model = defineModel<boolean>({ default: false });
@@ -22,6 +23,15 @@ const props = withDefaults(
}, },
); );
const ids = useFieldIds(props.id); const ids = useFieldIds(props.id);
const describedBy = computed(
() =>
[
props.description ? ids.descriptionId : null,
props.error ? ids.errorId : null,
]
.filter(Boolean)
.join(" ") || undefined,
);
</script> </script>
<template> <template>
@@ -35,9 +45,7 @@ const ids = useFieldIds(props.id);
:disabled="disabled" :disabled="disabled"
:required="required" :required="required"
:aria-invalid="Boolean(error) || undefined" :aria-invalid="Boolean(error) || undefined"
:aria-describedby=" :aria-describedby="describedBy"
description ? ids.descriptionId : error ? ids.errorId : undefined
"
/> />
<span class="ds-choice__content"> <span class="ds-choice__content">
<span class="ds-choice__label">{{ label }}</span> <span class="ds-choice__label">{{ label }}</span>
+16 -10
View File
@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, useId } from "vue";
import type { DsRadioOption } from "../../types"; import type { DsRadioOption } from "../../types";
const model = defineModel<string>({ default: "" }); const model = defineModel<string>({ default: "" });
withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
legend: string; legend: string;
name: string; name: string;
@@ -19,22 +20,25 @@ withDefaults(
required: false, required: false,
}, },
); );
const groupId = useId();
const descriptionId = `${groupId}-description`;
const errorId = `${groupId}-error`;
const describedBy = computed(
() =>
[props.description ? descriptionId : null, props.error ? errorId : null]
.filter(Boolean)
.join(" ") || undefined,
);
</script> </script>
<template> <template>
<fieldset <fieldset
class="ds-radio-group" class="ds-radio-group"
:disabled="disabled" :disabled="disabled"
:aria-describedby=" :aria-describedby="describedBy"
error ? `${name}-error` : description ? `${name}-description` : undefined
"
> >
<legend class="ds-radio-group__legend">{{ legend }}</legend> <legend class="ds-radio-group__legend">{{ legend }}</legend>
<p <p v-if="description" :id="descriptionId" class="ds-field__description">
v-if="description"
:id="`${name}-description`"
class="ds-field__description"
>
{{ description }} {{ description }}
</p> </p>
<div class="ds-radio-group__options"> <div class="ds-radio-group__options">
@@ -46,6 +50,8 @@ withDefaults(
:value="option.value" :value="option.value"
:disabled="option.disabled" :disabled="option.disabled"
:required="required" :required="required"
:aria-invalid="Boolean(error) || undefined"
:aria-describedby="describedBy"
/> />
<span class="ds-choice__content"> <span class="ds-choice__content">
<span class="ds-choice__label">{{ option.label }}</span> <span class="ds-choice__label">{{ option.label }}</span>
@@ -57,7 +63,7 @@ withDefaults(
</div> </div>
<p <p
v-if="error" v-if="error"
:id="`${name}-error`" :id="errorId"
class="ds-field__message ds-field__message--error" class="ds-field__message ds-field__message--error"
> >
{{ error }} {{ error }}
+14 -5
View File
@@ -1,10 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, ref, watchEffect } from "vue"; import { nextTick, ref, useId, watchEffect } from "vue";
import type { DsTabItem } from "../../types"; import type { DsTabItem } from "../../types";
const props = defineProps<{ items: DsTabItem[]; label: string }>(); const props = defineProps<{ items: DsTabItem[]; label: string }>();
const model = defineModel<string>({ default: "" }); const model = defineModel<string>({ default: "" });
const tabButtons = ref<HTMLButtonElement[]>([]); const tabButtons = ref<HTMLButtonElement[]>([]);
const instanceId = useId();
function tabId(itemId: string) {
return `${instanceId}-tab-${itemId}`;
}
function panelId(itemId: string) {
return `${instanceId}-panel-${itemId}`;
}
watchEffect(() => { watchEffect(() => {
if (!props.items.some((item) => item.id === model.value && !item.disabled)) { if (!props.items.some((item) => item.id === model.value && !item.disabled)) {
@@ -56,14 +65,14 @@ function onKeydown(event: KeyboardEvent) {
> >
<button <button
v-for="item in items" v-for="item in items"
:id="`ds-tab-${item.id}`" :id="tabId(item.id)"
:key="item.id" :key="item.id"
ref="tabButtons" ref="tabButtons"
type="button" type="button"
class="ds-tabs__tab" class="ds-tabs__tab"
role="tab" role="tab"
:aria-selected="model === item.id" :aria-selected="model === item.id"
:aria-controls="`ds-panel-${item.id}`" :aria-controls="panelId(item.id)"
:tabindex="model === item.id ? 0 : -1" :tabindex="model === item.id ? 0 : -1"
:disabled="item.disabled" :disabled="item.disabled"
@click="model = item.id" @click="model = item.id"
@@ -74,11 +83,11 @@ function onKeydown(event: KeyboardEvent) {
<div <div
v-for="item in items" v-for="item in items"
v-show="model === item.id" v-show="model === item.id"
:id="`ds-panel-${item.id}`" :id="panelId(item.id)"
:key="`panel-${item.id}`" :key="`panel-${item.id}`"
class="ds-tabs__panel" class="ds-tabs__panel"
role="tabpanel" role="tabpanel"
:aria-labelledby="`ds-tab-${item.id}`" :aria-labelledby="tabId(item.id)"
:tabindex="0" :tabindex="0"
> >
<slot :name="item.id" :item="item" /> <slot :name="item.id" :item="item" />
+14
View File
@@ -22,6 +22,8 @@ import DsCheckbox from "./components/forms/DsCheckbox.vue";
import DsRadioGroup from "./components/forms/DsRadioGroup.vue"; import DsRadioGroup from "./components/forms/DsRadioGroup.vue";
import DsSwitch from "./components/forms/DsSwitch.vue"; import DsSwitch from "./components/forms/DsSwitch.vue";
import DsSettingsSection from "./components/forms/DsSettingsSection.vue"; import DsSettingsSection from "./components/forms/DsSettingsSection.vue";
import DsSortableList from "./components/data/DsSortableList.vue";
import DsLogicTree from "./components/data/DsLogicTree.vue";
import DsSpinner from "./components/feedback/DsSpinner.vue"; import DsSpinner from "./components/feedback/DsSpinner.vue";
import DsSkeleton from "./components/feedback/DsSkeleton.vue"; import DsSkeleton from "./components/feedback/DsSkeleton.vue";
import DsProgressBar from "./components/feedback/DsProgressBar.vue"; import DsProgressBar from "./components/feedback/DsProgressBar.vue";
@@ -56,6 +58,8 @@ export {
DsRadioGroup, DsRadioGroup,
DsSwitch, DsSwitch,
DsSettingsSection, DsSettingsSection,
DsSortableList,
DsLogicTree,
DsSpinner, DsSpinner,
DsSkeleton, DsSkeleton,
DsProgressBar, DsProgressBar,
@@ -73,8 +77,16 @@ export type {
DsAlertVariant, DsAlertVariant,
DsButtonVariant, DsButtonVariant,
DsControlSize, DsControlSize,
DsLogicCondition,
DsLogicGroup,
DsLogicNode,
DsLogicOperator,
DsLogicOption,
DsLogicTreeLabels,
DsRadioOption, DsRadioOption,
DsSelectOption, DsSelectOption,
DsSortableChange,
DsSortableItem,
DsTabItem, DsTabItem,
} from "./types"; } from "./types";
@@ -100,6 +112,8 @@ const components = {
DsRadioGroup, DsRadioGroup,
DsSwitch, DsSwitch,
DsSettingsSection, DsSettingsSection,
DsSortableList,
DsLogicTree,
DsSpinner, DsSpinner,
DsSkeleton, DsSkeleton,
DsProgressBar, DsProgressBar,
+161
View File
@@ -407,6 +407,155 @@
transform: translateX(1.25rem); transform: translateX(1.25rem);
} }
.ds-sortable__list {
display: grid;
gap: var(--ds-space-2);
margin: 0;
padding: 0;
list-style: none;
}
.ds-sortable__item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--ds-space-3);
min-height: var(--ds-control-height-lg);
border: 1px solid var(--ds-color-border);
border-radius: var(--ds-radius-md);
padding: var(--ds-space-2) var(--ds-space-3);
background: var(--ds-color-surface);
transition:
border-color var(--ds-duration-fast),
background var(--ds-duration-fast),
opacity var(--ds-duration-fast);
}
.ds-sortable__item[draggable="true"] {
cursor: grab;
}
.ds-sortable__item[draggable="true"]:active {
cursor: grabbing;
}
.ds-sortable__item--dragging {
opacity: 0.48;
}
.ds-sortable__item--target {
border-color: var(--ds-color-primary);
background: var(--ds-color-info-surface);
}
.ds-sortable__item--disabled {
background: var(--ds-color-surface-muted);
}
.ds-sortable__handle {
color: var(--ds-color-text-muted);
font-weight: 800;
letter-spacing: -0.2em;
}
.ds-sortable__content {
display: grid;
min-width: 0;
gap: var(--ds-space-1);
}
.ds-sortable__label {
font-size: var(--ds-font-size-sm);
}
.ds-sortable__description {
overflow: hidden;
color: var(--ds-color-text-muted);
font-size: var(--ds-font-size-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.ds-sortable__actions {
display: flex;
gap: var(--ds-space-1);
}
.ds-sortable__empty {
border: 1px dashed var(--ds-color-border);
border-radius: var(--ds-radius-md);
padding: var(--ds-space-5);
color: var(--ds-color-text-muted);
text-align: center;
}
.ds-logic-tree {
min-width: 0;
}
.ds-logic-group {
min-width: 0;
border: 1px solid var(--ds-color-border);
border-radius: var(--ds-radius-lg);
padding: var(--ds-space-4);
background: var(--ds-color-surface);
}
.ds-logic-group--nested {
background: var(--ds-color-surface-muted);
}
.ds-logic-group__header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--ds-space-3);
}
.ds-logic-group__operator {
min-width: 0;
}
.ds-logic-group__select {
width: auto;
min-width: 12rem;
min-height: var(--ds-control-height-sm);
padding-block: var(--ds-space-1);
font-size: var(--ds-font-size-sm);
font-weight: 700;
}
.ds-logic-group__actions {
display: flex;
flex-wrap: wrap;
gap: var(--ds-space-2);
}
.ds-logic-group__empty {
margin: var(--ds-space-4) 0 0;
color: var(--ds-color-text-muted);
font-size: var(--ds-font-size-sm);
}
.ds-logic-group__children {
display: grid;
gap: var(--ds-space-3);
margin: var(--ds-space-4) 0 0 var(--ds-space-2);
padding: 0 0 0 var(--ds-space-5);
border-left: 2px solid var(--ds-color-border);
list-style: none;
}
.ds-logic-group__children > li {
position: relative;
min-width: 0;
}
.ds-logic-group__children > li::before {
position: absolute;
top: 1.4rem;
left: calc(-1 * var(--ds-space-5));
width: var(--ds-space-4);
height: 2px;
background: var(--ds-color-border);
content: "";
}
.ds-logic-condition {
display: grid;
grid-template-columns:
minmax(8rem, 1fr) minmax(9rem, 1fr) minmax(9rem, 1.25fr)
auto;
align-items: center;
gap: var(--ds-space-2);
min-width: 0;
border: 1px solid var(--ds-color-border);
border-radius: var(--ds-radius-md);
padding: var(--ds-space-3);
background: var(--ds-color-surface);
}
.ds-logic-condition > label {
min-width: 0;
}
.ds-spinner { .ds-spinner {
display: inline-block; display: inline-block;
width: 1.25rem; width: 1.25rem;
@@ -619,6 +768,18 @@
padding: var(--ds-space-8) var(--ds-space-10); padding: var(--ds-space-8) var(--ds-space-10);
} }
} }
@media (max-width: 47.99rem) {
.ds-logic-condition {
grid-template-columns: minmax(0, 1fr);
}
.ds-logic-condition > .ds-icon-button {
justify-self: end;
}
.ds-logic-group__children {
margin-left: 0;
padding-left: var(--ds-space-3);
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
+51
View File
@@ -20,3 +20,54 @@ export interface DsTabItem {
label: string; label: string;
disabled?: boolean; disabled?: boolean;
} }
export interface DsSortableItem {
id: string;
label: string;
description?: string;
disabled?: boolean;
}
export interface DsSortableChange {
item: DsSortableItem;
from: number;
to: number;
}
export type DsLogicOperator = "and" | "or";
export interface DsLogicOption {
label: string;
value: string;
disabled?: boolean;
}
export interface DsLogicCondition {
id: string;
type: "condition";
field: string;
comparison: string;
value: string;
}
export interface DsLogicGroup {
id: string;
type: "group";
operator: DsLogicOperator;
children: DsLogicNode[];
}
export type DsLogicNode = DsLogicCondition | DsLogicGroup;
export interface DsLogicTreeLabels {
all: string;
any: string;
operator: string;
field: string;
comparison: string;
value: string;
addCondition: string;
addGroup: string;
remove: string;
empty: string;
}
+123
View File
@@ -0,0 +1,123 @@
import { fireEvent, render } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import DsLogicTree from "../src/components/data/DsLogicTree.vue";
import DsSortableList from "../src/components/data/DsSortableList.vue";
import type { DsLogicGroup, DsSortableItem } from "../src/types";
const sortableItems: DsSortableItem[] = [
{ id: "one", label: "Primero" },
{ id: "two", label: "Segundo" },
{ id: "three", label: "Tercero" },
];
const emptyTree: DsLogicGroup = {
id: "root",
type: "group",
operator: "and",
children: [],
};
const fields = [
{ label: "Estado", value: "status" },
{ label: "Plan", value: "plan" },
];
describe("data tools", () => {
it("reorders items with drag-and-drop and emits the movement", async () => {
const view = render(DsSortableList, {
props: { modelValue: sortableItems, label: "Prioridades" },
});
const items = view.getAllByRole("listitem");
await fireEvent.dragStart(items[0] as HTMLElement);
await fireEvent.dragOver(items[2] as HTMLElement);
await fireEvent.drop(items[2] as HTMLElement);
const updates = view.emitted()["update:modelValue"] as
unknown[][] | undefined;
const reordered = updates?.at(-1)?.[0] as DsSortableItem[] | undefined;
expect(reordered?.map((item) => item.id)).toEqual(["two", "three", "one"]);
expect(view.emitted().reorder?.at(-1)).toEqual([
expect.objectContaining({ from: 0, to: 2 }),
]);
});
it("offers buttons as an accessible alternative to dragging", async () => {
const view = render(DsSortableList, {
props: { modelValue: sortableItems, label: "Prioridades" },
});
await fireEvent.click(
view.getByRole("button", { name: "Mover abajo: Primero" }),
);
const updates = view.emitted()["update:modelValue"] as
unknown[][] | undefined;
const reordered = updates?.at(-1)?.[0] as DsSortableItem[] | undefined;
expect(reordered?.map((item) => item.id)).toEqual(["two", "one", "three"]);
expect(view.getByRole("status").textContent).toContain(
"Primero, movido a la posición 2 de 3.",
);
});
it("adds a serializable condition to a logic tree", async () => {
const view = render(DsLogicTree, {
props: { modelValue: emptyTree, fields },
});
await fireEvent.click(
view.getByRole("button", { name: "Añadir condición" }),
);
const updates = view.emitted()["update:modelValue"] as
unknown[][] | undefined;
const tree = updates?.at(-1)?.[0] as DsLogicGroup | undefined;
expect(tree?.children).toHaveLength(1);
expect(tree?.children[0]).toEqual(
expect.objectContaining({
type: "condition",
field: "status",
comparison: "equals",
value: "",
}),
);
expect(view.emitted().change?.at(-1)).toEqual([tree]);
});
it("updates operators and removes nested rules", async () => {
const tree: DsLogicGroup = {
...emptyTree,
children: [
{
id: "status",
type: "condition",
field: "status",
comparison: "equals",
value: "active",
},
],
};
const operatorView = render(DsLogicTree, {
props: { modelValue: tree, fields },
});
await fireEvent.update(
operatorView.getByLabelText("Operador lógico"),
"or",
);
const operatorUpdates = operatorView.emitted()["update:modelValue"] as
unknown[][] | undefined;
expect(
(operatorUpdates?.at(-1)?.[0] as DsLogicGroup | undefined)?.operator,
).toBe("or");
operatorView.unmount();
const removeView = render(DsLogicTree, {
props: { modelValue: tree, fields },
});
await fireEvent.click(removeView.getByRole("button", { name: "Eliminar" }));
const removeUpdates = removeView.emitted()["update:modelValue"] as
unknown[][] | undefined;
const updated = removeUpdates?.at(-1)?.[0] as DsLogicGroup | undefined;
expect(updated?.children).toEqual([]);
});
});
+41
View File
@@ -1,6 +1,8 @@
import { fireEvent, render } from "@testing-library/vue"; import { fireEvent, render } from "@testing-library/vue";
import { h } from "vue";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import DsCheckbox from "../src/components/forms/DsCheckbox.vue"; import DsCheckbox from "../src/components/forms/DsCheckbox.vue";
import DsRadioGroup from "../src/components/forms/DsRadioGroup.vue";
import DsTextInput from "../src/components/forms/DsTextInput.vue"; import DsTextInput from "../src/components/forms/DsTextInput.vue";
describe("form controls", () => { describe("form controls", () => {
@@ -34,4 +36,43 @@ describe("form controls", () => {
true, true,
); );
}); });
it("associates checkbox descriptions and errors at the same time", () => {
const view = render(DsCheckbox, {
props: {
label: "Acepto",
description: "Lee las condiciones.",
error: "Debes aceptar.",
},
});
const checkbox = view.getByRole("checkbox");
const describedBy = checkbox.getAttribute("aria-describedby") ?? "";
expect(describedBy).toContain("description");
expect(describedBy).toContain("error");
expect(checkbox.getAttribute("aria-invalid")).toBe("true");
});
it("gives radio groups unique descriptions and exposes invalid state", () => {
const props = {
legend: "Plan",
name: "plan",
options: [{ label: "Profesional", value: "pro" }],
description: "Elige un plan.",
error: "Selecciona una opción.",
};
const view = render({
setup: () => () =>
h("div", [h(DsRadioGroup, props), h(DsRadioGroup, props)]),
});
const groups = view.getAllByRole("group");
const describedBy = groups.map((group) =>
group.getAttribute("aria-describedby"),
);
expect(describedBy[0]).not.toBe(describedBy[1]);
expect(describedBy[0]).toContain("description");
expect(describedBy[0]).toContain("error");
expect(view.getAllByRole("radio")[0]?.getAttribute("aria-invalid")).toBe(
"true",
);
});
}); });
+2
View File
@@ -7,6 +7,8 @@ describe("public API", () => {
expect(designSystem.DsTextInput).toBeDefined(); expect(designSystem.DsTextInput).toBeDefined();
expect(designSystem.DsTabs).toBeDefined(); expect(designSystem.DsTabs).toBeDefined();
expect(designSystem.DsDialog).toBeDefined(); expect(designSystem.DsDialog).toBeDefined();
expect(designSystem.DsSortableList).toBeDefined();
expect(designSystem.DsLogicTree).toBeDefined();
expect(designSystem.DesignSystem).toBeDefined(); expect(designSystem.DesignSystem).toBeDefined();
}); });
}); });
+23
View File
@@ -1,5 +1,6 @@
import { fireEvent, render } from "@testing-library/vue"; import { fireEvent, render } from "@testing-library/vue";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { h } from "vue";
import DsTabs from "../src/components/navigation/DsTabs.vue"; import DsTabs from "../src/components/navigation/DsTabs.vue";
describe("DsTabs", () => { describe("DsTabs", () => {
@@ -29,4 +30,26 @@ describe("DsTabs", () => {
}); });
expect(view.emitted()["update:modelValue"]?.at(-1)).toEqual(["billing"]); expect(view.emitted()["update:modelValue"]?.at(-1)).toEqual(["billing"]);
}); });
it("keeps tab and panel relationships unique across instances", () => {
const props = {
label: "Cuenta",
modelValue: "profile",
items: [{ id: "profile", label: "Perfil" }],
};
const view = render({
setup: () => () =>
h("div", [
h(DsTabs, props, { profile: () => "Perfil" }),
h(DsTabs, props, { profile: () => "Perfil" }),
]),
});
const [firstTab, secondTab] = view.getAllByRole("tab");
const [firstPanel, secondPanel] = view.getAllByRole("tabpanel");
expect(firstTab?.id).not.toBe(secondTab?.id);
expect(firstPanel?.id).not.toBe(secondPanel?.id);
expect(firstTab?.getAttribute("aria-controls")).toBe(firstPanel?.id);
expect(firstPanel?.getAttribute("aria-labelledby")).toBe(firstTab?.id);
});
}); });