@qijenchen/design-system 0.1.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +93 -0
- package/src/README.md +32 -0
- package/src/components/Accordion/accordion.tsx +104 -0
- package/src/components/Alert/alert.tsx +188 -0
- package/src/components/AppShell/_demo-helpers.tsx +198 -0
- package/src/components/AppShell/app-shell.tsx +364 -0
- package/src/components/AspectRatio/aspect-ratio.tsx +58 -0
- package/src/components/Avatar/avatar.tsx +368 -0
- package/src/components/Badge/badge.tsx +104 -0
- package/src/components/Breadcrumb/breadcrumb.tsx +609 -0
- package/src/components/BulkActionBar/bulk-action-bar.tsx +156 -0
- package/src/components/Button/button-group.tsx +96 -0
- package/src/components/Button/button.tsx +539 -0
- package/src/components/Calendar/calendar.tsx +411 -0
- package/src/components/Carousel/carousel.tsx +371 -0
- package/src/components/Chart/chart.tsx +376 -0
- package/src/components/Checkbox/checkbox-group.tsx +94 -0
- package/src/components/Checkbox/checkbox.tsx +237 -0
- package/src/components/Chip/chip.tsx +359 -0
- package/src/components/CircularProgress/circular-progress.tsx +204 -0
- package/src/components/Coachmark/coachmark.tsx +255 -0
- package/src/components/Combobox/combobox.tsx +826 -0
- package/src/components/Command/command.tsx +187 -0
- package/src/components/DataTable/active-editor-controller.ts +72 -0
- package/src/components/DataTable/cell-registry.tsx +520 -0
- package/src/components/DataTable/column-types.ts +180 -0
- package/src/components/DataTable/data-table-column-visibility-panel.tsx +261 -0
- package/src/components/DataTable/data-table-filter-panel.tsx +813 -0
- package/src/components/DataTable/data-table-interaction-layer.tsx +483 -0
- package/src/components/DataTable/data-table-sort-manager.tsx +210 -0
- package/src/components/DataTable/data-table.css +165 -0
- package/src/components/DataTable/data-table.tsx +2924 -0
- package/src/components/DataTable/filter-operators.ts +225 -0
- package/src/components/DataTable/filter-tree.ts +313 -0
- package/src/components/DataTable/lib/column-meta.ts +79 -0
- package/src/components/DateGrid/date-grid.tsx +209 -0
- package/src/components/DatePicker/date-picker.tsx +1114 -0
- package/src/components/DescriptionList/description-list.tsx +141 -0
- package/src/components/Dialog/dialog.tsx +267 -0
- package/src/components/DropdownMenu/dropdown-menu.tsx +475 -0
- package/src/components/Empty/empty.tsx +108 -0
- package/src/components/Field/field-context.ts +136 -0
- package/src/components/Field/field-types.ts +52 -0
- package/src/components/Field/field-wrapper.tsx +348 -0
- package/src/components/Field/field.tsx +535 -0
- package/src/components/FieldControlGroup/field-control-group.tsx +136 -0
- package/src/components/FileItem/file-item.tsx +322 -0
- package/src/components/FileUpload/file-upload.tsx +326 -0
- package/src/components/FileViewer/file-viewer-types.ts +76 -0
- package/src/components/FileViewer/file-viewer.tsx +1065 -0
- package/src/components/FileViewer/image-renderer.tsx +256 -0
- package/src/components/HoverCard/hover-card.tsx +79 -0
- package/src/components/Input/input.tsx +233 -0
- package/src/components/LinkInput/link-input.tsx +304 -0
- package/src/components/Menu/menu-item.tsx +334 -0
- package/src/components/NameCard/name-card.tsx +319 -0
- package/src/components/Notice/notice.tsx +196 -0
- package/src/components/NumberInput/number-input.tsx +203 -0
- package/src/components/OverflowIndicator/overflow-indicator.tsx +156 -0
- package/src/components/PeoplePicker/avatar-stack-overflow.ts +100 -0
- package/src/components/PeoplePicker/people-picker-helpers.ts +76 -0
- package/src/components/PeoplePicker/people-picker.tsx +455 -0
- package/src/components/PeoplePicker/person-display.tsx +358 -0
- package/src/components/Popover/popover.tsx +183 -0
- package/src/components/ProgressBar/progress-bar.tsx +157 -0
- package/src/components/README.md +58 -0
- package/src/components/RadioGroup/radio-group.tsx +261 -0
- package/src/components/Rating/rating.tsx +295 -0
- package/src/components/ScrollArea/scroll-area.tsx +110 -0
- package/src/components/SegmentedControl/segmented-control.tsx +304 -0
- package/src/components/Select/select.tsx +658 -0
- package/src/components/SelectMenu/select-menu.tsx +430 -0
- package/src/components/SelectionControl/selection-item.tsx +261 -0
- package/src/components/Separator/separator.tsx +48 -0
- package/src/components/Sheet/sheet.tsx +240 -0
- package/src/components/Sidebar/sidebar.tsx +1280 -0
- package/src/components/Skeleton/skeleton.tsx +35 -0
- package/src/components/Slider/slider.tsx +158 -0
- package/src/components/Steps/steps.tsx +850 -0
- package/src/components/Switch/switch.tsx +285 -0
- package/src/components/Tabs/tabs.tsx +515 -0
- package/src/components/Tag/tag.tsx +246 -0
- package/src/components/Textarea/textarea.tsx +280 -0
- package/src/components/TimePicker/time-columns.tsx +260 -0
- package/src/components/TimePicker/time-picker.tsx +419 -0
- package/src/components/Toast/toast.tsx +129 -0
- package/src/components/Tooltip/tooltip.tsx +68 -0
- package/src/components/TreeView/tree-view.tsx +1031 -0
- package/src/hooks/use-controllable.ts +40 -0
- package/src/hooks/use-is-narrow-viewport.ts +19 -0
- package/src/hooks/use-is-touch-device.ts +21 -0
- package/src/hooks/use-overflow-items.ts +256 -0
- package/src/index.ts +85 -0
- package/src/lib/README.md +82 -0
- package/src/lib/drag-visual.ts +272 -0
- package/src/lib/i18n/README.md +60 -0
- package/src/lib/i18n/i18n-context.tsx +129 -0
- package/src/lib/multi-select-ordering.ts +61 -0
- package/src/lib/utils.ts +93 -0
- package/src/patterns/README.md +67 -0
- package/src/patterns/element-anatomy/item-anatomy.tsx +744 -0
- package/src/patterns/header-canonical/chrome-header.tsx +175 -0
- package/src/patterns/header-canonical/header-canonical.css +27 -0
- package/src/patterns/horizontal-overflow/horizontal-overflow.tsx +217 -0
- package/src/patterns/overlay-surface/overlay-surface.tsx +191 -0
- package/src/patterns/resize-handle/resize-handle.tsx +188 -0
- package/src/stories-helpers/anatomy/anatomy-utils.tsx +64 -0
- package/src/tokens/README.md +53 -0
- package/src/tokens/color/primitives.css +429 -0
- package/src/tokens/color/semantic.css +539 -0
- package/src/tokens/elevation/overlay-geometry.ts +13 -0
- package/src/tokens/layoutSpace/layoutSpace.css +36 -0
- package/src/tokens/motion/motion.css +30 -0
- package/src/tokens/motion/motion.ts +17 -0
- package/src/tokens/opacity/opacity.css +23 -0
- package/src/tokens/radius/radius.css +19 -0
- package/src/tokens/typography/typography.css +118 -0
- package/src/tokens/uiSize/icon-size.ts +52 -0
- package/src/tokens/uiSize/uiSize.css +125 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// @benchmark-unverified-blanket: file-level retraction per M22 (d) — claims herein not individually URL-cited; treat as unverified visual/usage rumor unless retrofit per-claim. Hook escape preserved.
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'
|
|
4
|
+
import {
|
|
5
|
+
startOfMonth,
|
|
6
|
+
endOfMonth,
|
|
7
|
+
startOfWeek,
|
|
8
|
+
endOfWeek,
|
|
9
|
+
eachDayOfInterval,
|
|
10
|
+
format,
|
|
11
|
+
isSameMonth,
|
|
12
|
+
isSameDay,
|
|
13
|
+
addMonths,
|
|
14
|
+
subMonths,
|
|
15
|
+
} from 'date-fns'
|
|
16
|
+
import { cn } from '@/lib/utils'
|
|
17
|
+
import { Button } from '@/design-system/components/Button/button'
|
|
18
|
+
import { SegmentedControl, SegmentedControlItem } from '@/design-system/components/SegmentedControl/segmented-control'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Calendar — 事件檢視 canvas(月 view MVP)
|
|
22
|
+
*
|
|
23
|
+
* 定位:看事件的 page-level canvas,對齊 Notion Calendar / Google Calendar。
|
|
24
|
+
* 完整 spec 見 `event-calendar.spec.md`。
|
|
25
|
+
*
|
|
26
|
+
* ── Layout Family ──
|
|
27
|
+
* 非 4-Family,屬 page-composite(多區塊 Toolbar + Grid + EventTile)。
|
|
28
|
+
*
|
|
29
|
+
* ── MVP scope(本次 session)──
|
|
30
|
+
* - 月 view 完整(toolbar / grid / event tile / today highlight / outside days)
|
|
31
|
+
* - 週 / 日 view 是 tech debt
|
|
32
|
+
* - 拖拉增刪 event 是 tech debt
|
|
33
|
+
*
|
|
34
|
+
* ── 與 DatePicker 的區分 ──
|
|
35
|
+
* DatePicker 是「選日期」form control;Calendar 是「看事件」page canvas。
|
|
36
|
+
* 名字相近但職責完全不同,spec 頂段明示分界。
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
export interface CalendarEvent {
|
|
42
|
+
id: string
|
|
43
|
+
title: string
|
|
44
|
+
/** ISO 字串 "YYYY-MM-DD"(all-day)或 "YYYY-MM-DDTHH:mm"(timed) */
|
|
45
|
+
start: string | Date
|
|
46
|
+
end: string | Date
|
|
47
|
+
allDay?: boolean
|
|
48
|
+
/**
|
|
49
|
+
* 事件類別色。值為 DS primitive 色名(blue / green / orange / purple / red / yellow)。
|
|
50
|
+
* 對照 Badge / Tag 的 primitive color variants。
|
|
51
|
+
*/
|
|
52
|
+
color?: 'blue' | 'green' | 'orange' | 'purple' | 'red' | 'yellow'
|
|
53
|
+
metadata?: Record<string, unknown>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type CalendarView = 'month' | 'week' | 'day'
|
|
57
|
+
|
|
58
|
+
export interface CalendarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onSelect'> {
|
|
59
|
+
/** 當前 view(MVP 只 'month',其餘 view tech debt) */
|
|
60
|
+
view?: CalendarView
|
|
61
|
+
defaultView?: CalendarView
|
|
62
|
+
onViewChange?: (view: CalendarView) => void
|
|
63
|
+
|
|
64
|
+
/** 聚焦日期(月 view 的那個月) */
|
|
65
|
+
referenceDate?: Date
|
|
66
|
+
defaultReferenceDate?: Date
|
|
67
|
+
onReferenceDateChange?: (date: Date) => void
|
|
68
|
+
|
|
69
|
+
/** 事件資料 */
|
|
70
|
+
events?: CalendarEvent[]
|
|
71
|
+
|
|
72
|
+
/** 點 event tile 回調 */
|
|
73
|
+
onEventClick?: (event: CalendarEvent) => void
|
|
74
|
+
/** 點月 cell 回調(用於新增) */
|
|
75
|
+
onDateClick?: (date: Date) => void
|
|
76
|
+
/** 點新事件 CTA 回調 */
|
|
77
|
+
onCreateEvent?: () => void
|
|
78
|
+
|
|
79
|
+
/** 0 = Sunday, 1 = Monday。預設 0(對齊 Google Calendar 美系預設) */
|
|
80
|
+
weekStartsOn?: 0 | 1
|
|
81
|
+
|
|
82
|
+
/** 自訂 event tile 渲染 */
|
|
83
|
+
renderEventTile?: (event: CalendarEvent) => React.ReactNode
|
|
84
|
+
|
|
85
|
+
/** size(MVP 只 md;lg 為 tech debt) */
|
|
86
|
+
size?: 'md' | 'lg'
|
|
87
|
+
className?: string
|
|
88
|
+
|
|
89
|
+
/** locale(預設 'en-US') */
|
|
90
|
+
locale?: string
|
|
91
|
+
|
|
92
|
+
/** ARIA labels for chrome controls. Override for i18n. */
|
|
93
|
+
prevAriaLabel?: string
|
|
94
|
+
nextAriaLabel?: string
|
|
95
|
+
viewToggleAriaLabel?: string
|
|
96
|
+
todayLabel?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Event tile color tokens ─────────────────────────────────────────────────
|
|
100
|
+
// 對齊 Tag / Badge 的 primitive color system(見 `color.spec.md`)
|
|
101
|
+
|
|
102
|
+
const EVENT_COLOR_CLASSES: Record<NonNullable<CalendarEvent['color']>, string> = {
|
|
103
|
+
blue: 'bg-[var(--color-blue-1)] text-[var(--color-blue-7)] hover:bg-[var(--color-blue-2)]',
|
|
104
|
+
green: 'bg-[var(--color-green-1)] text-[var(--color-green-7)] hover:bg-[var(--color-green-2)]',
|
|
105
|
+
orange: 'bg-[var(--color-deep-orange-1)] text-[var(--color-deep-orange-7)] hover:bg-[var(--color-deep-orange-2)]',
|
|
106
|
+
purple: 'bg-[var(--color-purple-1)] text-[var(--color-purple-7)] hover:bg-[var(--color-purple-2)]',
|
|
107
|
+
red: 'bg-[var(--color-deep-orange-1)] text-[var(--color-deep-orange-7)] hover:bg-[var(--color-deep-orange-2)]',
|
|
108
|
+
yellow: 'bg-[var(--color-yellow-1)] text-[var(--color-yellow-7)] hover:bg-[var(--color-yellow-2)]',
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
function coerceDate(value: string | Date): Date {
|
|
114
|
+
return value instanceof Date ? value : new Date(value)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function eventsOnDate(events: CalendarEvent[], date: Date): CalendarEvent[] {
|
|
118
|
+
return events.filter((e) => {
|
|
119
|
+
const start = coerceDate(e.start)
|
|
120
|
+
const end = coerceDate(e.end)
|
|
121
|
+
// 日期落在 [start, end] 範圍內(日精度)
|
|
122
|
+
const d = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
|
|
123
|
+
const s = new Date(start.getFullYear(), start.getMonth(), start.getDate()).getTime()
|
|
124
|
+
const eEnd = new Date(end.getFullYear(), end.getMonth(), end.getDate()).getTime()
|
|
125
|
+
return d >= s && d <= eEnd
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Component ──────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
const MAX_TILES_PER_CELL = 3
|
|
132
|
+
|
|
133
|
+
const Calendar = React.forwardRef<HTMLDivElement, CalendarProps>(function Calendar({
|
|
134
|
+
view: viewProp,
|
|
135
|
+
defaultView = 'month',
|
|
136
|
+
onViewChange,
|
|
137
|
+
referenceDate: referenceDateProp,
|
|
138
|
+
defaultReferenceDate,
|
|
139
|
+
onReferenceDateChange,
|
|
140
|
+
events = [],
|
|
141
|
+
onEventClick,
|
|
142
|
+
onDateClick,
|
|
143
|
+
onCreateEvent,
|
|
144
|
+
weekStartsOn = 0,
|
|
145
|
+
renderEventTile,
|
|
146
|
+
size = 'md',
|
|
147
|
+
className,
|
|
148
|
+
locale = 'en-US',
|
|
149
|
+
prevAriaLabel = '上個月', // i18n-allow: DS default; consumer override via prevAriaLabel prop
|
|
150
|
+
nextAriaLabel = '下個月', // i18n-allow: DS default; consumer override via nextAriaLabel prop
|
|
151
|
+
viewToggleAriaLabel = '檢視切換', // i18n-allow: DS default; consumer override via viewToggleAriaLabel prop
|
|
152
|
+
todayLabel = '今天', // i18n-allow: DS default; consumer override via todayLabel prop
|
|
153
|
+
...props
|
|
154
|
+
}, ref) {
|
|
155
|
+
// Controlled / uncontrolled refDate
|
|
156
|
+
const [internalRef, setInternalRef] = React.useState<Date>(
|
|
157
|
+
defaultReferenceDate ?? new Date(),
|
|
158
|
+
)
|
|
159
|
+
const refDate = referenceDateProp ?? internalRef
|
|
160
|
+
const setRefDate = React.useCallback(
|
|
161
|
+
(next: Date) => {
|
|
162
|
+
if (referenceDateProp === undefined) setInternalRef(next)
|
|
163
|
+
onReferenceDateChange?.(next)
|
|
164
|
+
},
|
|
165
|
+
[referenceDateProp, onReferenceDateChange],
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
// View state(MVP 只用 month,其他 tech debt)
|
|
169
|
+
const [internalView, setInternalView] = React.useState<CalendarView>(defaultView)
|
|
170
|
+
const currentView = viewProp ?? internalView
|
|
171
|
+
const setView = React.useCallback(
|
|
172
|
+
(next: CalendarView) => {
|
|
173
|
+
if (viewProp === undefined) setInternalView(next)
|
|
174
|
+
onViewChange?.(next)
|
|
175
|
+
},
|
|
176
|
+
[viewProp, onViewChange],
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
// Build month grid
|
|
180
|
+
const days = React.useMemo(() => {
|
|
181
|
+
const monthStart = startOfMonth(refDate)
|
|
182
|
+
const monthEnd = endOfMonth(refDate)
|
|
183
|
+
const gridStart = startOfWeek(monthStart, { weekStartsOn })
|
|
184
|
+
const gridEnd = endOfWeek(monthEnd, { weekStartsOn })
|
|
185
|
+
return eachDayOfInterval({ start: gridStart, end: gridEnd })
|
|
186
|
+
}, [refDate, weekStartsOn])
|
|
187
|
+
|
|
188
|
+
const monthTitle = new Intl.DateTimeFormat(locale, {
|
|
189
|
+
year: 'numeric',
|
|
190
|
+
month: 'long',
|
|
191
|
+
}).format(refDate)
|
|
192
|
+
|
|
193
|
+
const today = new Date()
|
|
194
|
+
|
|
195
|
+
const weekdayNames = React.useMemo(() => {
|
|
196
|
+
// 取 `days[0..6]` 的名字(gridStart 開始 7 天,正好一週)
|
|
197
|
+
return days.slice(0, 7).map((d) =>
|
|
198
|
+
new Intl.DateTimeFormat(locale, { weekday: 'short' }).format(d),
|
|
199
|
+
)
|
|
200
|
+
}, [days, locale])
|
|
201
|
+
|
|
202
|
+
const handleToday = () => setRefDate(new Date())
|
|
203
|
+
const handlePrev = () => setRefDate(subMonths(refDate, 1))
|
|
204
|
+
const handleNext = () => setRefDate(addMonths(refDate, 1))
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div
|
|
208
|
+
ref={ref}
|
|
209
|
+
className={cn(
|
|
210
|
+
'flex flex-col w-full h-full bg-surface rounded-md border border-divider overflow-hidden',
|
|
211
|
+
className,
|
|
212
|
+
)}
|
|
213
|
+
data-view={currentView}
|
|
214
|
+
data-size={size}
|
|
215
|
+
{...props}
|
|
216
|
+
>
|
|
217
|
+
{/* Toolbar:[◀] [今天] [▶] title [view tabs] [+ new] */}
|
|
218
|
+
<div
|
|
219
|
+
className={cn(
|
|
220
|
+
'flex items-center gap-2 shrink-0 border-b border-divider',
|
|
221
|
+
'px-[var(--layout-space-loose)] py-[var(--layout-space-tight)]',
|
|
222
|
+
)}
|
|
223
|
+
>
|
|
224
|
+
<div className="flex items-center gap-2">
|
|
225
|
+
<Button
|
|
226
|
+
variant="text"
|
|
227
|
+
size="sm"
|
|
228
|
+
iconOnly
|
|
229
|
+
startIcon={ChevronLeft}
|
|
230
|
+
aria-label={prevAriaLabel}
|
|
231
|
+
onClick={handlePrev}
|
|
232
|
+
/>
|
|
233
|
+
<Button variant="tertiary" size="sm" onClick={handleToday}>
|
|
234
|
+
{todayLabel}
|
|
235
|
+
</Button>
|
|
236
|
+
<Button
|
|
237
|
+
variant="text"
|
|
238
|
+
size="sm"
|
|
239
|
+
iconOnly
|
|
240
|
+
startIcon={ChevronRight}
|
|
241
|
+
aria-label={nextAriaLabel}
|
|
242
|
+
onClick={handleNext}
|
|
243
|
+
/>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<h2 className="text-body-lg font-medium text-foreground flex-1 min-w-0 truncate ml-2">
|
|
247
|
+
{monthTitle}
|
|
248
|
+
</h2>
|
|
249
|
+
|
|
250
|
+
{/* View switcher:用 SegmentedControl(互斥多選一 canonical)——
|
|
251
|
+
對齊 CLAUDE.md「互斥分類選擇走 SegmentedControl,非 checked Button group」原則。
|
|
252
|
+
Button 的 pressed 是「toggle 持續狀態」語意,不適合「單選 view 切換」 */}
|
|
253
|
+
<SegmentedControl
|
|
254
|
+
size="sm"
|
|
255
|
+
value={currentView}
|
|
256
|
+
onValueChange={(v) => setView(v as CalendarView)}
|
|
257
|
+
aria-label={viewToggleAriaLabel}
|
|
258
|
+
>
|
|
259
|
+
<SegmentedControlItem value="day" disabled>日</SegmentedControlItem>
|
|
260
|
+
<SegmentedControlItem value="week" disabled>週</SegmentedControlItem>
|
|
261
|
+
<SegmentedControlItem value="month">月</SegmentedControlItem>
|
|
262
|
+
</SegmentedControl>
|
|
263
|
+
|
|
264
|
+
{onCreateEvent && (
|
|
265
|
+
<Button variant="primary" size="sm" startIcon={Plus} onClick={onCreateEvent}>
|
|
266
|
+
新事件
|
|
267
|
+
</Button>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
{/* Weekday header */}
|
|
272
|
+
<div className="grid grid-cols-7 border-b border-divider bg-muted">
|
|
273
|
+
{weekdayNames.map((name, i) => (
|
|
274
|
+
<div
|
|
275
|
+
key={i}
|
|
276
|
+
className="px-2 py-1.5 text-caption text-fg-muted font-normal text-center"
|
|
277
|
+
>
|
|
278
|
+
{name}
|
|
279
|
+
</div>
|
|
280
|
+
))}
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
{/* Month grid:7 cols, ~5-6 rows。a11y(2026-04-25):WAI-ARIA grid 要求 row > gridcell
|
|
284
|
+
階層,chunk days 7 一組,wrap 成 role='row'(display:contents 保 CSS grid 佈局)。 */}
|
|
285
|
+
<div
|
|
286
|
+
className="grid grid-cols-7 flex-1 min-h-0"
|
|
287
|
+
role="grid"
|
|
288
|
+
aria-label={`月行事曆,${monthTitle}`}
|
|
289
|
+
>
|
|
290
|
+
{Array.from({ length: Math.ceil(days.length / 7) }, (_, rowIdx) => (
|
|
291
|
+
<div key={rowIdx} role="row" style={{ display: 'contents' }}>
|
|
292
|
+
{days.slice(rowIdx * 7, rowIdx * 7 + 7).map((date) => {
|
|
293
|
+
const inMonth = isSameMonth(date, refDate)
|
|
294
|
+
const isToday = isSameDay(date, today)
|
|
295
|
+
const dayEvents = eventsOnDate(events, date)
|
|
296
|
+
const visibleEvents = dayEvents.slice(0, MAX_TILES_PER_CELL)
|
|
297
|
+
const overflowCount = dayEvents.length - visibleEvents.length
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<button
|
|
301
|
+
key={date.toISOString()}
|
|
302
|
+
type="button"
|
|
303
|
+
role="gridcell"
|
|
304
|
+
aria-label={`${format(date, 'yyyy-MM-dd')},${dayEvents.length} 個事件`}
|
|
305
|
+
onClick={() => onDateClick?.(date)}
|
|
306
|
+
className={cn(
|
|
307
|
+
'flex flex-col gap-1 min-h-28 p-1.5 text-left',
|
|
308
|
+
'border-r border-b border-divider last:border-r-0',
|
|
309
|
+
'[&:nth-child(7n)]:border-r-0',
|
|
310
|
+
'hover:bg-neutral-hover transition-colors',
|
|
311
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
312
|
+
!inMonth && 'bg-muted',
|
|
313
|
+
)}
|
|
314
|
+
>
|
|
315
|
+
{/* Date number header */}
|
|
316
|
+
<div className="flex items-start justify-end">
|
|
317
|
+
{isToday ? (
|
|
318
|
+
<span className="inline-flex items-center justify-center min-w-6 h-6 px-2 rounded-full bg-primary text-on-emphasis text-body font-medium">
|
|
319
|
+
{format(date, 'd')}
|
|
320
|
+
</span>
|
|
321
|
+
) : (
|
|
322
|
+
<span
|
|
323
|
+
className={cn(
|
|
324
|
+
'text-body font-medium',
|
|
325
|
+
!inMonth && 'text-fg-disabled',
|
|
326
|
+
)}
|
|
327
|
+
>
|
|
328
|
+
{format(date, 'd')}
|
|
329
|
+
</span>
|
|
330
|
+
)}
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
{/* Event tiles */}
|
|
334
|
+
<div className="flex flex-col gap-0.5 min-h-0">
|
|
335
|
+
{visibleEvents.map((event) => {
|
|
336
|
+
const colorClass = EVENT_COLOR_CLASSES[event.color ?? 'blue']
|
|
337
|
+
if (renderEventTile) {
|
|
338
|
+
return (
|
|
339
|
+
<div
|
|
340
|
+
key={event.id}
|
|
341
|
+
onClick={(e) => {
|
|
342
|
+
e.stopPropagation()
|
|
343
|
+
onEventClick?.(event)
|
|
344
|
+
}}
|
|
345
|
+
>
|
|
346
|
+
{renderEventTile(event)}
|
|
347
|
+
</div>
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
return (
|
|
351
|
+
<div
|
|
352
|
+
key={event.id}
|
|
353
|
+
role="button"
|
|
354
|
+
tabIndex={0}
|
|
355
|
+
onClick={(e) => {
|
|
356
|
+
e.stopPropagation()
|
|
357
|
+
onEventClick?.(event)
|
|
358
|
+
}}
|
|
359
|
+
onKeyDown={(e) => {
|
|
360
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
361
|
+
e.preventDefault()
|
|
362
|
+
onEventClick?.(event)
|
|
363
|
+
}
|
|
364
|
+
}}
|
|
365
|
+
aria-label={`事件:${event.title}`}
|
|
366
|
+
className={cn(
|
|
367
|
+
'rounded-md px-1.5 py-0.5 text-caption truncate cursor-pointer transition-colors',
|
|
368
|
+
colorClass,
|
|
369
|
+
)}
|
|
370
|
+
>
|
|
371
|
+
{event.title}
|
|
372
|
+
</div>
|
|
373
|
+
)
|
|
374
|
+
})}
|
|
375
|
+
{overflowCount > 0 && (
|
|
376
|
+
<div className="text-caption text-fg-muted px-1.5">
|
|
377
|
+
+{overflowCount} more
|
|
378
|
+
</div>
|
|
379
|
+
)}
|
|
380
|
+
</div>
|
|
381
|
+
</button>
|
|
382
|
+
)
|
|
383
|
+
})}
|
|
384
|
+
</div>
|
|
385
|
+
))}
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
)
|
|
389
|
+
})
|
|
390
|
+
Calendar.displayName = "Calendar"
|
|
391
|
+
|
|
392
|
+
// Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
|
|
393
|
+
// Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
|
|
394
|
+
export const calendarMeta = {
|
|
395
|
+
component: 'Calendar',
|
|
396
|
+
family: null, // non-family composite / overlay / layout
|
|
397
|
+
variants: {
|
|
398
|
+
|
|
399
|
+
},
|
|
400
|
+
sizes: {
|
|
401
|
+
|
|
402
|
+
},
|
|
403
|
+
states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
|
|
404
|
+
tokens: {
|
|
405
|
+
bg: ['bg-muted', 'bg-neutral-hover', 'bg-primary', 'bg-surface'],
|
|
406
|
+
fg: ['text-fg-disabled', 'text-fg-muted', 'text-foreground'],
|
|
407
|
+
ring: ['ring-ring'],
|
|
408
|
+
},
|
|
409
|
+
} as const
|
|
410
|
+
|
|
411
|
+
export { Calendar }
|