First commit
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-playground/
|
||||||
|
coverage/
|
||||||
|
.vite/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.tgz
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Material de referencia local, no parte del paquete
|
||||||
|
/prompt.md
|
||||||
|
/Captura de pantalla_*.png
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@omaresquivel:registry=https://gitea.autobyte.dev/api/packages/omararch48/npm/
|
||||||
|
//gitea.autobyte.dev/api/packages/omararch48/npm/:_authToken=${GITEA_NPM_TOKEN}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
dist
|
||||||
|
dist-playground
|
||||||
|
coverage
|
||||||
|
package-lock.json
|
||||||
|
*.png
|
||||||
|
prompt.md
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# @omaresquivel/design-system
|
||||||
|
|
||||||
|
Sistema de diseño para estandarizar estilos y componentes en aplicaciones Vue 3. Su núcleo son tokens semánticos sobrescribibles y una hoja CSS compilada; los componentes `Ds*` ofrecen implementaciones accesibles y consistentes sobre esos fundamentos.
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- Node.js 22 o posterior.
|
||||||
|
- Vue 3.5 o posterior en la aplicación consumidora.
|
||||||
|
- Navegadores modernos compatibles con Tailwind CSS v4 y `<dialog>`.
|
||||||
|
|
||||||
|
## Desarrollo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
El playground queda disponible en la dirección indicada por Vite. Incluye catálogo, perfil, configuración, formulario largo y estados de panel.
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run build` genera la librería en `dist/` y el catálogo en `dist-playground/`.
|
||||||
|
|
||||||
|
## Consumo
|
||||||
|
|
||||||
|
Mientras el paquete sea privado puede instalarse desde una ruta local:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install ../design_system
|
||||||
|
```
|
||||||
|
|
||||||
|
Importa componentes y la hoja compilada una sola vez:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { DsButton, DsTextInput } from "@omaresquivel/design-system";
|
||||||
|
import "@omaresquivel/design-system/style.css";
|
||||||
|
```
|
||||||
|
|
||||||
|
También puede registrarse el catálogo completo:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createApp } from "vue";
|
||||||
|
import { DesignSystem } from "@omaresquivel/design-system";
|
||||||
|
import "@omaresquivel/design-system/style.css";
|
||||||
|
|
||||||
|
createApp(App).use(DesignSystem).mount("#app");
|
||||||
|
```
|
||||||
|
|
||||||
|
Se recomienda usar importaciones nombradas para conservar tree-shaking. La personalización se realiza sobrescribiendo variables `--ds-*`; no es necesario instalar Tailwind en la aplicación consumidora.
|
||||||
|
|
||||||
|
## Documentación
|
||||||
|
|
||||||
|
- [Investigación y decisiones](docs/research.md)
|
||||||
|
- [Fundamentos y tokens](docs/foundations.md)
|
||||||
|
- [Catálogo de componentes](docs/components.md)
|
||||||
|
- [Accesibilidad](docs/accessibility.md)
|
||||||
|
- [Instalación y theming](docs/installation.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.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Accesibilidad
|
||||||
|
|
||||||
|
Objetivo: WCAG 2.2 nivel AA para los contratos entregados.
|
||||||
|
|
||||||
|
## Formularios
|
||||||
|
|
||||||
|
- Labels vinculados con `for`/`id` generado o explícito.
|
||||||
|
- Descripciones y errores unidos mediante `aria-describedby`.
|
||||||
|
- Los errores establecen `aria-invalid`; la aplicación decide cuándo anunciarlos dinámicamente.
|
||||||
|
- Checkbox, radio y select conservan semántica HTML nativa.
|
||||||
|
- `required` y `disabled` se transmiten al control real.
|
||||||
|
- El switch expone `role="switch"` y `aria-checked` porque representa encendido/apagado, no selección de formulario genérica.
|
||||||
|
|
||||||
|
## Teclado y foco
|
||||||
|
|
||||||
|
- El foco usa un contorno de 2 px con separación de 2 px.
|
||||||
|
- 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.
|
||||||
|
- Todos los botones tienen una altura mínima razonable; `DsIconButton` exige un `label` accesible.
|
||||||
|
|
||||||
|
## Estados
|
||||||
|
|
||||||
|
- Loading en botones conserva el texto, usa `aria-busy` y deshabilita interacciones duplicadas.
|
||||||
|
- `DsProgressBar` publica valor, mínimo y máximo.
|
||||||
|
- Alertas estáticas no usan live regions por defecto para evitar anuncios al cargar una página. `live` debe activarse solo cuando el mensaje aparece como resultado de una acción.
|
||||||
|
- Los iconos decorativos se ocultan con `aria-hidden`.
|
||||||
|
- Skeleton es decorativo; el contenedor que lo usa debe proporcionar el estado textual cuando sea necesario.
|
||||||
|
|
||||||
|
## Movimiento y contraste
|
||||||
|
|
||||||
|
`prefers-reduced-motion: reduce` reduce animaciones y transiciones. La paleta usa texto slate oscuro sobre fondos claros, blanco sobre acciones sky oscuras y variantes de estado con texto oscuro. Los indicadores de foco no dependen de cambios de color internos.
|
||||||
|
|
||||||
|
## Pruebas incluidas
|
||||||
|
|
||||||
|
- Asociación label/control, descripción, error y `v-model`.
|
||||||
|
- Disabled y loading.
|
||||||
|
- Navegación de Tabs con teclado.
|
||||||
|
- Apertura, nombre y cancelación de Dialog.
|
||||||
|
- Presencia de exportaciones públicas.
|
||||||
|
|
||||||
|
Limitación: jsdom permite verificar contratos DOM, pero no sustituye pruebas manuales con lector de pantalla, zoom al 200 %, alto contraste ni navegación real en Safari/iOS. Estas comprobaciones forman parte de la revisión previa a una publicación estable.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Catálogo de componentes
|
||||||
|
|
||||||
|
Todos los nombres comienzan con `Ds`. A menos que se indique lo contrario, los atributos HTML adicionales llegan al control raíz correspondiente.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Componente | Props principales | Slots |
|
||||||
|
| ----------------- | ----------------------------------------------- | -------------------------------------- |
|
||||||
|
| `DsAppShell` | `maxWidth` | default |
|
||||||
|
| `DsPageHeader` | `title`, `description`, `eyebrow`, `standalone` | default implícito en textos, `actions` |
|
||||||
|
| `DsPanel` | `as`, `padding: sm\|md\|lg`, `tone`, `flat` | default |
|
||||||
|
| `DsSectionHeader` | `title`, `description`, `eyebrow` | `actions` |
|
||||||
|
| `DsStack` | `as`, `gap`, `align` | default |
|
||||||
|
| `DsInline` | `as`, `gap`, `align`, `justify`, `wrap` | default |
|
||||||
|
| `DsDivider` | — | — |
|
||||||
|
|
||||||
|
## Acciones
|
||||||
|
|
||||||
|
| Componente | Props y eventos | Slots |
|
||||||
|
| --------------- | -------------------------------------------------------------------------------- | ------------- |
|
||||||
|
| `DsButton` | `variant`, `size`, `type`, `disabled`, `loading`, `block`, evento nativo `click` | default |
|
||||||
|
| `DsIconButton` | `label` obligatorio, más variantes y estados de `DsButton` | icono default |
|
||||||
|
| `DsButtonGroup` | `label` | default |
|
||||||
|
| `DsFormActions` | — | default |
|
||||||
|
|
||||||
|
`loading` conserva el texto como nombre accesible, añade `aria-busy` y deshabilita el botón.
|
||||||
|
|
||||||
|
## Formularios
|
||||||
|
|
||||||
|
| Componente | Modelo | Props principales |
|
||||||
|
| ------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `DsFormField` | — | `id`, `label`, `description`, `error`, `required`; slot scoped con `controlId`, `describedBy`, `invalid` |
|
||||||
|
| `DsTextInput` | `string` | `id`, `name`, `label`, `description`, `error`, `type`, `autocomplete`, `disabled`, `required`; slots `leading`, `trailing` |
|
||||||
|
| `DsTextarea` | `string` | contrato común más `rows` |
|
||||||
|
| `DsPasswordInput` | `string` | contrato común y botón mostrar/ocultar |
|
||||||
|
| `DsSearchInput` | `string` | contrato de texto con icono decorativo de búsqueda |
|
||||||
|
| `DsSelect` | `string` | contrato común y `options: DsSelectOption[]` |
|
||||||
|
| `DsCheckbox` | `boolean` | `label`, `description`, `error`, estados HTML |
|
||||||
|
| `DsRadioGroup` | `string` | `legend`, `name`, `options: DsRadioOption[]`, descripción y error |
|
||||||
|
| `DsSwitch` | `boolean` | `label`, `description`, `disabled`, `name` |
|
||||||
|
| `DsSettingsSection` | — | `title`, `description`; slots default y `actions` |
|
||||||
|
|
||||||
|
Ejemplo:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<DsTextInput
|
||||||
|
v-model="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
label="Correo electrónico"
|
||||||
|
description="Usaremos tu correo de trabajo."
|
||||||
|
:error="emailError"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Retroalimentación
|
||||||
|
|
||||||
|
| Componente | Props principales | Comportamiento |
|
||||||
|
| ----------------- | ------------------------------------ | -------------------------------- |
|
||||||
|
| `DsSpinner` | `size`, `label` | `role=status`, nombre oculto |
|
||||||
|
| `DsSkeleton` | `width`, `height`, `rounded` | decorativo y sin gradiente |
|
||||||
|
| `DsProgressBar` | `value`, `max`, `label`, `showValue` | semántica `progressbar` |
|
||||||
|
| `DsAlert` | `variant`, `title`, `live` | `live` activa `status` o `alert` |
|
||||||
|
| `DsInlineMessage` | `variant`, `live` | ayuda o error breve |
|
||||||
|
| `DsEmptyState` | `title`, `description` | slots `icon`, `actions` |
|
||||||
|
| `DsLoadingState` | `title`, `description` | región de estado anunciable |
|
||||||
|
| `DsErrorState` | `title`, `description` | alerta y slot `actions` |
|
||||||
|
|
||||||
|
## Navegación y overlays
|
||||||
|
|
||||||
|
`DsTabs` recibe `items: DsTabItem[]`, `label` y `v-model<string>`. Cada `id` define un slot del mismo nombre. Admite flechas, Home y End.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<DsTabs v-model="tab" :items="tabs" label="Preferencias">
|
||||||
|
<template #profile>…</template>
|
||||||
|
<template #security>…</template>
|
||||||
|
</DsTabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
`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`.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Fundamentos visuales
|
||||||
|
|
||||||
|
## Principios
|
||||||
|
|
||||||
|
1. Sobriedad: superficies claras, jerarquía tipográfica fuerte y sombras discretas.
|
||||||
|
2. Semántica antes que paleta: los componentes consumen intención (`primary`, `danger`, `surface`) y no tonos de producto directos.
|
||||||
|
3. Accesibilidad visible: foco, errores y estados no dependen únicamente del color.
|
||||||
|
4. Composición: `DsStack`, `DsInline`, `DsPanel` y encabezados resuelven layouts repetidos sin acoplarse a páginas.
|
||||||
|
5. Movimiento funcional: transiciones breves, anuladas con `prefers-reduced-motion`.
|
||||||
|
|
||||||
|
## Tokens
|
||||||
|
|
||||||
|
Los valores predeterminados viven en `src/tokens/`.
|
||||||
|
|
||||||
|
| Familia | Variables principales |
|
||||||
|
| ------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Fondos | `--ds-color-background`, `--ds-color-surface`, `--ds-color-surface-muted`, `--ds-color-surface-inverse` |
|
||||||
|
| Texto | `--ds-color-text`, `--ds-color-text-muted`, `--ds-color-text-inverse`, `--ds-color-text-danger` |
|
||||||
|
| Bordes y foco | `--ds-color-border`, `--ds-color-border-hover`, `--ds-color-border-danger`, `--ds-color-focus-ring` |
|
||||||
|
| Acciones | `--ds-color-primary`, `--ds-color-primary-hover`, `--ds-color-primary-active`, `--ds-color-primary-disabled`, `--ds-color-danger` |
|
||||||
|
| Estado | variables `info-*`, `success-*`, `warning-*` y `error-*` |
|
||||||
|
| Espacio | `--ds-space-1` a `--ds-space-12` |
|
||||||
|
| Forma | `--ds-radius-sm` a `--ds-radius-xl`, `--ds-shadow-sm`, `--ds-shadow-md` |
|
||||||
|
| Controles | `--ds-control-height-sm`, `--ds-control-height-md`, `--ds-control-height-lg` |
|
||||||
|
| Movimiento | `--ds-duration-fast`, `--ds-duration-normal` |
|
||||||
|
|
||||||
|
## Tema predeterminado
|
||||||
|
|
||||||
|
- `slate-50` como fondo.
|
||||||
|
- Blanco para superficies.
|
||||||
|
- `slate-900` para texto y superficies inversas.
|
||||||
|
- `sky-600` para acciones principales y `sky-400` para foco.
|
||||||
|
- Radios de 10–18 px y sombras de baja opacidad.
|
||||||
|
|
||||||
|
El sistema no usa gradientes, transparencias de tipo glass, sombras decorativas ni movimiento ornamental.
|
||||||
|
|
||||||
|
## Personalización
|
||||||
|
|
||||||
|
Sobrescribe variables después de importar la hoja del paquete:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--ds-color-primary: #2563eb;
|
||||||
|
--ds-color-primary-hover: #1d4ed8;
|
||||||
|
--ds-radius-md: 0.5rem;
|
||||||
|
--ds-page-width: 80rem;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No sobrescribas selectores internos salvo que estés corrigiendo una limitación documentada. La API estable de theming son las variables `--ds-*`.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Instalación, consumo y theming
|
||||||
|
|
||||||
|
## Instalar localmente
|
||||||
|
|
||||||
|
Construye la librería:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build:lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Desde otra aplicación Vue:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install ../design_system
|
||||||
|
```
|
||||||
|
|
||||||
|
El paquete está configurado para publicarse en el registro npm de Gitea de `omararch48`. La publicación sigue siendo manual y requiere `GITEA_NPM_TOKEN`.
|
||||||
|
|
||||||
|
## Publicar en Gitea
|
||||||
|
|
||||||
|
Define un token personal con permiso de paquetes sin guardarlo en el repositorio:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GITEA_NPM_TOKEN="tu-token"
|
||||||
|
npm publish
|
||||||
|
```
|
||||||
|
|
||||||
|
El hook `prepublishOnly` ejecuta type-check, pruebas y build de la librería antes de subir el paquete. Gitea no permite reemplazar una combinación existente de nombre y versión; incrementa la versión antes de una publicación posterior:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm version patch
|
||||||
|
npm publish
|
||||||
|
```
|
||||||
|
|
||||||
|
El scope `@omaresquivel` apunta a:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://gitea.autobyte.dev/api/packages/omararch48/npm/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Importar
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { DsButton, DsPanel, DsTextInput } from "@omaresquivel/design-system";
|
||||||
|
import "@omaresquivel/design-system/style.css";
|
||||||
|
```
|
||||||
|
|
||||||
|
Vue `^3.5.0` es peer dependency. Tailwind, Vite y TypeScript no son dependencias runtime del consumidor.
|
||||||
|
|
||||||
|
## Registro global opcional
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { DesignSystem } from "@omaresquivel/design-system";
|
||||||
|
import "@omaresquivel/design-system/style.css";
|
||||||
|
|
||||||
|
app.use(DesignSystem);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Personalizar el tema
|
||||||
|
|
||||||
|
Carga las personalizaciones después del CSS del paquete:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--ds-color-primary: #2563eb;
|
||||||
|
--ds-color-primary-hover: #1d4ed8;
|
||||||
|
--ds-color-focus-ring: #60a5fa;
|
||||||
|
--ds-radius-lg: 1rem;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No es necesario configurar `@source`, copiar `@apply` ni exponer Tailwind al consumidor. Si una aplicación ya usa Tailwind, ambos pueden coexistir porque los selectores del sistema llevan el prefijo `ds-` y los tokens llevan `--ds-`.
|
||||||
|
|
||||||
|
## Enlace durante desarrollo
|
||||||
|
|
||||||
|
Puede usarse una dependencia local en `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@omaresquivel/design-system": "file:../design_system"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Después de modificar la librería ejecuta `npm run build:lib` y actualiza la instalación de la aplicación consumidora según el flujo de su gestor de paquetes.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Investigación y decisiones
|
||||||
|
|
||||||
|
Investigación realizada el 10 de agosto de 2026. Se priorizaron fuentes oficiales y especificaciones.
|
||||||
|
|
||||||
|
## Fuentes consultadas
|
||||||
|
|
||||||
|
- [Vue: TypeScript con Composition API](https://vuejs.org/guide/typescript/composition-api): props y emits tipados con `<script setup>`.
|
||||||
|
- [Vue: descripción general de TypeScript](https://vuejs.org/guide/typescript/overview): Vite transpila y `vue-tsc` realiza la comprobación estática.
|
||||||
|
- [Vite: modo librería](https://vite.dev/guide/build.html#library-mode): entrada `build.lib`, externalización de dependencias y distribución de CSS.
|
||||||
|
- [Tailwind CSS: variables de tema](https://tailwindcss.com/docs/theme): configuración CSS-first y tokens disponibles como variables CSS.
|
||||||
|
- [Tailwind CSS: detección de clases](https://tailwindcss.com/docs/detecting-classes-in-source-files): una librería fuente externa exige `@source`; las clases deben existir de forma completa.
|
||||||
|
- [Tailwind CSS: funciones y directivas](https://tailwindcss.com/docs/functions-and-directives): `@reference` es necesario para usar `@apply` en bloques de estilo procesados por separado.
|
||||||
|
- [Tailwind CSS: compatibilidad](https://tailwindcss.com/docs/compatibility): Tailwind v4 está diseñado para navegadores modernos.
|
||||||
|
- [WAI-ARIA Authoring Practices: patrones](https://www.w3.org/WAI/ARIA/apg/patterns/): comportamiento esperado para botones, tabs, diálogos, radio groups y switches.
|
||||||
|
- [WAI-ARIA APG: Dialog modal](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/): nombre accesible, foco contenido, Escape y retorno de foco.
|
||||||
|
- [WAI-ARIA APG: Tabs](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/): flechas, Home, End, roving tabindex y relación tab/panel.
|
||||||
|
- [WCAG 2.2](https://www.w3.org/TR/WCAG22/), [Focus Appearance](https://www.w3.org/WAI/WCAG22/Understanding/focus-appearance.html) y [Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html): foco perceptible y anuncios sin cambios de contexto.
|
||||||
|
- [MDN: elemento dialog](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog) y [`showModal()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal): top layer, fondo inerte, Escape y colocación inicial del foco.
|
||||||
|
|
||||||
|
## Decisiones derivadas
|
||||||
|
|
||||||
|
### Distribución
|
||||||
|
|
||||||
|
Vite construye una entrada ES y externaliza Vue. El paquete publica declaraciones TypeScript, `index.js` y `design-system.css`. `vue` es `peerDependency` para evitar dos runtimes en una aplicación.
|
||||||
|
|
||||||
|
El CSS se importa desde la entrada durante el build para garantizar su extracción, pero se expone como `@omaresquivel/design-system/style.css` y se documenta su importación explícita. `sideEffects` conserva los archivos CSS durante tree-shaking.
|
||||||
|
|
||||||
|
### Tailwind y estilos
|
||||||
|
|
||||||
|
Se distribuye CSS compilado. Esta decisión evita que cada consumidor instale Tailwind, configure `@source` para `node_modules` o replique bloques `@apply`. Tailwind queda como herramienta interna y el resultado se controla mediante variables CSS semánticas.
|
||||||
|
|
||||||
|
Los componentes usan clases globales con namespace `ds-` en una única capa `components`. No se ejecuta Tailwind por cada `<style scoped>`, siguiendo su advertencia sobre el coste y el contexto aislado de los estilos SFC. Los nombres prefijados evitan colisiones y las variables conservan la capacidad de theming.
|
||||||
|
|
||||||
|
### Accesibilidad
|
||||||
|
|
||||||
|
- Controles de formulario nativos antes que widgets ARIA recreados.
|
||||||
|
- `<dialog>.showModal()` para obtener top layer e inertización nativas en los navegadores objetivo.
|
||||||
|
- Tabs implementadas con el patrón APG y activación automática porque sus paneles son locales e inmediatos.
|
||||||
|
- Estados dinámicos anuncian `status` o `alert` solo mediante la prop `live`; contenido estático no se anuncia al montar.
|
||||||
|
- Foco visible global de 2 px y controles de altura mínima entre 36 y 48 px.
|
||||||
|
|
||||||
|
## Alternativas descartadas
|
||||||
|
|
||||||
|
- **Distribuir solamente fuentes Tailwind:** obligaría al consumidor a escanear la librería y lo acoplaría a Tailwind.
|
||||||
|
- **Incluir Vue en el bundle:** produciría runtimes duplicados y rompería expectativas de plugins e inyección.
|
||||||
|
- **Adoptar una librería visual completa:** sustituiría el lenguaje propio en lugar de estandarizarlo.
|
||||||
|
- **Implementar todos los widgets con ARIA:** aumenta el riesgo frente a controles HTML nativos.
|
||||||
|
- **Storybook en la primera versión:** el playground separado ya valida la API pública y los casos de producto con menos infraestructura.
|
||||||
|
- **Modo oscuro inicial:** requiere una revisión visual y de contraste independiente; queda en el roadmap.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
## Próxima fase
|
||||||
|
|
||||||
|
- Auditoría manual con VoiceOver, NVDA y navegación al 200 % de zoom.
|
||||||
|
- Pruebas visuales automatizadas en anchos móvil, tablet y escritorio.
|
||||||
|
- Tema oscuro con revisión de contraste independiente.
|
||||||
|
- Iconografía mantenida como paquete o API propia.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Deliberadamente pospuesto
|
||||||
|
|
||||||
|
- Tabla de datos interactiva.
|
||||||
|
- Date/time pickers.
|
||||||
|
- Combobox y autocomplete avanzados.
|
||||||
|
- Editor rich text.
|
||||||
|
- Toast manager global.
|
||||||
|
- Menús, tooltips y popovers complejos.
|
||||||
|
- Integraciones con router, Pinia, autenticación, backend o persistencia.
|
||||||
|
|
||||||
|
Estos componentes requieren investigación y contratos de accesibilidad propios. No deben añadirse como simples variaciones visuales.
|
||||||
Generated
+4959
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "@omaresquivel/design-system",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": false,
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./style.css": "./dist/design-system.css"
|
||||||
|
},
|
||||||
|
"sideEffects": [
|
||||||
|
"**/*.css"
|
||||||
|
],
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.autobyte.dev/api/packages/omararch48/npm/"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --config vite.playground.config.ts",
|
||||||
|
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"build:lib": "vite build",
|
||||||
|
"build:playground": "vite build --config vite.playground.config.ts",
|
||||||
|
"build": "npm run build:lib && npm run build:playground",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"prepublishOnly": "npm run typecheck && npm test && npm run build:lib"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@testing-library/vue": "^8.1.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
|
"@vue/test-utils": "^2.4.11",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
|
"prettier": "^3.9.6",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"type-fest": "^2.19.0",
|
||||||
|
"typescript": "^6.0.0",
|
||||||
|
"vite": "^8.2.1",
|
||||||
|
"vite-plugin-dts": "^5.0.3",
|
||||||
|
"vitest": "^4.1.10",
|
||||||
|
"vue": "^3.5.41",
|
||||||
|
"vue-tsc": "^3.3.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Catálogo del sistema de diseño de Omaresquivel"
|
||||||
|
/>
|
||||||
|
<title>Design System · Playground</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import {
|
||||||
|
DsAlert,
|
||||||
|
DsAppShell,
|
||||||
|
DsButton,
|
||||||
|
DsButtonGroup,
|
||||||
|
DsCheckbox,
|
||||||
|
DsConfirmDialog,
|
||||||
|
DsDivider,
|
||||||
|
DsEmptyState,
|
||||||
|
DsErrorState,
|
||||||
|
DsFormActions,
|
||||||
|
DsIconButton,
|
||||||
|
DsInline,
|
||||||
|
DsInlineMessage,
|
||||||
|
DsLoadingState,
|
||||||
|
DsPageHeader,
|
||||||
|
DsPanel,
|
||||||
|
DsPasswordInput,
|
||||||
|
DsProgressBar,
|
||||||
|
DsRadioGroup,
|
||||||
|
DsSearchInput,
|
||||||
|
DsSectionHeader,
|
||||||
|
DsSelect,
|
||||||
|
DsSettingsSection,
|
||||||
|
DsSkeleton,
|
||||||
|
DsSpinner,
|
||||||
|
DsStack,
|
||||||
|
DsSwitch,
|
||||||
|
DsTabs,
|
||||||
|
DsTextarea,
|
||||||
|
DsTextInput,
|
||||||
|
type DsTabItem,
|
||||||
|
} from "@omaresquivel/design-system";
|
||||||
|
|
||||||
|
const sections: DsTabItem[] = [
|
||||||
|
{ id: "catalog", label: "Catálogo" },
|
||||||
|
{ id: "profile", label: "Perfil" },
|
||||||
|
{ id: "settings", label: "Configuración" },
|
||||||
|
{ id: "form", label: "Formulario largo" },
|
||||||
|
{ id: "states", label: "Estados de panel" },
|
||||||
|
];
|
||||||
|
const currentSection = ref("catalog");
|
||||||
|
const name = ref("Omar Esquivel");
|
||||||
|
const email = ref("omar@example.com");
|
||||||
|
const password = ref("correct horse battery staple");
|
||||||
|
const search = ref("");
|
||||||
|
const bio = ref("Diseño y desarrollo productos web mantenibles.");
|
||||||
|
const role = ref("designer");
|
||||||
|
const plan = ref("pro");
|
||||||
|
const notifications = ref(true);
|
||||||
|
const analytics = ref(false);
|
||||||
|
const terms = ref(false);
|
||||||
|
const dialogOpen = ref(false);
|
||||||
|
const confirmOpen = ref(false);
|
||||||
|
const panelState = ref<"loading" | "error" | "empty" | "success">("success");
|
||||||
|
const nameError = computed(() =>
|
||||||
|
name.value.length > 0 && name.value.length < 3
|
||||||
|
? "Escribe al menos tres caracteres."
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const roleOptions = [
|
||||||
|
{ label: "Diseño", value: "designer" },
|
||||||
|
{ label: "Desarrollo", value: "developer" },
|
||||||
|
{ label: "Producto", value: "product" },
|
||||||
|
];
|
||||||
|
const planOptions = [
|
||||||
|
{
|
||||||
|
label: "Inicial",
|
||||||
|
value: "starter",
|
||||||
|
description: "Para proyectos personales.",
|
||||||
|
},
|
||||||
|
{ label: "Profesional", value: "pro", description: "Para equipos pequeños." },
|
||||||
|
{
|
||||||
|
label: "Empresa",
|
||||||
|
value: "enterprise",
|
||||||
|
description: "Controles y soporte avanzados.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DsAppShell>
|
||||||
|
<main class="playground-frame">
|
||||||
|
<DsPageHeader
|
||||||
|
eyebrow="Sistema de diseño"
|
||||||
|
title="Fundamentos y componentes"
|
||||||
|
description="Una fuente única de estilos sobrios, accesibles y reutilizables para productos Vue."
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<DsButton variant="secondary" @click="dialogOpen = true"
|
||||||
|
>Ver instalación</DsButton
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</DsPageHeader>
|
||||||
|
|
||||||
|
<div class="playground-body">
|
||||||
|
<DsTabs
|
||||||
|
v-model="currentSection"
|
||||||
|
class="playground-nav"
|
||||||
|
:items="sections"
|
||||||
|
label="Secciones del catálogo"
|
||||||
|
>
|
||||||
|
<template #catalog>
|
||||||
|
<DsStack :gap="8">
|
||||||
|
<DsSectionHeader
|
||||||
|
title="Catálogo base"
|
||||||
|
description="Variantes, estados y controles que comparten el mismo lenguaje visual."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section class="playground-grid playground-grid--2">
|
||||||
|
<DsPanel>
|
||||||
|
<DsStack :gap="4">
|
||||||
|
<DsSectionHeader
|
||||||
|
title="Acciones"
|
||||||
|
description="Jerarquía explícita para cada intención."
|
||||||
|
/>
|
||||||
|
<DsButtonGroup label="Variantes de botones">
|
||||||
|
<DsButton>Guardar cambios</DsButton>
|
||||||
|
<DsButton variant="secondary">Cancelar</DsButton>
|
||||||
|
<DsButton variant="ghost">Más opciones</DsButton>
|
||||||
|
<DsButton variant="danger">Eliminar</DsButton>
|
||||||
|
</DsButtonGroup>
|
||||||
|
<DsButtonGroup label="Estados de botones">
|
||||||
|
<DsButton size="sm">Pequeño</DsButton>
|
||||||
|
<DsButton loading>Guardando</DsButton>
|
||||||
|
<DsButton disabled>Deshabilitado</DsButton>
|
||||||
|
<DsIconButton label="Añadir elemento" variant="secondary"
|
||||||
|
>+</DsIconButton
|
||||||
|
>
|
||||||
|
</DsButtonGroup>
|
||||||
|
</DsStack>
|
||||||
|
</DsPanel>
|
||||||
|
|
||||||
|
<DsPanel>
|
||||||
|
<DsStack :gap="4">
|
||||||
|
<DsSectionHeader
|
||||||
|
title="Tokens de color"
|
||||||
|
description="Valores semánticos sobrescribibles con CSS."
|
||||||
|
/>
|
||||||
|
<div class="playground-grid playground-grid--2">
|
||||||
|
<div class="playground-swatch playground-swatch--primary">
|
||||||
|
Primary
|
||||||
|
</div>
|
||||||
|
<div class="playground-swatch playground-swatch--inverse">
|
||||||
|
Surface inverse
|
||||||
|
</div>
|
||||||
|
<div class="playground-swatch playground-swatch--muted">
|
||||||
|
Surface muted
|
||||||
|
</div>
|
||||||
|
<div class="playground-swatch">Surface</div>
|
||||||
|
</div>
|
||||||
|
</DsStack>
|
||||||
|
</DsPanel>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<DsDivider />
|
||||||
|
|
||||||
|
<section class="playground-grid playground-grid--2">
|
||||||
|
<DsPanel>
|
||||||
|
<DsStack :gap="4">
|
||||||
|
<DsSectionHeader
|
||||||
|
title="Campos"
|
||||||
|
description="Label, ayuda y error comparten un contrato consistente."
|
||||||
|
/>
|
||||||
|
<DsTextInput
|
||||||
|
v-model="name"
|
||||||
|
label="Nombre completo"
|
||||||
|
required
|
||||||
|
:error="nameError"
|
||||||
|
autocomplete="name"
|
||||||
|
/>
|
||||||
|
<DsSearchInput
|
||||||
|
v-model="search"
|
||||||
|
label="Buscar proyecto"
|
||||||
|
description="Busca por nombre o identificador."
|
||||||
|
/>
|
||||||
|
<DsPasswordInput
|
||||||
|
v-model="password"
|
||||||
|
label="Contraseña"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
<DsSelect
|
||||||
|
v-model="role"
|
||||||
|
label="Área principal"
|
||||||
|
:options="roleOptions"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<DsTextarea
|
||||||
|
v-model="bio"
|
||||||
|
label="Biografía"
|
||||||
|
description="Máximo 280 caracteres."
|
||||||
|
:maxlength="280"
|
||||||
|
/>
|
||||||
|
</DsStack>
|
||||||
|
</DsPanel>
|
||||||
|
|
||||||
|
<DsPanel>
|
||||||
|
<DsStack :gap="4">
|
||||||
|
<DsSectionHeader
|
||||||
|
title="Selección"
|
||||||
|
description="Controles nativos y comportamiento predecible."
|
||||||
|
/>
|
||||||
|
<DsCheckbox
|
||||||
|
v-model="terms"
|
||||||
|
label="Acepto los términos"
|
||||||
|
description="Puedes consultar la política antes de continuar."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<DsRadioGroup
|
||||||
|
v-model="plan"
|
||||||
|
legend="Plan"
|
||||||
|
name="catalog-plan"
|
||||||
|
:options="planOptions"
|
||||||
|
/>
|
||||||
|
<DsSwitch
|
||||||
|
v-model="notifications"
|
||||||
|
label="Notificaciones"
|
||||||
|
description="Recibe novedades importantes por correo."
|
||||||
|
/>
|
||||||
|
<DsSwitch
|
||||||
|
v-model="analytics"
|
||||||
|
label="Analítica de uso"
|
||||||
|
description="Ayúdanos a mejorar el producto."
|
||||||
|
/>
|
||||||
|
</DsStack>
|
||||||
|
</DsPanel>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<DsPanel>
|
||||||
|
<DsStack :gap="4">
|
||||||
|
<DsSectionHeader
|
||||||
|
title="Retroalimentación"
|
||||||
|
description="Mensajes visibles y anunciables solo cuando son dinámicos."
|
||||||
|
/>
|
||||||
|
<DsAlert variant="info" title="Información"
|
||||||
|
>El nuevo tema se aplicará a todos tus proyectos.</DsAlert
|
||||||
|
>
|
||||||
|
<DsAlert variant="success" title="Cambios guardados"
|
||||||
|
>Tu configuración ya está actualizada.</DsAlert
|
||||||
|
>
|
||||||
|
<DsAlert variant="warning" title="Revisa la información"
|
||||||
|
>Algunos campos todavía usan valores
|
||||||
|
predeterminados.</DsAlert
|
||||||
|
>
|
||||||
|
<DsAlert variant="error" title="No pudimos guardar"
|
||||||
|
>Comprueba tu conexión e intenta nuevamente.</DsAlert
|
||||||
|
>
|
||||||
|
<DsProgressBar :value="68" label="Configuración completada" />
|
||||||
|
<DsInline align="center"
|
||||||
|
><DsSpinner size="sm" /><DsInlineMessage
|
||||||
|
>Sincronizando preferencias…</DsInlineMessage
|
||||||
|
></DsInline
|
||||||
|
>
|
||||||
|
<DsSkeleton height="2.75rem" />
|
||||||
|
</DsStack>
|
||||||
|
</DsPanel>
|
||||||
|
</DsStack>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #profile>
|
||||||
|
<DsStack :gap="6">
|
||||||
|
<DsSectionHeader
|
||||||
|
title="Perfil de usuario"
|
||||||
|
description="Ejemplo completo compuesto únicamente con la API pública."
|
||||||
|
/>
|
||||||
|
<div class="playground-profile">
|
||||||
|
<DsPanel>
|
||||||
|
<DsStack align="center" :gap="4">
|
||||||
|
<div class="playground-avatar" aria-hidden="true">OE</div>
|
||||||
|
<div style="text-align: center">
|
||||||
|
<strong>Omar Esquivel</strong>
|
||||||
|
<p class="playground-meta">Administrador</p>
|
||||||
|
</div>
|
||||||
|
<DsButton variant="secondary" size="sm"
|
||||||
|
>Cambiar fotografía</DsButton
|
||||||
|
>
|
||||||
|
</DsStack>
|
||||||
|
</DsPanel>
|
||||||
|
<form @submit.prevent>
|
||||||
|
<DsPanel>
|
||||||
|
<DsStack :gap="6">
|
||||||
|
<DsSectionHeader
|
||||||
|
title="Información personal"
|
||||||
|
description="Esta información aparecerá en tu cuenta y actividad."
|
||||||
|
/>
|
||||||
|
<div class="playground-grid playground-grid--2">
|
||||||
|
<DsTextInput
|
||||||
|
v-model="name"
|
||||||
|
label="Nombre completo"
|
||||||
|
required
|
||||||
|
autocomplete="name"
|
||||||
|
/>
|
||||||
|
<DsTextInput
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
label="Correo electrónico"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DsTextarea v-model="bio" label="Acerca de ti" />
|
||||||
|
<DsFormActions
|
||||||
|
><DsButton variant="secondary">Cancelar</DsButton
|
||||||
|
><DsButton type="submit"
|
||||||
|
>Guardar perfil</DsButton
|
||||||
|
></DsFormActions
|
||||||
|
>
|
||||||
|
</DsStack>
|
||||||
|
</DsPanel>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</DsStack>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #settings>
|
||||||
|
<DsStack :gap="6">
|
||||||
|
<DsSectionHeader
|
||||||
|
title="Configuración"
|
||||||
|
description="Secciones independientes que pueden guardarse por separado."
|
||||||
|
/>
|
||||||
|
<DsSettingsSection
|
||||||
|
title="Comunicaciones"
|
||||||
|
description="Elige cómo quieres recibir información."
|
||||||
|
>
|
||||||
|
<DsStack :gap="4">
|
||||||
|
<DsSwitch
|
||||||
|
v-model="notifications"
|
||||||
|
label="Actualizaciones del producto"
|
||||||
|
description="Noticias sobre funciones y mantenimiento."
|
||||||
|
/>
|
||||||
|
<DsSwitch
|
||||||
|
v-model="analytics"
|
||||||
|
label="Resumen semanal"
|
||||||
|
description="Actividad relevante en un solo correo."
|
||||||
|
/>
|
||||||
|
</DsStack>
|
||||||
|
<template #actions
|
||||||
|
><DsButton size="sm">Guardar</DsButton></template
|
||||||
|
>
|
||||||
|
</DsSettingsSection>
|
||||||
|
<DsSettingsSection
|
||||||
|
title="Zona de riesgo"
|
||||||
|
description="Estas acciones pueden afectar el acceso de todo el equipo."
|
||||||
|
>
|
||||||
|
<DsAlert variant="warning" title="Antes de continuar"
|
||||||
|
>Exporta una copia de la información que necesites
|
||||||
|
conservar.</DsAlert
|
||||||
|
>
|
||||||
|
<template #actions
|
||||||
|
><DsButton
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
@click="confirmOpen = true"
|
||||||
|
>Eliminar cuenta</DsButton
|
||||||
|
></template
|
||||||
|
>
|
||||||
|
</DsSettingsSection>
|
||||||
|
</DsStack>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #form>
|
||||||
|
<form @submit.prevent>
|
||||||
|
<DsStack :gap="6">
|
||||||
|
<DsSectionHeader
|
||||||
|
title="Solicitud de proyecto"
|
||||||
|
description="Formulario largo dividido en secciones claras."
|
||||||
|
/>
|
||||||
|
<DsSettingsSection
|
||||||
|
title="1. Contexto"
|
||||||
|
description="Cuéntanos qué necesitas resolver."
|
||||||
|
>
|
||||||
|
<DsTextarea
|
||||||
|
label="Objetivo y contexto de negocio"
|
||||||
|
required
|
||||||
|
placeholder="Agrega la información relevante para esta sección."
|
||||||
|
/>
|
||||||
|
</DsSettingsSection>
|
||||||
|
<DsSettingsSection
|
||||||
|
title="2. Responsables"
|
||||||
|
description="Personas que participarán en las decisiones."
|
||||||
|
>
|
||||||
|
<div class="playground-grid playground-grid--2">
|
||||||
|
<DsTextInput label="Responsable" required />
|
||||||
|
<DsTextInput label="Correo" type="email" required />
|
||||||
|
</div>
|
||||||
|
</DsSettingsSection>
|
||||||
|
<DsSettingsSection
|
||||||
|
title="3. Alcance"
|
||||||
|
description="Define prioridad y tipo de entrega."
|
||||||
|
>
|
||||||
|
<DsRadioGroup
|
||||||
|
v-model="plan"
|
||||||
|
legend="Prioridad"
|
||||||
|
name="project-priority"
|
||||||
|
:options="planOptions"
|
||||||
|
/>
|
||||||
|
</DsSettingsSection>
|
||||||
|
<DsFormActions
|
||||||
|
><DsButton variant="secondary">Guardar borrador</DsButton
|
||||||
|
><DsButton type="submit"
|
||||||
|
>Enviar solicitud</DsButton
|
||||||
|
></DsFormActions
|
||||||
|
>
|
||||||
|
</DsStack>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #states>
|
||||||
|
<DsStack :gap="6">
|
||||||
|
<DsSectionHeader
|
||||||
|
title="Estados de panel"
|
||||||
|
description="Prueba loading, error, vacío y contenido correcto."
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<DsButtonGroup label="Seleccionar estado">
|
||||||
|
<DsButton
|
||||||
|
v-for="state in [
|
||||||
|
'loading',
|
||||||
|
'error',
|
||||||
|
'empty',
|
||||||
|
'success',
|
||||||
|
] as const"
|
||||||
|
:key="state"
|
||||||
|
size="sm"
|
||||||
|
:variant="panelState === state ? 'primary' : 'secondary'"
|
||||||
|
@click="panelState = state"
|
||||||
|
>{{ state }}</DsButton
|
||||||
|
>
|
||||||
|
</DsButtonGroup>
|
||||||
|
</template>
|
||||||
|
</DsSectionHeader>
|
||||||
|
<DsPanel>
|
||||||
|
<DsLoadingState v-if="panelState === 'loading'" />
|
||||||
|
<DsErrorState
|
||||||
|
v-else-if="panelState === 'error'"
|
||||||
|
title="No pudimos cargar los proyectos"
|
||||||
|
description="Comprueba tu conexión e intenta nuevamente."
|
||||||
|
><template #actions
|
||||||
|
><DsButton>Reintentar</DsButton></template
|
||||||
|
></DsErrorState
|
||||||
|
>
|
||||||
|
<DsEmptyState
|
||||||
|
v-else-if="panelState === 'empty'"
|
||||||
|
title="Aún no hay proyectos"
|
||||||
|
description="Crea el primero para comenzar a organizar tu trabajo."
|
||||||
|
><template #actions
|
||||||
|
><DsButton>Crear proyecto</DsButton></template
|
||||||
|
></DsEmptyState
|
||||||
|
>
|
||||||
|
<DsStack v-else :gap="4"
|
||||||
|
><DsAlert variant="success" title="Todo está al día"
|
||||||
|
>Hay 12 proyectos activos y ninguno requiere
|
||||||
|
atención.</DsAlert
|
||||||
|
><DsProgressBar :value="100" label="Sincronización"
|
||||||
|
/></DsStack>
|
||||||
|
</DsPanel>
|
||||||
|
</DsStack>
|
||||||
|
</template>
|
||||||
|
</DsTabs>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<DsConfirmDialog
|
||||||
|
v-model="confirmOpen"
|
||||||
|
title="¿Eliminar la cuenta?"
|
||||||
|
description="Esta acción no se puede deshacer."
|
||||||
|
confirm-label="Sí, eliminar"
|
||||||
|
danger
|
||||||
|
@confirm="confirmOpen = false"
|
||||||
|
>
|
||||||
|
Se eliminarán los proyectos y preferencias vinculados a esta cuenta.
|
||||||
|
</DsConfirmDialog>
|
||||||
|
|
||||||
|
<DsConfirmDialog
|
||||||
|
v-model="dialogOpen"
|
||||||
|
title="Instalación"
|
||||||
|
confirm-label="Entendido"
|
||||||
|
@confirm="dialogOpen = false"
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
class="playground-code"
|
||||||
|
><code>import { DsButton } from '@omaresquivel/design-system'
|
||||||
|
import '@omaresquivel/design-system/style.css'</code></pre>
|
||||||
|
</DsConfirmDialog>
|
||||||
|
</DsAppShell>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import "@omaresquivel/design-system/style.css";
|
||||||
|
import "./playground.css";
|
||||||
|
import App from "./App.vue";
|
||||||
|
|
||||||
|
createApp(App).mount("#app");
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
.playground-frame {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--ds-color-border);
|
||||||
|
border-radius: var(--ds-radius-xl);
|
||||||
|
background: var(--ds-color-surface);
|
||||||
|
box-shadow: var(--ds-shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-body {
|
||||||
|
padding: var(--ds-space-5);
|
||||||
|
}
|
||||||
|
.playground-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ds-space-5);
|
||||||
|
}
|
||||||
|
.playground-grid--2 {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.playground-nav {
|
||||||
|
margin-bottom: var(--ds-space-6);
|
||||||
|
}
|
||||||
|
.playground-swatch {
|
||||||
|
min-height: 5rem;
|
||||||
|
border: 1px solid var(--ds-color-border);
|
||||||
|
border-radius: var(--ds-radius-md);
|
||||||
|
padding: var(--ds-space-3);
|
||||||
|
font-size: var(--ds-font-size-xs);
|
||||||
|
}
|
||||||
|
.playground-swatch--primary {
|
||||||
|
color: white;
|
||||||
|
background: var(--ds-color-primary);
|
||||||
|
}
|
||||||
|
.playground-swatch--inverse {
|
||||||
|
color: white;
|
||||||
|
background: var(--ds-color-surface-inverse);
|
||||||
|
}
|
||||||
|
.playground-swatch--muted {
|
||||||
|
background: var(--ds-color-surface-muted);
|
||||||
|
}
|
||||||
|
.playground-icon {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
}
|
||||||
|
.playground-profile {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--ds-space-6);
|
||||||
|
}
|
||||||
|
.playground-avatar {
|
||||||
|
display: grid;
|
||||||
|
width: 5rem;
|
||||||
|
height: 5rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: white;
|
||||||
|
background: var(--ds-color-primary);
|
||||||
|
font-size: var(--ds-font-size-xl);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.playground-meta {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ds-color-text-muted);
|
||||||
|
font-size: var(--ds-font-size-sm);
|
||||||
|
}
|
||||||
|
.playground-code {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: var(--ds-radius-md);
|
||||||
|
padding: var(--ds-space-4);
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: var(--ds-color-surface-inverse);
|
||||||
|
font-size: var(--ds-font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 48rem) {
|
||||||
|
.playground-body {
|
||||||
|
padding: var(--ds-space-8);
|
||||||
|
}
|
||||||
|
.playground-grid--2 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.playground-profile {
|
||||||
|
grid-template-columns: 15rem minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DsSpinner from "../feedback/DsSpinner.vue";
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
type?: "button" | "submit" | "reset";
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
block?: boolean;
|
||||||
|
loadingLabel?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
variant: "primary",
|
||||||
|
size: "md",
|
||||||
|
type: "button",
|
||||||
|
disabled: false,
|
||||||
|
loading: false,
|
||||||
|
block: false,
|
||||||
|
loadingLabel: "Cargando",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="ds-button"
|
||||||
|
:class="[
|
||||||
|
`ds-button--${variant}`,
|
||||||
|
`ds-button--${size}`,
|
||||||
|
{ 'ds-button--block': block },
|
||||||
|
]"
|
||||||
|
:type="type"
|
||||||
|
:disabled="disabled || loading"
|
||||||
|
:aria-busy="loading || undefined"
|
||||||
|
>
|
||||||
|
<DsSpinner v-if="loading" size="sm" :label="loadingLabel" />
|
||||||
|
<span><slot /></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(defineProps<{ label?: string }>(), { label: "Acciones" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ds-button-group" role="group" :aria-label="label"><slot /></div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ds-form-actions"><slot /></div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DsSpinner from "../feedback/DsSpinner.vue";
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label: string;
|
||||||
|
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
type?: "button" | "submit" | "reset";
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
variant: "ghost",
|
||||||
|
size: "md",
|
||||||
|
type: "button",
|
||||||
|
disabled: false,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="ds-button ds-icon-button"
|
||||||
|
:class="[`ds-button--${variant}`, `ds-button--${size}`]"
|
||||||
|
:type="type"
|
||||||
|
:disabled="disabled || loading"
|
||||||
|
:aria-label="label"
|
||||||
|
:aria-busy="loading || undefined"
|
||||||
|
>
|
||||||
|
<DsSpinner v-if="loading" size="sm" :label="label" />
|
||||||
|
<slot v-else />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
variant?: "info" | "success" | "warning" | "error";
|
||||||
|
title?: string;
|
||||||
|
live?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ variant: "info", title: undefined, live: false },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="ds-alert"
|
||||||
|
:class="`ds-alert--${variant}`"
|
||||||
|
:role="live ? (variant === 'error' ? 'alert' : 'status') : undefined"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">●</span>
|
||||||
|
<div>
|
||||||
|
<h3 v-if="title" class="ds-alert__title">{{ title }}</h3>
|
||||||
|
<div class="ds-alert__body"><slot /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ title: string; description?: string }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ds-state">
|
||||||
|
<div class="ds-state__icon" aria-hidden="true">
|
||||||
|
<slot name="icon">◇</slot>
|
||||||
|
</div>
|
||||||
|
<h3 class="ds-state__title">{{ title }}</h3>
|
||||||
|
<p v-if="description" class="ds-state__description">{{ description }}</p>
|
||||||
|
<div v-if="$slots.actions"><slot name="actions" /></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ title: string; description?: string }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ds-state" role="alert">
|
||||||
|
<div class="ds-state__icon" aria-hidden="true">!</div>
|
||||||
|
<h3 class="ds-state__title">{{ title }}</h3>
|
||||||
|
<p v-if="description" class="ds-state__description">{{ description }}</p>
|
||||||
|
<div v-if="$slots.actions"><slot name="actions" /></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(defineProps<{ variant?: "muted" | "error"; live?: boolean }>(), {
|
||||||
|
variant: "muted",
|
||||||
|
live: false,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p
|
||||||
|
class="ds-inline-message"
|
||||||
|
:class="{ 'ds-inline-message--error': variant === 'error' }"
|
||||||
|
:role="live ? (variant === 'error' ? 'alert' : 'status') : undefined"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DsSpinner from "./DsSpinner.vue";
|
||||||
|
withDefaults(defineProps<{ title?: string; description?: string }>(), {
|
||||||
|
title: "Cargando",
|
||||||
|
description: "Espera un momento mientras obtenemos la información.",
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ds-state" role="status" aria-live="polite">
|
||||||
|
<div class="ds-state__icon"><DsSpinner :label="title" /></div>
|
||||||
|
<h3 class="ds-state__title">{{ title }}</h3>
|
||||||
|
<p class="ds-state__description">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
value?: number;
|
||||||
|
max?: number;
|
||||||
|
label?: string;
|
||||||
|
showValue?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ value: 0, max: 100, label: "Progreso", showValue: true },
|
||||||
|
);
|
||||||
|
const normalized = computed(() =>
|
||||||
|
Math.min(Math.max(props.value, 0), props.max),
|
||||||
|
);
|
||||||
|
const percentage = computed(() =>
|
||||||
|
props.max > 0 ? Math.round((normalized.value / props.max) * 100) : 0,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ds-progress">
|
||||||
|
<div class="ds-progress__meta">
|
||||||
|
<span>{{ label }}</span
|
||||||
|
><span v-if="showValue">{{ percentage }}%</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="ds-progress__track"
|
||||||
|
role="progressbar"
|
||||||
|
:aria-label="label"
|
||||||
|
:aria-valuenow="normalized"
|
||||||
|
aria-valuemin="0"
|
||||||
|
:aria-valuemax="max"
|
||||||
|
>
|
||||||
|
<div class="ds-progress__bar" :style="{ width: `${percentage}%` }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{ width?: string; height?: string; rounded?: boolean }>(),
|
||||||
|
{
|
||||||
|
width: "100%",
|
||||||
|
height: "1rem",
|
||||||
|
rounded: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="ds-skeleton"
|
||||||
|
:style="{ width, height, borderRadius: rounded ? '999px' : undefined }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(defineProps<{ size?: "sm" | "md" | "lg"; label?: string }>(), {
|
||||||
|
size: "md",
|
||||||
|
label: "Cargando",
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="ds-spinner" :class="`ds-spinner--${size}`" role="status">
|
||||||
|
<span class="sr-only">{{ label }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useFieldIds } from "../../composables/useFieldIds";
|
||||||
|
|
||||||
|
const model = defineModel<boolean>({ default: false });
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: undefined,
|
||||||
|
name: undefined,
|
||||||
|
description: undefined,
|
||||||
|
disabled: false,
|
||||||
|
required: false,
|
||||||
|
error: undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const ids = useFieldIds(props.id);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label class="ds-choice" :for="ids.controlId">
|
||||||
|
<input
|
||||||
|
:id="ids.controlId"
|
||||||
|
v-model="model"
|
||||||
|
type="checkbox"
|
||||||
|
:name="name"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="required"
|
||||||
|
:aria-invalid="Boolean(error) || undefined"
|
||||||
|
:aria-describedby="
|
||||||
|
description ? ids.descriptionId : error ? ids.errorId : undefined
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="ds-choice__content">
|
||||||
|
<span class="ds-choice__label">{{ label }}</span>
|
||||||
|
<span
|
||||||
|
v-if="description"
|
||||||
|
:id="ids.descriptionId"
|
||||||
|
class="ds-choice__description"
|
||||||
|
>{{ description }}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p
|
||||||
|
v-if="error"
|
||||||
|
:id="ids.errorId"
|
||||||
|
class="ds-field__message ds-field__message--error"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useFieldIds } from "../../composables/useFieldIds";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string;
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
error?: string;
|
||||||
|
required?: boolean;
|
||||||
|
optionalText?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: undefined,
|
||||||
|
label: undefined,
|
||||||
|
description: undefined,
|
||||||
|
error: undefined,
|
||||||
|
required: false,
|
||||||
|
optionalText: "Opcional",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const ids = useFieldIds(props.id);
|
||||||
|
const describedBy = computed(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
props.description ? ids.descriptionId : null,
|
||||||
|
props.error ? ids.errorId : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ") || undefined,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ds-field">
|
||||||
|
<label v-if="label" class="ds-field__label" :for="ids.controlId">
|
||||||
|
{{ label }}
|
||||||
|
<span v-if="!required" class="ds-field__optional"
|
||||||
|
>({{ optionalText }})</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<slot
|
||||||
|
:control-id="ids.controlId"
|
||||||
|
:described-by="describedBy"
|
||||||
|
:invalid="Boolean(error)"
|
||||||
|
/>
|
||||||
|
<p v-if="description" :id="ids.descriptionId" class="ds-field__description">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="error"
|
||||||
|
:id="ids.errorId"
|
||||||
|
class="ds-field__message ds-field__message--error"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import DsTextInput from "./DsTextInput.vue";
|
||||||
|
|
||||||
|
const model = defineModel<string>({ default: "" });
|
||||||
|
defineProps<{
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
error?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
autocomplete?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
}>();
|
||||||
|
const visible = ref(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DsTextInput
|
||||||
|
:id="id"
|
||||||
|
v-model="model"
|
||||||
|
:name="name"
|
||||||
|
:label="label"
|
||||||
|
:description="description"
|
||||||
|
:error="error"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:autocomplete="autocomplete"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="required"
|
||||||
|
:type="visible ? 'text' : 'password'"
|
||||||
|
>
|
||||||
|
<template #trailing>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ds-control-action"
|
||||||
|
:aria-label="visible ? 'Ocultar contraseña' : 'Mostrar contraseña'"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="visible = !visible"
|
||||||
|
>
|
||||||
|
{{ visible ? "Ocultar" : "Ver" }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</DsTextInput>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DsRadioOption } from "../../types";
|
||||||
|
|
||||||
|
const model = defineModel<string>({ default: "" });
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
legend: string;
|
||||||
|
name: string;
|
||||||
|
options: DsRadioOption[];
|
||||||
|
description?: string;
|
||||||
|
error?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
description: undefined,
|
||||||
|
error: undefined,
|
||||||
|
disabled: false,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<fieldset
|
||||||
|
class="ds-radio-group"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-describedby="
|
||||||
|
error ? `${name}-error` : description ? `${name}-description` : undefined
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<legend class="ds-radio-group__legend">{{ legend }}</legend>
|
||||||
|
<p
|
||||||
|
v-if="description"
|
||||||
|
:id="`${name}-description`"
|
||||||
|
class="ds-field__description"
|
||||||
|
>
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
<div class="ds-radio-group__options">
|
||||||
|
<label v-for="option in options" :key="option.value" class="ds-choice">
|
||||||
|
<input
|
||||||
|
v-model="model"
|
||||||
|
type="radio"
|
||||||
|
:name="name"
|
||||||
|
:value="option.value"
|
||||||
|
:disabled="option.disabled"
|
||||||
|
:required="required"
|
||||||
|
/>
|
||||||
|
<span class="ds-choice__content">
|
||||||
|
<span class="ds-choice__label">{{ option.label }}</span>
|
||||||
|
<span v-if="option.description" class="ds-choice__description">{{
|
||||||
|
option.description
|
||||||
|
}}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="error"
|
||||||
|
:id="`${name}-error`"
|
||||||
|
class="ds-field__message ds-field__message--error"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DsTextInput from "./DsTextInput.vue";
|
||||||
|
|
||||||
|
const model = defineModel<string>({ default: "" });
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ placeholder: "Buscar…" },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DsTextInput
|
||||||
|
:id="id"
|
||||||
|
v-model="model"
|
||||||
|
:name="name"
|
||||||
|
:label="label"
|
||||||
|
:description="description"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
type="text"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="8.5" cy="8.5" r="5.5" />
|
||||||
|
<path d="m13 13 4 4" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
</DsTextInput>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAttrs } from "vue";
|
||||||
|
import DsFormField from "./DsFormField.vue";
|
||||||
|
import type { DsSelectOption } from "../../types";
|
||||||
|
|
||||||
|
defineOptions({ inheritAttrs: false });
|
||||||
|
const model = defineModel<string>({ default: "" });
|
||||||
|
const attrs = useAttrs();
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
options: DsSelectOption[];
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
error?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: undefined,
|
||||||
|
name: undefined,
|
||||||
|
label: undefined,
|
||||||
|
description: undefined,
|
||||||
|
error: undefined,
|
||||||
|
placeholder: "Selecciona una opción",
|
||||||
|
disabled: false,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DsFormField
|
||||||
|
v-slot="field"
|
||||||
|
:id="id"
|
||||||
|
:label="label"
|
||||||
|
:description="description"
|
||||||
|
:error="error"
|
||||||
|
:required="required"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
v-bind="attrs"
|
||||||
|
:id="field.controlId"
|
||||||
|
v-model="model"
|
||||||
|
class="ds-control"
|
||||||
|
:name="name"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="required"
|
||||||
|
:aria-invalid="field.invalid || undefined"
|
||||||
|
:aria-describedby="field.describedBy"
|
||||||
|
>
|
||||||
|
<option value="" disabled>{{ placeholder }}</option>
|
||||||
|
<option
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
:disabled="option.disabled"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</DsFormField>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DsPanel from "../layout/DsPanel.vue";
|
||||||
|
import DsSectionHeader from "../layout/DsSectionHeader.vue";
|
||||||
|
import DsStack from "../layout/DsStack.vue";
|
||||||
|
|
||||||
|
defineProps<{ title: string; description?: string }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DsPanel>
|
||||||
|
<DsStack :gap="6">
|
||||||
|
<DsSectionHeader :title="title" :description="description">
|
||||||
|
<template v-if="$slots.actions" #actions
|
||||||
|
><slot name="actions"
|
||||||
|
/></template>
|
||||||
|
</DsSectionHeader>
|
||||||
|
<slot />
|
||||||
|
</DsStack>
|
||||||
|
</DsPanel>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const model = defineModel<boolean>({ default: false });
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
name?: string;
|
||||||
|
}>(),
|
||||||
|
{ description: undefined, disabled: false, name: undefined },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
class="ds-switch"
|
||||||
|
:aria-checked="model"
|
||||||
|
:disabled="disabled"
|
||||||
|
:name="name"
|
||||||
|
@click="model = !model"
|
||||||
|
>
|
||||||
|
<span class="ds-switch__track" aria-hidden="true"
|
||||||
|
><span class="ds-switch__thumb"
|
||||||
|
/></span>
|
||||||
|
<span class="ds-choice__content">
|
||||||
|
<span class="ds-choice__label">{{ label }}</span>
|
||||||
|
<span v-if="description" class="ds-choice__description">{{
|
||||||
|
description
|
||||||
|
}}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAttrs } from "vue";
|
||||||
|
import DsFormField from "./DsFormField.vue";
|
||||||
|
|
||||||
|
defineOptions({ inheritAttrs: false });
|
||||||
|
const model = defineModel<string>({ default: "" });
|
||||||
|
const attrs = useAttrs();
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
error?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
type?: "text" | "email" | "url" | "tel" | "password";
|
||||||
|
autocomplete?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: undefined,
|
||||||
|
name: undefined,
|
||||||
|
label: undefined,
|
||||||
|
description: undefined,
|
||||||
|
error: undefined,
|
||||||
|
placeholder: undefined,
|
||||||
|
type: "text",
|
||||||
|
autocomplete: undefined,
|
||||||
|
disabled: false,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DsFormField
|
||||||
|
v-slot="field"
|
||||||
|
:id="id"
|
||||||
|
:label="label"
|
||||||
|
:description="description"
|
||||||
|
:error="error"
|
||||||
|
:required="required"
|
||||||
|
>
|
||||||
|
<div class="ds-control-wrap">
|
||||||
|
<span v-if="$slots.leading" class="ds-control-icon" aria-hidden="true"
|
||||||
|
><slot name="leading"
|
||||||
|
/></span>
|
||||||
|
<input
|
||||||
|
v-bind="attrs"
|
||||||
|
:id="field.controlId"
|
||||||
|
v-model="model"
|
||||||
|
class="ds-control"
|
||||||
|
:class="{
|
||||||
|
'ds-control--with-leading': $slots.leading,
|
||||||
|
'ds-control--with-trailing': $slots.trailing,
|
||||||
|
}"
|
||||||
|
:name="name"
|
||||||
|
:type="type"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:autocomplete="autocomplete"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="required"
|
||||||
|
:aria-invalid="field.invalid || undefined"
|
||||||
|
:aria-describedby="field.describedBy"
|
||||||
|
/>
|
||||||
|
<slot v-if="$slots.trailing" name="trailing" />
|
||||||
|
</div>
|
||||||
|
</DsFormField>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAttrs } from "vue";
|
||||||
|
import DsFormField from "./DsFormField.vue";
|
||||||
|
|
||||||
|
defineOptions({ inheritAttrs: false });
|
||||||
|
const model = defineModel<string>({ default: "" });
|
||||||
|
const attrs = useAttrs();
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
error?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
rows?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: undefined,
|
||||||
|
name: undefined,
|
||||||
|
label: undefined,
|
||||||
|
description: undefined,
|
||||||
|
error: undefined,
|
||||||
|
placeholder: undefined,
|
||||||
|
rows: 5,
|
||||||
|
disabled: false,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DsFormField
|
||||||
|
v-slot="field"
|
||||||
|
:id="id"
|
||||||
|
:label="label"
|
||||||
|
:description="description"
|
||||||
|
:error="error"
|
||||||
|
:required="required"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
v-bind="attrs"
|
||||||
|
:id="field.controlId"
|
||||||
|
v-model="model"
|
||||||
|
class="ds-control"
|
||||||
|
:name="name"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:rows="rows"
|
||||||
|
:disabled="disabled"
|
||||||
|
:required="required"
|
||||||
|
:aria-invalid="field.invalid || undefined"
|
||||||
|
:aria-describedby="field.describedBy"
|
||||||
|
/>
|
||||||
|
</DsFormField>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(defineProps<{ maxWidth?: string }>(), {
|
||||||
|
maxWidth: "var(--ds-page-width)",
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ds-app-shell">
|
||||||
|
<div class="ds-app-shell__inner" :style="{ maxWidth }">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<template><hr class="ds-divider" /></template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
as?: string;
|
||||||
|
gap?: 1 | 2 | 3 | 4 | 6 | 8;
|
||||||
|
align?: "start" | "center" | "end" | "stretch";
|
||||||
|
justify?: "start" | "center" | "end" | "between";
|
||||||
|
wrap?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ as: "div", gap: 3, align: "center", justify: "start", wrap: false },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="as"
|
||||||
|
class="ds-inline"
|
||||||
|
:class="[
|
||||||
|
`ds-gap-${gap}`,
|
||||||
|
`ds-align-${align}`,
|
||||||
|
`ds-justify-${justify}`,
|
||||||
|
{ 'ds-wrap': wrap },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
eyebrow?: string;
|
||||||
|
standalone?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ description: undefined, eyebrow: undefined, standalone: false },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="ds-page-header"
|
||||||
|
:class="{ 'ds-page-header--standalone': standalone }"
|
||||||
|
>
|
||||||
|
<div class="ds-page-header__content">
|
||||||
|
<p v-if="eyebrow" class="ds-page-header__eyebrow">{{ eyebrow }}</p>
|
||||||
|
<h1 class="ds-page-header__title">{{ title }}</h1>
|
||||||
|
<p v-if="description" class="ds-page-header__description">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.actions"><slot name="actions" /></div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
as?: string;
|
||||||
|
padding?: "sm" | "md" | "lg";
|
||||||
|
tone?: "default" | "muted";
|
||||||
|
flat?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ as: "section", padding: "md", tone: "default", flat: false },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="as"
|
||||||
|
class="ds-panel"
|
||||||
|
:class="[
|
||||||
|
`ds-panel--${padding}`,
|
||||||
|
{ 'ds-panel--flat': flat, 'ds-panel--muted': tone === 'muted' },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ title: string; description?: string; eyebrow?: string }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="ds-section-header">
|
||||||
|
<div>
|
||||||
|
<p v-if="eyebrow" class="ds-section-header__eyebrow">{{ eyebrow }}</p>
|
||||||
|
<h2 class="ds-section-header__title">{{ title }}</h2>
|
||||||
|
<p v-if="description" class="ds-section-header__description">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.actions"><slot name="actions" /></div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
as?: string;
|
||||||
|
gap?: 1 | 2 | 3 | 4 | 6 | 8;
|
||||||
|
align?: "start" | "center" | "end" | "stretch";
|
||||||
|
}>(),
|
||||||
|
{ as: "div", gap: 4, align: "stretch" },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="as"
|
||||||
|
class="ds-stack"
|
||||||
|
:class="[`ds-gap-${gap}`, `ds-align-${align}`]"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, ref, watchEffect } from "vue";
|
||||||
|
import type { DsTabItem } from "../../types";
|
||||||
|
|
||||||
|
const props = defineProps<{ items: DsTabItem[]; label: string }>();
|
||||||
|
const model = defineModel<string>({ default: "" });
|
||||||
|
const tabButtons = ref<HTMLButtonElement[]>([]);
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (!props.items.some((item) => item.id === model.value && !item.disabled)) {
|
||||||
|
model.value = props.items.find((item) => !item.disabled)?.id ?? "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectAndFocus(index: number) {
|
||||||
|
const enabled = props.items
|
||||||
|
.map((item, itemIndex) => ({ item, itemIndex }))
|
||||||
|
.filter(({ item }) => !item.disabled);
|
||||||
|
if (!enabled.length) return;
|
||||||
|
const normalized = (index + enabled.length) % enabled.length;
|
||||||
|
const target = enabled[normalized];
|
||||||
|
if (!target) return;
|
||||||
|
model.value = target.item.id;
|
||||||
|
nextTick(() => tabButtons.value[target.itemIndex]?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
const enabled = props.items.filter((item) => !item.disabled);
|
||||||
|
const current = enabled.findIndex((item) => item.id === model.value);
|
||||||
|
if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
selectAndFocus(current + 1);
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
selectAndFocus(current - 1);
|
||||||
|
}
|
||||||
|
if (event.key === "Home") {
|
||||||
|
event.preventDefault();
|
||||||
|
selectAndFocus(0);
|
||||||
|
}
|
||||||
|
if (event.key === "End") {
|
||||||
|
event.preventDefault();
|
||||||
|
selectAndFocus(enabled.length - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ds-tabs">
|
||||||
|
<div
|
||||||
|
class="ds-tabs__list"
|
||||||
|
role="tablist"
|
||||||
|
:aria-label="label"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="item in items"
|
||||||
|
:id="`ds-tab-${item.id}`"
|
||||||
|
:key="item.id"
|
||||||
|
ref="tabButtons"
|
||||||
|
type="button"
|
||||||
|
class="ds-tabs__tab"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="model === item.id"
|
||||||
|
:aria-controls="`ds-panel-${item.id}`"
|
||||||
|
:tabindex="model === item.id ? 0 : -1"
|
||||||
|
:disabled="item.disabled"
|
||||||
|
@click="model = item.id"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="item in items"
|
||||||
|
v-show="model === item.id"
|
||||||
|
:id="`ds-panel-${item.id}`"
|
||||||
|
:key="`panel-${item.id}`"
|
||||||
|
class="ds-tabs__panel"
|
||||||
|
role="tabpanel"
|
||||||
|
:aria-labelledby="`ds-tab-${item.id}`"
|
||||||
|
:tabindex="0"
|
||||||
|
>
|
||||||
|
<slot :name="item.id" :item="item" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DsButton from "../actions/DsButton.vue";
|
||||||
|
import DsDialog from "./DsDialog.vue";
|
||||||
|
|
||||||
|
const model = defineModel<boolean>({ default: false });
|
||||||
|
const emit = defineEmits<{ confirm: []; cancel: [] }>();
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
description: undefined,
|
||||||
|
confirmLabel: "Confirmar",
|
||||||
|
cancelLabel: "Cancelar",
|
||||||
|
danger: false,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
emit("confirm");
|
||||||
|
if (!props.loading) model.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DsDialog
|
||||||
|
v-model="model"
|
||||||
|
:title="title"
|
||||||
|
:description="description"
|
||||||
|
@cancel="emit('cancel')"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<template #footer>
|
||||||
|
<DsButton
|
||||||
|
variant="secondary"
|
||||||
|
@click="
|
||||||
|
model = false;
|
||||||
|
emit('cancel');
|
||||||
|
"
|
||||||
|
>{{ cancelLabel }}</DsButton
|
||||||
|
>
|
||||||
|
<DsButton
|
||||||
|
:variant="danger ? 'danger' : 'primary'"
|
||||||
|
:loading="loading"
|
||||||
|
@click="confirm"
|
||||||
|
>{{ confirmLabel }}</DsButton
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</DsDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from "vue";
|
||||||
|
import DsIconButton from "../actions/DsIconButton.vue";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
closeLabel?: string;
|
||||||
|
closeOnBackdrop?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
description: undefined,
|
||||||
|
closeLabel: "Cerrar diálogo",
|
||||||
|
closeOnBackdrop: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const model = defineModel<boolean>({ default: false });
|
||||||
|
const emit = defineEmits<{ close: []; cancel: [] }>();
|
||||||
|
const dialog = ref<HTMLDialogElement | null>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
const descriptionId = useId();
|
||||||
|
let previousFocus: HTMLElement | null = null;
|
||||||
|
|
||||||
|
async function syncDialog(open: boolean) {
|
||||||
|
await nextTick();
|
||||||
|
if (open && dialog.value && !dialog.value.open) {
|
||||||
|
previousFocus = document.activeElement as HTMLElement | null;
|
||||||
|
dialog.value.showModal();
|
||||||
|
} else if (!open && dialog.value?.open) {
|
||||||
|
dialog.value.close();
|
||||||
|
previousFocus?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(model, syncDialog);
|
||||||
|
onMounted(() => syncDialog(model.value));
|
||||||
|
|
||||||
|
function requestClose(cancelled = false) {
|
||||||
|
model.value = false;
|
||||||
|
if (cancelled) emit("cancel");
|
||||||
|
emit("close");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
requestClose(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBackdrop(event: MouseEvent) {
|
||||||
|
if (props.closeOnBackdrop && event.target === dialog.value)
|
||||||
|
requestClose(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (dialog.value?.open) dialog.value.close();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<dialog
|
||||||
|
ref="dialog"
|
||||||
|
class="ds-dialog"
|
||||||
|
:aria-labelledby="titleId"
|
||||||
|
:aria-describedby="description ? descriptionId : undefined"
|
||||||
|
@cancel="onCancel"
|
||||||
|
@click="onBackdrop"
|
||||||
|
>
|
||||||
|
<div class="ds-dialog__header">
|
||||||
|
<div>
|
||||||
|
<h2 :id="titleId" class="ds-dialog__title">{{ title }}</h2>
|
||||||
|
<p
|
||||||
|
v-if="description"
|
||||||
|
:id="descriptionId"
|
||||||
|
class="ds-dialog__description"
|
||||||
|
>
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DsIconButton :label="closeLabel" @click="requestClose(true)"
|
||||||
|
>×</DsIconButton
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="ds-dialog__body"><slot /></div>
|
||||||
|
<footer v-if="$slots.footer" class="ds-dialog__footer">
|
||||||
|
<slot name="footer" :close="requestClose" />
|
||||||
|
</footer>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { getCurrentInstance } from "vue";
|
||||||
|
|
||||||
|
export function useFieldIds(explicitId?: string) {
|
||||||
|
const uid = getCurrentInstance()?.uid ?? Math.random().toString(36).slice(2);
|
||||||
|
const controlId = explicitId || `ds-field-${uid}`;
|
||||||
|
return {
|
||||||
|
controlId,
|
||||||
|
descriptionId: `${controlId}-description`,
|
||||||
|
errorId: `${controlId}-error`,
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
import type { App, Plugin } from "vue";
|
||||||
|
import "./styles/index.css";
|
||||||
|
|
||||||
|
import DsAppShell from "./components/layout/DsAppShell.vue";
|
||||||
|
import DsPageHeader from "./components/layout/DsPageHeader.vue";
|
||||||
|
import DsPanel from "./components/layout/DsPanel.vue";
|
||||||
|
import DsSectionHeader from "./components/layout/DsSectionHeader.vue";
|
||||||
|
import DsStack from "./components/layout/DsStack.vue";
|
||||||
|
import DsInline from "./components/layout/DsInline.vue";
|
||||||
|
import DsDivider from "./components/layout/DsDivider.vue";
|
||||||
|
import DsButton from "./components/actions/DsButton.vue";
|
||||||
|
import DsIconButton from "./components/actions/DsIconButton.vue";
|
||||||
|
import DsButtonGroup from "./components/actions/DsButtonGroup.vue";
|
||||||
|
import DsFormActions from "./components/actions/DsFormActions.vue";
|
||||||
|
import DsFormField from "./components/forms/DsFormField.vue";
|
||||||
|
import DsTextInput from "./components/forms/DsTextInput.vue";
|
||||||
|
import DsTextarea from "./components/forms/DsTextarea.vue";
|
||||||
|
import DsPasswordInput from "./components/forms/DsPasswordInput.vue";
|
||||||
|
import DsSearchInput from "./components/forms/DsSearchInput.vue";
|
||||||
|
import DsSelect from "./components/forms/DsSelect.vue";
|
||||||
|
import DsCheckbox from "./components/forms/DsCheckbox.vue";
|
||||||
|
import DsRadioGroup from "./components/forms/DsRadioGroup.vue";
|
||||||
|
import DsSwitch from "./components/forms/DsSwitch.vue";
|
||||||
|
import DsSettingsSection from "./components/forms/DsSettingsSection.vue";
|
||||||
|
import DsSpinner from "./components/feedback/DsSpinner.vue";
|
||||||
|
import DsSkeleton from "./components/feedback/DsSkeleton.vue";
|
||||||
|
import DsProgressBar from "./components/feedback/DsProgressBar.vue";
|
||||||
|
import DsAlert from "./components/feedback/DsAlert.vue";
|
||||||
|
import DsInlineMessage from "./components/feedback/DsInlineMessage.vue";
|
||||||
|
import DsEmptyState from "./components/feedback/DsEmptyState.vue";
|
||||||
|
import DsLoadingState from "./components/feedback/DsLoadingState.vue";
|
||||||
|
import DsErrorState from "./components/feedback/DsErrorState.vue";
|
||||||
|
import DsTabs from "./components/navigation/DsTabs.vue";
|
||||||
|
import DsDialog from "./components/overlays/DsDialog.vue";
|
||||||
|
import DsConfirmDialog from "./components/overlays/DsConfirmDialog.vue";
|
||||||
|
|
||||||
|
export {
|
||||||
|
DsAppShell,
|
||||||
|
DsPageHeader,
|
||||||
|
DsPanel,
|
||||||
|
DsSectionHeader,
|
||||||
|
DsStack,
|
||||||
|
DsInline,
|
||||||
|
DsDivider,
|
||||||
|
DsButton,
|
||||||
|
DsIconButton,
|
||||||
|
DsButtonGroup,
|
||||||
|
DsFormActions,
|
||||||
|
DsFormField,
|
||||||
|
DsTextInput,
|
||||||
|
DsTextarea,
|
||||||
|
DsPasswordInput,
|
||||||
|
DsSearchInput,
|
||||||
|
DsSelect,
|
||||||
|
DsCheckbox,
|
||||||
|
DsRadioGroup,
|
||||||
|
DsSwitch,
|
||||||
|
DsSettingsSection,
|
||||||
|
DsSpinner,
|
||||||
|
DsSkeleton,
|
||||||
|
DsProgressBar,
|
||||||
|
DsAlert,
|
||||||
|
DsInlineMessage,
|
||||||
|
DsEmptyState,
|
||||||
|
DsLoadingState,
|
||||||
|
DsErrorState,
|
||||||
|
DsTabs,
|
||||||
|
DsDialog,
|
||||||
|
DsConfirmDialog,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type {
|
||||||
|
DsAlertVariant,
|
||||||
|
DsButtonVariant,
|
||||||
|
DsControlSize,
|
||||||
|
DsRadioOption,
|
||||||
|
DsSelectOption,
|
||||||
|
DsTabItem,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const components = {
|
||||||
|
DsAppShell,
|
||||||
|
DsPageHeader,
|
||||||
|
DsPanel,
|
||||||
|
DsSectionHeader,
|
||||||
|
DsStack,
|
||||||
|
DsInline,
|
||||||
|
DsDivider,
|
||||||
|
DsButton,
|
||||||
|
DsIconButton,
|
||||||
|
DsButtonGroup,
|
||||||
|
DsFormActions,
|
||||||
|
DsFormField,
|
||||||
|
DsTextInput,
|
||||||
|
DsTextarea,
|
||||||
|
DsPasswordInput,
|
||||||
|
DsSearchInput,
|
||||||
|
DsSelect,
|
||||||
|
DsCheckbox,
|
||||||
|
DsRadioGroup,
|
||||||
|
DsSwitch,
|
||||||
|
DsSettingsSection,
|
||||||
|
DsSpinner,
|
||||||
|
DsSkeleton,
|
||||||
|
DsProgressBar,
|
||||||
|
DsAlert,
|
||||||
|
DsInlineMessage,
|
||||||
|
DsEmptyState,
|
||||||
|
DsLoadingState,
|
||||||
|
DsErrorState,
|
||||||
|
DsTabs,
|
||||||
|
DsDialog,
|
||||||
|
DsConfirmDialog,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DesignSystem: Plugin = {
|
||||||
|
install(app: App) {
|
||||||
|
for (const [name, component] of Object.entries(components))
|
||||||
|
app.component(name, component);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: var(--ds-font-sans);
|
||||||
|
color: var(--ds-color-text);
|
||||||
|
background: var(--ds-color-background);
|
||||||
|
line-height: var(--ds-line-height-normal);
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 20rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
[role="button"] {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--ds-color-focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
color: var(--ds-color-text-inverse);
|
||||||
|
background: var(--ds-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
@layer components {
|
||||||
|
.ds-app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--ds-color-background);
|
||||||
|
padding: var(--ds-space-4);
|
||||||
|
}
|
||||||
|
.ds-app-shell__inner {
|
||||||
|
width: min(100%, var(--ds-page-width));
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
.ds-page-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--ds-space-5);
|
||||||
|
padding: var(--ds-space-8);
|
||||||
|
border-radius: var(--ds-radius-xl) var(--ds-radius-xl) 0 0;
|
||||||
|
background: var(--ds-color-surface-inverse);
|
||||||
|
color: var(--ds-color-text-inverse);
|
||||||
|
}
|
||||||
|
.ds-page-header--standalone {
|
||||||
|
border-radius: var(--ds-radius-xl);
|
||||||
|
}
|
||||||
|
.ds-page-header__content {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 48rem;
|
||||||
|
}
|
||||||
|
.ds-page-header__eyebrow,
|
||||||
|
.ds-section-header__eyebrow {
|
||||||
|
margin: 0 0 var(--ds-space-2);
|
||||||
|
color: var(--ds-color-accent-on-inverse);
|
||||||
|
font-size: var(--ds-font-size-xs);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.ds-page-header__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--ds-font-size-2xl);
|
||||||
|
line-height: var(--ds-line-height-tight);
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
}
|
||||||
|
.ds-page-header__description {
|
||||||
|
margin: var(--ds-space-3) 0 0;
|
||||||
|
color: var(--ds-color-text-inverse-muted);
|
||||||
|
}
|
||||||
|
.ds-panel {
|
||||||
|
border: 1px solid var(--ds-color-border);
|
||||||
|
border-radius: var(--ds-radius-lg);
|
||||||
|
background: var(--ds-color-surface);
|
||||||
|
box-shadow: var(--ds-shadow-sm);
|
||||||
|
}
|
||||||
|
.ds-panel--sm {
|
||||||
|
padding: var(--ds-space-4);
|
||||||
|
}
|
||||||
|
.ds-panel--md {
|
||||||
|
padding: var(--ds-space-6);
|
||||||
|
}
|
||||||
|
.ds-panel--lg {
|
||||||
|
padding: var(--ds-space-8);
|
||||||
|
}
|
||||||
|
.ds-panel--flat {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.ds-panel--muted {
|
||||||
|
background: var(--ds-color-surface-muted);
|
||||||
|
}
|
||||||
|
.ds-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--ds-space-4);
|
||||||
|
}
|
||||||
|
.ds-section-header__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--ds-font-size-lg);
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
}
|
||||||
|
.ds-section-header__description {
|
||||||
|
margin: var(--ds-space-1) 0 0;
|
||||||
|
color: var(--ds-color-text-muted);
|
||||||
|
font-size: var(--ds-font-size-sm);
|
||||||
|
}
|
||||||
|
.ds-divider {
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
background: var(--ds-color-border);
|
||||||
|
}
|
||||||
|
.ds-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.ds-inline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.ds-gap-1 {
|
||||||
|
gap: var(--ds-space-1);
|
||||||
|
}
|
||||||
|
.ds-gap-2 {
|
||||||
|
gap: var(--ds-space-2);
|
||||||
|
}
|
||||||
|
.ds-gap-3 {
|
||||||
|
gap: var(--ds-space-3);
|
||||||
|
}
|
||||||
|
.ds-gap-4 {
|
||||||
|
gap: var(--ds-space-4);
|
||||||
|
}
|
||||||
|
.ds-gap-6 {
|
||||||
|
gap: var(--ds-space-6);
|
||||||
|
}
|
||||||
|
.ds-gap-8 {
|
||||||
|
gap: var(--ds-space-8);
|
||||||
|
}
|
||||||
|
.ds-align-start {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.ds-align-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ds-align-end {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.ds-align-stretch {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.ds-justify-start {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.ds-justify-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.ds-justify-end {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.ds-justify-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.ds-wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-button {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: var(--ds-control-height-md);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--ds-space-2);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--ds-radius-md);
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
font-size: var(--ds-font-size-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color var(--ds-duration-fast),
|
||||||
|
background var(--ds-duration-fast),
|
||||||
|
border-color var(--ds-duration-fast),
|
||||||
|
transform var(--ds-duration-fast);
|
||||||
|
}
|
||||||
|
.ds-button:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
.ds-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
.ds-button--sm {
|
||||||
|
min-height: var(--ds-control-height-sm);
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
}
|
||||||
|
.ds-button--lg {
|
||||||
|
min-height: var(--ds-control-height-lg);
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
font-size: var(--ds-font-size-md);
|
||||||
|
}
|
||||||
|
.ds-button--block {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.ds-button--primary {
|
||||||
|
color: white;
|
||||||
|
background: var(--ds-color-primary);
|
||||||
|
box-shadow: var(--ds-shadow-sm);
|
||||||
|
}
|
||||||
|
.ds-button--primary:hover:not(:disabled) {
|
||||||
|
background: var(--ds-color-primary-hover);
|
||||||
|
}
|
||||||
|
.ds-button--secondary {
|
||||||
|
color: var(--ds-color-text);
|
||||||
|
border-color: var(--ds-color-border);
|
||||||
|
background: var(--ds-color-surface);
|
||||||
|
}
|
||||||
|
.ds-button--secondary:hover:not(:disabled) {
|
||||||
|
border-color: var(--ds-color-border-hover);
|
||||||
|
background: var(--ds-color-surface-muted);
|
||||||
|
}
|
||||||
|
.ds-button--ghost {
|
||||||
|
color: var(--ds-color-text-muted);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.ds-button--ghost:hover:not(:disabled) {
|
||||||
|
color: var(--ds-color-text);
|
||||||
|
background: var(--ds-color-surface-muted);
|
||||||
|
}
|
||||||
|
.ds-button--danger {
|
||||||
|
color: white;
|
||||||
|
background: var(--ds-color-danger);
|
||||||
|
}
|
||||||
|
.ds-button--danger:hover:not(:disabled) {
|
||||||
|
background: var(--ds-color-danger-hover);
|
||||||
|
}
|
||||||
|
.ds-icon-button {
|
||||||
|
width: var(--ds-control-height-md);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.ds-button-group {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--ds-space-2);
|
||||||
|
}
|
||||||
|
.ds-form-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--ds-space-3);
|
||||||
|
padding-top: var(--ds-space-5);
|
||||||
|
border-top: 1px solid var(--ds-color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-field {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ds-space-2);
|
||||||
|
}
|
||||||
|
.ds-field__label,
|
||||||
|
.ds-choice__label {
|
||||||
|
color: var(--ds-color-text);
|
||||||
|
font-size: var(--ds-font-size-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.ds-field__optional {
|
||||||
|
color: var(--ds-color-text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.ds-field__description,
|
||||||
|
.ds-field__message {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ds-color-text-muted);
|
||||||
|
font-size: var(--ds-font-size-xs);
|
||||||
|
}
|
||||||
|
.ds-field__message--error {
|
||||||
|
color: var(--ds-color-text-danger);
|
||||||
|
}
|
||||||
|
.ds-control-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.ds-control {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: var(--ds-control-height-md);
|
||||||
|
border: 1px solid var(--ds-color-border);
|
||||||
|
border-radius: var(--ds-radius-md);
|
||||||
|
color: var(--ds-color-text);
|
||||||
|
background: var(--ds-color-surface);
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
transition:
|
||||||
|
border-color var(--ds-duration-fast),
|
||||||
|
box-shadow var(--ds-duration-fast),
|
||||||
|
background var(--ds-duration-fast);
|
||||||
|
}
|
||||||
|
.ds-control::placeholder {
|
||||||
|
color: var(--ds-color-placeholder);
|
||||||
|
}
|
||||||
|
.ds-control:hover:not(:disabled) {
|
||||||
|
border-color: var(--ds-color-border-hover);
|
||||||
|
}
|
||||||
|
.ds-control:focus {
|
||||||
|
outline: 0;
|
||||||
|
border-color: var(--ds-color-primary);
|
||||||
|
box-shadow: 0 0 0 3px
|
||||||
|
color-mix(in srgb, var(--ds-color-focus-ring) 28%, transparent);
|
||||||
|
}
|
||||||
|
.ds-control[aria-invalid="true"] {
|
||||||
|
border-color: var(--ds-color-border-danger);
|
||||||
|
}
|
||||||
|
.ds-control:disabled {
|
||||||
|
color: var(--ds-color-text-muted);
|
||||||
|
background: var(--ds-color-surface-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
textarea.ds-control {
|
||||||
|
min-height: 8rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
select.ds-control {
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
.ds-control--with-leading {
|
||||||
|
padding-left: 2.5rem;
|
||||||
|
}
|
||||||
|
.ds-control--with-trailing {
|
||||||
|
padding-right: 3rem;
|
||||||
|
}
|
||||||
|
.ds-control-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0.8rem;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
color: var(--ds-color-text-muted);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.ds-control-action {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 0.35rem;
|
||||||
|
min-width: 2.25rem;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--ds-radius-sm);
|
||||||
|
color: var(--ds-color-text-muted);
|
||||||
|
background: transparent;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ds-control-action:hover {
|
||||||
|
color: var(--ds-color-text);
|
||||||
|
background: var(--ds-color-surface-muted);
|
||||||
|
}
|
||||||
|
.ds-choice {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--ds-space-3);
|
||||||
|
}
|
||||||
|
.ds-choice input {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
accent-color: var(--ds-color-primary);
|
||||||
|
}
|
||||||
|
.ds-choice__content {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ds-space-1);
|
||||||
|
}
|
||||||
|
.ds-choice__description {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ds-color-text-muted);
|
||||||
|
font-size: var(--ds-font-size-xs);
|
||||||
|
}
|
||||||
|
.ds-radio-group {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.ds-radio-group__legend {
|
||||||
|
margin-bottom: var(--ds-space-2);
|
||||||
|
font-size: var(--ds-font-size-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.ds-radio-group__options {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ds-space-3);
|
||||||
|
}
|
||||||
|
.ds-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--ds-space-3);
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ds-switch:disabled {
|
||||||
|
opacity: 0.62;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.ds-switch__track {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--ds-color-control-inactive);
|
||||||
|
transition: background var(--ds-duration-fast);
|
||||||
|
}
|
||||||
|
.ds-switch[aria-checked="true"] .ds-switch__track {
|
||||||
|
background: var(--ds-color-primary);
|
||||||
|
}
|
||||||
|
.ds-switch__thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.1875rem;
|
||||||
|
left: 0.1875rem;
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
box-shadow: var(--ds-shadow-sm);
|
||||||
|
transition: transform var(--ds-duration-fast);
|
||||||
|
}
|
||||||
|
.ds-switch[aria-checked="true"] .ds-switch__thumb {
|
||||||
|
transform: translateX(1.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-right-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: ds-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
.ds-spinner--sm {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
.ds-spinner--lg {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
.ds-skeleton {
|
||||||
|
display: block;
|
||||||
|
min-height: 1rem;
|
||||||
|
border-radius: var(--ds-radius-sm);
|
||||||
|
background: var(--ds-color-skeleton);
|
||||||
|
animation: ds-pulse 1.4s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
.ds-progress {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ds-space-2);
|
||||||
|
}
|
||||||
|
.ds-progress__meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--ds-space-4);
|
||||||
|
font-size: var(--ds-font-size-sm);
|
||||||
|
}
|
||||||
|
.ds-progress__track {
|
||||||
|
overflow: hidden;
|
||||||
|
height: 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--ds-color-surface-muted);
|
||||||
|
}
|
||||||
|
.ds-progress__bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--ds-color-primary);
|
||||||
|
transition: width var(--ds-duration-normal);
|
||||||
|
}
|
||||||
|
.ds-alert {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--ds-space-3);
|
||||||
|
padding: var(--ds-space-4);
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: var(--ds-radius-md);
|
||||||
|
}
|
||||||
|
.ds-alert--info {
|
||||||
|
color: var(--ds-color-info-text);
|
||||||
|
border-color: var(--ds-color-info-border);
|
||||||
|
background: var(--ds-color-info-surface);
|
||||||
|
}
|
||||||
|
.ds-alert--success {
|
||||||
|
color: var(--ds-color-success-text);
|
||||||
|
border-color: var(--ds-color-success-border);
|
||||||
|
background: var(--ds-color-success-surface);
|
||||||
|
}
|
||||||
|
.ds-alert--warning {
|
||||||
|
color: var(--ds-color-warning-text);
|
||||||
|
border-color: var(--ds-color-warning-border);
|
||||||
|
background: var(--ds-color-warning-surface);
|
||||||
|
}
|
||||||
|
.ds-alert--error {
|
||||||
|
color: var(--ds-color-error-text);
|
||||||
|
border-color: var(--ds-color-error-border);
|
||||||
|
background: var(--ds-color-error-surface);
|
||||||
|
}
|
||||||
|
.ds-alert__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--ds-font-size-sm);
|
||||||
|
}
|
||||||
|
.ds-alert__body {
|
||||||
|
margin: var(--ds-space-1) 0 0;
|
||||||
|
font-size: var(--ds-font-size-sm);
|
||||||
|
}
|
||||||
|
.ds-inline-message {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ds-color-text-muted);
|
||||||
|
font-size: var(--ds-font-size-sm);
|
||||||
|
}
|
||||||
|
.ds-inline-message--error {
|
||||||
|
color: var(--ds-color-text-danger);
|
||||||
|
}
|
||||||
|
.ds-state {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: var(--ds-space-3);
|
||||||
|
padding: var(--ds-space-10) var(--ds-space-6);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ds-state__icon {
|
||||||
|
display: grid;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--ds-color-primary);
|
||||||
|
background: var(--ds-color-info-surface);
|
||||||
|
}
|
||||||
|
.ds-state__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--ds-font-size-lg);
|
||||||
|
}
|
||||||
|
.ds-state__description {
|
||||||
|
max-width: 34rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ds-color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-tabs__list {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--ds-space-1);
|
||||||
|
overflow-x: auto;
|
||||||
|
border-bottom: 1px solid var(--ds-color-border);
|
||||||
|
}
|
||||||
|
.ds-tabs__tab {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
color: var(--ds-color-text-muted);
|
||||||
|
background: transparent;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ds-tabs__tab:hover:not(:disabled) {
|
||||||
|
color: var(--ds-color-text);
|
||||||
|
background: var(--ds-color-surface-muted);
|
||||||
|
}
|
||||||
|
.ds-tabs__tab[aria-selected="true"] {
|
||||||
|
color: var(--ds-color-primary);
|
||||||
|
border-bottom-color: var(--ds-color-primary);
|
||||||
|
}
|
||||||
|
.ds-tabs__tab:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.ds-tabs__panel {
|
||||||
|
padding-top: var(--ds-space-6);
|
||||||
|
}
|
||||||
|
.ds-dialog {
|
||||||
|
width: min(calc(100% - 2rem), 34rem);
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--ds-color-border);
|
||||||
|
border-radius: var(--ds-radius-xl);
|
||||||
|
padding: 0;
|
||||||
|
color: var(--ds-color-text);
|
||||||
|
background: var(--ds-color-surface);
|
||||||
|
box-shadow: 0 24px 64px -24px rgb(15 23 42 / 0.45);
|
||||||
|
}
|
||||||
|
.ds-dialog::backdrop {
|
||||||
|
background: rgb(15 23 42 / 0.58);
|
||||||
|
}
|
||||||
|
.ds-dialog__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--ds-space-4);
|
||||||
|
padding: var(--ds-space-6);
|
||||||
|
border-bottom: 1px solid var(--ds-color-border);
|
||||||
|
}
|
||||||
|
.ds-dialog__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--ds-font-size-xl);
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
.ds-dialog__description {
|
||||||
|
margin: var(--ds-space-2) 0 0;
|
||||||
|
color: var(--ds-color-text-muted);
|
||||||
|
font-size: var(--ds-font-size-sm);
|
||||||
|
}
|
||||||
|
.ds-dialog__body {
|
||||||
|
padding: var(--ds-space-6);
|
||||||
|
}
|
||||||
|
.ds-dialog__footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--ds-space-3);
|
||||||
|
padding: var(--ds-space-4) var(--ds-space-6);
|
||||||
|
border-top: 1px solid var(--ds-color-border);
|
||||||
|
background: var(--ds-color-surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ds-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes ds-pulse {
|
||||||
|
to {
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 48rem) {
|
||||||
|
.ds-app-shell {
|
||||||
|
padding: var(--ds-space-8);
|
||||||
|
}
|
||||||
|
.ds-page-header {
|
||||||
|
padding: var(--ds-space-8) var(--ds-space-10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@import "tailwindcss" source(none);
|
||||||
|
@import "../tokens/index.css";
|
||||||
|
@import "./base.css";
|
||||||
|
@import "./components.css";
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
:root,
|
||||||
|
[data-ds-theme="light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--ds-color-background: #f8fafc;
|
||||||
|
--ds-color-surface: #ffffff;
|
||||||
|
--ds-color-surface-muted: #f1f5f9;
|
||||||
|
--ds-color-surface-inverse: #0f172a;
|
||||||
|
--ds-color-text: #0f172a;
|
||||||
|
--ds-color-text-muted: #526178;
|
||||||
|
--ds-color-text-inverse: #ffffff;
|
||||||
|
--ds-color-text-inverse-muted: #cbd5e1;
|
||||||
|
--ds-color-text-danger: #b91c1c;
|
||||||
|
--ds-color-accent-on-inverse: #7dd3fc;
|
||||||
|
--ds-color-placeholder: #94a3b8;
|
||||||
|
--ds-color-control-inactive: #94a3b8;
|
||||||
|
--ds-color-skeleton: #e2e8f0;
|
||||||
|
--ds-color-border: #d7e0ea;
|
||||||
|
--ds-color-border-hover: #94a3b8;
|
||||||
|
--ds-color-border-danger: #dc2626;
|
||||||
|
--ds-color-focus-ring: #38bdf8;
|
||||||
|
--ds-color-primary: #0284c7;
|
||||||
|
--ds-color-primary-hover: #0369a1;
|
||||||
|
--ds-color-primary-active: #075985;
|
||||||
|
--ds-color-primary-disabled: #bae6fd;
|
||||||
|
--ds-color-danger: #dc2626;
|
||||||
|
--ds-color-danger-hover: #b91c1c;
|
||||||
|
--ds-color-info-surface: #f0f9ff;
|
||||||
|
--ds-color-info-border: #7dd3fc;
|
||||||
|
--ds-color-info-text: #075985;
|
||||||
|
--ds-color-success-surface: #f0fdf4;
|
||||||
|
--ds-color-success-border: #86efac;
|
||||||
|
--ds-color-success-text: #166534;
|
||||||
|
--ds-color-warning-surface: #fffbeb;
|
||||||
|
--ds-color-warning-border: #fcd34d;
|
||||||
|
--ds-color-warning-text: #92400e;
|
||||||
|
--ds-color-error-surface: #fef2f2;
|
||||||
|
--ds-color-error-border: #fca5a5;
|
||||||
|
--ds-color-error-text: #991b1b;
|
||||||
|
}
|
||||||
Vendored
+35
@@ -0,0 +1,35 @@
|
|||||||
|
:root {
|
||||||
|
--ds-font-sans:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
|
"Segoe UI", sans-serif;
|
||||||
|
--ds-font-size-xs: 0.75rem;
|
||||||
|
--ds-font-size-sm: 0.875rem;
|
||||||
|
--ds-font-size-md: 1rem;
|
||||||
|
--ds-font-size-lg: 1.125rem;
|
||||||
|
--ds-font-size-xl: 1.5rem;
|
||||||
|
--ds-font-size-2xl: clamp(1.75rem, 4vw, 2.25rem);
|
||||||
|
--ds-line-height-tight: 1.2;
|
||||||
|
--ds-line-height-normal: 1.5;
|
||||||
|
--ds-space-1: 0.25rem;
|
||||||
|
--ds-space-2: 0.5rem;
|
||||||
|
--ds-space-3: 0.75rem;
|
||||||
|
--ds-space-4: 1rem;
|
||||||
|
--ds-space-5: 1.25rem;
|
||||||
|
--ds-space-6: 1.5rem;
|
||||||
|
--ds-space-8: 2rem;
|
||||||
|
--ds-space-10: 2.5rem;
|
||||||
|
--ds-space-12: 3rem;
|
||||||
|
--ds-radius-sm: 0.375rem;
|
||||||
|
--ds-radius-md: 0.625rem;
|
||||||
|
--ds-radius-lg: 0.875rem;
|
||||||
|
--ds-radius-xl: 1.125rem;
|
||||||
|
--ds-shadow-sm: 0 1px 2px rgb(15 23 42 / 0.06);
|
||||||
|
--ds-shadow-md: 0 10px 24px -14px rgb(15 23 42 / 0.24);
|
||||||
|
--ds-control-height-sm: 2.25rem;
|
||||||
|
--ds-control-height-md: 2.75rem;
|
||||||
|
--ds-control-height-lg: 3rem;
|
||||||
|
--ds-duration-fast: 140ms;
|
||||||
|
--ds-duration-normal: 200ms;
|
||||||
|
--ds-z-overlay: 50;
|
||||||
|
--ds-page-width: 72rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@import "./color.css";
|
||||||
|
@import "./foundation.css";
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-sans: var(--ds-font-sans);
|
||||||
|
--color-ds-background: var(--ds-color-background);
|
||||||
|
--color-ds-surface: var(--ds-color-surface);
|
||||||
|
--color-ds-text: var(--ds-color-text);
|
||||||
|
--color-ds-primary: var(--ds-color-primary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export type DsButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
||||||
|
export type DsControlSize = "sm" | "md" | "lg";
|
||||||
|
export type DsAlertVariant = "info" | "success" | "warning" | "error";
|
||||||
|
|
||||||
|
export interface DsSelectOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DsRadioOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
description?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DsTabItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { render } from "@testing-library/vue";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import DsButton from "../src/components/actions/DsButton.vue";
|
||||||
|
|
||||||
|
describe("DsButton", () => {
|
||||||
|
it("preserves its accessible name while loading and prevents duplicate actions", async () => {
|
||||||
|
const view = render(DsButton, {
|
||||||
|
props: { loading: true },
|
||||||
|
slots: { default: "Guardar" },
|
||||||
|
});
|
||||||
|
const button = view.getByRole("button", {
|
||||||
|
name: /guardar/i,
|
||||||
|
}) as HTMLButtonElement;
|
||||||
|
expect(button.disabled).toBe(true);
|
||||||
|
expect(button.getAttribute("aria-busy")).toBe("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { fireEvent, render, waitFor } from "@testing-library/vue";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import DsDialog from "../src/components/overlays/DsDialog.vue";
|
||||||
|
|
||||||
|
describe("DsDialog", () => {
|
||||||
|
it("opens modally, has an accessible name and handles Escape cancellation", async () => {
|
||||||
|
const view = render(DsDialog, {
|
||||||
|
props: {
|
||||||
|
modelValue: true,
|
||||||
|
title: "Editar perfil",
|
||||||
|
description: "Actualiza tus datos.",
|
||||||
|
},
|
||||||
|
slots: { default: "Contenido" },
|
||||||
|
});
|
||||||
|
const dialog = view.getByRole("dialog", {
|
||||||
|
hidden: true,
|
||||||
|
}) as HTMLDialogElement;
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(dialog.open || dialog.hasAttribute("open")).toBe(true),
|
||||||
|
);
|
||||||
|
expect(view.getByRole("dialog", { name: "Editar perfil" })).toBe(dialog);
|
||||||
|
await fireEvent(dialog, new Event("cancel", { cancelable: true }));
|
||||||
|
expect(view.emitted()["update:modelValue"]?.at(-1)).toEqual([false]);
|
||||||
|
expect(view.emitted().cancel).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { fireEvent, render } from "@testing-library/vue";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import DsCheckbox from "../src/components/forms/DsCheckbox.vue";
|
||||||
|
import DsTextInput from "../src/components/forms/DsTextInput.vue";
|
||||||
|
|
||||||
|
describe("form controls", () => {
|
||||||
|
it("associates labels, help, errors and updates v-model", async () => {
|
||||||
|
const view = render(DsTextInput, {
|
||||||
|
props: {
|
||||||
|
label: "Correo",
|
||||||
|
description: "Usaremos tu correo de trabajo.",
|
||||||
|
error: "El correo no es válido.",
|
||||||
|
modelValue: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const input = view.getByLabelText("Correo (Opcional)") as HTMLInputElement;
|
||||||
|
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||||
|
expect(input.getAttribute("aria-describedby")).toContain("description");
|
||||||
|
expect(input.getAttribute("aria-describedby")).toContain("error");
|
||||||
|
await fireEvent.update(input, "omar@example.com");
|
||||||
|
expect(view.emitted()["update:modelValue"]?.[0]).toEqual([
|
||||||
|
"omar@example.com",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports a boolean v-model and disabled state", async () => {
|
||||||
|
const view = render(DsCheckbox, {
|
||||||
|
props: { label: "Acepto", modelValue: false },
|
||||||
|
});
|
||||||
|
await fireEvent.click(view.getByRole("checkbox", { name: "Acepto" }));
|
||||||
|
expect(view.emitted()["update:modelValue"]?.[0]).toEqual([true]);
|
||||||
|
await view.rerender({ disabled: true });
|
||||||
|
expect((view.getByRole("checkbox") as HTMLInputElement).disabled).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import pkg from "../package.json";
|
||||||
|
|
||||||
|
describe("package contract", () => {
|
||||||
|
it("publishes an explicit stylesheet and preserves CSS side effects", () => {
|
||||||
|
expect(pkg.exports["./style.css"]).toBe("./dist/design-system.css");
|
||||||
|
expect(pkg.sideEffects).toContain("**/*.css");
|
||||||
|
expect(pkg.peerDependencies.vue).toMatch(/^\^3/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import * as designSystem from "../src";
|
||||||
|
|
||||||
|
describe("public API", () => {
|
||||||
|
it("exports the documented components and plugin", () => {
|
||||||
|
expect(designSystem.DsButton).toBeDefined();
|
||||||
|
expect(designSystem.DsTextInput).toBeDefined();
|
||||||
|
expect(designSystem.DsTabs).toBeDefined();
|
||||||
|
expect(designSystem.DsDialog).toBeDefined();
|
||||||
|
expect(designSystem.DesignSystem).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { afterEach } from "vitest";
|
||||||
|
import { cleanup } from "@testing-library/vue";
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
HTMLDialogElement.prototype.showModal = function showModal() {
|
||||||
|
this.setAttribute("open", "");
|
||||||
|
};
|
||||||
|
|
||||||
|
HTMLDialogElement.prototype.close = function close() {
|
||||||
|
this.removeAttribute("open");
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { fireEvent, render } from "@testing-library/vue";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import DsTabs from "../src/components/navigation/DsTabs.vue";
|
||||||
|
|
||||||
|
describe("DsTabs", () => {
|
||||||
|
it("uses arrow, Home and End keys with automatic activation", async () => {
|
||||||
|
const view = render(DsTabs, {
|
||||||
|
props: {
|
||||||
|
label: "Cuenta",
|
||||||
|
modelValue: "profile",
|
||||||
|
items: [
|
||||||
|
{ id: "profile", label: "Perfil" },
|
||||||
|
{ id: "security", label: "Seguridad" },
|
||||||
|
{ id: "billing", label: "Facturación" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
slots: {
|
||||||
|
profile: "Perfil",
|
||||||
|
security: "Seguridad",
|
||||||
|
billing: "Facturación",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const profile = view.getByRole("tab", { name: "Perfil" });
|
||||||
|
await fireEvent.keyDown(profile, { key: "ArrowRight" });
|
||||||
|
expect(view.emitted()["update:modelValue"]?.at(-1)).toEqual(["security"]);
|
||||||
|
await view.rerender({ modelValue: "security" });
|
||||||
|
await fireEvent.keyDown(view.getByRole("tab", { name: "Seguridad" }), {
|
||||||
|
key: "End",
|
||||||
|
});
|
||||||
|
expect(view.emitted()["update:modelValue"]?.at(-1)).toEqual(["billing"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vitest/globals"],
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"paths": {
|
||||||
|
"@omaresquivel/design-system": ["./src/index.ts"],
|
||||||
|
"@omaresquivel/design-system/style.css": ["./src/styles/index.css"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.vue",
|
||||||
|
"playground/**/*.ts",
|
||||||
|
"playground/**/*.vue",
|
||||||
|
"tests/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts", "vite.playground.config.ts", "vitest.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import dts from "vite-plugin-dts";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
tailwindcss(),
|
||||||
|
dts({ tsconfigPath: "./tsconfig.app.json", include: ["src"] }),
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: "src/index.ts",
|
||||||
|
formats: ["es"],
|
||||||
|
fileName: "index",
|
||||||
|
cssFileName: "design-system",
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ["vue"],
|
||||||
|
output: {
|
||||||
|
globals: { vue: "Vue" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: "playground",
|
||||||
|
plugins: [vue(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: [
|
||||||
|
{
|
||||||
|
find: "@omaresquivel/design-system/style.css",
|
||||||
|
replacement: fileURLToPath(
|
||||||
|
new URL("./src/styles/index.css", import.meta.url),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
find: "@omaresquivel/design-system",
|
||||||
|
replacement: fileURLToPath(new URL("./src/index.ts", import.meta.url)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "../dist-playground",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./tests/setup.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user