kaizen-lib 0.2.11 → 0.2.13
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 +337 -318
- package/package.json +1 -1
- package/src/ui/DiscoHaxball.tsx +101 -81
- package/src/ui/PanelExpandible.tsx +299 -0
- package/src/ui/avatarHaxball.ts +23 -0
- package/src/ui/camisetasHaxball.ts +128 -128
- package/src/ui/index.ts +19 -17
package/package.json
CHANGED
package/src/ui/DiscoHaxball.tsx
CHANGED
|
@@ -1,81 +1,101 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useId } from 'react';
|
|
4
|
-
import styled from 'styled-components';
|
|
5
|
-
import { CAMISETAS_HAXBALL, type CamisetaHaxball, type NombreCamiseta } from './camisetasHaxball';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* `
|
|
44
|
-
*
|
|
45
|
-
* no
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useId } from 'react';
|
|
4
|
+
import styled from 'styled-components';
|
|
5
|
+
import { CAMISETAS_HAXBALL, type CamisetaHaxball, type NombreCamiseta } from './camisetasHaxball';
|
|
6
|
+
import { recortarAvatarHaxball } from './avatarHaxball';
|
|
7
|
+
|
|
8
|
+
export { CAMISETAS_HAXBALL } from './camisetasHaxball';
|
|
9
|
+
export type { CamisetaHaxball, NombreCamiseta } from './camisetasHaxball';
|
|
10
|
+
export { recortarAvatarHaxball, LUGARES_AVATAR_HAXBALL } from './avatarHaxball';
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
/** Una clave de `CAMISETAS_HAXBALL`, o una camiseta suelta para previsualizar valores que todavía no están en el catálogo. */
|
|
14
|
+
camiseta: NombreCamiseta | CamisetaHaxball;
|
|
15
|
+
/** El avatar del jugador sobre el disco, en `colorTexto`: lo que no entra se corta con `recortarAvatarHaxball`. */
|
|
16
|
+
texto?: string;
|
|
17
|
+
/** Lado del cuadrado, en px. */
|
|
18
|
+
size?: number;
|
|
19
|
+
className?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Ancho del `viewBox`: un solo ciclo de colores cubre el disco de punta a punta, como en Haxball — 3 colores son 3 franjas, nunca un patrón que se repite. */
|
|
23
|
+
const ANCHO_DISCO = 100;
|
|
24
|
+
|
|
25
|
+
/** Alto de sobra del patrón para que, rotado a cualquier ángulo, siga cubriendo el círculo entero (diagonal de un cuadrado de 100 ≈ 141). */
|
|
26
|
+
const ALTO_PATRON = 200;
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
function resolverCamiseta(camiseta: Props['camiseta']): CamisetaHaxball {
|
|
30
|
+
return typeof camiseta === 'string' ? CAMISETAS_HAXBALL[camiseta] : camiseta;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const Svg = styled.svg`
|
|
34
|
+
display: block;
|
|
35
|
+
flex-shrink: 0;
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* El disco de Haxball: la ficha con la que un jugador aparece en la cancha,
|
|
40
|
+
* pintada con las franjas que arma `room.setTeamColors(equipo, angulo,
|
|
41
|
+
* colorTexto, colores)` — la única personalización visual que da la API del
|
|
42
|
+
* juego, y la fuente de `CAMISETAS_HAXBALL` (copiada de
|
|
43
|
+
* `kaizen-bot/structure/base.js`).
|
|
44
|
+
*
|
|
45
|
+
* **Es una aproximación, no un renderer pixel-perfect.** No hay documentación
|
|
46
|
+
* pública del algoritmo exacto de Haxball (grosor real de franja, radio real
|
|
47
|
+
* del disco): esto reproduce el modelo general —franjas paralelas rotadas por
|
|
48
|
+
* `angulo`, una por color y de igual ancho, repartidas sobre el disco—
|
|
49
|
+
* calibrado a ojo. Si en algún momento hace falta fidelidad exacta, hay que
|
|
50
|
+
* calibrarlo contra capturas del juego real, no contra esta implementación.
|
|
51
|
+
*
|
|
52
|
+
* `texto` se dibuja en `colorTexto`, el mismo color que usa el juego para el
|
|
53
|
+
* avatar del jugador sobre el disco.
|
|
54
|
+
*/
|
|
55
|
+
export function DiscoHaxball({ camiseta, texto, size = 64, className }: Props) {
|
|
56
|
+
const { angulo, colorTexto, colores, nombre } = resolverCamiseta(camiseta);
|
|
57
|
+
const caracteres = texto ? recortarAvatarHaxball(texto) : '';
|
|
58
|
+
const patternId = `disco-haxball-${useId()}`;
|
|
59
|
+
const grosorFranja = ANCHO_DISCO / colores.length;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Svg
|
|
63
|
+
viewBox={`0 0 ${ANCHO_DISCO} ${ANCHO_DISCO}`}
|
|
64
|
+
width={size}
|
|
65
|
+
height={size}
|
|
66
|
+
role="img"
|
|
67
|
+
aria-label={`Camiseta de ${nombre}`}
|
|
68
|
+
className={className}
|
|
69
|
+
>
|
|
70
|
+
<defs>
|
|
71
|
+
<pattern
|
|
72
|
+
id={patternId}
|
|
73
|
+
patternUnits="userSpaceOnUse"
|
|
74
|
+
width={ANCHO_DISCO}
|
|
75
|
+
height={ALTO_PATRON}
|
|
76
|
+
patternTransform={`rotate(${angulo} 50 50)`}
|
|
77
|
+
>
|
|
78
|
+
{colores.map((color, indice) => (
|
|
79
|
+
<rect key={indice} x={indice * grosorFranja} y={0} width={grosorFranja} height={ALTO_PATRON} fill={color} />
|
|
80
|
+
))}
|
|
81
|
+
</pattern>
|
|
82
|
+
</defs>
|
|
83
|
+
<circle cx={50} cy={50} r={48} fill={`url(#${patternId})`} />
|
|
84
|
+
{caracteres && (
|
|
85
|
+
<text
|
|
86
|
+
x={50}
|
|
87
|
+
y={50}
|
|
88
|
+
textAnchor="middle"
|
|
89
|
+
dominantBaseline="central"
|
|
90
|
+
fontFamily="Arial, Helvetica, sans-serif"
|
|
91
|
+
fontWeight={700}
|
|
92
|
+
fontSize={44}
|
|
93
|
+
fill={colorTexto}
|
|
94
|
+
aria-hidden="true"
|
|
95
|
+
>
|
|
96
|
+
{caracteres}
|
|
97
|
+
</text>
|
|
98
|
+
)}
|
|
99
|
+
</Svg>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useState, type ReactNode } from 'react';
|
|
4
|
+
import styled from 'styled-components';
|
|
5
|
+
import { fg, font, hairline, motion, radius, ring, shadow, space, surface } from '../tokens';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Un **panel** de una pantalla: una elevación partida en dos. Elicitado de
|
|
9
|
+
* valle-verde (v1.3.13), donde ya lo usaban siete pantallas.
|
|
10
|
+
*
|
|
11
|
+
* - **Cabecera**: el título y, bien a la vista, el **indicador** que resume lo
|
|
12
|
+
* que hay adentro, con una leyenda chica debajo que dice qué es ese número.
|
|
13
|
+
* En el extremo, el chevron.
|
|
14
|
+
* - **Cuerpo**: el contenido. Se pliega y se despliega.
|
|
15
|
+
*
|
|
16
|
+
* El indicador es lo que justifica plegar: con el panel cerrado la cabecera
|
|
17
|
+
* sigue diciendo lo que importa, y la pantalla entra entera sin scrollear.
|
|
18
|
+
*
|
|
19
|
+
* ## Decisiones
|
|
20
|
+
*
|
|
21
|
+
* - **Plegado no desmonta.** El cuerpo queda en el DOM con `hidden`: una tabla
|
|
22
|
+
* con filas seleccionadas o filtros cargados los conserva al reabrir, y no
|
|
23
|
+
* vuelve a pedir sus datos.
|
|
24
|
+
* - **El control accesible es el chevron**, un botón con `aria-expanded` y
|
|
25
|
+
* `aria-controls`. Toda la cabecera responde al click por comodidad de mouse
|
|
26
|
+
* y de dedo, pero no es un botón: adentro lleva un encabezado y un número, y
|
|
27
|
+
* un botón no puede contener un encabezado.
|
|
28
|
+
* - **Controlado o no**: `abierto` + `onCambiar`, o `abiertoInicial`.
|
|
29
|
+
* - **Se compacta, no desborda.** El plegable es un grid con la columna en
|
|
30
|
+
* `minmax(0, 1fr)`: sin columna declarada, la implícita medía el ancho
|
|
31
|
+
* máximo del contenido y una tabla ancha empujaba el panel fuera de la
|
|
32
|
+
* pantalla (valle-verde 1.4.3).
|
|
33
|
+
*
|
|
34
|
+
* ```tsx
|
|
35
|
+
* <PanelExpandible titulo="Comprometido" indicador={12} leyenda="pacientes sin entrega">
|
|
36
|
+
* <Tabla />
|
|
37
|
+
* </PanelExpandible>
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const Raiz = styled.section`
|
|
42
|
+
border-radius: ${radius.lg};
|
|
43
|
+
background: ${surface[1]};
|
|
44
|
+
box-shadow: ${shadow.sm};
|
|
45
|
+
min-width: 0;
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
const Cabecera = styled.div<{ $abierto: boolean; $plegable: boolean }>`
|
|
49
|
+
display: flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
gap: ${space[4]};
|
|
52
|
+
padding: ${space[4]} ${space[5]};
|
|
53
|
+
border-radius: ${({ $abierto }) => ($abierto ? `${radius.lg} ${radius.lg} 0 0` : radius.lg)};
|
|
54
|
+
cursor: ${({ $plegable }) => ($plegable ? 'pointer' : 'default')};
|
|
55
|
+
user-select: none;
|
|
56
|
+
transition: background ${motion.fast} ${motion.ease};
|
|
57
|
+
|
|
58
|
+
&:hover {
|
|
59
|
+
background: ${({ $plegable }) => ($plegable ? surface.hover : 'transparent')};
|
|
60
|
+
}
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const Titulo = styled.h2`
|
|
64
|
+
flex: 1 1 auto;
|
|
65
|
+
min-width: 0;
|
|
66
|
+
margin: 0;
|
|
67
|
+
font-size: ${font.xl};
|
|
68
|
+
font-weight: 600;
|
|
69
|
+
line-height: 1.2;
|
|
70
|
+
color: ${fg.DEFAULT};
|
|
71
|
+
overflow-wrap: anywhere;
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
const Indicador = styled.div`
|
|
75
|
+
flex: 0 1 auto;
|
|
76
|
+
min-width: 0;
|
|
77
|
+
display: flex;
|
|
78
|
+
flex-direction: column;
|
|
79
|
+
align-items: flex-end;
|
|
80
|
+
text-align: right;
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
const Valor = styled.strong`
|
|
84
|
+
font-size: ${font['2xl']};
|
|
85
|
+
font-weight: 600;
|
|
86
|
+
line-height: 1.1;
|
|
87
|
+
font-variant-numeric: tabular-nums;
|
|
88
|
+
color: ${fg.DEFAULT};
|
|
89
|
+
white-space: nowrap;
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
const Leyenda = styled.span`
|
|
93
|
+
font-size: ${font.xs};
|
|
94
|
+
color: ${fg.subtle};
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
const Indicadores = styled.div`
|
|
98
|
+
flex: 0 1 auto;
|
|
99
|
+
min-width: 0;
|
|
100
|
+
display: flex;
|
|
101
|
+
flex-wrap: wrap;
|
|
102
|
+
justify-content: flex-end;
|
|
103
|
+
gap: ${space[2]} ${space[5]};
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
const Chevron = styled.button<{ $abierto: boolean; $duracion: number }>`
|
|
107
|
+
flex: 0 0 auto;
|
|
108
|
+
display: inline-flex;
|
|
109
|
+
align-items: center;
|
|
110
|
+
justify-content: center;
|
|
111
|
+
width: 36px;
|
|
112
|
+
height: 36px;
|
|
113
|
+
padding: 0;
|
|
114
|
+
border: none;
|
|
115
|
+
border-radius: ${radius.md};
|
|
116
|
+
background: transparent;
|
|
117
|
+
color: ${fg.muted};
|
|
118
|
+
cursor: pointer;
|
|
119
|
+
outline: 2px solid transparent;
|
|
120
|
+
outline-offset: 2px;
|
|
121
|
+
|
|
122
|
+
&:focus-visible { outline-color: ${ring}; }
|
|
123
|
+
|
|
124
|
+
svg {
|
|
125
|
+
transform: rotate(${({ $abierto }) => ($abierto ? '180deg' : '0deg')});
|
|
126
|
+
transition: transform ${({ $duracion }) => Math.min($duracion, 300)}ms ${motion.ease};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@media (prefers-reduced-motion: reduce) {
|
|
130
|
+
svg { transition: none; }
|
|
131
|
+
}
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
const Cuerpo = styled.div`
|
|
135
|
+
padding: ${space[5]};
|
|
136
|
+
border-top: 1px solid ${hairline.DEFAULT};
|
|
137
|
+
min-width: 0;
|
|
138
|
+
`;
|
|
139
|
+
|
|
140
|
+
/*
|
|
141
|
+
* El alto se anima con `grid-template-rows` de 0fr a 1fr: anima hasta el alto
|
|
142
|
+
* real del contenido sin medirlo, que con `height` no se puede (no anima a
|
|
143
|
+
* `auto`). El hijo directo recorta lo que no entra mientras tanto.
|
|
144
|
+
*/
|
|
145
|
+
const Plegable = styled.div<{ $expandido: boolean; $duracion: number }>`
|
|
146
|
+
display: grid;
|
|
147
|
+
grid-template-columns: minmax(0, 1fr);
|
|
148
|
+
grid-template-rows: ${({ $expandido }) => ($expandido ? '1fr' : '0fr')};
|
|
149
|
+
transition: grid-template-rows ${({ $duracion }) => $duracion}ms ${motion.ease};
|
|
150
|
+
|
|
151
|
+
> div {
|
|
152
|
+
min-height: 0;
|
|
153
|
+
overflow: hidden;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
&[hidden] { display: none; }
|
|
157
|
+
|
|
158
|
+
@media (prefers-reduced-motion: reduce) {
|
|
159
|
+
transition: none;
|
|
160
|
+
}
|
|
161
|
+
`;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* ¿Se anima? Con `prefers-reduced-motion`, y donde el navegador no sabe
|
|
165
|
+
* preguntarlo, el panel se abre y se cierra de una (el estado seguro).
|
|
166
|
+
*/
|
|
167
|
+
const puedeAnimar = (): boolean =>
|
|
168
|
+
typeof window !== 'undefined'
|
|
169
|
+
&& typeof window.matchMedia === 'function'
|
|
170
|
+
&& typeof window.requestAnimationFrame === 'function'
|
|
171
|
+
&& !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
172
|
+
|
|
173
|
+
export type PanelExpandibleProps = {
|
|
174
|
+
/** El nombre del panel. Se escribe como se lee: "Comprometido", no "COMPROMETIDO". */
|
|
175
|
+
titulo: string;
|
|
176
|
+
/** El número (o dato) que resume el contenido. Va grande, a la derecha de la cabecera. */
|
|
177
|
+
indicador?: ReactNode;
|
|
178
|
+
/** Qué es el indicador, en una línea corta. */
|
|
179
|
+
leyenda?: ReactNode;
|
|
180
|
+
/** Más de un indicador, cada uno con su leyenda. Con esto, `indicador` y `leyenda` no se miran. */
|
|
181
|
+
indicadores?: { valor: ReactNode; leyenda?: ReactNode }[];
|
|
182
|
+
/**
|
|
183
|
+
* Si el panel se pliega (por defecto, sí). Con `false` conserva el formato pero no
|
|
184
|
+
* tiene chevron ni responde al click: para la pantalla que tiene **un solo** panel.
|
|
185
|
+
*/
|
|
186
|
+
plegable?: boolean;
|
|
187
|
+
/** Arranca abierto (por defecto) o plegado, cuando el panel maneja su propio estado. */
|
|
188
|
+
abiertoInicial?: boolean;
|
|
189
|
+
/** Controlado desde afuera. Con esto, `abiertoInicial` no se mira. */
|
|
190
|
+
abierto?: boolean;
|
|
191
|
+
onCambiar?: (abierto: boolean) => void;
|
|
192
|
+
/** Cuánto tarda en plegarse o desplegarse, en ms. Default 300. */
|
|
193
|
+
duracionMs?: number;
|
|
194
|
+
/**
|
|
195
|
+
* Nombre accesible del chevron, sin el título (que se agrega solo). Defaults
|
|
196
|
+
* `'Plegar'` y `'Desplegar'`: un consumidor con más de un idioma los pisa.
|
|
197
|
+
*/
|
|
198
|
+
etiquetaPlegar?: string;
|
|
199
|
+
etiquetaDesplegar?: string;
|
|
200
|
+
className?: string;
|
|
201
|
+
children: ReactNode;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export function PanelExpandible({
|
|
205
|
+
titulo,
|
|
206
|
+
indicador,
|
|
207
|
+
leyenda,
|
|
208
|
+
indicadores,
|
|
209
|
+
plegable = true,
|
|
210
|
+
abiertoInicial = true,
|
|
211
|
+
abierto: abiertoControlado,
|
|
212
|
+
onCambiar,
|
|
213
|
+
duracionMs = 300,
|
|
214
|
+
etiquetaPlegar = 'Plegar',
|
|
215
|
+
etiquetaDesplegar = 'Desplegar',
|
|
216
|
+
className,
|
|
217
|
+
children,
|
|
218
|
+
}: PanelExpandibleProps) {
|
|
219
|
+
const [abiertoPropio, setAbiertoPropio] = useState(abiertoInicial);
|
|
220
|
+
const abierto = !plegable || (abiertoControlado ?? abiertoPropio);
|
|
221
|
+
|
|
222
|
+
/*
|
|
223
|
+
* Dos estados además de `abierto`: si el cuerpo está en el árbol visible
|
|
224
|
+
* (`montado`) y si está desplegado (`expandido`). Al abrir, primero se
|
|
225
|
+
* muestra plegado y en el cuadro siguiente se despliega, para que haya algo
|
|
226
|
+
* que animar. Al cerrar, se pliega y recién al terminar se oculta: así lo
|
|
227
|
+
* plegado no queda enfocable ni lo lee un lector de pantalla. Por eso la
|
|
228
|
+
* duración es una prop y no sólo CSS: el temporizador tiene que coincidir.
|
|
229
|
+
*/
|
|
230
|
+
const [montado, setMontado] = useState(abierto);
|
|
231
|
+
const [expandido, setExpandido] = useState(abierto);
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (!puedeAnimar()) {
|
|
234
|
+
setMontado(abierto);
|
|
235
|
+
setExpandido(abierto);
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
if (abierto) {
|
|
239
|
+
setMontado(true);
|
|
240
|
+
const cuadro = window.requestAnimationFrame(() => setExpandido(true));
|
|
241
|
+
return () => window.cancelAnimationFrame(cuadro);
|
|
242
|
+
}
|
|
243
|
+
setExpandido(false);
|
|
244
|
+
const espera = window.setTimeout(() => setMontado(false), duracionMs);
|
|
245
|
+
return () => window.clearTimeout(espera);
|
|
246
|
+
}, [abierto, duracionMs]);
|
|
247
|
+
const idCuerpo = useId();
|
|
248
|
+
|
|
249
|
+
const alternar = () => {
|
|
250
|
+
if (!plegable) return;
|
|
251
|
+
if (abiertoControlado === undefined) setAbiertoPropio(!abierto);
|
|
252
|
+
onCambiar?.(!abierto);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const hayIndicador = indicador !== undefined && indicador !== null;
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<Raiz className={className}>
|
|
259
|
+
<Cabecera $abierto={abierto} $plegable={plegable} onClick={alternar}>
|
|
260
|
+
<Titulo>{titulo}</Titulo>
|
|
261
|
+
{indicadores ? (
|
|
262
|
+
<Indicadores>
|
|
263
|
+
{indicadores.map((it, i) => (
|
|
264
|
+
<Indicador key={i}>
|
|
265
|
+
<Valor>{it.valor}</Valor>
|
|
266
|
+
{it.leyenda && <Leyenda>{it.leyenda}</Leyenda>}
|
|
267
|
+
</Indicador>
|
|
268
|
+
))}
|
|
269
|
+
</Indicadores>
|
|
270
|
+
) : (hayIndicador || leyenda) && (
|
|
271
|
+
<Indicador>
|
|
272
|
+
{hayIndicador && <Valor>{indicador}</Valor>}
|
|
273
|
+
{leyenda && <Leyenda>{leyenda}</Leyenda>}
|
|
274
|
+
</Indicador>
|
|
275
|
+
)}
|
|
276
|
+
{plegable && (
|
|
277
|
+
<Chevron
|
|
278
|
+
type="button"
|
|
279
|
+
$abierto={abierto}
|
|
280
|
+
$duracion={duracionMs}
|
|
281
|
+
aria-expanded={abierto}
|
|
282
|
+
aria-controls={idCuerpo}
|
|
283
|
+
aria-label={`${abierto ? etiquetaPlegar : etiquetaDesplegar} ${titulo}`}
|
|
284
|
+
// El click sube a la cabecera, que es la que alterna: acá no se repite.
|
|
285
|
+
>
|
|
286
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
287
|
+
<polyline points="6 9 12 15 18 9" />
|
|
288
|
+
</svg>
|
|
289
|
+
</Chevron>
|
|
290
|
+
)}
|
|
291
|
+
</Cabecera>
|
|
292
|
+
<Plegable id={idCuerpo} $expandido={expandido} $duracion={duracionMs} hidden={!montado}>
|
|
293
|
+
<div>
|
|
294
|
+
<Cuerpo>{children}</Cuerpo>
|
|
295
|
+
</div>
|
|
296
|
+
</Plegable>
|
|
297
|
+
</Raiz>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Lugares del avatar de Haxball sobre el disco: dos caracteres, o un emoji que ocupa los dos. */
|
|
2
|
+
export const LUGARES_AVATAR_HAXBALL = 2;
|
|
3
|
+
|
|
4
|
+
const EMOJI = /\p{Extended_Pictographic}|\p{Regional_Indicator}/u;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Recorta un texto a lo que entra en el avatar: cuenta por grafemas (una
|
|
8
|
+
* bandera o una familia es uno solo), un emoji ocupa los 2 lugares y lo que
|
|
9
|
+
* no entra entero se descarta, en orden. Es la regla que valida quien guarda
|
|
10
|
+
* el avatar y la que aplica `DiscoHaxball` al dibujarlo.
|
|
11
|
+
*/
|
|
12
|
+
export function recortarAvatarHaxball(texto: string): string {
|
|
13
|
+
const grafemas = new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(texto.trim());
|
|
14
|
+
let resultado = '';
|
|
15
|
+
let ocupados = 0;
|
|
16
|
+
for (const { segment } of grafemas) {
|
|
17
|
+
const lugares = EMOJI.test(segment) ? LUGARES_AVATAR_HAXBALL : 1;
|
|
18
|
+
if (ocupados + lugares > LUGARES_AVATAR_HAXBALL) break;
|
|
19
|
+
resultado += segment;
|
|
20
|
+
ocupados += lugares;
|
|
21
|
+
}
|
|
22
|
+
return resultado;
|
|
23
|
+
}
|