kaizen-lib 0.2.0 → 0.2.1
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/README.md +83 -1
- package/package.json +1 -1
- package/src/tokens/defaults.css +16 -0
- package/src/tokens/index.ts +12 -0
- package/src/ui/IconButton.tsx +4 -42
- package/src/ui/Medidor.tsx +159 -0
- package/src/ui/ReactionButton.tsx +149 -0
- package/src/ui/Relieve.tsx +52 -0
- package/src/ui/index.ts +5 -0
- package/src/ui/shell.ts +58 -0
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ styled-components con `moduleResolution: "bundler"`, así que lo transpilan ello
|
|
|
12
12
|
|---|---|---|
|
|
13
13
|
| `kaizen-lib/tokens` | Referencias `var(--kz-*)` tipadas | listo |
|
|
14
14
|
| `kaizen-lib/tokens.css` | Valores por defecto, tema oscuro y claro | listo |
|
|
15
|
-
| `kaizen-lib/ui` | Componentes (
|
|
15
|
+
| `kaizen-lib/ui` | Componentes (`IconButton`, `ReactionButton`, `Medidor`, `Relieve`) | en curso |
|
|
16
16
|
|
|
17
17
|
## Instalación
|
|
18
18
|
|
|
@@ -89,6 +89,88 @@ qué.
|
|
|
89
89
|
|
|
90
90
|
La librería no trae íconos: cada producto usa los suyos.
|
|
91
91
|
|
|
92
|
+
```tsx
|
|
93
|
+
import { ReactionButton } from 'kaizen-lib/ui';
|
|
94
|
+
|
|
95
|
+
<ReactionButton tone="danger" active={likeado} glow count={likes} label={`Me gusta, ${likes}`}>
|
|
96
|
+
<IconoCorazon />
|
|
97
|
+
</ReactionButton>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**`ReactionButton`** es un ícono que cambia de color al reaccionar (`tone`: `neutral` |
|
|
101
|
+
`accent` | `success` | `danger` | `warning` | `info`), con resplandor opcional (`glow`,
|
|
102
|
+
del mismo color vía `currentColor`) y una cuenta al lado (`count`; `0` se muestra, no es
|
|
103
|
+
una ausencia). No es `IconButton` con más props: **nunca tiñe el fondo**. `IconButton` es
|
|
104
|
+
"esto está seleccionado" (la página actual, un filtro aplicado) y por eso tiñe el círculo
|
|
105
|
+
entero; `ReactionButton` es "reaccioné con esto" (un me gusta, un guardado), y ahí sólo el
|
|
106
|
+
ícono cambia — un corazón marcado no necesita, además, un círculo rosa detrás.
|
|
107
|
+
|
|
108
|
+
Un color que los seis tonos no cubren —la marca de un producto, no una categoría
|
|
109
|
+
semántica— se pisa con `style`, porque el color activo sale de la variable CSS
|
|
110
|
+
`--kz-icon-on` y un estilo en línea le gana a la regla de la librería:
|
|
111
|
+
|
|
112
|
+
```tsx
|
|
113
|
+
<ReactionButton
|
|
114
|
+
active={guardado}
|
|
115
|
+
style={{ '--kz-icon-on': 'var(--mi-color-de-marca)' } as React.CSSProperties}
|
|
116
|
+
label="Guardar"
|
|
117
|
+
>
|
|
118
|
+
<IconoMarcador />
|
|
119
|
+
</ReactionButton>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
import { Medidor } from 'kaizen-lib/ui';
|
|
124
|
+
|
|
125
|
+
<Medidor valor={8} maximo={10} sentido="menos-es-mejor" label="Anotados: 8 de 10" />
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**`Medidor`** es un arco que dice cuánto de un total está ocupado, pintado por escalón. No es
|
|
129
|
+
un control: es un `role="img"`, así que si el dato tiene que llevar a algún lado lo envuelve
|
|
130
|
+
un link o un botón del producto. `label` es obligatorio y va en palabras — el arco y el
|
|
131
|
+
número del centro son decorativos, y el color nunca es el único portador del significado.
|
|
132
|
+
|
|
133
|
+
Los cuatro escalones salen de la **proporción** `valor / maximo`, no del valor (8 no significa
|
|
134
|
+
lo mismo sobre 10 que sobre 22). Sobre una escala de 0 a 10: `10` tope, `8` a `9,99` alto,
|
|
135
|
+
`4` a `7,99` medio, menos de `4` bajo. Cada corte es inclusivo abajo.
|
|
136
|
+
|
|
137
|
+
`sentido` es obligatorio y decide qué extremo es el bueno, porque eso lo define lo que el
|
|
138
|
+
número *significa* en el producto y no el componente:
|
|
139
|
+
|
|
140
|
+
| Escalón | `menos-es-mejor` | `mas-es-mejor` |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| bajo (< 40%) | verde | rojo |
|
|
143
|
+
| medio (40–79%) | azul | naranja |
|
|
144
|
+
| alto (80–99%) | naranja | azul |
|
|
145
|
+
| tope (100%) | rojo | verde |
|
|
146
|
+
|
|
147
|
+
`menos-es-mejor` es "cuánto lugar queda": vacío es una oportunidad, lleno una puerta
|
|
148
|
+
cerrada. `mas-es-mejor` es un progreso o una meta. Usa los tonos `success`, `info`,
|
|
149
|
+
`warning` y `danger`, así que un producto que ya los definió no declara nada nuevo. El
|
|
150
|
+
centro muestra `valor/maximo` y se reemplaza con `children` cuando el formato es del producto.
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
import { IconButton, Relieve } from 'kaizen-lib/ui';
|
|
154
|
+
import Link from 'next/link';
|
|
155
|
+
|
|
156
|
+
<Relieve>
|
|
157
|
+
<IconButton label="Notificaciones, 3 sin leer" badge={3}><IconoCampana /></IconButton>
|
|
158
|
+
</Relieve>
|
|
159
|
+
|
|
160
|
+
<Relieve $prendido={enPerfil}>
|
|
161
|
+
<IconButton as={Link} href="/perfil" active={enPerfil} label="Perfil"><IconoPerfil /></IconButton>
|
|
162
|
+
</Relieve>
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**`Relieve`** envuelve a `IconButton` con el relieve neumórfico: un círculo sin relleno ni
|
|
166
|
+
borde, dibujado por un par de sombras opuestas (`--kz-relief-out`) que se invierten al
|
|
167
|
+
presionar (`--kz-relief-in`) o con `$prendido` (el equivalente sostenido de "esto está
|
|
168
|
+
activo" o "vos estás acá"). Es un `<span>` alrededor del control y no un
|
|
169
|
+
`styled(IconButton)`: `IconButton` es polimórfico (`as`), y styled-components
|
|
170
|
+
intercepta el `as` de cualquier componente que envuelve, así que envolverlo así lo haría
|
|
171
|
+
saltear toda su lógica. El relieve es neutro por defecto (negro/blanco) — un producto con
|
|
172
|
+
marca lo tiñe redefiniendo esos tres tokens, igual que con cualquier otro.
|
|
173
|
+
|
|
92
174
|
## Desarrollo
|
|
93
175
|
|
|
94
176
|
```bash
|
package/package.json
CHANGED
package/src/tokens/defaults.css
CHANGED
|
@@ -87,6 +87,17 @@
|
|
|
87
87
|
--kz-shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5);
|
|
88
88
|
--kz-shadow-xl: 0 24px 60px rgba(0, 0, 0, 0.6);
|
|
89
89
|
|
|
90
|
+
/* ---- Relieve neumórfico -------------------------------------------------
|
|
91
|
+
Par de sombras opuestas para un control sin relleno ni borde: el círculo
|
|
92
|
+
lo dibuja la luz, no un fondo. `out` es el reposo, `out-strong` el hover
|
|
93
|
+
y `in` (invertida) el presionado o el "esto está activo". Neutras —negro
|
|
94
|
+
y blanco— porque la paleta por defecto ya lo es; un producto con marca
|
|
95
|
+
las tiñe (ver Relieve en ui/, y el ejemplo de mixbol-front en el README:
|
|
96
|
+
ahí la sombra oscura sale del azul de marca, no de un negro genérico). */
|
|
97
|
+
--kz-relief-out: 3px 3px 7px rgba(0, 0, 0, 0.5), -3px -3px 7px rgba(255, 255, 255, 0.05);
|
|
98
|
+
--kz-relief-out-strong: 4px 4px 10px rgba(0, 0, 0, 0.6), -4px -4px 10px rgba(255, 255, 255, 0.08);
|
|
99
|
+
--kz-relief-in: inset 3px 3px 6px rgba(0, 0, 0, 0.55), inset -3px -3px 6px rgba(255, 255, 255, 0.06);
|
|
100
|
+
|
|
90
101
|
/* ---- Espaciado ---------------------------------------------------------
|
|
91
102
|
Escala en pasos de 4px. Los saltos (no hay 7, 9, 11) son a propósito: una
|
|
92
103
|
escala continua no es una escala, es un número libre con prefijo. */
|
|
@@ -115,6 +126,7 @@
|
|
|
115
126
|
--kz-font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
116
127
|
--kz-font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
117
128
|
|
|
129
|
+
--kz-font-2xs: 11px;
|
|
118
130
|
--kz-font-xs: 12px;
|
|
119
131
|
--kz-font-sm: 14px;
|
|
120
132
|
--kz-font-md: 16px;
|
|
@@ -182,4 +194,8 @@
|
|
|
182
194
|
--kz-shadow-md: 0 4px 12px rgba(16, 24, 40, 0.1);
|
|
183
195
|
--kz-shadow-lg: 0 10px 30px rgba(16, 24, 40, 0.12);
|
|
184
196
|
--kz-shadow-xl: 0 24px 60px rgba(16, 24, 40, 0.16);
|
|
197
|
+
|
|
198
|
+
--kz-relief-out: 3px 3px 6px rgba(16, 24, 40, 0.14), -3px -3px 6px rgba(255, 255, 255, 0.9);
|
|
199
|
+
--kz-relief-out-strong: 4px 4px 9px rgba(16, 24, 40, 0.18), -4px -4px 9px rgba(255, 255, 255, 0.95);
|
|
200
|
+
--kz-relief-in: inset 3px 3px 6px rgba(16, 24, 40, 0.16), inset -3px -3px 6px rgba(255, 255, 255, 0.85);
|
|
185
201
|
}
|
package/src/tokens/index.ts
CHANGED
|
@@ -104,6 +104,17 @@ export const shadow = {
|
|
|
104
104
|
xl: 'var(--kz-shadow-xl)',
|
|
105
105
|
} as const;
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Relieve neumórfico: el par de sombras opuestas que usa `Relieve` (`ui/`).
|
|
109
|
+
* `out` es el reposo y el hover fuerte, `in` es el presionado o "esto está
|
|
110
|
+
* prendido" — ver el docblock de `Relieve` para cuándo va cada uno.
|
|
111
|
+
*/
|
|
112
|
+
export const relief = {
|
|
113
|
+
out: 'var(--kz-relief-out)',
|
|
114
|
+
outStrong: 'var(--kz-relief-out-strong)',
|
|
115
|
+
in: 'var(--kz-relief-in)',
|
|
116
|
+
} as const;
|
|
117
|
+
|
|
107
118
|
/** Escala de espaciado, en pasos de 4px. Las claves salteadas son a propósito. */
|
|
108
119
|
export const space = {
|
|
109
120
|
1: 'var(--kz-space-1)',
|
|
@@ -131,6 +142,7 @@ export const radius = {
|
|
|
131
142
|
export const font = {
|
|
132
143
|
family: 'var(--kz-font-family)',
|
|
133
144
|
mono: 'var(--kz-font-mono)',
|
|
145
|
+
'2xs': 'var(--kz-font-2xs)',
|
|
134
146
|
xs: 'var(--kz-font-xs)',
|
|
135
147
|
sm: 'var(--kz-font-sm)',
|
|
136
148
|
md: 'var(--kz-font-md)',
|
package/src/ui/IconButton.tsx
CHANGED
|
@@ -2,17 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import type { ComponentPropsWithoutRef, ElementType, MouseEvent } from 'react';
|
|
4
4
|
import styled, { css } from 'styled-components';
|
|
5
|
-
import { accent, danger, fg, font,
|
|
5
|
+
import { accent, danger, fg, font, radius, surface } from '../tokens';
|
|
6
|
+
import { formaBase, type IconButtonSize } from './shell';
|
|
6
7
|
|
|
7
|
-
export type IconButtonSize
|
|
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 };
|
|
8
|
+
export type { IconButtonSize };
|
|
16
9
|
|
|
17
10
|
/** Tope del badge: "+9" ocupa lo mismo que un dígito y dice lo mismo. */
|
|
18
11
|
const BADGE_MAXIMO = 9;
|
|
@@ -53,35 +46,9 @@ type Props<T extends ElementType> = Propios & {
|
|
|
53
46
|
} & Omit<ComponentPropsWithoutRef<T>, keyof Propios | 'as'>;
|
|
54
47
|
|
|
55
48
|
const Raiz = styled.button<{ $size: IconButtonSize; $active: boolean }>`
|
|
56
|
-
|
|
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};
|
|
49
|
+
${({ $size }) => formaBase($size)}
|
|
66
50
|
background: ${({ $active }) => ($active ? accent.soft : 'transparent')};
|
|
67
51
|
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
52
|
|
|
86
53
|
${({ $active }) =>
|
|
87
54
|
!$active &&
|
|
@@ -95,11 +62,6 @@ const Raiz = styled.button<{ $size: IconButtonSize; $active: boolean }>`
|
|
|
95
62
|
background: ${surface.active};
|
|
96
63
|
}
|
|
97
64
|
`}
|
|
98
|
-
|
|
99
|
-
&[aria-disabled='true'] {
|
|
100
|
-
opacity: 0.45;
|
|
101
|
-
cursor: not-allowed;
|
|
102
|
-
}
|
|
103
65
|
`;
|
|
104
66
|
|
|
105
67
|
const Contador = styled.span`
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import styled from 'styled-components';
|
|
5
|
+
import { danger, fg, font, info, success, warning } from '../tokens';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Hacia dónde apunta lo bueno en la escala.
|
|
9
|
+
*
|
|
10
|
+
* - `menos-es-mejor`: vacío es verde y lleno es rojo. Es la lectura de "cuánto
|
|
11
|
+
* lugar queda": un partido con dos anotados es una oportunidad, uno lleno es
|
|
12
|
+
* una puerta cerrada.
|
|
13
|
+
* - `mas-es-mejor`: la inversa. Lleno es verde y vacío es rojo. Es la lectura
|
|
14
|
+
* de un progreso, una meta o un puntaje.
|
|
15
|
+
*
|
|
16
|
+
* No hay un default universal a propósito: cuál corresponde depende de lo que
|
|
17
|
+
* el número **significa** en el producto, no del componente. Por eso es
|
|
18
|
+
* obligatoria — un default sería elegir en silencio el sentido de todos los
|
|
19
|
+
* medidores que no la pasen.
|
|
20
|
+
*/
|
|
21
|
+
export type MedidorSentido = 'menos-es-mejor' | 'mas-es-mejor';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Los cuatro escalones de la escala, del más vacío al más lleno.
|
|
25
|
+
*
|
|
26
|
+
* Cortes sobre la **proporción** `valor / maximo` y no sobre el valor: sobre
|
|
27
|
+
* una escala de 0 a 10 son 10 → tope, 8 a 9,99 → alto, 4 a 7,99 → medio y
|
|
28
|
+
* menos de 4 → bajo, pero sobre un cupo de 22 el mismo `8` no significa lo
|
|
29
|
+
* mismo. Cada corte es inclusivo abajo: exactamente 8 de 10 ya es `alto`.
|
|
30
|
+
*/
|
|
31
|
+
type Escalon = 'bajo' | 'medio' | 'alto' | 'tope';
|
|
32
|
+
|
|
33
|
+
const CORTE_MEDIO = 0.4;
|
|
34
|
+
const CORTE_ALTO = 0.8;
|
|
35
|
+
|
|
36
|
+
function escalonDe(proporcion: number): Escalon {
|
|
37
|
+
if (proporcion >= 1) return 'tope';
|
|
38
|
+
if (proporcion >= CORTE_ALTO) return 'alto';
|
|
39
|
+
if (proporcion >= CORTE_MEDIO) return 'medio';
|
|
40
|
+
return 'bajo';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Color de cada escalón según el sentido. Los cuatro son los tonos semánticos
|
|
45
|
+
* de la librería —ninguno inventado acá— y las dos rampas usan los mismos
|
|
46
|
+
* cuatro: el `info` (azul) es el escalón templado en las dos direcciones, y es
|
|
47
|
+
* azul y no un cuarto verde/ámbar a propósito, porque los otros tres van por
|
|
48
|
+
* temperatura y meter un cuarto dentro de esa franja los vuelve
|
|
49
|
+
* indistinguibles de un vistazo.
|
|
50
|
+
*/
|
|
51
|
+
const COLOR: Record<MedidorSentido, Record<Escalon, string>> = {
|
|
52
|
+
'menos-es-mejor': {
|
|
53
|
+
bajo: success.DEFAULT,
|
|
54
|
+
medio: info.DEFAULT,
|
|
55
|
+
alto: warning.DEFAULT,
|
|
56
|
+
tope: danger.DEFAULT,
|
|
57
|
+
},
|
|
58
|
+
'mas-es-mejor': {
|
|
59
|
+
bajo: danger.DEFAULT,
|
|
60
|
+
medio: warning.DEFAULT,
|
|
61
|
+
alto: info.DEFAULT,
|
|
62
|
+
tope: success.DEFAULT,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type Props = {
|
|
67
|
+
/** Cuánto hay. Fuera de `0..maximo` se recorta: el arco nunca se sale de su pista. */
|
|
68
|
+
valor: number;
|
|
69
|
+
/** Cuánto es el 100%. Debe ser mayor que 0; si no, el arco se dibuja vacío. */
|
|
70
|
+
maximo: number;
|
|
71
|
+
/**
|
|
72
|
+
* Nombre accesible, obligatorio y en palabras ("Anotados: 8 de 10"). El
|
|
73
|
+
* arco y el número son decorativos (`aria-hidden`): sin esto un lector de
|
|
74
|
+
* pantalla leería el mismo dato dos veces, o ninguna.
|
|
75
|
+
*
|
|
76
|
+
* El color nunca es el único portador del significado: el número va en
|
|
77
|
+
* texto en el centro y esto lo dice en palabras.
|
|
78
|
+
*/
|
|
79
|
+
label: string;
|
|
80
|
+
sentido: MedidorSentido;
|
|
81
|
+
/** Lo que va en el centro. Por defecto `valor/maximo`. */
|
|
82
|
+
children?: ReactNode;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Un semicírculo de 100 unidades de perímetro: `pathLength={100}` hace que
|
|
87
|
+
* `stroke-dashoffset` sea directamente el porcentaje, sin calcular la longitud
|
|
88
|
+
* real del arco.
|
|
89
|
+
*/
|
|
90
|
+
const ARCO_D = 'M12 52 A38 38 0 0 1 88 52';
|
|
91
|
+
|
|
92
|
+
const Raiz = styled.div`
|
|
93
|
+
position: relative;
|
|
94
|
+
flex-shrink: 0;
|
|
95
|
+
width: 56px;
|
|
96
|
+
height: 34px;
|
|
97
|
+
`;
|
|
98
|
+
|
|
99
|
+
const Svg = styled.svg`
|
|
100
|
+
display: block;
|
|
101
|
+
width: 100%;
|
|
102
|
+
height: 100%;
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
const Centro = styled.span`
|
|
106
|
+
position: absolute;
|
|
107
|
+
bottom: 2px;
|
|
108
|
+
left: 50%;
|
|
109
|
+
transform: translateX(-50%);
|
|
110
|
+
font-size: ${font['2xs']};
|
|
111
|
+
font-weight: 800;
|
|
112
|
+
line-height: 1;
|
|
113
|
+
color: ${fg.DEFAULT};
|
|
114
|
+
font-variant-numeric: tabular-nums;
|
|
115
|
+
white-space: nowrap;
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
/** Proporción en `0..1`. Un `maximo` inválido o un valor no finito dan 0, no NaN. */
|
|
119
|
+
function proporcionDe(valor: number, maximo: number): number {
|
|
120
|
+
if (!Number.isFinite(valor) || !Number.isFinite(maximo) || maximo <= 0) return 0;
|
|
121
|
+
return Math.min(1, Math.max(0, valor / maximo));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Medidor de arco: cuánto de un total está ocupado, con el color por escalón.
|
|
126
|
+
*
|
|
127
|
+
* Es un `role="img"`, no un control: no se enfoca ni se opera. Si el dato
|
|
128
|
+
* tiene que llevar a algún lado, lo envuelve un link o un botón del producto.
|
|
129
|
+
*/
|
|
130
|
+
export function Medidor({ valor, maximo, label, sentido, children }: Props) {
|
|
131
|
+
const proporcion = proporcionDe(valor, maximo);
|
|
132
|
+
const color = COLOR[sentido][escalonDe(proporcion)];
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<Raiz role="img" aria-label={label}>
|
|
136
|
+
<Svg viewBox="0 0 100 60" aria-hidden="true">
|
|
137
|
+
<path
|
|
138
|
+
d={ARCO_D}
|
|
139
|
+
fill="none"
|
|
140
|
+
stroke={`color-mix(in srgb, ${fg.muted} 25%, transparent)`}
|
|
141
|
+
strokeWidth={10}
|
|
142
|
+
strokeLinecap="round"
|
|
143
|
+
pathLength={100}
|
|
144
|
+
/>
|
|
145
|
+
<path
|
|
146
|
+
d={ARCO_D}
|
|
147
|
+
fill="none"
|
|
148
|
+
stroke={color}
|
|
149
|
+
strokeWidth={10}
|
|
150
|
+
strokeLinecap="round"
|
|
151
|
+
pathLength={100}
|
|
152
|
+
strokeDasharray={100}
|
|
153
|
+
strokeDashoffset={100 - proporcion * 100}
|
|
154
|
+
/>
|
|
155
|
+
</Svg>
|
|
156
|
+
<Centro aria-hidden="true">{children ?? `${valor}/${maximo}`}</Centro>
|
|
157
|
+
</Raiz>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ButtonHTMLAttributes, MouseEvent, ReactNode } from 'react';
|
|
4
|
+
import styled from 'styled-components';
|
|
5
|
+
import { accent, danger, fg, font, info, motion, success, warning } from '../tokens';
|
|
6
|
+
import { formaBase, LADO_ICONO, type IconButtonSize } from './shell';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* El color que toma el ícono al reaccionar. Reusa los tonos semánticos que
|
|
10
|
+
* la librería ya tiene — no hay `heart` ni `bookmark`: eso es marca de
|
|
11
|
+
* producto, no una categoría que otro consumidor vaya a repetir. Un color
|
|
12
|
+
* que ninguno de estos seis cubre se pisa con `style` (ver `label`, abajo
|
|
13
|
+
* del componente, para el ejemplo).
|
|
14
|
+
*/
|
|
15
|
+
export type ReactionTone = 'neutral' | 'accent' | 'success' | 'danger' | 'warning' | 'info';
|
|
16
|
+
|
|
17
|
+
const TONO_ENCENDIDO: Record<ReactionTone, string> = {
|
|
18
|
+
neutral: fg.DEFAULT,
|
|
19
|
+
accent: accent.DEFAULT,
|
|
20
|
+
success: success.DEFAULT,
|
|
21
|
+
danger: danger.DEFAULT,
|
|
22
|
+
warning: warning.DEFAULT,
|
|
23
|
+
info: info.DEFAULT,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type Props = {
|
|
27
|
+
/**
|
|
28
|
+
* Nombre accesible. Si hay `count`, la cifra va DENTRO del label ("Me
|
|
29
|
+
* gusta, 24"): el número visible es decorativo (`aria-hidden`), mismo
|
|
30
|
+
* criterio que el `badge` de `IconButton`.
|
|
31
|
+
*/
|
|
32
|
+
label: string;
|
|
33
|
+
children: ReactNode;
|
|
34
|
+
tone?: ReactionTone;
|
|
35
|
+
size?: IconButtonSize;
|
|
36
|
+
/** Reaccionado o no. A diferencia de `IconButton`, no tiñe el fondo: sólo el ícono cambia de color. */
|
|
37
|
+
active?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Resplandor alrededor del ícono cuando está activo. Del color del tono
|
|
40
|
+
* (`currentColor`), no uno propio — así no hace falta un token de glow por
|
|
41
|
+
* tono, y un color pisado con `style` resplandece del mismo color.
|
|
42
|
+
*/
|
|
43
|
+
glow?: boolean;
|
|
44
|
+
/** Cuenta visible junto al ícono. `0` se muestra: "0 me gusta" es un dato, no una ausencia. */
|
|
45
|
+
count?: number;
|
|
46
|
+
motivo?: string;
|
|
47
|
+
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const Raiz = styled.button<{ $size: IconButtonSize; $tone: ReactionTone; $active: boolean; $glow: boolean }>`
|
|
51
|
+
${({ $size }) => formaBase($size)}
|
|
52
|
+
gap: 4px;
|
|
53
|
+
width: auto;
|
|
54
|
+
min-width: ${({ $size }) => LADO_ICONO[$size]}px;
|
|
55
|
+
padding: 0 8px;
|
|
56
|
+
background: transparent;
|
|
57
|
+
--kz-icon-on: ${({ $tone }) => TONO_ENCENDIDO[$tone]};
|
|
58
|
+
color: ${({ $active }) => ($active ? 'var(--kz-icon-on)' : fg.muted)};
|
|
59
|
+
|
|
60
|
+
svg {
|
|
61
|
+
filter: ${({ $active, $glow }) =>
|
|
62
|
+
$active && $glow ? 'drop-shadow(0 0 8px color-mix(in srgb, currentColor 65%, transparent))' : 'none'};
|
|
63
|
+
transition: filter ${motion.fast} ${motion.ease};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
&:hover:not([aria-disabled='true']) {
|
|
67
|
+
background: transparent;
|
|
68
|
+
color: ${({ $active }) => ($active ? 'var(--kz-icon-on)' : fg.DEFAULT)};
|
|
69
|
+
}
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
const Cuenta = styled.span`
|
|
73
|
+
font-size: ${font.xs};
|
|
74
|
+
font-weight: 600;
|
|
75
|
+
line-height: 1;
|
|
76
|
+
pointer-events: none;
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Botón de reacción: ícono que cambia de color (y opcionalmente resplandece)
|
|
81
|
+
* al activarse, con una cuenta al lado. Generalizado del `IconButton` de
|
|
82
|
+
* taboo-next.
|
|
83
|
+
*
|
|
84
|
+
* No es `IconButton` con más props: `IconButton` tiñe el FONDO cuando está
|
|
85
|
+
* `active` (es "esto está seleccionado" — la página actual, un filtro
|
|
86
|
+
* aplicado). `ReactionButton` nunca tiñe el fondo — sólo el ícono cambia
|
|
87
|
+
* (es "reaccioné con esto"): un corazón marcado no necesita, además, un
|
|
88
|
+
* círculo rosa detrás. Por eso es un componente aparte y no una prop más.
|
|
89
|
+
*
|
|
90
|
+
* ```tsx
|
|
91
|
+
* <ReactionButton tone="danger" active={likeado} glow count={likes} label={`Me gusta, ${likes}`}>
|
|
92
|
+
* <IconoCorazon />
|
|
93
|
+
* </ReactionButton>
|
|
94
|
+
* ```
|
|
95
|
+
*
|
|
96
|
+
* Un color que los seis tonos no cubren (la marca de un producto, no una
|
|
97
|
+
* categoría semántica) se pisa con `style`, ya que `--kz-icon-on` es una
|
|
98
|
+
* variable CSS común y un estilo en línea le gana a la de acá:
|
|
99
|
+
*
|
|
100
|
+
* ```tsx
|
|
101
|
+
* <ReactionButton
|
|
102
|
+
* active={guardado}
|
|
103
|
+
* glow
|
|
104
|
+
* style={{ '--kz-icon-on': 'var(--mi-color-de-marca)' } as React.CSSProperties}
|
|
105
|
+
* label="Guardar"
|
|
106
|
+
* >
|
|
107
|
+
* <IconoMarcador />
|
|
108
|
+
* </ReactionButton>
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export function ReactionButton({
|
|
112
|
+
label,
|
|
113
|
+
children,
|
|
114
|
+
tone = 'neutral',
|
|
115
|
+
size = 'md',
|
|
116
|
+
active = false,
|
|
117
|
+
glow = false,
|
|
118
|
+
count,
|
|
119
|
+
motivo,
|
|
120
|
+
onClick,
|
|
121
|
+
...rest
|
|
122
|
+
}: Props & Omit<ButtonHTMLAttributes<HTMLButtonElement>, keyof Props>) {
|
|
123
|
+
const bloqueado = Boolean(motivo);
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<Raiz
|
|
127
|
+
{...rest}
|
|
128
|
+
type="button"
|
|
129
|
+
$size={size}
|
|
130
|
+
$tone={tone}
|
|
131
|
+
$active={active}
|
|
132
|
+
$glow={glow}
|
|
133
|
+
aria-label={label}
|
|
134
|
+
aria-pressed={active}
|
|
135
|
+
aria-disabled={bloqueado || undefined}
|
|
136
|
+
title={motivo ?? label}
|
|
137
|
+
onClick={(event) => {
|
|
138
|
+
if (bloqueado) {
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
onClick?.(event);
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
{children}
|
|
146
|
+
{typeof count === 'number' ? <Cuenta aria-hidden="true">{count}</Cuenta> : null}
|
|
147
|
+
</Raiz>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import styled from 'styled-components';
|
|
4
|
+
import { motion, relief } from '../tokens';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Relieve neumórfico: envuelve a `IconButton` (o cualquier control) y le
|
|
8
|
+
* agrega el par de sombras opuestas que dibuja un círculo sin relleno ni
|
|
9
|
+
* borde. El botón se lee "hundido" al presionar, y "prendido" con `$prendido`
|
|
10
|
+
* — el mismo tratamiento en reposo mientras el estado se sostiene, para "vos
|
|
11
|
+
* estás acá" o "esto está activo".
|
|
12
|
+
*
|
|
13
|
+
* Es un `<span>` alrededor del control, no un `styled(IconButton)`, porque
|
|
14
|
+
* `IconButton` es polimórfico (`as={Link}`): styled-components intercepta su
|
|
15
|
+
* propio `as` en cualquier componente que envuelve, así que envolverlo así
|
|
16
|
+
* haría que renderice el `Link` directo y se saltee toda la lógica de
|
|
17
|
+
* `IconButton` (aria, badge, bloqueo por motivo). El span sólo pinta la
|
|
18
|
+
* sombra; el click, el foco y el aria son enteramente del control de adentro.
|
|
19
|
+
*
|
|
20
|
+
* ```tsx
|
|
21
|
+
* <Relieve>
|
|
22
|
+
* <IconButton label="Notificaciones" badge={3}><IconoCampana /></IconButton>
|
|
23
|
+
* </Relieve>
|
|
24
|
+
*
|
|
25
|
+
* <Relieve $prendido={enPerfil}>
|
|
26
|
+
* <IconButton as={Link} href="/perfil" active={enPerfil} label="Perfil">
|
|
27
|
+
* <IconoPerfil />
|
|
28
|
+
* </IconButton>
|
|
29
|
+
* </Relieve>
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* Nace en mixbol-front y valle-verde, cada uno con su propia paleta —el
|
|
33
|
+
* relieve es neutro (`--kz-relief-*`, negro/blanco) y cada producto lo tiñe
|
|
34
|
+
* redefiniendo esos tres tokens con el color de su marca, igual que hace
|
|
35
|
+
* cualquier otro token de la librería.
|
|
36
|
+
*/
|
|
37
|
+
export const Relieve = styled.span<{ $prendido?: boolean }>`
|
|
38
|
+
display: inline-flex;
|
|
39
|
+
border-radius: 50%;
|
|
40
|
+
box-shadow: ${({ $prendido }) => ($prendido ? relief.in : relief.out)};
|
|
41
|
+
transition: box-shadow ${motion.fast} ${motion.ease};
|
|
42
|
+
|
|
43
|
+
/* $prendido ya vale el relieve hundido: hover no le suma nada. */
|
|
44
|
+
&:hover {
|
|
45
|
+
box-shadow: ${({ $prendido }) => ($prendido ? relief.in : relief.outStrong)};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
&:active {
|
|
49
|
+
box-shadow: ${relief.in};
|
|
50
|
+
transform: translateY(1px);
|
|
51
|
+
}
|
|
52
|
+
`;
|
package/src/ui/index.ts
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
export { IconButton } from './IconButton';
|
|
2
2
|
export type { IconButtonSize } from './IconButton';
|
|
3
|
+
export { Medidor } from './Medidor';
|
|
4
|
+
export type { MedidorSentido } from './Medidor';
|
|
5
|
+
export { Relieve } from './Relieve';
|
|
6
|
+
export { ReactionButton } from './ReactionButton';
|
|
7
|
+
export type { ReactionTone } from './ReactionButton';
|
package/src/ui/shell.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { css } from 'styled-components';
|
|
2
|
+
import { motion, radius, ring } from '../tokens';
|
|
3
|
+
|
|
4
|
+
export type IconButtonSize = 'sm' | 'md' | 'lg';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Lado del círculo, en px. `lg` es el área táctil mínima de una topbar móvil
|
|
8
|
+
* (44px): si el control va en una barra que se toca con el pulgar, es el que
|
|
9
|
+
* corresponde. No son tokens porque son medidas de este componente, no una
|
|
10
|
+
* escala que otros usen.
|
|
11
|
+
*/
|
|
12
|
+
export const LADO_ICONO: Record<'sm' | 'md' | 'lg', number> = { sm: 32, md: 40, lg: 44 };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Forma compartida entre `IconButton` y `ReactionButton`: el círculo, el
|
|
16
|
+
* foco y el bloqueo. Lo que NO comparten —de dónde sale el color, si hay
|
|
17
|
+
* fondo teñido— es justamente lo que los distingue, y vive en cada uno.
|
|
18
|
+
*
|
|
19
|
+
* `IconButton` es "esto está seleccionado" (fondo teñido, un solo color de
|
|
20
|
+
* encendido). `ReactionButton` es "reaccioné con esto" (sin fondo, color +
|
|
21
|
+
* resplandor por tono, con contador). Forzar los dos casos por una sola
|
|
22
|
+
* prop de color es lo que hacía que una reacción con `tone="heart"` te
|
|
23
|
+
* dejara, de yapa, un círculo rosa detrás del ícono que nadie pidió.
|
|
24
|
+
*/
|
|
25
|
+
export const formaBase = (size: 'sm' | 'md' | 'lg') => css`
|
|
26
|
+
position: relative;
|
|
27
|
+
display: inline-flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
justify-content: center;
|
|
30
|
+
flex-shrink: 0;
|
|
31
|
+
width: ${LADO_ICONO[size]}px;
|
|
32
|
+
height: ${LADO_ICONO[size]}px;
|
|
33
|
+
padding: 0;
|
|
34
|
+
border: none;
|
|
35
|
+
border-radius: ${radius.circle};
|
|
36
|
+
font-family: inherit;
|
|
37
|
+
text-decoration: none;
|
|
38
|
+
cursor: pointer;
|
|
39
|
+
transition:
|
|
40
|
+
background ${motion.fast} ${motion.ease},
|
|
41
|
+
color ${motion.fast} ${motion.ease};
|
|
42
|
+
|
|
43
|
+
svg {
|
|
44
|
+
display: block;
|
|
45
|
+
width: 22px;
|
|
46
|
+
height: 22px;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
&:focus-visible {
|
|
50
|
+
outline: 2px solid ${ring};
|
|
51
|
+
outline-offset: 2px;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
&[aria-disabled='true'] {
|
|
55
|
+
opacity: 0.45;
|
|
56
|
+
cursor: not-allowed;
|
|
57
|
+
}
|
|
58
|
+
`;
|