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,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