@umituz/web-design-system 4.34.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,298 +0,0 @@
1
- /**
2
- * Calendar Board Component (Organism)
3
- * @description Month/week/day event board with tone-vocabulary chips; the
4
- * app owns the event data and the tone mapping, the board owns the grid.
5
- */
6
-
7
- import { useMemo, useState } from 'react';
8
- import type { ReactNode } from 'react';
9
- import { ChevronLeft, ChevronRight } from 'lucide-react';
10
- import { cn } from '../../infrastructure/utils';
11
- import { Button } from '../atoms/Button';
12
- import { IconButton } from '../atoms/IconButton';
13
- import { ToggleGroup, ToggleGroupItem } from './ToggleGroup';
14
- import type { BaseProps } from '../../domain/types';
15
-
16
- export type CalendarView = 'month' | 'week' | 'day';
17
- export type CalendarEventTone = 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'neutral' | 'accent';
18
-
19
- export interface CalendarEvent {
20
- id: string;
21
- /** Day the event lands on, `YYYY-MM-DD`. */
22
- date: string;
23
- /** Start time `HH:MM`; sorts events within a day and drives the day-view hour rows. */
24
- time?: string;
25
- title: ReactNode;
26
- tone?: CalendarEventTone;
27
- icon?: ReactNode;
28
- meta?: ReactNode;
29
- onClick?: () => void;
30
- }
31
-
32
- export interface CalendarLegendItem {
33
- tone: CalendarEventTone;
34
- label: string;
35
- }
36
-
37
- export interface CalendarBoardProps extends BaseProps {
38
- events: readonly CalendarEvent[];
39
- /** Initial view; the board owns its view state afterwards. */
40
- view?: CalendarView;
41
- /** Cursor start day, `YYYY-MM-DD`; defaults to today. */
42
- initialDate?: string;
43
- onViewEvent?: (event: CalendarEvent) => void;
44
- /** Fires when a month cell or a week-day header is picked. */
45
- onSelectDate?: (date: string) => void;
46
- legend?: readonly CalendarLegendItem[];
47
- /** 0 = Sunday, 1 = Monday (default). */
48
- weekStartsOn?: 0 | 1;
49
- /** Inclusive hour range for the day view, default `[6, 21]`. */
50
- dayHours?: readonly [number, number];
51
- /** Event chips shown per month cell before the `+N` overflow, default 3. */
52
- maxPerDay?: number;
53
- /** Locale for weekday/month labels, default `en-US`. */
54
- locale?: string;
55
- }
56
-
57
- const chipTones: Record<CalendarEventTone, string> = {
58
- primary: 'bg-primary/10 text-primary',
59
- success: 'bg-success/10 text-success',
60
- warning: 'bg-warning/15 text-warning',
61
- danger: 'bg-destructive/10 text-destructive',
62
- info: 'bg-info/10 text-info',
63
- neutral: 'bg-secondary text-secondary-foreground',
64
- accent: 'bg-accent text-accent-foreground',
65
- };
66
-
67
- function toYmd(d: Date): string {
68
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
69
- }
70
-
71
- function parseYmd(s: string): Date {
72
- const [y, m, d] = s.split('-').map(Number);
73
- return new Date(y!, (m ?? 1) - 1, d ?? 1);
74
- }
75
-
76
- function shift(cursor: Date, view: CalendarView, dir: number): Date {
77
- const d = new Date(cursor);
78
- if (view === 'month') d.setMonth(d.getMonth() + dir);
79
- else if (view === 'week') d.setDate(d.getDate() + dir * 7);
80
- else d.setDate(d.getDate() + dir);
81
- return d;
82
- }
83
-
84
- export function CalendarBoard({
85
- events,
86
- view: initialView = 'month',
87
- initialDate,
88
- onViewEvent,
89
- onSelectDate,
90
- legend,
91
- weekStartsOn = 1,
92
- dayHours = [6, 21],
93
- maxPerDay = 3,
94
- locale = 'en-US',
95
- className,
96
- }: CalendarBoardProps) {
97
- const [view, setView] = useState<CalendarView>(initialView);
98
- const [cursor, setCursor] = useState(() => (initialDate ? parseYmd(initialDate) : new Date()));
99
- const today = toYmd(new Date());
100
-
101
- const byDate = useMemo(() => {
102
- const m = new Map<string, CalendarEvent[]>();
103
- for (const e of events) {
104
- const bucket = m.get(e.date);
105
- if (bucket) bucket.push(e);
106
- else m.set(e.date, [e]);
107
- }
108
- for (const list of m.values()) list.sort((a, b) => (a.time ?? '').localeCompare(b.time ?? ''));
109
- return m;
110
- }, [events]);
111
-
112
- const weekdays = useMemo(() => {
113
- const base = new Date(2024, 0, 7 + weekStartsOn); // a Sunday + offset
114
- return Array.from({ length: 7 }, (_, i) =>
115
- new Date(base.getFullYear(), base.getMonth(), base.getDate() + i).toLocaleDateString(locale, { weekday: 'short' }),
116
- );
117
- }, [locale, weekStartsOn]);
118
-
119
- const monthCells = useMemo(() => {
120
- const y = cursor.getFullYear();
121
- const m = cursor.getMonth();
122
- const startOffset = (new Date(y, m, 1).getDay() + 7 - weekStartsOn) % 7;
123
- const start = new Date(y, m, 1 - startOffset);
124
- return Array.from({ length: 42 }, (_, i) => {
125
- const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
126
- return { date: toYmd(d), inMonth: d.getMonth() === m };
127
- });
128
- }, [cursor, weekStartsOn]);
129
-
130
- const weekDates = useMemo(() => {
131
- const base = new Date(cursor);
132
- base.setDate(base.getDate() - ((base.getDay() + 7 - weekStartsOn) % 7));
133
- return Array.from({ length: 7 }, (_, i) => {
134
- const d = new Date(base.getFullYear(), base.getMonth(), base.getDate() + i);
135
- return toYmd(d);
136
- });
137
- }, [cursor, weekStartsOn]);
138
-
139
- const title = useMemo(() => {
140
- if (view === 'day') return cursor.toLocaleDateString(locale, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
141
- if (view === 'week') {
142
- const first = parseYmd(weekDates[0]!);
143
- const last = parseYmd(weekDates[6]!);
144
- const sameMonth = first.getMonth() === last.getMonth();
145
- const left = sameMonth
146
- ? first.toLocaleDateString(locale, { month: 'short', day: 'numeric' })
147
- : first.toLocaleDateString(locale, { month: 'short', day: 'numeric' });
148
- const right = last.toLocaleDateString(locale, sameMonth ? { day: 'numeric', year: 'numeric' } : { month: 'short', day: 'numeric', year: 'numeric' });
149
- return `${left} – ${right}`;
150
- }
151
- return cursor.toLocaleDateString(locale, { month: 'long', year: 'numeric' });
152
- }, [cursor, locale, view, weekDates]);
153
-
154
- const hours = useMemo(() => {
155
- const [from, to] = dayHours;
156
- return Array.from({ length: to - from + 1 }, (_, i) => from! + i);
157
- }, [dayHours]);
158
-
159
- const fire = (e: CalendarEvent) => (onViewEvent ? onViewEvent(e) : e.onClick?.());
160
-
161
- const chip = (e: CalendarEvent) => (
162
- <button
163
- key={e.id}
164
- type="button"
165
- onClick={(ev) => { ev.stopPropagation(); fire(e); }}
166
- className={cn(
167
- 'flex w-full cursor-pointer items-center gap-1.5 truncate rounded-md px-1.5 py-1 text-left text-xs font-medium transition-colors hover:brightness-95',
168
- chipTones[e.tone ?? 'primary'],
169
- )}
170
- >
171
- {e.icon}
172
- {e.time && <span className="font-mono text-[9.5px] opacity-80">{e.time}</span>}
173
- <span className="truncate">{e.title}</span>
174
- {e.meta}
175
- </button>
176
- );
177
-
178
- return (
179
- <div className={cn('overflow-hidden rounded-xl border bg-card text-card-foreground', className)}>
180
- <div className="flex flex-wrap items-center justify-between gap-2.5 border-b px-3 py-2.5">
181
- <div className="flex items-center gap-1.5">
182
- <IconButton icon={<ChevronLeft />} label="Previous" variant="outline" onClick={() => setCursor((c) => shift(c, view, -1))} />
183
- <IconButton icon={<ChevronRight />} label="Next" variant="outline" onClick={() => setCursor((c) => shift(c, view, 1))} />
184
- <h3 className="ml-1.5 min-w-40 text-sm font-semibold">{title}</h3>
185
- <Button variant="ghost" size="sm" onClick={() => setCursor(new Date())}>Today</Button>
186
- </div>
187
- <div className="flex flex-wrap items-center gap-2.5">
188
- {legend && legend.length > 0 && (
189
- <div className="flex flex-wrap gap-2.5">
190
- {legend.map((l) => (
191
- <span key={l.tone + l.label} className="flex items-center gap-1 text-[11px] font-medium text-muted-foreground">
192
- <span className={cn('size-2 rounded-sm', l.tone === 'primary' ? 'bg-primary' : l.tone === 'success' ? 'bg-success' : l.tone === 'warning' ? 'bg-warning' : l.tone === 'danger' ? 'bg-destructive' : l.tone === 'info' ? 'bg-info' : l.tone === 'accent' ? 'bg-accent' : 'bg-secondary-foreground/40')} />
193
- {l.label}
194
- </span>
195
- ))}
196
- </div>
197
- )}
198
- <ToggleGroup type="single" size="sm" value={view} onValueChange={(v) => v && setView(v as CalendarView)}>
199
- {(['month', 'week', 'day'] as const).map((v) => (
200
- <ToggleGroupItem key={v} value={v} className="capitalize">{v}</ToggleGroupItem>
201
- ))}
202
- </ToggleGroup>
203
- </div>
204
- </div>
205
-
206
- {view === 'month' && (
207
- <>
208
- <div className="grid grid-cols-7 border-b bg-muted/50">
209
- {weekdays.map((d) => (
210
- <div key={d} className="py-1.5 text-center text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{d}</div>
211
- ))}
212
- </div>
213
- <div className="grid grid-cols-7">
214
- {monthCells.map((cell) => {
215
- const dayEvents = byDate.get(cell.date) ?? [];
216
- return (
217
- <div
218
- key={cell.date}
219
- onClick={() => onSelectDate?.(cell.date)}
220
- className={cn(
221
- 'min-h-26 border-b border-r p-1.5 last:border-r-0',
222
- !cell.inMonth && 'bg-muted/40 text-muted-foreground/50',
223
- cell.date === today && 'bg-primary/5',
224
- onSelectDate && 'cursor-pointer',
225
- )}
226
- >
227
- <div className="flex items-center justify-between">
228
- <span className={cn(
229
- 'inline-grid size-5 place-items-center rounded-full text-[11px] font-medium',
230
- cell.date === today && 'bg-primary text-primary-foreground',
231
- )}>
232
- {Number(cell.date.slice(8))}
233
- </span>
234
- {dayEvents.length > maxPerDay && <span className="font-mono text-[9.5px] text-muted-foreground">+{dayEvents.length - maxPerDay}</span>}
235
- </div>
236
- <div className="mt-1 flex flex-col gap-1">
237
- {dayEvents.slice(0, maxPerDay).map((e) => chip(e))}
238
- </div>
239
- </div>
240
- );
241
- })}
242
- </div>
243
- </>
244
- )}
245
-
246
- {view === 'week' && (
247
- <div className="grid grid-cols-7">
248
- {weekDates.map((date) => {
249
- const dayEvents = byDate.get(date) ?? [];
250
- const isToday = date === today;
251
- return (
252
- <div key={date} className="min-h-95 border-r last:border-r-0">
253
- <div className="border-b px-2 py-1.5 text-center">
254
- <div className="text-[9px] font-semibold uppercase text-muted-foreground">
255
- {parseYmd(date).toLocaleDateString(locale, { weekday: 'short' })}
256
- </div>
257
- <span className={cn('mt-0.5 inline-grid size-5 place-items-center rounded-full text-[11px] font-medium', isToday && 'bg-primary text-primary-foreground')}>
258
- {Number(date.slice(8))}
259
- </span>
260
- </div>
261
- <div className="flex flex-col gap-1 p-1.5" onClick={() => onSelectDate?.(date)}>
262
- {dayEvents.map((e) => chip(e))}
263
- </div>
264
- </div>
265
- );
266
- })}
267
- </div>
268
- )}
269
-
270
- {view === 'day' && (() => {
271
- const date = toYmd(cursor);
272
- const dayEvents = byDate.get(date) ?? [];
273
- return (
274
- <div className="p-4">
275
- {dayEvents.length === 0 && (
276
- <p className="py-10 text-center text-sm text-muted-foreground">Nothing scheduled.</p>
277
- )}
278
- {hours.map((h) => {
279
- const hourEvents = dayEvents.filter((e) => e.time && Math.floor(Number(e.time.split(':')[0])) === h);
280
- return (
281
- <div key={h} className="grid grid-cols-[56px_1fr] border-t">
282
- <div className="border-r py-1.5 pr-2.5 text-right font-mono text-[10.5px] text-muted-foreground">
283
- {String(h).padStart(2, '0')}:00
284
- </div>
285
- <div className="min-h-8 p-1">
286
- {hourEvents.map((e) => (
287
- <div key={e.id} className="mb-1 last:mb-0">{chip(e)}</div>
288
- ))}
289
- </div>
290
- </div>
291
- );
292
- })}
293
- </div>
294
- );
295
- })()}
296
- </div>
297
- );
298
- }