First commit

This commit is contained in:
2026-08-10 04:29:24 -06:00
commit 26851fa750
71 changed files with 8333 additions and 0 deletions
+17
View File
@@ -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
+2
View File
@@ -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}
+6
View File
@@ -0,0 +1,6 @@
dist
dist-playground
coverage
package-lock.json
*.png
prompt.md
+66
View File
@@ -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.
+41
View File
@@ -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.
+83
View File
@@ -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`.
+50
View File
@@ -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 1018 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-*`.
+87
View File
@@ -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.
+49
View File
@@ -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.
+22
View File
@@ -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.
+4959
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -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"
}
}
+16
View File
@@ -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>
+490
View File
@@ -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>
+6
View File
@@ -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");
+84
View File
@@ -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);
}
}
+41
View File
@@ -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>
+7
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
<template>
<div class="ds-form-actions"><slot /></div>
</template>
+35
View File
@@ -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>
+24
View File
@@ -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>
+14
View File
@@ -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>
+12
View File
@@ -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>
+38
View File
@@ -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>
+18
View File
@@ -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>
+12
View File
@@ -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>
+60
View File
@@ -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>
+60
View File
@@ -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>
+46
View File
@@ -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>
+66
View File
@@ -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>
+41
View File
@@ -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>
+65
View File
@@ -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>
+34
View File
@@ -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>
+70
View File
@@ -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>
+57
View File
@@ -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>
+13
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
<template><hr class="ds-divider" /></template>
+27
View File
@@ -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>
+27
View File
@@ -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>
+24
View File
@@ -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>
+16
View File
@@ -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>
+20
View File
@@ -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>
+87
View File
@@ -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>
+89
View File
@@ -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>
+11
View File
@@ -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`,
};
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+121
View File
@@ -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);
},
};
+55
View File
@@ -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;
}
}
+632
View File
@@ -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;
}
}
}
+4
View File
@@ -0,0 +1,4 @@
@import "tailwindcss" source(none);
@import "../tokens/index.css";
@import "./base.css";
@import "./components.css";
+39
View File
@@ -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;
}
+35
View File
@@ -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;
}
+10
View File
@@ -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);
}
+22
View File
@@ -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;
}
+17
View File
@@ -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");
});
});
+26
View File
@@ -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);
});
});
+37
View File
@@ -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,
);
});
});
+10
View File
@@ -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/);
});
});
+12
View File
@@ -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();
});
});
+12
View File
@@ -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");
};
+32
View File
@@ -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"]);
});
});
+29
View File
@@ -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"
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+11
View File
@@ -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"]
}
+26
View File
@@ -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" },
},
},
},
});
+27
View File
@@ -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,
},
});
+11
View File
@@ -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"],
},
});