.gitignore and v0.2.0
This commit is contained in:
@@ -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
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user