kaizen-lib 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/package.json +52 -0
- package/src/tokens/defaults.css +185 -0
- package/src/tokens/index.ts +157 -0
- package/src/ui/IconButton.tsx +173 -0
- package/src/ui/index.ts +2 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nicolás Sandobal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# kaizen-lib
|
|
2
|
+
|
|
3
|
+
Tokens y componentes compartidos entre los fronts de Kaizen. Un solo paquete, con
|
|
4
|
+
subpath exports: cada parte se importa por separado y el resto no entra al bundle.
|
|
5
|
+
|
|
6
|
+
Se publica en **TypeScript crudo**, sin build. Los consumidores son Next + React 19 +
|
|
7
|
+
styled-components con `moduleResolution: "bundler"`, así que lo transpilan ellos.
|
|
8
|
+
|
|
9
|
+
## Estado
|
|
10
|
+
|
|
11
|
+
| Subpath | Qué es | Estado |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `kaizen-lib/tokens` | Referencias `var(--kz-*)` tipadas | listo |
|
|
14
|
+
| `kaizen-lib/tokens.css` | Valores por defecto, tema oscuro y claro | listo |
|
|
15
|
+
| `kaizen-lib/ui` | Componentes (por ahora `IconButton`) | en curso |
|
|
16
|
+
|
|
17
|
+
## Instalación
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install kaizen-lib
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`react` y `styled-components` son **peerDependencies** y las pone el consumidor. Si
|
|
24
|
+
alguna vez quedan como `dependencies`, el consumidor termina con dos instancias de
|
|
25
|
+
styled-components y se rompen el theming y el SSR de un modo difícil de diagnosticar.
|
|
26
|
+
|
|
27
|
+
En `next.config`:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
const nextConfig = { transpilePackages: ['kaizen-lib'] };
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Tokens
|
|
34
|
+
|
|
35
|
+
El contrato es uno solo: **los nombres**. Los valores los define cada producto.
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import styled from 'styled-components';
|
|
39
|
+
import { surface, fg, space, radius } from 'kaizen-lib/tokens';
|
|
40
|
+
|
|
41
|
+
const Card = styled.div`
|
|
42
|
+
background: ${surface[2]};
|
|
43
|
+
color: ${fg.DEFAULT};
|
|
44
|
+
padding: ${space[4]};
|
|
45
|
+
border-radius: ${radius.md};
|
|
46
|
+
`;
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```css
|
|
50
|
+
/* globals.css — opcional: valores por defecto, después los pisás con los tuyos */
|
|
51
|
+
@import 'kaizen-lib/tokens.css';
|
|
52
|
+
|
|
53
|
+
:root {
|
|
54
|
+
--kz-accent: #e8318f; /* la marca del producto */
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Adopción parcial.** Si un producto redefine sólo algunos tokens, el resto cae al
|
|
59
|
+
valor por defecto. Si no importa `tokens.css` y no define un token que un componente
|
|
60
|
+
usa, el estilo simplemente no se aplica — no hay error. Por eso los tests exigen que
|
|
61
|
+
todo token de la API esté definido en `defaults.css`.
|
|
62
|
+
|
|
63
|
+
**Tema.** Oscuro en `:root`; claro cuando `<html>` lleva la clase `light`. Es el
|
|
64
|
+
mecanismo de tutipoker-school-front, el único consumidor con toggle real.
|
|
65
|
+
|
|
66
|
+
**Elevación.** Cuatro niveles de uso fijo: `0` página, `1` panel, `2` card, `3` overlay.
|
|
67
|
+
`sunken` es el pozo (inputs, tracks) y va más oscuro que su contenedor.
|
|
68
|
+
|
|
69
|
+
## Componentes
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
import { IconButton } from 'kaizen-lib/ui';
|
|
73
|
+
|
|
74
|
+
<IconButton label="Cambiar tema" size="lg" onClick={alternar}>
|
|
75
|
+
<IconoLuna />
|
|
76
|
+
</IconButton>
|
|
77
|
+
|
|
78
|
+
<IconButton as={Link} href="/notificaciones" label="Notificaciones, 3 sin leer" badge={3}>
|
|
79
|
+
<IconoCampana />
|
|
80
|
+
</IconButton>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**`IconButton`** es el botón redondo de sólo ícono de una topbar. `label` es obligatorio
|
|
84
|
+
(es el nombre accesible). Con `badge` dibuja un contador, y ese número es decorativo: la
|
|
85
|
+
cuenta va dentro del `label`, porque el idioma no es asunto de la librería. Con `as` toma
|
|
86
|
+
otro elemento —un `<Link>` de Next— sin que la librería dependa de Next. `size="lg"` es el
|
|
87
|
+
área táctil de 44px. Con `motivo` queda bloqueado pero enfocable, para poder mostrar por
|
|
88
|
+
qué.
|
|
89
|
+
|
|
90
|
+
La librería no trae íconos: cada producto usa los suyos.
|
|
91
|
+
|
|
92
|
+
## Desarrollo
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npm run check # typecheck + tests
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Los tests verifican dos cosas:
|
|
99
|
+
|
|
100
|
+
- **Paridad** (`tokens.test.ts`): todo token de la API está definido en el CSS y todo
|
|
101
|
+
token del CSS está en la API. El tema claro no inventa tokens ni deja colores sin
|
|
102
|
+
redefinir.
|
|
103
|
+
- **Contraste** (`defaults.test.ts`): los pares texto/fondo de la paleta por defecto
|
|
104
|
+
cumplen WCAG 2.1 (4.5:1 texto, 3:1 no textual), en los dos temas.
|
|
105
|
+
|
|
106
|
+
Si cambiás un color, el número lo da la suite, no el ojo.
|
|
107
|
+
|
|
108
|
+
## Licencia
|
|
109
|
+
|
|
110
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kaizen-lib",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Tokens y componentes compartidos entre los fronts de Kaizen.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Nicolás Sandobal",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/nikosanarg/kaizen-lib.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": [
|
|
13
|
+
"*.css"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"!src/**/*.test.*",
|
|
18
|
+
"!src/**/testing"
|
|
19
|
+
],
|
|
20
|
+
"exports": {
|
|
21
|
+
"./tokens": "./src/tokens/index.ts",
|
|
22
|
+
"./tokens.css": "./src/tokens/defaults.css",
|
|
23
|
+
"./ui": "./src/ui/index.ts",
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:watch": "vitest",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"check": "npm run typecheck && npm run test"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"react": ">=19",
|
|
34
|
+
"styled-components": ">=6"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@testing-library/react": "16.3.2",
|
|
38
|
+
"@testing-library/user-event": "14.6.1",
|
|
39
|
+
"@types/node": "24.10.1",
|
|
40
|
+
"@types/react": "19.2.7",
|
|
41
|
+
"@types/react-dom": "19.2.3",
|
|
42
|
+
"jsdom": "29.1.1",
|
|
43
|
+
"react": "19.2.4",
|
|
44
|
+
"react-dom": "19.2.4",
|
|
45
|
+
"styled-components": "6.4.2",
|
|
46
|
+
"typescript": "5.9.3",
|
|
47
|
+
"vitest": "5.0.1"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=20"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Valores por defecto de los tokens de kaizen-lib.
|
|
3
|
+
|
|
4
|
+
Importar este archivo es OPCIONAL y sirve para dos cosas: arrancar un
|
|
5
|
+
proyecto sin definir nada, y tener un valor de respaldo para cada token
|
|
6
|
+
mientras la adopción es parcial. Un producto con identidad propia redefine
|
|
7
|
+
los que le importan en su propio `globals.css`, después de este import.
|
|
8
|
+
|
|
9
|
+
La paleta es deliberadamente neutra: gris azulado y un azul de acento. No es
|
|
10
|
+
la marca de ningún producto y no debería serlo — si se pareciera a alguno, el
|
|
11
|
+
resto heredaría esa identidad sin haberla elegido.
|
|
12
|
+
|
|
13
|
+
Tema: oscuro en `:root`, claro cuando `<html>` lleva la clase `light`. El
|
|
14
|
+
mecanismo sale de tutipoker-school-front, el único consumidor con un toggle
|
|
15
|
+
real (`context/useDarkMode.tsx`), para no inventar un tercero.
|
|
16
|
+
|
|
17
|
+
Los pares de contraste de estos valores los verifica `defaults.test.ts`
|
|
18
|
+
contra los mínimos de WCAG 2.1. Si cambiás un color, corré los tests: el
|
|
19
|
+
número lo da la suite, no el ojo.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
:root {
|
|
23
|
+
/* ---- Superficies -------------------------------------------------------
|
|
24
|
+
Cuatro niveles de elevación con uso fijo; no se mezclan libremente. El
|
|
25
|
+
modelo viene de kaizen-next, que es el que lo dejó explícito.
|
|
26
|
+
0 fondo de página
|
|
27
|
+
1 panel o sección
|
|
28
|
+
2 card dentro de un panel
|
|
29
|
+
3 overlay: modal, popover, dropdown
|
|
30
|
+
`sunken` es el pozo: va más oscuro que su contenedor (inputs, tracks,
|
|
31
|
+
wells), así que no es un quinto nivel de la escala sino su opuesto. */
|
|
32
|
+
--kz-surface-0: #0b0d10;
|
|
33
|
+
--kz-surface-1: #14171c;
|
|
34
|
+
--kz-surface-2: #1b1f26;
|
|
35
|
+
--kz-surface-3: #232830;
|
|
36
|
+
--kz-surface-sunken: #07080a;
|
|
37
|
+
--kz-surface-hover: #262c35;
|
|
38
|
+
--kz-surface-active: #2e3540;
|
|
39
|
+
|
|
40
|
+
/* ---- Texto -------------------------------------------------------------
|
|
41
|
+
Tres niveles. `subtle` es el piso: por debajo de eso no hay token porque
|
|
42
|
+
no habría contraste suficiente, y el que lo necesite está pidiendo texto
|
|
43
|
+
ilegible. El texto que va ENCIMA de un color de acento o semántico no es
|
|
44
|
+
un nivel de `fg`: cada tono trae el suyo (`--kz-accent-fg`, `--kz-danger-fg`). */
|
|
45
|
+
--kz-fg: #e8ecf1;
|
|
46
|
+
--kz-fg-muted: #a3adba;
|
|
47
|
+
--kz-fg-subtle: #8792a1;
|
|
48
|
+
|
|
49
|
+
/* ---- Bordes y foco ----------------------------------------------------- */
|
|
50
|
+
--kz-hairline: #262b33;
|
|
51
|
+
--kz-hairline-strong: #3a424e;
|
|
52
|
+
/* Anillo de foco. Es un elemento no textual: el mínimo que le aplica es
|
|
53
|
+
3:1, no 4.5:1. */
|
|
54
|
+
--kz-ring: #5b9dff;
|
|
55
|
+
|
|
56
|
+
/* ---- Acento ------------------------------------------------------------
|
|
57
|
+
`soft` es el fondo lavado para chips y estados seleccionados; el texto que
|
|
58
|
+
va encima de `soft` es el acento mismo, no `accent-fg`. */
|
|
59
|
+
--kz-accent: #5a9df8;
|
|
60
|
+
--kz-accent-hover: #78b0fa;
|
|
61
|
+
--kz-accent-active: #3b82f6;
|
|
62
|
+
--kz-accent-soft: rgba(90, 157, 248, 0.16);
|
|
63
|
+
--kz-accent-fg: #06111f;
|
|
64
|
+
|
|
65
|
+
/* ---- Tonos semánticos --------------------------------------------------
|
|
66
|
+
Cuatro tonos × tres ranuras. `danger` y `warning` ganan sobre `error` y
|
|
67
|
+
`warn` por ser los nombres que más consumidores ya usaban. */
|
|
68
|
+
--kz-success: #3ad07d;
|
|
69
|
+
--kz-success-soft: rgba(58, 208, 125, 0.16);
|
|
70
|
+
--kz-success-fg: #04180d;
|
|
71
|
+
|
|
72
|
+
--kz-danger: #f87171;
|
|
73
|
+
--kz-danger-soft: rgba(248, 113, 113, 0.16);
|
|
74
|
+
--kz-danger-fg: #1d0505;
|
|
75
|
+
|
|
76
|
+
--kz-warning: #f0ab3e;
|
|
77
|
+
--kz-warning-soft: rgba(240, 171, 62, 0.16);
|
|
78
|
+
--kz-warning-fg: #1c1002;
|
|
79
|
+
|
|
80
|
+
--kz-info: #56c4f5;
|
|
81
|
+
--kz-info-soft: rgba(86, 196, 245, 0.16);
|
|
82
|
+
--kz-info-fg: #04161f;
|
|
83
|
+
|
|
84
|
+
/* ---- Sombras ----------------------------------------------------------- */
|
|
85
|
+
--kz-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
|
86
|
+
--kz-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.45);
|
|
87
|
+
--kz-shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5);
|
|
88
|
+
--kz-shadow-xl: 0 24px 60px rgba(0, 0, 0, 0.6);
|
|
89
|
+
|
|
90
|
+
/* ---- Espaciado ---------------------------------------------------------
|
|
91
|
+
Escala en pasos de 4px. Los saltos (no hay 7, 9, 11) son a propósito: una
|
|
92
|
+
escala continua no es una escala, es un número libre con prefijo. */
|
|
93
|
+
--kz-space-1: 4px;
|
|
94
|
+
--kz-space-2: 8px;
|
|
95
|
+
--kz-space-3: 12px;
|
|
96
|
+
--kz-space-4: 16px;
|
|
97
|
+
--kz-space-5: 20px;
|
|
98
|
+
--kz-space-6: 24px;
|
|
99
|
+
--kz-space-8: 32px;
|
|
100
|
+
--kz-space-10: 40px;
|
|
101
|
+
--kz-space-12: 48px;
|
|
102
|
+
--kz-space-16: 64px;
|
|
103
|
+
|
|
104
|
+
/* ---- Radios ------------------------------------------------------------
|
|
105
|
+
No hay token para 0: eso es `0`, y darle nombre sólo agrega una
|
|
106
|
+
indirección que hay que resolver al leer. */
|
|
107
|
+
--kz-radius-sm: 4px;
|
|
108
|
+
--kz-radius-md: 8px;
|
|
109
|
+
--kz-radius-lg: 12px;
|
|
110
|
+
--kz-radius-xl: 20px;
|
|
111
|
+
--kz-radius-pill: 999px;
|
|
112
|
+
--kz-radius-circle: 50%;
|
|
113
|
+
|
|
114
|
+
/* ---- Tipografía -------------------------------------------------------- */
|
|
115
|
+
--kz-font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
116
|
+
--kz-font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
117
|
+
|
|
118
|
+
--kz-font-xs: 12px;
|
|
119
|
+
--kz-font-sm: 14px;
|
|
120
|
+
--kz-font-md: 16px;
|
|
121
|
+
--kz-font-lg: 20px;
|
|
122
|
+
--kz-font-xl: 26px;
|
|
123
|
+
--kz-font-2xl: 34px;
|
|
124
|
+
|
|
125
|
+
/* ---- Movimiento --------------------------------------------------------
|
|
126
|
+
Una sola duración para los cambios de estado de un control (hover, foco,
|
|
127
|
+
encendido). Lo que necesite otra es una animación, no una transición. */
|
|
128
|
+
--kz-duration-fast: 120ms;
|
|
129
|
+
--kz-ease: cubic-bezier(0.2, 0, 0, 1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/*
|
|
133
|
+
Tema claro. Sólo se redefine lo que cambia —color y sombra—: espaciado,
|
|
134
|
+
radios y tipografía valen igual en los dos temas y repetirlos acá sería una
|
|
135
|
+
segunda fuente que algún día diverge.
|
|
136
|
+
|
|
137
|
+
Las superficies no se "aclaran" una a una: se invierte la relación. En
|
|
138
|
+
oscuro la página es lo más negro y las cards suben; en claro la página es
|
|
139
|
+
gris y las cards son blancas. Por eso 1, 2 y 3 son todas blancas y lo que
|
|
140
|
+
las separa del fondo es el hairline.
|
|
141
|
+
*/
|
|
142
|
+
:root.light {
|
|
143
|
+
--kz-surface-0: #f4f6f9;
|
|
144
|
+
--kz-surface-1: #ffffff;
|
|
145
|
+
--kz-surface-2: #ffffff;
|
|
146
|
+
--kz-surface-3: #ffffff;
|
|
147
|
+
--kz-surface-sunken: #e8ecf1;
|
|
148
|
+
--kz-surface-hover: #eef1f5;
|
|
149
|
+
--kz-surface-active: #e2e7ee;
|
|
150
|
+
|
|
151
|
+
--kz-fg: #12161c;
|
|
152
|
+
--kz-fg-muted: #4d5762;
|
|
153
|
+
--kz-fg-subtle: #656f7c;
|
|
154
|
+
|
|
155
|
+
--kz-hairline: #e1e6ec;
|
|
156
|
+
--kz-hairline-strong: #c4ccd6;
|
|
157
|
+
--kz-ring: #1d4ed8;
|
|
158
|
+
|
|
159
|
+
--kz-accent: #1d62d8;
|
|
160
|
+
--kz-accent-hover: #1a56bf;
|
|
161
|
+
--kz-accent-active: #17499f;
|
|
162
|
+
--kz-accent-soft: rgba(29, 98, 216, 0.12);
|
|
163
|
+
--kz-accent-fg: #ffffff;
|
|
164
|
+
|
|
165
|
+
--kz-success: #11763d;
|
|
166
|
+
--kz-success-soft: rgba(17, 118, 61, 0.12);
|
|
167
|
+
--kz-success-fg: #ffffff;
|
|
168
|
+
|
|
169
|
+
--kz-danger: #c02626;
|
|
170
|
+
--kz-danger-soft: rgba(192, 38, 38, 0.12);
|
|
171
|
+
--kz-danger-fg: #ffffff;
|
|
172
|
+
|
|
173
|
+
--kz-warning: #96500a;
|
|
174
|
+
--kz-warning-soft: rgba(150, 80, 10, 0.12);
|
|
175
|
+
--kz-warning-fg: #ffffff;
|
|
176
|
+
|
|
177
|
+
--kz-info: #0b5f8a;
|
|
178
|
+
--kz-info-soft: rgba(11, 95, 138, 0.12);
|
|
179
|
+
--kz-info-fg: #ffffff;
|
|
180
|
+
|
|
181
|
+
--kz-shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.08);
|
|
182
|
+
--kz-shadow-md: 0 4px 12px rgba(16, 24, 40, 0.1);
|
|
183
|
+
--kz-shadow-lg: 0 10px 30px rgba(16, 24, 40, 0.12);
|
|
184
|
+
--kz-shadow-xl: 0 24px 60px rgba(16, 24, 40, 0.16);
|
|
185
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nombres de los tokens de diseño de kaizen-lib (`--kz-*`).
|
|
3
|
+
*
|
|
4
|
+
* Esto NO declara valores: son referencias `var(...)` para escribir estilos sin
|
|
5
|
+
* literales mágicos y con autocompletado. Los valores viven en CSS —
|
|
6
|
+
* `defaults.css` acá, o el `globals.css` de cada producto.
|
|
7
|
+
*
|
|
8
|
+
* La regla es de taboo-next, que la aprendió al revés: su `tokens.ts` original
|
|
9
|
+
* declaraba los valores en TypeScript en paralelo a los de `globals.css`, y no
|
|
10
|
+
* lo importaba ni un componente. Dos fuentes de verdad, las dos ignoradas. Por
|
|
11
|
+
* eso acá sólo hay referencias, y `tokens.test.ts` falla si alguna se escapa.
|
|
12
|
+
*
|
|
13
|
+
* El contrato en una línea: si un token está acá, tiene que estar definido en
|
|
14
|
+
* `defaults.css`, y al revés. Los tests lo verifican en las dos direcciones.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Superficies por nivel de elevación. El uso de cada nivel es fijo — elegir
|
|
19
|
+
* por "cuánto quiero que resalte" es lo que termina en seis grises distintos
|
|
20
|
+
* que significan lo mismo.
|
|
21
|
+
*
|
|
22
|
+
* - `0` fondo de página
|
|
23
|
+
* - `1` panel o sección
|
|
24
|
+
* - `2` card dentro de un panel
|
|
25
|
+
* - `3` overlay: modal, popover, dropdown
|
|
26
|
+
*
|
|
27
|
+
* `sunken` no es un quinto nivel: va más oscuro que su contenedor (inputs,
|
|
28
|
+
* tracks, wells). `hover` y `active` son estados de una superficie
|
|
29
|
+
* clickeable, no elevaciones.
|
|
30
|
+
*/
|
|
31
|
+
export const surface = {
|
|
32
|
+
0: 'var(--kz-surface-0)',
|
|
33
|
+
1: 'var(--kz-surface-1)',
|
|
34
|
+
2: 'var(--kz-surface-2)',
|
|
35
|
+
3: 'var(--kz-surface-3)',
|
|
36
|
+
sunken: 'var(--kz-surface-sunken)',
|
|
37
|
+
hover: 'var(--kz-surface-hover)',
|
|
38
|
+
active: 'var(--kz-surface-active)',
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Color de texto sobre una superficie. El texto que va encima de un color de
|
|
43
|
+
* acento o semántico es la ranura `fg` de ese tono (`accent.fg`, `danger.fg`).
|
|
44
|
+
*/
|
|
45
|
+
export const fg = {
|
|
46
|
+
DEFAULT: 'var(--kz-fg)',
|
|
47
|
+
muted: 'var(--kz-fg-muted)',
|
|
48
|
+
subtle: 'var(--kz-fg-subtle)',
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
/** Bordes de 1px. `strong` es para separar, no para decorar. */
|
|
52
|
+
export const hairline = {
|
|
53
|
+
DEFAULT: 'var(--kz-hairline)',
|
|
54
|
+
strong: 'var(--kz-hairline-strong)',
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
57
|
+
/** Anillo de foco. Elemento no textual: el mínimo de contraste es 3:1. */
|
|
58
|
+
export const ring = 'var(--kz-ring)';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Acento de marca. `soft` es el fondo lavado de chips y estados
|
|
62
|
+
* seleccionados; encima de `soft` va el acento mismo, no `fg`.
|
|
63
|
+
*/
|
|
64
|
+
export const accent = {
|
|
65
|
+
DEFAULT: 'var(--kz-accent)',
|
|
66
|
+
hover: 'var(--kz-accent-hover)',
|
|
67
|
+
active: 'var(--kz-accent-active)',
|
|
68
|
+
soft: 'var(--kz-accent-soft)',
|
|
69
|
+
fg: 'var(--kz-accent-fg)',
|
|
70
|
+
} as const;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Tonos semánticos. Tres ranuras cada uno: el color pleno, el fondo lavado y
|
|
74
|
+
* el texto que va encima del pleno.
|
|
75
|
+
*/
|
|
76
|
+
export const success = {
|
|
77
|
+
DEFAULT: 'var(--kz-success)',
|
|
78
|
+
soft: 'var(--kz-success-soft)',
|
|
79
|
+
fg: 'var(--kz-success-fg)',
|
|
80
|
+
} as const;
|
|
81
|
+
|
|
82
|
+
export const danger = {
|
|
83
|
+
DEFAULT: 'var(--kz-danger)',
|
|
84
|
+
soft: 'var(--kz-danger-soft)',
|
|
85
|
+
fg: 'var(--kz-danger-fg)',
|
|
86
|
+
} as const;
|
|
87
|
+
|
|
88
|
+
export const warning = {
|
|
89
|
+
DEFAULT: 'var(--kz-warning)',
|
|
90
|
+
soft: 'var(--kz-warning-soft)',
|
|
91
|
+
fg: 'var(--kz-warning-fg)',
|
|
92
|
+
} as const;
|
|
93
|
+
|
|
94
|
+
export const info = {
|
|
95
|
+
DEFAULT: 'var(--kz-info)',
|
|
96
|
+
soft: 'var(--kz-info-soft)',
|
|
97
|
+
fg: 'var(--kz-info-fg)',
|
|
98
|
+
} as const;
|
|
99
|
+
|
|
100
|
+
export const shadow = {
|
|
101
|
+
sm: 'var(--kz-shadow-sm)',
|
|
102
|
+
md: 'var(--kz-shadow-md)',
|
|
103
|
+
lg: 'var(--kz-shadow-lg)',
|
|
104
|
+
xl: 'var(--kz-shadow-xl)',
|
|
105
|
+
} as const;
|
|
106
|
+
|
|
107
|
+
/** Escala de espaciado, en pasos de 4px. Las claves salteadas son a propósito. */
|
|
108
|
+
export const space = {
|
|
109
|
+
1: 'var(--kz-space-1)',
|
|
110
|
+
2: 'var(--kz-space-2)',
|
|
111
|
+
3: 'var(--kz-space-3)',
|
|
112
|
+
4: 'var(--kz-space-4)',
|
|
113
|
+
5: 'var(--kz-space-5)',
|
|
114
|
+
6: 'var(--kz-space-6)',
|
|
115
|
+
8: 'var(--kz-space-8)',
|
|
116
|
+
10: 'var(--kz-space-10)',
|
|
117
|
+
12: 'var(--kz-space-12)',
|
|
118
|
+
16: 'var(--kz-space-16)',
|
|
119
|
+
} as const;
|
|
120
|
+
|
|
121
|
+
/** Radios. Para 0 no hay token: se escribe `0`. */
|
|
122
|
+
export const radius = {
|
|
123
|
+
sm: 'var(--kz-radius-sm)',
|
|
124
|
+
md: 'var(--kz-radius-md)',
|
|
125
|
+
lg: 'var(--kz-radius-lg)',
|
|
126
|
+
xl: 'var(--kz-radius-xl)',
|
|
127
|
+
pill: 'var(--kz-radius-pill)',
|
|
128
|
+
circle: 'var(--kz-radius-circle)',
|
|
129
|
+
} as const;
|
|
130
|
+
|
|
131
|
+
export const font = {
|
|
132
|
+
family: 'var(--kz-font-family)',
|
|
133
|
+
mono: 'var(--kz-font-mono)',
|
|
134
|
+
xs: 'var(--kz-font-xs)',
|
|
135
|
+
sm: 'var(--kz-font-sm)',
|
|
136
|
+
md: 'var(--kz-font-md)',
|
|
137
|
+
lg: 'var(--kz-font-lg)',
|
|
138
|
+
xl: 'var(--kz-font-xl)',
|
|
139
|
+
'2xl': 'var(--kz-font-2xl)',
|
|
140
|
+
} as const;
|
|
141
|
+
|
|
142
|
+
/** Transición de un cambio de estado de control: hover, foco, encendido. */
|
|
143
|
+
export const motion = {
|
|
144
|
+
fast: 'var(--kz-duration-fast)',
|
|
145
|
+
ease: 'var(--kz-ease)',
|
|
146
|
+
} as const;
|
|
147
|
+
|
|
148
|
+
export type Surface = keyof typeof surface;
|
|
149
|
+
export type Space = keyof typeof space;
|
|
150
|
+
export type Radius = keyof typeof radius;
|
|
151
|
+
export type Shadow = keyof typeof shadow;
|
|
152
|
+
export type FontSize = Exclude<keyof typeof font, 'family' | 'mono'>;
|
|
153
|
+
|
|
154
|
+
/** Los cuatro tonos semánticos, para tipar la prop `tone` de un componente. */
|
|
155
|
+
export type Tone = 'success' | 'danger' | 'warning' | 'info';
|
|
156
|
+
|
|
157
|
+
export const tone = { success, danger, warning, info } as const;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ComponentPropsWithoutRef, ElementType, MouseEvent } from 'react';
|
|
4
|
+
import styled, { css } from 'styled-components';
|
|
5
|
+
import { accent, danger, fg, font, motion, radius, ring, surface } from '../tokens';
|
|
6
|
+
|
|
7
|
+
export type IconButtonSize = 'sm' | 'md' | 'lg';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Lado del círculo, en px. `lg` es el área táctil mínima de una topbar móvil
|
|
11
|
+
* (44px): si el control va en una barra que se toca con el pulgar, es el que
|
|
12
|
+
* corresponde. No son tokens porque son medidas de este componente, no una
|
|
13
|
+
* escala que otros usen.
|
|
14
|
+
*/
|
|
15
|
+
const LADO: Record<IconButtonSize, number> = { sm: 32, md: 40, lg: 44 };
|
|
16
|
+
|
|
17
|
+
/** Tope del badge: "+9" ocupa lo mismo que un dígito y dice lo mismo. */
|
|
18
|
+
const BADGE_MAXIMO = 9;
|
|
19
|
+
|
|
20
|
+
type Propios = {
|
|
21
|
+
/**
|
|
22
|
+
* Nombre accesible. Obligatorio: un botón de sólo ícono sin rótulo no existe
|
|
23
|
+
* para un lector de pantalla. También es el tooltip nativo, salvo que haya
|
|
24
|
+
* `motivo`.
|
|
25
|
+
*
|
|
26
|
+
* Si hay `badge`, la cuenta va **dentro** del label ("Notificaciones, 3 sin
|
|
27
|
+
* leer"): el número visual es decorativo (`aria-hidden`) y el idioma del
|
|
28
|
+
* texto no es asunto de esta librería.
|
|
29
|
+
*/
|
|
30
|
+
label: string;
|
|
31
|
+
/** El ícono. La librería no trae íconos: cada producto usa los suyos. */
|
|
32
|
+
children: React.ReactNode;
|
|
33
|
+
size?: IconButtonSize;
|
|
34
|
+
/** Encendido: filtro aplicado, panel abierto, sección actual. */
|
|
35
|
+
active?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Contador. `0`, `undefined` y negativos no dibujan nada: "0 avisos" no es
|
|
38
|
+
* un aviso. Pasado el tope se muestra "+9".
|
|
39
|
+
*/
|
|
40
|
+
badge?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Motivo por el que está bloqueado. Marca `aria-disabled` en vez del
|
|
43
|
+
* `disabled` nativo: un botón realmente deshabilitado no recibe mouse ni
|
|
44
|
+
* foco, así que nunca podría mostrar el motivo — que es lo único que hay
|
|
45
|
+
* para comunicar.
|
|
46
|
+
*/
|
|
47
|
+
motivo?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type Props<T extends ElementType> = Propios & {
|
|
51
|
+
/** Renderiza otro elemento o componente (un `<Link>` de Next, un `<a>`). */
|
|
52
|
+
as?: T;
|
|
53
|
+
} & Omit<ComponentPropsWithoutRef<T>, keyof Propios | 'as'>;
|
|
54
|
+
|
|
55
|
+
const Raiz = styled.button<{ $size: IconButtonSize; $active: boolean }>`
|
|
56
|
+
position: relative;
|
|
57
|
+
display: inline-flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
justify-content: center;
|
|
60
|
+
flex-shrink: 0;
|
|
61
|
+
width: ${({ $size }) => LADO[$size]}px;
|
|
62
|
+
height: ${({ $size }) => LADO[$size]}px;
|
|
63
|
+
padding: 0;
|
|
64
|
+
border: none;
|
|
65
|
+
border-radius: ${radius.circle};
|
|
66
|
+
background: ${({ $active }) => ($active ? accent.soft : 'transparent')};
|
|
67
|
+
color: ${({ $active }) => ($active ? accent.DEFAULT : fg.muted)};
|
|
68
|
+
font-family: inherit;
|
|
69
|
+
text-decoration: none;
|
|
70
|
+
cursor: pointer;
|
|
71
|
+
transition:
|
|
72
|
+
background ${motion.fast} ${motion.ease},
|
|
73
|
+
color ${motion.fast} ${motion.ease};
|
|
74
|
+
|
|
75
|
+
svg {
|
|
76
|
+
display: block;
|
|
77
|
+
width: 22px;
|
|
78
|
+
height: 22px;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
&:focus-visible {
|
|
82
|
+
outline: 2px solid ${ring};
|
|
83
|
+
outline-offset: 2px;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
${({ $active }) =>
|
|
87
|
+
!$active &&
|
|
88
|
+
css`
|
|
89
|
+
&:hover:not([aria-disabled='true']) {
|
|
90
|
+
background: ${surface.hover};
|
|
91
|
+
color: ${fg.DEFAULT};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
&:active:not([aria-disabled='true']) {
|
|
95
|
+
background: ${surface.active};
|
|
96
|
+
}
|
|
97
|
+
`}
|
|
98
|
+
|
|
99
|
+
&[aria-disabled='true'] {
|
|
100
|
+
opacity: 0.45;
|
|
101
|
+
cursor: not-allowed;
|
|
102
|
+
}
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
const Contador = styled.span`
|
|
106
|
+
position: absolute;
|
|
107
|
+
top: 2px;
|
|
108
|
+
right: 2px;
|
|
109
|
+
min-width: 18px;
|
|
110
|
+
height: 18px;
|
|
111
|
+
padding: 0 4px;
|
|
112
|
+
border-radius: ${radius.pill};
|
|
113
|
+
background: ${danger.DEFAULT};
|
|
114
|
+
color: ${danger.fg};
|
|
115
|
+
font-size: ${font.xs};
|
|
116
|
+
font-weight: 700;
|
|
117
|
+
line-height: 18px;
|
|
118
|
+
text-align: center;
|
|
119
|
+
pointer-events: none;
|
|
120
|
+
`;
|
|
121
|
+
|
|
122
|
+
function textoDelBadge(badge: number | undefined): string | null {
|
|
123
|
+
if (badge === undefined || !Number.isFinite(badge) || badge < 1) return null;
|
|
124
|
+
return badge > BADGE_MAXIMO ? `+${BADGE_MAXIMO}` : String(Math.floor(badge));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Botón redondo de sólo ícono: el de la derecha de una topbar (notificaciones,
|
|
129
|
+
* usuario, idioma, tema).
|
|
130
|
+
*
|
|
131
|
+
* Sin `as` es un `<button type="button">`. Con `as` toma otro elemento y no le
|
|
132
|
+
* pone `type`, que en un `<a>` sería un atributo inválido.
|
|
133
|
+
*/
|
|
134
|
+
export function IconButton<T extends ElementType = 'button'>({
|
|
135
|
+
label,
|
|
136
|
+
children,
|
|
137
|
+
size = 'md',
|
|
138
|
+
active = false,
|
|
139
|
+
badge,
|
|
140
|
+
motivo,
|
|
141
|
+
as,
|
|
142
|
+
onClick,
|
|
143
|
+
...rest
|
|
144
|
+
}: Props<T>) {
|
|
145
|
+
const bloqueado = Boolean(motivo);
|
|
146
|
+
const textoBadge = textoDelBadge(badge);
|
|
147
|
+
const esBoton = as === undefined || as === 'button';
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<Raiz
|
|
151
|
+
{...rest}
|
|
152
|
+
as={as}
|
|
153
|
+
type={esBoton ? 'button' : undefined}
|
|
154
|
+
$size={size}
|
|
155
|
+
$active={active}
|
|
156
|
+
aria-label={label}
|
|
157
|
+
aria-pressed={esBoton && active ? true : undefined}
|
|
158
|
+
aria-current={!esBoton && active ? 'page' : undefined}
|
|
159
|
+
aria-disabled={bloqueado || undefined}
|
|
160
|
+
title={motivo ?? label}
|
|
161
|
+
onClick={(evento: MouseEvent<HTMLElement>) => {
|
|
162
|
+
if (bloqueado) {
|
|
163
|
+
evento.preventDefault();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
(onClick as ((e: MouseEvent<HTMLElement>) => void) | undefined)?.(evento);
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
{children}
|
|
170
|
+
{textoBadge ? <Contador aria-hidden="true">{textoBadge}</Contador> : null}
|
|
171
|
+
</Raiz>
|
|
172
|
+
);
|
|
173
|
+
}
|
package/src/ui/index.ts
ADDED