chore: auto-commit (57 archivos)
- frontend/functions/core/format_datetime_short.md - frontend/functions/core/format_datetime_short.test.ts - frontend/functions/core/format_datetime_short.ts - frontend/functions/core/format_duration.md - frontend/functions/core/format_duration.test.ts - frontend/functions/core/format_duration.ts - frontend/functions/core/month_grid.md - frontend/functions/core/month_grid.test.ts - frontend/functions/core/month_grid.ts - frontend/functions/core/string_hash_palette.md - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: format_datetime_short
|
||||
kind: function
|
||||
lang: ts
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "formatDateTimeShort(iso: string): string"
|
||||
description: "Formatea un string ISO datetime a formato corto \"dd/mm/yy hh:mm\". ISO invalido retorna string vacio."
|
||||
tags: [format, datetime, date, display, utility]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: iso
|
||||
desc: "String ISO 8601 con fecha y hora (ej: \"2026-05-09T14:30:00.000Z\" o \"2026-05-09T14:30:00\"). Cualquier string que `new Date()` no pueda parsear retorna \"\"."
|
||||
output: "String con formato \"dd/mm/yy hh:mm\" usando la hora local del entorno. String vacio si el ISO es invalido."
|
||||
tested: true
|
||||
tests:
|
||||
- "ISO valido retorna dd/mm/yy hh:mm"
|
||||
- "string vacio retorna vacio"
|
||||
- "ISO invalido retorna vacio"
|
||||
- "padding de dia y mes con cero"
|
||||
test_file_path: "frontend/functions/core/format_datetime_short.test.ts"
|
||||
file_path: "frontend/functions/core/format_datetime_short.ts"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```typescript
|
||||
formatDateTimeShort("2026-05-09T14:30:00") // "09/05/26 14:30"
|
||||
formatDateTimeShort("2026-01-01T00:00:00") // "01/01/26 00:00"
|
||||
formatDateTimeShort("not-a-date") // ""
|
||||
formatDateTimeShort("") // ""
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraida de `apps/kanban/frontend/src/components/format.ts` lineas 32-41.
|
||||
Usa `Date` nativo del entorno. La hora mostrada es **hora local** del navegador/runtime (getHours, getMinutes).
|
||||
El ano se trunca a 2 digitos (slice(-2)), correcto para fechas 2000-2099.
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatDateTimeShort } from "./format_datetime_short";
|
||||
|
||||
// Nota: la funcion usa hora local del entorno (getHours/getMinutes).
|
||||
// Los tests usan fechas con offset conocido o verifican el patron de formato.
|
||||
|
||||
describe("formatDateTimeShort", () => {
|
||||
it("string vacio retorna vacio", () => {
|
||||
expect(formatDateTimeShort("")).toBe("");
|
||||
});
|
||||
|
||||
it("ISO invalido retorna vacio", () => {
|
||||
expect(formatDateTimeShort("not-a-date")).toBe("");
|
||||
expect(formatDateTimeShort("2026-13-01")).toBe("");
|
||||
});
|
||||
|
||||
it("ISO valido retorna dd/mm/yy hh:mm", () => {
|
||||
// Verificamos el patron sin asumir timezone: resultado debe tener forma xx/xx/xx xx:xx
|
||||
const result = formatDateTimeShort("2026-05-09T14:30:00");
|
||||
expect(result).toMatch(/^\d{2}\/\d{2}\/\d{2} \d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it("padding de dia y mes con cero", () => {
|
||||
// Dia 1, mes enero: debe ser "01/01/..."
|
||||
const result = formatDateTimeShort("2026-01-01T00:00:00");
|
||||
expect(result).toMatch(/^01\/01\//);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Formatea un string ISO a "dd/mm/yy hh:mm".
|
||||
* Retorna "" si el ISO es invalido o no parseable.
|
||||
*/
|
||||
export function formatDateTimeShort(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const yy = String(d.getFullYear()).slice(-2);
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${dd}/${mm}/${yy} ${hh}:${mi}`;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: format_duration
|
||||
kind: function
|
||||
lang: ts
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "formatDuration(ms: number): string"
|
||||
description: "Formatea una duracion en milisegundos a string humano escalado. Escala: m | h Xm | D Xh | S XD | M XS. NaN, Infinity o negativo retornan \"0m\"."
|
||||
tags: [format, duration, time, display, utility]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: ms
|
||||
desc: "Duracion en milisegundos. Debe ser un numero finito no negativo. NaN, Infinity o negativos producen \"0m\"."
|
||||
output: "String humano escalado segun magnitud: minutos (Xm), horas+minutos (Xh Ym), dias+horas (XD Yh), semanas+dias (XS YD), meses+semanas (XM YS). Menor de un minuto devuelve \"0m\"."
|
||||
tested: true
|
||||
tests:
|
||||
- "menos de un minuto retorna 0m"
|
||||
- "exactamente 1 hora retorna 1h"
|
||||
- "1h 30m retorna 1h 30m"
|
||||
- "1 dia sin horas retorna 1D"
|
||||
- "2 dias 3 horas retorna 2D 3h"
|
||||
- "1 semana sin dias retorna 1S"
|
||||
- "2 semanas 3 dias retorna 2S 3D"
|
||||
- "1 mes sin semanas retorna 1M"
|
||||
- "2 meses 1 semana retorna 2M 1S"
|
||||
- "NaN retorna 0m"
|
||||
- "negativo retorna 0m"
|
||||
- "Infinity retorna 0m"
|
||||
test_file_path: "frontend/functions/core/format_duration.test.ts"
|
||||
file_path: "frontend/functions/core/format_duration.ts"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```typescript
|
||||
formatDuration(0) // "0m"
|
||||
formatDuration(30_000) // "0m" (30s < 1min)
|
||||
formatDuration(90_000) // "1m"
|
||||
formatDuration(3_600_000) // "1h"
|
||||
formatDuration(5_400_000) // "1h 30m"
|
||||
formatDuration(86_400_000) // "1D"
|
||||
formatDuration(97_200_000) // "1D 3h"
|
||||
formatDuration(604_800_000) // "1S"
|
||||
formatDuration(2_592_000_000) // "1M"
|
||||
formatDuration(NaN) // "0m"
|
||||
formatDuration(-1) // "0m"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraida de `apps/kanban/frontend/src/components/format.ts` lineas 9-30.
|
||||
Funcion pura sin dependencias externas. Usa constantes locales privadas (MIN, HOUR, DAY, WEEK, MONTH).
|
||||
Escala en ingles/abreviada: m=minutos, h=horas, D=dias, S=semanas, M=meses.
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatDuration } from "./format_duration";
|
||||
|
||||
const MIN = 60_000;
|
||||
const HOUR = 60 * MIN;
|
||||
const DAY = 24 * HOUR;
|
||||
const WEEK = 7 * DAY;
|
||||
const MONTH = 30 * DAY;
|
||||
|
||||
describe("formatDuration", () => {
|
||||
it("menos de un minuto retorna 0m", () => {
|
||||
expect(formatDuration(0)).toBe("0m");
|
||||
expect(formatDuration(30_000)).toBe("0m");
|
||||
expect(formatDuration(59_999)).toBe("0m");
|
||||
});
|
||||
|
||||
it("exactamente 1 hora retorna 1h", () => {
|
||||
expect(formatDuration(HOUR)).toBe("1h");
|
||||
});
|
||||
|
||||
it("1h 30m retorna 1h 30m", () => {
|
||||
expect(formatDuration(HOUR + 30 * MIN)).toBe("1h 30m");
|
||||
});
|
||||
|
||||
it("1 dia sin horas retorna 1D", () => {
|
||||
expect(formatDuration(DAY)).toBe("1D");
|
||||
});
|
||||
|
||||
it("2 dias 3 horas retorna 2D 3h", () => {
|
||||
expect(formatDuration(2 * DAY + 3 * HOUR)).toBe("2D 3h");
|
||||
});
|
||||
|
||||
it("1 semana sin dias retorna 1S", () => {
|
||||
expect(formatDuration(WEEK)).toBe("1S");
|
||||
});
|
||||
|
||||
it("2 semanas 3 dias retorna 2S 3D", () => {
|
||||
expect(formatDuration(2 * WEEK + 3 * DAY)).toBe("2S 3D");
|
||||
});
|
||||
|
||||
it("1 mes sin semanas retorna 1M", () => {
|
||||
expect(formatDuration(MONTH)).toBe("1M");
|
||||
});
|
||||
|
||||
it("2 meses 1 semana retorna 2M 1S", () => {
|
||||
expect(formatDuration(2 * MONTH + WEEK)).toBe("2M 1S");
|
||||
});
|
||||
|
||||
it("NaN retorna 0m", () => {
|
||||
expect(formatDuration(NaN)).toBe("0m");
|
||||
});
|
||||
|
||||
it("negativo retorna 0m", () => {
|
||||
expect(formatDuration(-1)).toBe("0m");
|
||||
expect(formatDuration(-999)).toBe("0m");
|
||||
});
|
||||
|
||||
it("Infinity retorna 0m", () => {
|
||||
expect(formatDuration(Infinity)).toBe("0m");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// Escala unidades segun magnitud: m | h Xm | D Xh | S XD | M XS.
|
||||
// <1 minuto cae como "0m" para mantener la unidad mas pequena coherente.
|
||||
const MIN = 60_000;
|
||||
const HOUR = 60 * MIN;
|
||||
const DAY = 24 * HOUR;
|
||||
const WEEK = 7 * DAY;
|
||||
const MONTH = 30 * DAY;
|
||||
|
||||
/**
|
||||
* Formatea una duracion en milisegundos a string humano escalado.
|
||||
*
|
||||
* Escala: m | h Xm | D Xh | S XD | M XS.
|
||||
* NaN, Infinity o negativo retornan "0m".
|
||||
*/
|
||||
export function formatDuration(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms < 0) return "0m";
|
||||
if (ms < HOUR) return `${Math.floor(ms / MIN)}m`;
|
||||
if (ms < DAY) {
|
||||
const h = Math.floor(ms / HOUR);
|
||||
const m = Math.floor((ms % HOUR) / MIN);
|
||||
return m === 0 ? `${h}h` : `${h}h ${m}m`;
|
||||
}
|
||||
if (ms < WEEK) {
|
||||
const d = Math.floor(ms / DAY);
|
||||
const h = Math.floor((ms % DAY) / HOUR);
|
||||
return h === 0 ? `${d}D` : `${d}D ${h}h`;
|
||||
}
|
||||
if (ms < MONTH) {
|
||||
const w = Math.floor(ms / WEEK);
|
||||
const d = Math.floor((ms % WEEK) / DAY);
|
||||
return d === 0 ? `${w}S` : `${w}S ${d}D`;
|
||||
}
|
||||
const m = Math.floor(ms / MONTH);
|
||||
const w = Math.floor((ms % MONTH) / WEEK);
|
||||
return w === 0 ? `${m}M` : `${m}M ${w}S`;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: month_grid
|
||||
kind: function
|
||||
lang: ts
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "monthGrid(year: number, month: number): { date: string | null; inMonth: boolean }[]"
|
||||
description: "Genera la cuadricula mensual con inicio en lunes (Mon-first ISO). Retorna array de celdas multiplo de 7 con padding nulo al inicio y final. Util para calendarios y vistas de mes."
|
||||
tags: [calendar, date, grid, month, utility]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: year
|
||||
desc: "Ano completo del mes a generar (ej: 2026). Rango valido: cualquier ano soportado por Date nativo."
|
||||
- name: month
|
||||
desc: "Mes 1-12 donde 1=Enero y 12=Diciembre."
|
||||
output: "Array de MonthGridCell ({ date: string|null; inMonth: boolean }) con longitud siempre multiplo de 7. Celdas de padding tienen date=null e inMonth=false. Celdas del mes tienen date en formato YYYY-MM-DD e inMonth=true."
|
||||
tested: true
|
||||
tests:
|
||||
- "enero 2026 empieza en jueves (dow=3)"
|
||||
- "febrero 2024 bisiesto tiene 29 dias"
|
||||
- "longitud siempre multiplo de 7"
|
||||
- "primer dia inMonth es el primero del mes"
|
||||
- "ultimo dia inMonth es el ultimo del mes"
|
||||
- "celdas de padding tienen date null"
|
||||
- "mes que empieza en lunes no tiene padding inicial"
|
||||
test_file_path: "frontend/functions/core/month_grid.test.ts"
|
||||
file_path: "frontend/functions/core/month_grid.ts"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```typescript
|
||||
const cells = monthGrid(2026, 5); // Mayo 2026 (1 mayo = viernes => firstDow=4)
|
||||
// cells.length es multiplo de 7 (ej: 35)
|
||||
// cells[0..3] => { date: null, inMonth: false } (padding lun-jue)
|
||||
// cells[4] => { date: "2026-05-01", inMonth: true }
|
||||
// cells[34] => { date: "2026-05-31", inMonth: true }
|
||||
|
||||
monthGrid(2026, 1); // Enero 2026: jueves=dow 3, 3 celdas padding inicial
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraida de `apps/kanban/frontend/src/components/CalendarView.tsx` lineas 72-84.
|
||||
Reemplaza la dependencia de `dayjs` por `Date` nativo para mantener la funcion sin dependencias externas.
|
||||
Algoritmo: `firstDow = (new Date(year, month-1, 1).getDay() + 6) % 7` convierte domingo=0 de JS a lunes=0 ISO.
|
||||
`new Date(year, month, 0).getDate()` obtiene el ultimo dia del mes (dia 0 del mes siguiente = ultimo del actual).
|
||||
Exporta tambien el tipo `MonthGridCell` para uso con TypeScript estricto.
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { monthGrid } from "./month_grid";
|
||||
|
||||
describe("monthGrid", () => {
|
||||
it("longitud siempre multiplo de 7", () => {
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
expect(monthGrid(2026, m).length % 7).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("enero 2026 empieza en jueves (dow=3)", () => {
|
||||
// 1 enero 2026 es jueves. Mon-first => dow=3 (0=lun,1=mar,2=mie,3=jue)
|
||||
const cells = monthGrid(2026, 1);
|
||||
expect(cells[0].date).toBeNull();
|
||||
expect(cells[1].date).toBeNull();
|
||||
expect(cells[2].date).toBeNull();
|
||||
expect(cells[3].date).toBe("2026-01-01");
|
||||
expect(cells[3].inMonth).toBe(true);
|
||||
});
|
||||
|
||||
it("febrero 2024 bisiesto tiene 29 dias", () => {
|
||||
const cells = monthGrid(2024, 2);
|
||||
const inMonth = cells.filter((c) => c.inMonth);
|
||||
expect(inMonth).toHaveLength(29);
|
||||
expect(inMonth[28].date).toBe("2024-02-29");
|
||||
});
|
||||
|
||||
it("primer dia inMonth es el primero del mes", () => {
|
||||
const cells = monthGrid(2026, 5);
|
||||
const first = cells.find((c) => c.inMonth);
|
||||
expect(first?.date).toBe("2026-05-01");
|
||||
});
|
||||
|
||||
it("ultimo dia inMonth es el ultimo del mes", () => {
|
||||
const cells = monthGrid(2026, 5); // mayo tiene 31 dias
|
||||
const last = [...cells].reverse().find((c) => c.inMonth);
|
||||
expect(last?.date).toBe("2026-05-31");
|
||||
});
|
||||
|
||||
it("celdas de padding tienen date null", () => {
|
||||
const cells = monthGrid(2026, 1);
|
||||
const padding = cells.filter((c) => !c.inMonth);
|
||||
for (const p of padding) {
|
||||
expect(p.date).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("mes que empieza en lunes no tiene padding inicial", () => {
|
||||
// Buscamos un mes que empiece en lunes. Enero 2024: lunes.
|
||||
// 1 enero 2024 es lunes => firstDow=0 => no padding inicial
|
||||
const cells = monthGrid(2024, 1);
|
||||
expect(cells[0].date).toBe("2024-01-01");
|
||||
expect(cells[0].inMonth).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface MonthGridCell {
|
||||
date: string | null;
|
||||
inMonth: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera la cuadricula mensual con inicio en lunes (ISO week).
|
||||
*
|
||||
* @param year Año completo (ej: 2026).
|
||||
* @param month Mes 1-12 (Enero = 1).
|
||||
* @returns Array de celdas con longitud multiplo de 7.
|
||||
* Celdas de padding tienen date=null e inMonth=false.
|
||||
* Celdas del mes tienen date="YYYY-MM-DD" e inMonth=true.
|
||||
*/
|
||||
export function monthGrid(year: number, month: number): MonthGridCell[] {
|
||||
// getDay() devuelve 0=Dom..6=Sab. Convertir a 0=Lun..6=Dom.
|
||||
const firstDow = (new Date(year, month - 1, 1).getDay() + 6) % 7;
|
||||
const lastDay = new Date(year, month, 0).getDate();
|
||||
|
||||
const cells: MonthGridCell[] = [];
|
||||
|
||||
// Padding inicial
|
||||
for (let i = 0; i < firstDow; i++) {
|
||||
cells.push({ date: null, inMonth: false });
|
||||
}
|
||||
|
||||
// Dias del mes
|
||||
for (let d = 1; d <= lastDay; d++) {
|
||||
const mm = String(month).padStart(2, "0");
|
||||
const dd = String(d).padStart(2, "0");
|
||||
cells.push({ date: `${year}-${mm}-${dd}`, inMonth: true });
|
||||
}
|
||||
|
||||
// Padding final hasta multiplo de 7
|
||||
while (cells.length % 7 !== 0) {
|
||||
cells.push({ date: null, inMonth: false });
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: string_hash_palette
|
||||
kind: function
|
||||
lang: ts
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "stringHashPalette(s: string, palette: string[]): string"
|
||||
description: "Mapea un string a un elemento de la paleta usando hash DJB-31 deterministico (h = (h*31 + charCodeAt) >>> 0). Util para asignar colores consistentes a tags, usuarios u otros identificadores de texto."
|
||||
tags: [hash, color, palette, string, deterministic, utility]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: s
|
||||
desc: "String de entrada a hashear. Cualquier string Unicode. String vacio produce h=0, retorna palette[0]."
|
||||
- name: palette
|
||||
desc: "Array de strings (colores u otros valores) a usar como paleta. No debe estar vacio (lanza Error si lo esta)."
|
||||
output: "Un elemento de palette seleccionado deterministicamente por h % palette.length. El mismo string siempre retorna el mismo elemento para la misma paleta."
|
||||
tested: true
|
||||
tests:
|
||||
- "string vacio retorna palette[0]"
|
||||
- "mismo string siempre retorna mismo color"
|
||||
- "strings distintos pueden dar colores distintos"
|
||||
- "paleta vacia lanza error"
|
||||
- "paleta de un elemento siempre retorna ese elemento"
|
||||
test_file_path: "frontend/functions/core/string_hash_palette.test.ts"
|
||||
file_path: "frontend/functions/core/string_hash_palette.ts"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```typescript
|
||||
const TAG_PALETTE = ["blue", "cyan", "teal", "green", "lime", "yellow", "orange", "red", "pink", "grape", "violet", "indigo"];
|
||||
|
||||
stringHashPalette("frontend", TAG_PALETTE) // deterministico, ej: "teal"
|
||||
stringHashPalette("backend", TAG_PALETTE) // deterministico, diferente
|
||||
stringHashPalette("frontend", TAG_PALETTE) // mismo resultado siempre
|
||||
|
||||
stringHashPalette("x", []) // throws Error("palette must not be empty")
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraida de `apps/kanban/frontend/src/components/colors.ts` lineas 70-76 (funcion `tagColor`).
|
||||
Generalizada para aceptar cualquier paleta en vez de la constante `TAG_PALETTE` hardcodeada.
|
||||
El hash DJB-31 (variante de DJB2 con multiplicador 31) produce distribucion uniforme suficiente para asignacion de colores. No es criptografico.
|
||||
El operador `>>> 0` garantiza entero de 32 bits sin signo, evitando overflow en strings largos.
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { stringHashPalette } from "./string_hash_palette";
|
||||
|
||||
const PALETTE = ["blue", "cyan", "teal", "green", "lime", "yellow", "orange", "red", "pink", "grape", "violet", "indigo"];
|
||||
|
||||
describe("stringHashPalette", () => {
|
||||
it("string vacio retorna palette[0]", () => {
|
||||
// h empieza en 0, loop no itera, h%n = 0 => palette[0]
|
||||
expect(stringHashPalette("", PALETTE)).toBe(PALETTE[0]);
|
||||
});
|
||||
|
||||
it("mismo string siempre retorna mismo color", () => {
|
||||
const a = stringHashPalette("frontend", PALETTE);
|
||||
const b = stringHashPalette("frontend", PALETTE);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("strings distintos pueden dar colores distintos", () => {
|
||||
const colors = new Set(["alpha", "beta", "gamma", "delta", "epsilon"].map((s) => stringHashPalette(s, PALETTE)));
|
||||
// Con 5 strings distintos y paleta de 12 es muy probable obtener al menos 2 distintos
|
||||
expect(colors.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("paleta vacia lanza error", () => {
|
||||
expect(() => stringHashPalette("hello", [])).toThrow("palette must not be empty");
|
||||
});
|
||||
|
||||
it("paleta de un elemento siempre retorna ese elemento", () => {
|
||||
expect(stringHashPalette("anything", ["only"])).toBe("only");
|
||||
expect(stringHashPalette("", ["only"])).toBe("only");
|
||||
expect(stringHashPalette("xyz", ["only"])).toBe("only");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Mapea un string a un color de la paleta usando hash DJB-31 deterministico.
|
||||
*
|
||||
* Hash: h = (h * 31 + charCodeAt(i)) >>> 0 (unsigned 32-bit).
|
||||
* El indice es h % palette.length.
|
||||
*
|
||||
* @throws Error si palette esta vacio.
|
||||
*/
|
||||
export function stringHashPalette(s: string, palette: string[]): string {
|
||||
if (palette.length === 0) throw new Error("palette must not be empty");
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return palette[h % palette.length];
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: fetch_json
|
||||
kind: function
|
||||
lang: ts
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "async function fetchJSON<T>(path: string, init?: RequestInit, baseUrl?: string): Promise<T>"
|
||||
description: "Wrapper de fetch que parsea JSON, lanza HTTPError en errores HTTP y retorna undefined en 204. Exporta la clase HTTPError con el status code de la respuesta fallida."
|
||||
tags: [http, fetch, json, rest, api, error, httperror, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: path
|
||||
desc: "Path relativo a baseUrl (ej: /api/users). Se concatena directamente: ${baseUrl ?? ''}${path}"
|
||||
- name: init
|
||||
desc: "RequestInit de fetch (method, body, headers, etc.). headers se mergea con el default Content-Type: application/json. credentials se puede sobreescribir (default: include)"
|
||||
- name: baseUrl
|
||||
desc: "URL base sin slash final (ej: https://api.example.com). Opcional, default string vacio"
|
||||
output: "Promise<T> con el JSON parseado. Lanza HTTPError si !res.ok. Retorna undefined as T si status 204."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/infra/fetch_json.ts"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```typescript
|
||||
import { fetchJSON, HTTPError } from "@fn_library/../infra/fetch_json";
|
||||
|
||||
// GET con base URL
|
||||
const users = await fetchJSON<User[]>("/users", undefined, "https://api.example.com");
|
||||
|
||||
// POST con body
|
||||
const card = await fetchJSON<Card>(
|
||||
"/cards",
|
||||
{ method: "POST", body: JSON.stringify({ title: "Nueva tarea" }) },
|
||||
"/api",
|
||||
);
|
||||
|
||||
// Capturar HTTPError
|
||||
try {
|
||||
await fetchJSON("/protected");
|
||||
} catch (err) {
|
||||
if (err instanceof HTTPError) {
|
||||
console.error(`HTTP ${err.status}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Comportamiento detallado
|
||||
|
||||
- **URL**: `${baseUrl ?? ""}${path}` — si baseUrl es undefined o vacío, path se usa tal cual.
|
||||
- **Headers**: `{ "Content-Type": "application/json" }` como base, mergeados con `init?.headers`. Los headers de init tienen precedencia.
|
||||
- **credentials**: `"include"` por defecto (envía cookies). Se puede sobreescribir pasando `credentials: "omit"` en init.
|
||||
- **Error HTTP** (`!res.ok`): intenta `res.json()` para leer el cuerpo del error. Si el parse falla (body vacío, no-JSON), usa `{ Message: res.statusText }`. Construye `HTTPError(status, err.Message ?? err.message ?? res.statusText)`.
|
||||
- **204 No Content**: retorna `undefined as T` sin intentar parsear el body (que está vacío).
|
||||
- **Errores de red**: no se capturan — el rechazo de la Promise nativa de `fetch` se propaga tal cual.
|
||||
|
||||
## Notas
|
||||
|
||||
Extraído de `apps/kanban/frontend/src/api.ts` (líneas 14–32) y generalizado añadiendo el parámetro `baseUrl` opcional para reutilización fuera del contexto kanban.
|
||||
|
||||
`HTTPError` se exporta para que el consumidor pueda hacer `instanceof HTTPError` en sus catch.
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* HTTPError — error HTTP con status code.
|
||||
* Lanzado por fetchJSON cuando la respuesta no es ok.
|
||||
*/
|
||||
export class HTTPError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "HTTPError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* fetchJSON — wrapper de fetch que parsea JSON, lanza HTTPError en errores HTTP
|
||||
* y retorna undefined en 204 No Content.
|
||||
*
|
||||
* URL final: `${baseUrl ?? ""}${path}`.
|
||||
* Headers default: `{ "Content-Type": "application/json" }`, mergeables via init.headers.
|
||||
* credentials: "include" por defecto, sobreescribible via init.
|
||||
*/
|
||||
export async function fetchJSON<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
baseUrl?: string,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${baseUrl ?? ""}${path}`, {
|
||||
credentials: "include",
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ Message: res.statusText }));
|
||||
throw new HTTPError(
|
||||
res.status,
|
||||
err.Message ?? err.message ?? res.statusText,
|
||||
);
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Helpers privados compartidos por color_bg, color_border y color_swatch.
|
||||
* No exportar desde index.ts ni @fn_library directamente.
|
||||
*/
|
||||
|
||||
/** Tokens de color nombrados de Mantine que tienen escala de tonos (ej: --mantine-color-blue-9). */
|
||||
export const MANTINE_TOKENS = new Set([
|
||||
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
|
||||
"red", "pink", "grape", "violet", "indigo", "gray", "dark",
|
||||
]);
|
||||
|
||||
/** Retorna true si el string es un hex color valido (#RGB o #RRGGBB). */
|
||||
export function isHex(v: string): boolean {
|
||||
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: color_bg
|
||||
kind: function
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "colorBg(color: string): string"
|
||||
description: "Genera el valor CSS color-mix para el fondo de un elemento con color Mantine. Sin color retorna dark-6. Hex: mezcla 18% con dark-6. Token Mantine (blue, red, ...): mezcla tono -9 al 18% con dark-6."
|
||||
tags: [color, mantine, css, background, ui, theme]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: color
|
||||
desc: "Color de entrada. Puede ser: string vacio (sin color), hex valido (#RGB o #RRGGBB), o token de color Mantine (\"blue\", \"red\", \"teal\", etc.). Valores no reconocidos retornan el fondo base."
|
||||
output: "String CSS listo para usar en style.background o style.backgroundColor. Produce un valor color-mix() o una CSS variable Mantine."
|
||||
tested: true
|
||||
tests:
|
||||
- "string vacio retorna dark-6"
|
||||
- "hex valido produce color-mix con 18 porciento"
|
||||
- "token mantine produce color-mix con tono -9"
|
||||
- "valor desconocido retorna dark-6"
|
||||
- "hex de 3 digitos es valido"
|
||||
test_file_path: "frontend/functions/ui/color_bg.test.ts"
|
||||
file_path: "frontend/functions/ui/color_bg.ts"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```typescript
|
||||
colorBg("") // "var(--mantine-color-dark-6)"
|
||||
colorBg("#0ea5e9") // "color-mix(in srgb, #0ea5e9 18%, var(--mantine-color-dark-6))"
|
||||
colorBg("blue") // "color-mix(in srgb, var(--mantine-color-blue-9) 18%, var(--mantine-color-dark-6))"
|
||||
colorBg("unknown") // "var(--mantine-color-dark-6)"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraida de `apps/kanban/frontend/src/components/colors.ts` lineas 46-51.
|
||||
Junto con `color_border` y `color_swatch` forma el trio de helpers de color para tarjetas Mantine.
|
||||
|
||||
**Decision de helpers compartidos:** `MANTINE_TOKENS` e `isHex` se comparten via `_mantine_color_helpers.ts` en el mismo directorio `ui/`. Alternativa descartada: duplicar en cada archivo — genera drift si el set de tokens cambia (ya ocurrio en kanban al agregar "dark"). El prefijo underscore indica que el archivo es privado del directorio y no debe reexportarse desde `index.ts`.
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { colorBg } from "./color_bg";
|
||||
|
||||
describe("colorBg", () => {
|
||||
it("string vacio retorna dark-6", () => {
|
||||
expect(colorBg("")).toBe("var(--mantine-color-dark-6)");
|
||||
});
|
||||
|
||||
it("hex valido produce color-mix con 18 porciento", () => {
|
||||
expect(colorBg("#0ea5e9")).toBe(
|
||||
"color-mix(in srgb, #0ea5e9 18%, var(--mantine-color-dark-6))"
|
||||
);
|
||||
});
|
||||
|
||||
it("hex de 3 digitos es valido", () => {
|
||||
expect(colorBg("#0ea")).toBe(
|
||||
"color-mix(in srgb, #0ea 18%, var(--mantine-color-dark-6))"
|
||||
);
|
||||
});
|
||||
|
||||
it("token mantine produce color-mix con tono -9", () => {
|
||||
expect(colorBg("blue")).toBe(
|
||||
"color-mix(in srgb, var(--mantine-color-blue-9) 18%, var(--mantine-color-dark-6))"
|
||||
);
|
||||
expect(colorBg("red")).toBe(
|
||||
"color-mix(in srgb, var(--mantine-color-red-9) 18%, var(--mantine-color-dark-6))"
|
||||
);
|
||||
});
|
||||
|
||||
it("valor desconocido retorna dark-6", () => {
|
||||
expect(colorBg("unknown")).toBe("var(--mantine-color-dark-6)");
|
||||
expect(colorBg("sky-blue")).toBe("var(--mantine-color-dark-6)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MANTINE_TOKENS, isHex } from "./_mantine_color_helpers";
|
||||
|
||||
/**
|
||||
* Genera el valor CSS para el fondo de un elemento con color de tarjeta Mantine.
|
||||
*
|
||||
* - Sin color (""): fondo base dark-6.
|
||||
* - Hex (#rgb/#rrggbb): mezcla 18% del hex con dark-6.
|
||||
* - Token Mantine (blue, red, ...): mezcla 18% del tono -9 con dark-6.
|
||||
* - Valor desconocido: fondo base dark-6.
|
||||
*/
|
||||
export function colorBg(color: string): string {
|
||||
if (!color) return "var(--mantine-color-dark-6)";
|
||||
if (isHex(color)) return `color-mix(in srgb, ${color} 18%, var(--mantine-color-dark-6))`;
|
||||
if (MANTINE_TOKENS.has(color)) return `color-mix(in srgb, var(--mantine-color-${color}-9) 18%, var(--mantine-color-dark-6))`;
|
||||
return "var(--mantine-color-dark-6)";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: color_border
|
||||
kind: function
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "colorBorder(color: string): string"
|
||||
description: "Genera el valor CSS color-mix para el borde de un elemento con color Mantine. Sin color retorna dark-4. Hex: mezcla 30% con dark-4. Token Mantine: mezcla tono -7 al 30% con dark-4."
|
||||
tags: [color, mantine, css, border, ui, theme]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: color
|
||||
desc: "Color de entrada. Puede ser: string vacio (sin color), hex valido (#RGB o #RRGGBB), o token de color Mantine (\"blue\", \"red\", \"teal\", etc.). Valores no reconocidos retornan el borde base."
|
||||
output: "String CSS listo para usar en style.borderColor o style.border. Produce un valor color-mix() o una CSS variable Mantine."
|
||||
tested: true
|
||||
tests:
|
||||
- "string vacio retorna dark-4"
|
||||
- "hex valido produce color-mix con 30 porciento"
|
||||
- "token mantine produce color-mix con tono -7"
|
||||
- "valor desconocido retorna dark-4"
|
||||
test_file_path: "frontend/functions/ui/color_border.test.ts"
|
||||
file_path: "frontend/functions/ui/color_border.ts"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```typescript
|
||||
colorBorder("") // "var(--mantine-color-dark-4)"
|
||||
colorBorder("#0ea5e9") // "color-mix(in srgb, #0ea5e9 30%, var(--mantine-color-dark-4))"
|
||||
colorBorder("blue") // "color-mix(in srgb, var(--mantine-color-blue-7) 30%, var(--mantine-color-dark-4))"
|
||||
colorBorder("unknown") // "var(--mantine-color-dark-4)"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraida de `apps/kanban/frontend/src/components/colors.ts` lineas 53-58.
|
||||
Diferencia clave respecto a `color_bg`: usa tono `-7` (no `-9`) y mezcla al 30% (no 18%) para conseguir un borde mas visible que el fondo.
|
||||
|
||||
**Decision de helpers compartidos:** ver `color_bg.md`. Misma logica: `_mantine_color_helpers.ts` compartido.
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { colorBorder } from "./color_border";
|
||||
|
||||
describe("colorBorder", () => {
|
||||
it("string vacio retorna dark-4", () => {
|
||||
expect(colorBorder("")).toBe("var(--mantine-color-dark-4)");
|
||||
});
|
||||
|
||||
it("hex valido produce color-mix con 30 porciento", () => {
|
||||
expect(colorBorder("#0ea5e9")).toBe(
|
||||
"color-mix(in srgb, #0ea5e9 30%, var(--mantine-color-dark-4))"
|
||||
);
|
||||
});
|
||||
|
||||
it("token mantine produce color-mix con tono -7", () => {
|
||||
expect(colorBorder("blue")).toBe(
|
||||
"color-mix(in srgb, var(--mantine-color-blue-7) 30%, var(--mantine-color-dark-4))"
|
||||
);
|
||||
expect(colorBorder("teal")).toBe(
|
||||
"color-mix(in srgb, var(--mantine-color-teal-7) 30%, var(--mantine-color-dark-4))"
|
||||
);
|
||||
});
|
||||
|
||||
it("valor desconocido retorna dark-4", () => {
|
||||
expect(colorBorder("unknown")).toBe("var(--mantine-color-dark-4)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MANTINE_TOKENS, isHex } from "./_mantine_color_helpers";
|
||||
|
||||
/**
|
||||
* Genera el valor CSS para el borde de un elemento con color de tarjeta Mantine.
|
||||
*
|
||||
* - Sin color (""): borde base dark-4.
|
||||
* - Hex (#rgb/#rrggbb): mezcla 30% del hex con dark-4.
|
||||
* - Token Mantine (blue, red, ...): mezcla 30% del tono -7 con dark-4.
|
||||
* - Valor desconocido: borde base dark-4.
|
||||
*/
|
||||
export function colorBorder(color: string): string {
|
||||
if (!color) return "var(--mantine-color-dark-4)";
|
||||
if (isHex(color)) return `color-mix(in srgb, ${color} 30%, var(--mantine-color-dark-4))`;
|
||||
if (MANTINE_TOKENS.has(color)) return `color-mix(in srgb, var(--mantine-color-${color}-7) 30%, var(--mantine-color-dark-4))`;
|
||||
return "var(--mantine-color-dark-4)";
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: color_picker_grid
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
framework: react
|
||||
signature: "ColorPickerGrid(props: ColorPickerGridProps): JSX.Element"
|
||||
description: "Grid de swatches de color con boton extra que abre un modal con ColorPicker de Mantine para seleccionar un hexadecimal libre. Soporta tokens Mantine y valores hex. El swatch activo recibe borde blanco + box-shadow azul; el custom-active aplica el mismo feedback visual."
|
||||
tags: [color, picker, swatch, grid, modal, mantine, ui]
|
||||
uses_functions:
|
||||
- color_swatch_ts_ui
|
||||
- color_border_ts_ui
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports:
|
||||
- "@mantine/core"
|
||||
- "@tabler/icons-react"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/color_picker_grid.tsx"
|
||||
has_state: true
|
||||
props:
|
||||
- name: value
|
||||
type: "string"
|
||||
required: true
|
||||
description: "Color seleccionado actualmente. Token Mantine (blue, red, ...) o hex (#rrggbb)."
|
||||
- name: onChange
|
||||
type: "(color: string) => void"
|
||||
required: true
|
||||
description: "Callback invocado al seleccionar cualquier swatch o cambiar el color en el picker libre."
|
||||
- name: options
|
||||
type: "{ value: string; label: string }[]"
|
||||
required: false
|
||||
description: "Lista de opciones a mostrar como swatches circulares. Default: DEFAULT_COLOR_OPTIONS (27 entradas)."
|
||||
- name: swatchSize
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Diametro de cada swatch en px. Default: 26."
|
||||
- name: customSwatches
|
||||
type: "string[]"
|
||||
required: false
|
||||
description: "Array de hex para los swatches rapidos dentro del ColorPicker de Mantine. Default: 14 colores."
|
||||
- name: modalTitle
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Titulo del modal que contiene el ColorPicker libre. Default: 'Color personalizado'."
|
||||
emits:
|
||||
- onChange
|
||||
params:
|
||||
- name: value
|
||||
desc: "Color activo. Token Mantine o hex."
|
||||
- name: onChange
|
||||
desc: "Recibe el nuevo color cada vez que el usuario selecciona un swatch o mueve el ColorPicker."
|
||||
- name: options
|
||||
desc: "Paleta de swatches a renderizar. Omitir para usar DEFAULT_COLOR_OPTIONS."
|
||||
- name: swatchSize
|
||||
desc: "Tamaño del circulo en pixeles."
|
||||
- name: customSwatches
|
||||
desc: "Hexadecimales de acceso rapido dentro del picker modal."
|
||||
- name: modalTitle
|
||||
desc: "Titulo que aparece en la cabecera del modal del picker."
|
||||
output: "Grupo horizontal de swatches circulares mas boton '+' (IconPalette). Click en swatch llama onChange con el token/hex. Click '+' abre Modal interno con ColorPicker de Mantine; onChange se dispara en cada cambio del picker. Exporta DEFAULT_COLOR_OPTIONS con 27 entradas (tokens Mantine + hexadecimales de acento)."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
import { ColorPickerGrid, DEFAULT_COLOR_OPTIONS } from "@fn_library/color_picker_grid";
|
||||
import { useState } from "react";
|
||||
|
||||
function Demo() {
|
||||
const [color, setColor] = useState("blue");
|
||||
|
||||
return (
|
||||
<ColorPickerGrid
|
||||
value={color}
|
||||
onChange={setColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Con opciones personalizadas y swatch reducido:
|
||||
|
||||
```tsx
|
||||
<ColorPickerGrid
|
||||
value={color}
|
||||
onChange={setColor}
|
||||
options={[
|
||||
{ value: "red", label: "Rojo" },
|
||||
{ value: "blue", label: "Azul" },
|
||||
{ value: "#ff6600", label: "Naranja custom" },
|
||||
]}
|
||||
swatchSize={32}
|
||||
modalTitle="Elige un color"
|
||||
/>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- `DEFAULT_COLOR_OPTIONS` se exporta desde el mismo archivo para que los consumidores puedan extenderla o filtrarla.
|
||||
- El estado del color custom (hex libre) es local al componente. Si el valor inicial es hex, se inicializa con el; si no, arranca en `#888888`.
|
||||
- La deteccion de "custom activo" usa `value.startsWith("#") && !options.some(o => o.value === value)` — un hex que coincide con una opcion de la paleta se trata como swatch fijo, no como custom.
|
||||
- `colorSwatch` y `colorBorder` del registry resuelven tokens Mantine y hex a valores CSS listos para `background` y `border`.
|
||||
- El Modal usa `withinPortal`, `zIndex: 2000`, `closeOnClickOutside: false`, `closeOnEscape: false` y `trapFocus: false` para evitar conflictos con Popovers / Menus padres.
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Box, ColorPicker, Group, Modal, Text, Tooltip } from "@mantine/core";
|
||||
import { IconPalette } from "@tabler/icons-react";
|
||||
import { useState, type FC } from "react";
|
||||
import { colorBorder } from "./color_border";
|
||||
import { colorSwatch } from "./color_swatch";
|
||||
|
||||
/** Paleta de 27 opciones por defecto: tokens Mantine + hexadecimales de acento. */
|
||||
export const DEFAULT_COLOR_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "", label: "Default" },
|
||||
{ value: "blue", label: "Azul" },
|
||||
{ value: "cyan", label: "Cian" },
|
||||
{ value: "teal", label: "Teal" },
|
||||
{ value: "green", label: "Verde" },
|
||||
{ value: "lime", label: "Lima" },
|
||||
{ value: "yellow", label: "Amarillo" },
|
||||
{ value: "orange", label: "Naranja" },
|
||||
{ value: "red", label: "Rojo" },
|
||||
{ value: "pink", label: "Rosa" },
|
||||
{ value: "grape", label: "Uva" },
|
||||
{ value: "violet", label: "Violeta" },
|
||||
{ value: "indigo", label: "Indigo" },
|
||||
{ value: "gray", label: "Gris" },
|
||||
{ value: "#0ea5e9", label: "Sky" },
|
||||
{ value: "#14b8a6", label: "Esmeralda" },
|
||||
{ value: "#84cc16", label: "Lima fluor" },
|
||||
{ value: "#ec4899", label: "Magenta" },
|
||||
{ value: "#a855f7", label: "Lavanda" },
|
||||
{ value: "#f97316", label: "Mandarina" },
|
||||
{ value: "#dc2626", label: "Rubi" },
|
||||
{ value: "#0891b2", label: "Petroleo" },
|
||||
{ value: "#fde047", label: "Limon" },
|
||||
{ value: "#10b981", label: "Menta" },
|
||||
{ value: "#fb7185", label: "Coral" },
|
||||
{ value: "#6366f1", label: "Iris" },
|
||||
{ value: "#94a3b8", label: "Pizarra" },
|
||||
];
|
||||
|
||||
const DEFAULT_CUSTOM_SWATCHES = [
|
||||
"#1c7ed6", "#15aabf", "#12b886", "#37b24d", "#82c91e",
|
||||
"#fab005", "#fd7e14", "#fa5252", "#e64980", "#be4bdb",
|
||||
"#7950f2", "#4c6ef5", "#868e96", "#212529",
|
||||
];
|
||||
|
||||
export interface ColorPickerGridProps {
|
||||
/** Color seleccionado actualmente (token Mantine o hex). */
|
||||
value: string;
|
||||
/** Callback invocado al seleccionar cualquier color, incluido el picker libre. */
|
||||
onChange: (color: string) => void;
|
||||
/** Lista de opciones a mostrar como swatches. Default: DEFAULT_COLOR_OPTIONS. */
|
||||
options?: { value: string; label: string }[];
|
||||
/** Diametro de cada swatch en px. Default: 26. */
|
||||
swatchSize?: number;
|
||||
/** Swatches rapidos dentro del ColorPicker de Mantine. Default: 14 hexadecimales. */
|
||||
customSwatches?: string[];
|
||||
/** Titulo del modal del picker libre. Default: "Color personalizado". */
|
||||
modalTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid de swatches de color con boton "+" que abre un ColorPicker de Mantine
|
||||
* para seleccionar un hexadecimal personalizado via modal.
|
||||
*/
|
||||
export const ColorPickerGrid: FC<ColorPickerGridProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
options = DEFAULT_COLOR_OPTIONS,
|
||||
swatchSize = 26,
|
||||
customSwatches = DEFAULT_CUSTOM_SWATCHES,
|
||||
modalTitle = "Color personalizado",
|
||||
}) => {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [custom, setCustom] = useState(
|
||||
value && value.startsWith("#") ? value : "#888888"
|
||||
);
|
||||
|
||||
const isCustomActive =
|
||||
!!value && value.startsWith("#") && !options.some((o) => o.value === value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group gap={6} maw={280}>
|
||||
{options.map((c) => {
|
||||
const selected = value === c.value;
|
||||
return (
|
||||
<Tooltip key={c.value || "default"} label={c.label} withArrow>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(c.value);
|
||||
}}
|
||||
aria-label={c.label}
|
||||
style={{
|
||||
width: swatchSize,
|
||||
height: swatchSize,
|
||||
borderRadius: "50%",
|
||||
background: colorSwatch(c.value),
|
||||
border: `2px solid ${selected ? "var(--mantine-color-white)" : colorBorder(c.value)}`,
|
||||
boxShadow: selected
|
||||
? "0 0 0 2px var(--mantine-color-blue-5)"
|
||||
: undefined,
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
transition: "transform .1s",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
<Tooltip label="Color personalizado" withArrow>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPickerOpen(true);
|
||||
}}
|
||||
aria-label="Color personalizado"
|
||||
style={{
|
||||
width: swatchSize,
|
||||
height: swatchSize,
|
||||
borderRadius: "50%",
|
||||
background: isCustomActive ? custom : "transparent",
|
||||
border: `2px dashed ${isCustomActive ? custom : "var(--mantine-color-gray-5)"}`,
|
||||
boxShadow: isCustomActive
|
||||
? "0 0 0 2px var(--mantine-color-blue-5)"
|
||||
: undefined,
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--mantine-color-gray-3)",
|
||||
}}
|
||||
>
|
||||
<IconPalette size={14} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Modal
|
||||
opened={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
title={modalTitle}
|
||||
size="auto"
|
||||
centered
|
||||
withinPortal
|
||||
zIndex={2000}
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={false}
|
||||
trapFocus={false}
|
||||
>
|
||||
<ColorPicker
|
||||
value={custom}
|
||||
onChange={(v) => {
|
||||
setCustom(v);
|
||||
onChange(v);
|
||||
}}
|
||||
format="hex"
|
||||
swatches={customSwatches}
|
||||
fullWidth
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt="sm">
|
||||
{custom.toUpperCase()}
|
||||
</Text>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: color_swatch
|
||||
kind: function
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "colorSwatch(color: string): string"
|
||||
description: "Genera el valor CSS para mostrar un swatch (muestra de color) Mantine. Sin color retorna dark-3. Hex: retorna el hex directamente. Token Mantine: retorna CSS var del tono -7."
|
||||
tags: [color, mantine, css, swatch, ui, theme]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: color
|
||||
desc: "Color de entrada. Puede ser: string vacio (sin color), hex valido (#RGB o #RRGGBB), o token de color Mantine (\"blue\", \"red\", \"teal\", etc.). Valores no reconocidos retornan el color neutro."
|
||||
output: "String CSS listo para usar en style.backgroundColor de un circulo/swatch. Para hex retorna el valor literal. Para tokens retorna una CSS variable Mantine. Sin color retorna dark-3."
|
||||
tested: true
|
||||
tests:
|
||||
- "string vacio retorna dark-3"
|
||||
- "hex valido retorna el hex sin modificar"
|
||||
- "token mantine retorna css var tono -7"
|
||||
- "valor desconocido retorna dark-3"
|
||||
- "hex de 3 digitos retorna hex de 3 digitos"
|
||||
test_file_path: "frontend/functions/ui/color_swatch.test.ts"
|
||||
file_path: "frontend/functions/ui/color_swatch.ts"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```typescript
|
||||
colorSwatch("") // "var(--mantine-color-dark-3)"
|
||||
colorSwatch("#0ea5e9") // "#0ea5e9"
|
||||
colorSwatch("#0ea") // "#0ea"
|
||||
colorSwatch("blue") // "var(--mantine-color-blue-7)"
|
||||
colorSwatch("unknown") // "var(--mantine-color-dark-3)"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraida de `apps/kanban/frontend/src/components/colors.ts` lineas 60-65.
|
||||
A diferencia de `color_bg` y `color_border`, para hex no aplica `color-mix` — retorna el valor crudo porque el swatch muestra el color puro, sin mezclarlo con el fondo.
|
||||
|
||||
**Decision de helpers compartidos:** ver `color_bg.md`. Misma logica: `_mantine_color_helpers.ts` compartido.
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { colorSwatch } from "./color_swatch";
|
||||
|
||||
describe("colorSwatch", () => {
|
||||
it("string vacio retorna dark-3", () => {
|
||||
expect(colorSwatch("")).toBe("var(--mantine-color-dark-3)");
|
||||
});
|
||||
|
||||
it("hex valido retorna el hex sin modificar", () => {
|
||||
expect(colorSwatch("#0ea5e9")).toBe("#0ea5e9");
|
||||
expect(colorSwatch("#dc2626")).toBe("#dc2626");
|
||||
});
|
||||
|
||||
it("hex de 3 digitos retorna hex de 3 digitos", () => {
|
||||
expect(colorSwatch("#0ea")).toBe("#0ea");
|
||||
});
|
||||
|
||||
it("token mantine retorna css var tono -7", () => {
|
||||
expect(colorSwatch("blue")).toBe("var(--mantine-color-blue-7)");
|
||||
expect(colorSwatch("grape")).toBe("var(--mantine-color-grape-7)");
|
||||
});
|
||||
|
||||
it("valor desconocido retorna dark-3", () => {
|
||||
expect(colorSwatch("unknown")).toBe("var(--mantine-color-dark-3)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MANTINE_TOKENS, isHex } from "./_mantine_color_helpers";
|
||||
|
||||
/**
|
||||
* Genera el valor CSS para mostrar un swatch (muestra de color) Mantine.
|
||||
*
|
||||
* - Sin color (""): color neutro dark-3.
|
||||
* - Hex (#rgb/#rrggbb): el hex directamente (sin mezcla).
|
||||
* - Token Mantine (blue, red, ...): CSS variable del tono -7.
|
||||
* - Valor desconocido: color neutro dark-3.
|
||||
*/
|
||||
export function colorSwatch(color: string): string {
|
||||
if (!color) return "var(--mantine-color-dark-3)";
|
||||
if (isHex(color)) return color;
|
||||
if (MANTINE_TOKENS.has(color)) return `var(--mantine-color-${color}-7)`;
|
||||
return "var(--mantine-color-dark-3)";
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
name: month_heatmap
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
framework: react
|
||||
purity: pure
|
||||
signature: "MonthHeatmap(props: MonthHeatmapProps): JSX.Element"
|
||||
description: "Grid mensual de calor (heatmap) con inicio en lunes. Renderiza 7 columnas con header de dias de semana y celdas que muestran numero del dia, hasta dos contadores con icono, borde azul para hoy y fondo tintado segun valores primary/secondary."
|
||||
tags: [calendar, heatmap, grid, month, ui, mantine]
|
||||
uses_functions: [month_grid_ts_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports:
|
||||
- "@mantine/core"
|
||||
- "@tabler/icons-react"
|
||||
- "../core/month_grid"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
props:
|
||||
- name: month
|
||||
type: "Date"
|
||||
required: true
|
||||
description: "Mes a mostrar. Se usan getFullYear() y getMonth(). El dia se ignora."
|
||||
- name: cells
|
||||
type: "Map<string, MonthHeatmapCell>"
|
||||
required: true
|
||||
description: "Datos por dia. Clave YYYY-MM-DD. Valores: primary (contador azulado) y secondary (contador verdoso)."
|
||||
- name: primaryIcon
|
||||
type: "ReactNode"
|
||||
required: false
|
||||
description: "Icono junto al contador primary. Default: IconPlus de @tabler/icons-react."
|
||||
- name: secondaryIcon
|
||||
type: "ReactNode"
|
||||
required: false
|
||||
description: "Icono junto al contador secondary. Default: IconCheckbox de @tabler/icons-react."
|
||||
- name: primaryColor
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Color Mantine del contador primary. Default: 'blue'."
|
||||
- name: secondaryColor
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Color Mantine del contador secondary. Default: 'green'."
|
||||
- name: dayLabels
|
||||
type: "string[]"
|
||||
required: false
|
||||
description: "Array de 7 strings para el header de columnas, empezando por lunes. Default: ['Lun','Mar','Mie','Jue','Vie','Sab','Dom']."
|
||||
- name: cellMinHeight
|
||||
type: "number"
|
||||
required: false
|
||||
description: "Altura minima de cada celda en px. Default: 72."
|
||||
emits: []
|
||||
has_state: false
|
||||
params:
|
||||
- name: month
|
||||
desc: "Mes a renderizar como objeto Date (year + month extraidos con getFullYear/getMonth)."
|
||||
- name: cells
|
||||
desc: "Map de YYYY-MM-DD a { primary?, secondary? } con los contadores de cada dia."
|
||||
- name: primaryIcon
|
||||
desc: "ReactNode opcional para el icono del contador primary (creado, pendiente, etc.)."
|
||||
- name: secondaryIcon
|
||||
desc: "ReactNode opcional para el icono del contador secondary (completado, resuelto, etc.)."
|
||||
- name: primaryColor
|
||||
desc: "Nombre de color Mantine para el contador primary y su tinte de fondo (rgba 6%)."
|
||||
- name: secondaryColor
|
||||
desc: "Nombre de color Mantine para el contador secondary y su tinte de fondo (rgba 8%)."
|
||||
- name: dayLabels
|
||||
desc: "Labels del header de dias de semana, 7 elementos, orden lunes-domingo."
|
||||
- name: cellMinHeight
|
||||
desc: "Altura minima en px de cada celda del grid."
|
||||
output: "Componente React que renderiza un grid de 7 columnas con el calendario del mes indicado. Celdas de relleno vacias, celdas del mes con numero, contadores opcionales e iconos, borde azul en el dia de hoy y fondo tintado segun presencia de valores."
|
||||
file_path: "frontend/functions/ui/month_heatmap.tsx"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
import { MonthHeatmap, type MonthHeatmapCell } from "@fn_library/ui/month_heatmap";
|
||||
|
||||
const cells = new Map<string, MonthHeatmapCell>([
|
||||
["2026-05-01", { primary: 3 }],
|
||||
["2026-05-09", { primary: 1, secondary: 2 }],
|
||||
["2026-05-15", { secondary: 4 }],
|
||||
]);
|
||||
|
||||
<MonthHeatmap
|
||||
month={new Date(2026, 4, 1)}
|
||||
cells={cells}
|
||||
/>
|
||||
```
|
||||
|
||||
## Ejemplo con iconos y colores custom
|
||||
|
||||
```tsx
|
||||
import { IconBug, IconCheck } from "@tabler/icons-react";
|
||||
|
||||
<MonthHeatmap
|
||||
month={new Date(2026, 4, 1)}
|
||||
cells={cells}
|
||||
primaryIcon={<IconBug size={10} />}
|
||||
secondaryIcon={<IconCheck size={10} />}
|
||||
primaryColor="red"
|
||||
secondaryColor="teal"
|
||||
cellMinHeight={88}
|
||||
/>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Componente puro (sin estado propio, sin efectos). El calculo del grid se delega a
|
||||
`month_grid_ts_core` (`monthGrid(year, month)`), que genera un array de celdas
|
||||
de longitud multiplo de 7 con `date: null` para el padding.
|
||||
|
||||
La deteccion de "hoy" usa `Date` nativo sin dayjs para no introducir dependencias.
|
||||
|
||||
El fondo tintado prioriza `secondary` sobre `primary` cuando ambos son > 0,
|
||||
replicando la logica del componente original en `apps/kanban`.
|
||||
|
||||
Compatible con Mantine v9. No usa CSS variables custom — emplea props de Mantine
|
||||
(`c`, `fw`, `p`, `gap`, `radius`) y solo dos `style` inline para `minHeight`
|
||||
y `borderColor` condicional (que no tienen equivalente en props Mantine).
|
||||
@@ -0,0 +1,143 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { Box, Group, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconCheckbox, IconPlus } from "@tabler/icons-react";
|
||||
import { monthGrid } from "../core/month_grid";
|
||||
|
||||
export interface MonthHeatmapCell {
|
||||
primary?: number;
|
||||
secondary?: number;
|
||||
}
|
||||
|
||||
export interface MonthHeatmapProps {
|
||||
/** Mes a mostrar (se usan year y month, el dia se ignora). */
|
||||
month: Date;
|
||||
/** Datos por dia, key "YYYY-MM-DD". */
|
||||
cells: Map<string, MonthHeatmapCell>;
|
||||
/** Icono junto al contador primary. Default: IconPlus. */
|
||||
primaryIcon?: ReactNode;
|
||||
/** Icono junto al contador secondary. Default: IconCheckbox. */
|
||||
secondaryIcon?: ReactNode;
|
||||
/** Color Mantine del counter primary. Default: "blue". */
|
||||
primaryColor?: string;
|
||||
/** Color Mantine del counter secondary. Default: "green". */
|
||||
secondaryColor?: string;
|
||||
/** Labels de cabecera, 7 elementos empezando por lunes. */
|
||||
dayLabels?: string[];
|
||||
/** Altura minima de cada celda en px. Default: 72. */
|
||||
cellMinHeight?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_DAY_LABELS = ["Lun", "Mar", "Mie", "Jue", "Vie", "Sab", "Dom"];
|
||||
|
||||
/**
|
||||
* Grid mensual de calor (heatmap) con inicio en lunes.
|
||||
*
|
||||
* Renderiza 7 columnas con header de dias de la semana.
|
||||
* Cada celda muestra el numero del dia y hasta dos contadores opcionales
|
||||
* (primary / secondary) con icono. El dia de hoy recibe borde azul y
|
||||
* numero en negrita. El fondo de la celda se tinta segun los valores:
|
||||
* secondary > 0 → verdoso, primary > 0 → azulado.
|
||||
* Las celdas de relleno (fuera del mes) se renderizan vacias.
|
||||
*/
|
||||
export function MonthHeatmap({
|
||||
month,
|
||||
cells,
|
||||
primaryIcon,
|
||||
secondaryIcon,
|
||||
primaryColor = "blue",
|
||||
secondaryColor = "green",
|
||||
dayLabels = DEFAULT_DAY_LABELS,
|
||||
cellMinHeight = 72,
|
||||
}: MonthHeatmapProps) {
|
||||
const year = month.getFullYear();
|
||||
const monthNum = month.getMonth() + 1; // getMonth() es 0-based
|
||||
const grid = monthGrid(year, monthNum);
|
||||
|
||||
// Hoy como "YYYY-MM-DD" usando Date nativo
|
||||
const now = new Date();
|
||||
const todayStr = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, "0"),
|
||||
String(now.getDate()).padStart(2, "0"),
|
||||
].join("-");
|
||||
|
||||
const resolvedPrimaryIcon = primaryIcon ?? <IconPlus size={10} />;
|
||||
const resolvedSecondaryIcon = secondaryIcon ?? <IconCheckbox size={10} />;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header dias de la semana */}
|
||||
<SimpleGrid cols={7} spacing={4} mb={4}>
|
||||
{dayLabels.map((label) => (
|
||||
<Text key={label} size="xs" c="dimmed" ta="center" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Celdas del mes */}
|
||||
<SimpleGrid cols={7} spacing={4}>
|
||||
{grid.map((cell, i) => {
|
||||
if (!cell.date) {
|
||||
return <Box key={i} style={{ minHeight: cellMinHeight }} />;
|
||||
}
|
||||
|
||||
const data = cells.get(cell.date) ?? {};
|
||||
const primary = data.primary ?? 0;
|
||||
const secondary = data.secondary ?? 0;
|
||||
const dayNum = parseInt(cell.date.slice(8, 10), 10);
|
||||
const isToday = cell.date === todayStr;
|
||||
|
||||
const background =
|
||||
secondary > 0
|
||||
? "rgba(81, 207, 102, 0.08)"
|
||||
: primary > 0
|
||||
? "rgba(34, 139, 230, 0.06)"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
key={i}
|
||||
p={6}
|
||||
withBorder
|
||||
radius="sm"
|
||||
style={{
|
||||
minHeight: cellMinHeight,
|
||||
borderColor: isToday ? "var(--mantine-color-blue-5)" : undefined,
|
||||
background,
|
||||
}}
|
||||
>
|
||||
<Stack gap={2}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={isToday ? 700 : 500}
|
||||
c={isToday ? "blue" : undefined}
|
||||
>
|
||||
{dayNum}
|
||||
</Text>
|
||||
|
||||
{primary > 0 && (
|
||||
<Group gap={3} wrap="nowrap" c={primaryColor}>
|
||||
{resolvedPrimaryIcon}
|
||||
<Text size="xs" c={primaryColor}>
|
||||
{primary}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{secondary > 0 && (
|
||||
<Group gap={3} wrap="nowrap" c={secondaryColor}>
|
||||
{resolvedSecondaryIcon}
|
||||
<Text size="xs" c={secondaryColor}>
|
||||
{secondary}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: sticker_picker
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
framework: react
|
||||
purity: impure
|
||||
signature: "StickerPicker(props: StickerPickerProps): JSX.Element"
|
||||
description: "Selector de emoji/sticker encapsulado en un Popover de Mantine. Monta emoji-mart Picker una sola vez para evitar re-creaciones en cada render."
|
||||
tags: [emoji, sticker, picker, popover, mantine, emoji-mart, react]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports:
|
||||
- "@mantine/core"
|
||||
- "@emoji-mart/data"
|
||||
- "emoji-mart"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "frontend/functions/ui/sticker_picker.tsx"
|
||||
props:
|
||||
- name: opened
|
||||
type: "boolean"
|
||||
required: true
|
||||
description: "Controla si el Popover está abierto."
|
||||
- name: onClose
|
||||
type: "() => void"
|
||||
required: true
|
||||
description: "Callback invocado al cerrar (click fuera, Escape, o tras selección)."
|
||||
- name: onSelect
|
||||
type: "(emoji: string) => void"
|
||||
required: true
|
||||
description: "Callback invocado con el emoji seleccionado. Prefiere unicode nativo; si no está disponible usa shortcode."
|
||||
- name: target
|
||||
type: "React.ReactNode"
|
||||
required: true
|
||||
description: "Elemento ancla que dispara el Popover."
|
||||
- name: theme
|
||||
type: '"dark" | "light" | "auto"'
|
||||
required: false
|
||||
description: 'Tema visual del Picker de emoji-mart. Por defecto "dark".'
|
||||
- name: position
|
||||
type: 'PopoverProps["position"]'
|
||||
required: false
|
||||
description: 'Posición del Popover respecto al ancla. Por defecto "bottom-start".'
|
||||
emits: [onClose, onSelect]
|
||||
has_state: false
|
||||
output: "Popover de Mantine con emoji-mart Picker embebido. Tras selección emite onSelect(emoji) y onClose()."
|
||||
params:
|
||||
- name: opened
|
||||
desc: "Estado de visibilidad del picker. Gestionado por el componente padre."
|
||||
- name: onClose
|
||||
desc: "Se llama cuando el picker debe cerrarse."
|
||||
- name: onSelect
|
||||
desc: "Se llama con el string unicode o shortcode del emoji elegido."
|
||||
- name: target
|
||||
desc: "Nodo React que actúa como ancla del Popover (generalmente un botón o icono)."
|
||||
- name: theme
|
||||
desc: 'Tema de color del picker. Valores: "dark" | "light" | "auto". Por defecto "dark".'
|
||||
- name: position
|
||||
desc: 'Posición del Popover. Cualquier valor de PopoverProps["position"] de Mantine. Por defecto "bottom-start".'
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { StickerPicker } from "@fn_library/sticker_picker";
|
||||
|
||||
function MyCard() {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [sticker, setSticker] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<StickerPicker
|
||||
opened={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onSelect={(emoji) => setSticker(emoji)}
|
||||
target={
|
||||
<ActionIcon onClick={() => setPickerOpen((o) => !o)}>
|
||||
{sticker ?? "😀"}
|
||||
</ActionIcon>
|
||||
}
|
||||
theme="dark"
|
||||
position="bottom-start"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
**Dependencias externas requeridas** (no incluidas en el registry):
|
||||
```bash
|
||||
pnpm add @emoji-mart/data emoji-mart
|
||||
```
|
||||
|
||||
El componente interno `PickerInner` instancia `emoji-mart` Picker con `useEffect` y lo monta en un `<div ref>`. El cleanup borra el `innerHTML` del div al desmontar. `onSelectRef` mantiene el callback actualizado sin recrear la instancia en cada render del padre. El prop `theme` se pasa al montar — cambios posteriores de `theme` no provocan remontaje (la instancia mantiene el tema inicial), comportamiento aceptable para la mayoría de casos de uso. Si se necesita cambio dinámico de tema, destructurar el key del componente padre fuerza remontaje.
|
||||
|
||||
El Popover usa `withinPortal` para evitar clipping por overflow de contenedores padre, `closeOnClickOutside` y `closeOnEscape` para comportamiento estándar, y `trapFocus={false}` para que emoji-mart gestione el foco internamente.
|
||||
@@ -0,0 +1,108 @@
|
||||
import { type FC, useEffect, useRef } from "react";
|
||||
import { Popover, type PopoverProps } from "@mantine/core";
|
||||
import data from "@emoji-mart/data";
|
||||
import { Picker } from "emoji-mart";
|
||||
|
||||
export interface StickerPickerProps {
|
||||
/** Whether the picker popover is open. */
|
||||
opened: boolean;
|
||||
/** Called when the picker should close (click outside, Escape, or after selection). */
|
||||
onClose: () => void;
|
||||
/** Called with the selected emoji string (native unicode preferred, fallback to shortcode). */
|
||||
onSelect: (emoji: string) => void;
|
||||
/** Anchor element that opens the popover. */
|
||||
target: React.ReactNode;
|
||||
/** Color theme for the emoji-mart Picker. Defaults to "dark". */
|
||||
theme?: "dark" | "light" | "auto";
|
||||
/** Popover placement relative to the anchor. Defaults to "bottom-start". */
|
||||
position?: PopoverProps["position"];
|
||||
}
|
||||
|
||||
/**
|
||||
* StickerPicker — emoji picker wrapped in a Mantine Popover.
|
||||
*
|
||||
* Renders an emoji-mart Picker inside a transparent Popover dropdown.
|
||||
* The Picker instance is created once on mount and cleaned up on unmount
|
||||
* so re-renders caused by parent state changes do not recreate it.
|
||||
* onSelect and theme changes are reflected via refs without remounting.
|
||||
*/
|
||||
export const StickerPicker: FC<StickerPickerProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
onSelect,
|
||||
target,
|
||||
theme = "dark",
|
||||
position = "bottom-start",
|
||||
}) => {
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={(o) => {
|
||||
if (!o) onClose();
|
||||
}}
|
||||
onDismiss={onClose}
|
||||
position={position}
|
||||
withArrow
|
||||
shadow="md"
|
||||
withinPortal
|
||||
closeOnClickOutside
|
||||
closeOnEscape
|
||||
trapFocus={false}
|
||||
>
|
||||
<Popover.Target>{target}</Popover.Target>
|
||||
<Popover.Dropdown p={0} style={{ background: "transparent", border: "none" }}>
|
||||
<PickerInner
|
||||
theme={theme}
|
||||
onSelect={(emoji) => {
|
||||
onSelect(emoji);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: mounts emoji-mart Picker once, updates callbacks via refs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PickerInnerProps {
|
||||
onSelect: (emoji: string) => void;
|
||||
theme: "dark" | "light" | "auto";
|
||||
}
|
||||
|
||||
function PickerInner({ onSelect, theme }: PickerInnerProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const instanceRef = useRef<unknown>(null);
|
||||
// Keep latest callback in ref so the Picker closure never goes stale.
|
||||
const onSelectRef = useRef(onSelect);
|
||||
onSelectRef.current = onSelect;
|
||||
const themeRef = useRef(theme);
|
||||
themeRef.current = theme;
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
instanceRef.current = new Picker({
|
||||
data,
|
||||
onEmojiSelect: (e: { native?: string; shortcodes?: string }) => {
|
||||
const cb = onSelectRef.current;
|
||||
if (e.native) cb(e.native);
|
||||
else if (e.shortcodes) cb(e.shortcodes);
|
||||
},
|
||||
theme: themeRef.current,
|
||||
previewPosition: "none",
|
||||
skinTonePosition: "search",
|
||||
autoFocus: true,
|
||||
maxFrequentRows: 2,
|
||||
ref,
|
||||
});
|
||||
return () => {
|
||||
if (ref.current) ref.current.innerHTML = "";
|
||||
instanceRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
Reference in New Issue
Block a user