Contexto
Los tres paquetes GUI de ulfblk (calendar-ui, chat-ui, forms) necesitan ser usables en proyectos en espanol e ingles sin agregar una libreria de i18n como react-intl o next-intl. Tambien necesitan ser customizables en apariencia sin forzar un design system especifico. La solucion: hooks headless que separan logica de presentacion, e i18n via props con defaults en espanol.
Lo que aprendi
El patron headless hook expone estado y acciones, el componente de render los consume. Los strings de UI van como props con valores default, asi el componente funciona out-of-the-box en espanol pero se puede sobreescribir para cualquier idioma.
Hook headless: useCalendar
El hook maneja toda la logica (seleccion de fechas, navegacion entre semanas, slots disponibles) y retorna estado + acciones.
'use client';
import { useState, useCallback, useMemo } from 'react';
interface CalendarSlot {
id: string;
startTime: string;
endTime: string;
available: boolean;
}
interface UseCalendarOptions {
initialDate?: Date;
onSlotSelect?: (slot: CalendarSlot) => void;
}
interface UseCalendarReturn {
currentDate: Date;
selectedSlot: CalendarSlot | null;
weekDays: Date[];
goToNextWeek: () => void;
goToPrevWeek: () => void;
selectSlot: (slot: CalendarSlot) => void;
isToday: (date: Date) => boolean;
}
export function useCalendar(options: UseCalendarOptions = {}): UseCalendarReturn {
const [currentDate, setCurrentDate] = useState(options.initialDate ?? new Date());
const [selectedSlot, setSelectedSlot] = useState<CalendarSlot | null>(null);
const weekDays = useMemo(() => {
const start = new Date(currentDate);
start.setDate(start.getDate() - start.getDay() + 1); // Lunes
return Array.from({ length: 7 }, (_, i) => {
const day = new Date(start);
day.setDate(start.getDate() + i);
return day;
});
}, [currentDate]);
const goToNextWeek = useCallback(() => {
setCurrentDate(prev => {
const next = new Date(prev);
next.setDate(next.getDate() + 7);
return next;
});
}, []);
const goToPrevWeek = useCallback(() => {
setCurrentDate(prev => {
const next = new Date(prev);
next.setDate(next.getDate() - 7);
return next;
});
}, []);
const selectSlot = useCallback((slot: CalendarSlot) => {
setSelectedSlot(slot);
options.onSlotSelect?.(slot);
}, [options.onSlotSelect]);
const isToday = useCallback((date: Date) => {
const today = new Date();
return date.toDateString() === today.toDateString();
}, []);
return {
currentDate,
selectedSlot,
weekDays,
goToNextWeek,
goToPrevWeek,
selectSlot,
isToday,
};
}
i18n via props con defaults en espanol
Los strings de UI son props opcionales. Si no los pasas, se usan los defaults en espanol.
interface CalendarLabels {
nextWeek: string;
prevWeek: string;
noSlotsAvailable: string;
selectTime: string;
today: string;
weekDays: string[];
}
const DEFAULT_LABELS: CalendarLabels = {
nextWeek: 'Siguiente semana',
prevWeek: 'Semana anterior',
noSlotsAvailable: 'No hay horarios disponibles',
selectTime: 'Selecciona un horario',
today: 'Hoy',
weekDays: ['Lun', 'Mar', 'Mie', 'Jue', 'Vie', 'Sab', 'Dom'],
};
Componente que consume el hook
'use client';
interface SlotPickerProps {
slots: CalendarSlot[];
onSelect: (slot: CalendarSlot) => void;
labels?: Partial<CalendarLabels>;
className?: string;
}
export function SlotPicker({
slots,
onSelect,
labels: customLabels,
className,
}: SlotPickerProps) {
const labels = { ...DEFAULT_LABELS, ...customLabels };
const calendar = useCalendar({ onSlotSelect: onSelect });
return (
<div className={className}>
{/* Navegacion */}
<div className="flex items-center justify-between mb-4">
<button
onClick={calendar.goToPrevWeek}
className="text-sm text-[--color-text-secondary] hover:text-[--color-text-primary]"
>
{labels.prevWeek}
</button>
<button
onClick={calendar.goToNextWeek}
className="text-sm text-[--color-text-secondary] hover:text-[--color-text-primary]"
>
{labels.nextWeek}
</button>
</div>
{/* Dias de la semana */}
<div className="grid grid-cols-7 gap-1 mb-4">
{calendar.weekDays.map((day, i) => (
<div
key={day.toISOString()}
className={`text-center text-sm p-2 rounded ${
calendar.isToday(day)
? 'bg-[--color-accent] text-[--color-bg]'
: 'text-[--color-text-secondary]'
}`}
>
<span className="block text-xs">{labels.weekDays[i]}</span>
<span className="block font-medium">{day.getDate()}</span>
</div>
))}
</div>
{/* Slots */}
{slots.length === 0 ? (
<p className="text-sm text-[--color-text-secondary]">
{labels.noSlotsAvailable}
</p>
) : (
<div className="grid grid-cols-3 gap-2">
{slots.filter(s => s.available).map(slot => (
<button
key={slot.id}
onClick={() => calendar.selectSlot(slot)}
className={`p-2 text-sm rounded border transition-colors ${
calendar.selectedSlot?.id === slot.id
? 'border-[--color-accent] bg-[--color-accent]/10'
: 'border-[--color-border] hover:border-[--color-accent]'
}`}
>
{slot.startTime}
</button>
))}
</div>
)}
</div>
);
}
Uso en ingles (sobreescribiendo labels)
<SlotPicker
slots={availableSlots}
onSelect={handleSelect}
labels={{
nextWeek: 'Next week',
prevWeek: 'Previous week',
noSlotsAvailable: 'No time slots available',
weekDays: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
}}
/>
Tailwind CSS variables en vez de colores hardcodeados
Los componentes usan CSS variables (--color-accent, --color-text-primary, etc.) en vez de clases como text-blue-500. Esto permite que cada proyecto defina su propia paleta sin modificar los componentes.
/* En el proyecto que consume el paquete */
:root {
--color-accent: #2563eb;
--color-bg: #ffffff;
--color-text-primary: #1a1a1a;
--color-text-secondary: #6b7280;
--color-border: #e5e7eb;
}
Leccion clave
Headless hooks + i18n via props = internacionalizacion sin dependencias. No necesitas react-intl ni next-intl si tus componentes aceptan los strings como props opcionales con defaults. El consumidor elige: usa los defaults en espanol o pasa sus propios strings. Y con CSS variables en vez de colores de Tailwind, el componente se adapta a cualquier design system.