@monkey-mini-app/ui 0.1.0 → 0.1.4

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.
Files changed (203) hide show
  1. package/README.md +58 -0
  2. package/dist/globals.css +1 -1
  3. package/dist/index.js +12 -2
  4. package/dist/manifest.json +6 -6
  5. package/dist/runtime.js +9 -0
  6. package/dist/sdk.js +158 -0
  7. package/dist/src/blocks/activity-feed.tsx +7 -0
  8. package/dist/src/blocks/app-shell.tsx +1 -0
  9. package/dist/src/blocks/attachment-gallery.tsx +7 -0
  10. package/dist/src/blocks/bar-chart.tsx +15 -1
  11. package/dist/src/blocks/comment-thread.tsx +7 -0
  12. package/dist/src/blocks/dashboard-shell.tsx +109 -0
  13. package/dist/src/blocks/description-list.tsx +7 -0
  14. package/dist/src/blocks/detail-panel.tsx +1 -0
  15. package/dist/src/blocks/donut-chart.tsx +19 -2
  16. package/dist/src/blocks/env-badge.tsx +7 -0
  17. package/dist/src/blocks/env-table.tsx +316 -0
  18. package/dist/src/blocks/file-tree.tsx +7 -0
  19. package/dist/src/blocks/filter-bar.tsx +1 -0
  20. package/dist/src/blocks/form-sheet.tsx +160 -0
  21. package/dist/src/blocks/gauge.tsx +29 -6
  22. package/dist/src/blocks/list-detail.tsx +121 -0
  23. package/dist/src/blocks/notification-center.tsx +7 -0
  24. package/dist/src/blocks/page-header.tsx +1 -0
  25. package/dist/src/blocks/progress-ring.tsx +20 -2
  26. package/dist/src/blocks/radar-chart.tsx +18 -1
  27. package/dist/src/blocks/request-inspector.tsx +7 -0
  28. package/dist/src/blocks/reveal.tsx +56 -0
  29. package/dist/src/blocks/run-timeline.tsx +7 -0
  30. package/dist/src/blocks/scrollspy.tsx +7 -0
  31. package/dist/src/blocks/settings-split.tsx +154 -0
  32. package/dist/src/blocks/severity-chip.tsx +7 -0
  33. package/dist/src/blocks/sparkline.tsx +12 -3
  34. package/dist/src/blocks/stat-card.tsx +1 -0
  35. package/dist/src/blocks/status-badge.tsx +1 -0
  36. package/dist/src/blocks/table-page.tsx +83 -0
  37. package/dist/src/blocks/terminal.tsx +21 -3
  38. package/dist/src/blocks/test-step-list.tsx +7 -0
  39. package/dist/src/blocks/trend-card.tsx +7 -0
  40. package/dist/src/blocks/wizard-shell.tsx +132 -0
  41. package/dist/src/cdn-modules.d.ts +5 -0
  42. package/dist/src/components/accordion.tsx +7 -0
  43. package/dist/src/components/alert-dialog.tsx +7 -0
  44. package/dist/src/components/alert.tsx +7 -0
  45. package/dist/src/components/aspect-ratio.tsx +4 -0
  46. package/dist/src/components/attachment.tsx +4 -0
  47. package/dist/src/components/avatar.tsx +4 -0
  48. package/dist/src/components/badge.tsx +7 -0
  49. package/dist/src/components/breadcrumb.tsx +4 -0
  50. package/dist/src/components/bubble.tsx +4 -0
  51. package/dist/src/components/button-group.tsx +6 -0
  52. package/dist/src/components/button.tsx +7 -0
  53. package/dist/src/components/calendar.tsx +4 -0
  54. package/dist/src/components/card.tsx +8 -1
  55. package/dist/src/components/chart.tsx +7 -0
  56. package/dist/src/components/checkbox.tsx +5 -0
  57. package/dist/src/components/collapsible.tsx +4 -0
  58. package/dist/src/components/combobox.tsx +4 -0
  59. package/dist/src/components/command.tsx +6 -0
  60. package/dist/src/components/context-menu.tsx +4 -0
  61. package/dist/src/components/dialog.tsx +21 -0
  62. package/dist/src/components/drawer.tsx +4 -0
  63. package/dist/src/components/dropdown-menu.tsx +7 -0
  64. package/dist/src/components/empty.tsx +7 -0
  65. package/dist/src/components/field.tsx +6 -0
  66. package/dist/src/components/hover-card.tsx +4 -0
  67. package/dist/src/components/input-group.tsx +4 -0
  68. package/dist/src/components/input.tsx +7 -0
  69. package/dist/src/components/item.tsx +4 -0
  70. package/dist/src/components/kbd.tsx +4 -0
  71. package/dist/src/components/label.tsx +7 -0
  72. package/dist/src/components/marker.tsx +4 -0
  73. package/dist/src/components/menubar.tsx +4 -0
  74. package/dist/src/components/message-scroller.tsx +4 -0
  75. package/dist/src/components/message.tsx +4 -0
  76. package/dist/src/components/native-select.tsx +4 -0
  77. package/dist/src/components/navigation-menu.tsx +4 -0
  78. package/dist/src/components/pagination.tsx +4 -0
  79. package/dist/src/components/popover.tsx +7 -0
  80. package/dist/src/components/progress.tsx +7 -0
  81. package/dist/src/components/questionnaire.tsx +4 -0
  82. package/dist/src/components/radio-group.tsx +4 -0
  83. package/dist/src/components/resizable.tsx +7 -0
  84. package/dist/src/components/scroll-area.tsx +4 -0
  85. package/dist/src/components/select.tsx +7 -0
  86. package/dist/src/components/separator.tsx +5 -0
  87. package/dist/src/components/sheet.tsx +22 -0
  88. package/dist/src/components/sidebar.tsx +4 -0
  89. package/dist/src/components/skeleton.tsx +7 -0
  90. package/dist/src/components/slider.tsx +4 -0
  91. package/dist/src/components/spinner.tsx +6 -0
  92. package/dist/src/components/switch.tsx +7 -0
  93. package/dist/src/components/table.tsx +7 -0
  94. package/dist/src/components/tabs.tsx +7 -0
  95. package/dist/src/components/textarea.tsx +7 -0
  96. package/dist/src/components/toast.tsx +7 -0
  97. package/dist/src/components/toggle-group.tsx +4 -0
  98. package/dist/src/components/toggle.tsx +4 -0
  99. package/dist/src/components/tooltip.tsx +7 -0
  100. package/dist/src/composites/autocomplete.tsx +7 -0
  101. package/dist/src/composites/cascader.tsx +7 -0
  102. package/dist/src/composites/color-picker.tsx +7 -0
  103. package/dist/src/composites/confirm-dialog.tsx +7 -0
  104. package/dist/src/composites/copyable.tsx +9 -1
  105. package/dist/src/composites/currency-input.tsx +7 -0
  106. package/dist/src/composites/date-picker.tsx +7 -0
  107. package/dist/src/composites/date-range-picker.tsx +4 -0
  108. package/dist/src/composites/date-time-picker.tsx +7 -0
  109. package/dist/src/composites/date-time-range-picker.tsx +7 -0
  110. package/dist/src/composites/duration-input.tsx +7 -0
  111. package/dist/src/composites/mini-calendar.tsx +7 -0
  112. package/dist/src/composites/number-field.tsx +7 -0
  113. package/dist/src/composites/password-field.tsx +7 -0
  114. package/dist/src/composites/phone-input.tsx +7 -0
  115. package/dist/src/composites/rating.tsx +7 -0
  116. package/dist/src/composites/relative-date-picker.tsx +7 -0
  117. package/dist/src/composites/search-input.tsx +7 -0
  118. package/dist/src/composites/slider-range.tsx +7 -0
  119. package/dist/src/composites/tag-input.tsx +7 -0
  120. package/dist/src/composites/time-picker.tsx +11 -1
  121. package/dist/src/composites/time-range-picker.tsx +7 -0
  122. package/dist/src/composites/timezone-select.tsx +7 -0
  123. package/dist/src/composites/transfer.tsx +7 -0
  124. package/dist/src/composites/user-picker.tsx +7 -0
  125. package/dist/src/hooks/use-html-dark.ts +37 -0
  126. package/dist/src/hooks/use-mobile.ts +3 -0
  127. package/dist/src/i18n/en.ts +109 -2
  128. package/dist/src/i18n/zh.ts +99 -2
  129. package/dist/src/index.ts +147 -134
  130. package/dist/src/lib/clipboard.ts +10 -0
  131. package/dist/src/lib/illustrations.tsx +2 -2
  132. package/dist/src/products/code-block.tsx +35 -18
  133. package/dist/src/products/code-editor.tsx +156 -24
  134. package/dist/src/products/commit-graph.tsx +644 -0
  135. package/dist/src/products/data-grid.tsx +2 -1
  136. package/dist/src/products/diff-viewer.tsx +104 -8
  137. package/dist/src/products/event-calendar.tsx +28 -823
  138. package/dist/src/products/file-dropzone.tsx +7 -0
  139. package/dist/src/products/full-calendar/animations.ts +38 -0
  140. package/dist/src/products/full-calendar/calendar-body.tsx +77 -0
  141. package/dist/src/products/full-calendar/constants.ts +10 -0
  142. package/dist/src/products/full-calendar/contexts/calendar-context.tsx +255 -0
  143. package/dist/src/products/full-calendar/contexts/dnd-context.tsx +157 -0
  144. package/dist/src/products/full-calendar/dialogs/add-edit-event-dialog.tsx +187 -0
  145. package/dist/src/products/full-calendar/dialogs/delete-event-dialog.tsx +39 -0
  146. package/dist/src/products/full-calendar/dialogs/event-details-dialog.tsx +100 -0
  147. package/dist/src/products/full-calendar/dialogs/events-list-dialog.tsx +87 -0
  148. package/dist/src/products/full-calendar/dnd/draggable-event.tsx +45 -0
  149. package/dist/src/products/full-calendar/dnd/droppable-area.tsx +45 -0
  150. package/dist/src/products/full-calendar/dnd/resizable-event.tsx +179 -0
  151. package/dist/src/products/full-calendar/header/calendar-header.tsx +65 -0
  152. package/dist/src/products/full-calendar/header/date-navigator.tsx +110 -0
  153. package/dist/src/products/full-calendar/header/filter.tsx +58 -0
  154. package/dist/src/products/full-calendar/header/today-button.tsx +45 -0
  155. package/dist/src/products/full-calendar/header/user-select.tsx +64 -0
  156. package/dist/src/products/full-calendar/header/view-tabs.tsx +36 -0
  157. package/dist/src/products/full-calendar/helpers.ts +453 -0
  158. package/dist/src/products/full-calendar/hooks/use-time-range-create.ts +152 -0
  159. package/dist/src/products/full-calendar/hooks.ts +70 -0
  160. package/dist/src/products/full-calendar/interfaces.ts +23 -0
  161. package/dist/src/products/full-calendar/settings/settings.tsx +80 -0
  162. package/dist/src/products/full-calendar/shims/avatar-group.tsx +16 -0
  163. package/dist/src/products/full-calendar/shims/day-picker.tsx +1 -0
  164. package/dist/src/products/full-calendar/shims/motion.tsx +97 -0
  165. package/dist/src/products/full-calendar/shims/next-themes.ts +6 -0
  166. package/dist/src/products/full-calendar/shims/re-resizable.tsx +25 -0
  167. package/dist/src/products/full-calendar/shims/sonner.ts +4 -0
  168. package/dist/src/products/full-calendar/types.ts +8 -0
  169. package/dist/src/products/full-calendar/views/agenda-view/agenda-events.tsx +140 -0
  170. package/dist/src/products/full-calendar/views/month-view/calendar-month-view.tsx +69 -0
  171. package/dist/src/products/full-calendar/views/month-view/day-cell.tsx +201 -0
  172. package/dist/src/products/full-calendar/views/month-view/event-bullet.tsx +40 -0
  173. package/dist/src/products/full-calendar/views/month-view/month-event-badge.tsx +143 -0
  174. package/dist/src/products/full-calendar/views/week-and-day-view/calendar-day-view.tsx +295 -0
  175. package/dist/src/products/full-calendar/views/week-and-day-view/calendar-time-line.tsx +35 -0
  176. package/dist/src/products/full-calendar/views/week-and-day-view/calendar-week-view.tsx +253 -0
  177. package/dist/src/products/full-calendar/views/week-and-day-view/day-view-multi-day-events-row.tsx +73 -0
  178. package/dist/src/products/full-calendar/views/week-and-day-view/event-block.tsx +114 -0
  179. package/dist/src/products/full-calendar/views/week-and-day-view/render-grouped-events.tsx +49 -0
  180. package/dist/src/products/full-calendar/views/week-and-day-view/week-view-multi-day-events-row.tsx +143 -0
  181. package/dist/src/products/full-calendar/views/year-view/calendar-year-view.tsx +161 -0
  182. package/dist/src/products/gantt.tsx +7 -0
  183. package/dist/src/products/jira-wiki.tsx +1 -0
  184. package/dist/src/products/jql-input.tsx +340 -37
  185. package/dist/src/products/json-viewer.tsx +7 -0
  186. package/dist/src/products/kanban-issue-panel.tsx +7 -0
  187. package/dist/src/products/kanban.tsx +1 -0
  188. package/dist/src/products/log-viewer.tsx +390 -18
  189. package/dist/src/products/markdown-editor.tsx +21 -6
  190. package/dist/src/products/markdown.tsx +7 -0
  191. package/dist/src/products/rich-text-editor.tsx +65 -59
  192. package/dist/src/products/sortable-list.tsx +7 -0
  193. package/dist/src/products/stepper.tsx +219 -70
  194. package/dist/src/products/timeline.tsx +7 -0
  195. package/dist/src/products/tree-view.tsx +7 -0
  196. package/dist/src/sdk/app-error-boundary.tsx +240 -0
  197. package/dist/src/sdk/app-id.ts +7 -0
  198. package/dist/src/sdk/use-app.ts +126 -0
  199. package/dist/src/styles/globals.css +7 -7
  200. package/dist/vendors/lodash.js +20 -0
  201. package/dist/vendors/motion.js +12 -0
  202. package/package.json +23 -19
  203. package/dist/src/products/jql-language.ts +0 -70
@@ -1,845 +1,50 @@
1
1
  "use client"
2
2
 
3
- import * as React from "react"
4
- import {
5
- addDays,
6
- addMinutes,
7
- addMonths,
8
- addWeeks,
9
- differenceInCalendarDays,
10
- differenceInMinutes,
11
- endOfMonth,
12
- endOfWeek,
13
- format,
14
- isSameDay,
15
- isSameMonth,
16
- isToday,
17
- setHours,
18
- setMinutes,
19
- startOfDay,
20
- startOfMonth,
21
- startOfWeek,
22
- } from "date-fns"
23
-
24
- import { Button } from "@monkey-mini-app/ui/components/button"
25
- import {
26
- Dialog,
27
- DialogContent,
28
- DialogFooter,
29
- DialogHeader,
30
- DialogTitle,
31
- } from "@monkey-mini-app/ui/components/dialog"
32
- import { Input } from "@monkey-mini-app/ui/components/input"
33
- import { Label } from "@monkey-mini-app/ui/components/label"
34
- import {
35
- Popover,
36
- PopoverContent,
37
- PopoverTrigger,
38
- } from "@monkey-mini-app/ui/components/popover"
39
- import { Switch } from "@monkey-mini-app/ui/components/switch"
40
- import { DateTimeRangePicker } from "@monkey-mini-app/ui/composites/date-time-range-picker"
41
- import { useDateLocale, useLabels } from "@monkey-mini-app/ui/i18n/context"
42
3
  import { cn } from "@monkey-mini-app/ui/lib/utils"
43
4
 
44
- export type CalendarView = "month" | "week" | "day"
45
-
46
- export type CalendarEvent = {
47
- id: string
48
- title: string
49
- start: Date
50
- end: Date
51
- allDay?: boolean
52
- }
53
-
54
- type Draft = {
55
- start: Date
56
- end: Date
57
- allDay: boolean
58
- event?: CalendarEvent
59
- }
60
-
61
- const HOUR_HEIGHT = 48
62
- const SNAP = 15
63
- const MONTH_MAX = 3
64
- const WEEK_STARTS_ON = 1 as const
65
-
66
- function applyTime(date: Date, time: string) {
67
- const [h, m] = time.split(":").map(Number)
68
- return setMinutes(setHours(startOfDay(date), h || 0), m || 0)
69
- }
70
-
71
- function snapMinutes(value: number) {
72
- const max = 24 * 60
73
- const next = Math.round(value / SNAP) * SNAP
74
- return Math.min(max, Math.max(0, next))
75
- }
76
-
77
- function minutesOfDay(date: Date) {
78
- return date.getHours() * 60 + date.getMinutes()
79
- }
80
-
81
- function atMinutes(day: Date, minutes: number) {
82
- return addMinutes(startOfDay(day), Math.min(24 * 60, Math.max(0, minutes)))
83
- }
84
-
85
- function isBarEvent(event: CalendarEvent) {
86
- if (event.allDay) return true
87
- return differenceInCalendarDays(event.end, event.start) >= 1
88
- }
89
-
90
- function exclusiveAllDayEnd(inclusive: Date) {
91
- return addDays(startOfDay(inclusive), 1)
92
- }
93
-
94
- function inclusiveAllDayEnd(exclusive: Date) {
95
- const start = startOfDay(exclusive)
96
- return addDays(start, start.getTime() === exclusive.getTime() ? -1 : 0)
97
- }
98
-
99
- function eventTouchesDay(event: CalendarEvent, day: Date) {
100
- const start = startOfDay(event.start)
101
- const end = event.allDay ? event.end : startOfDay(addMinutes(event.end, -1))
102
- const t = startOfDay(day)
103
- return t >= start && t < (event.allDay ? event.end : addDays(end, 1))
104
- }
105
-
106
- function timedOnDay(event: CalendarEvent, day: Date) {
107
- if (isBarEvent(event)) return false
108
- return isSameDay(event.start, day)
109
- }
110
-
111
- type Packed = CalendarEvent & { startMin: number; endMin: number; col: number; cols: number }
112
-
113
- function packDay(events: CalendarEvent[], day: Date): Packed[] {
114
- const origin = startOfDay(day)
115
- const items: Packed[] = events
116
- .filter((event) => timedOnDay(event, day))
117
- .map((event) => {
118
- const startMin = Math.max(0, differenceInMinutes(event.start, origin))
119
- const endMin = Math.min(24 * 60, Math.max(startMin + SNAP, differenceInMinutes(event.end, origin)))
120
- return { ...event, startMin, endMin, col: 0, cols: 1 }
121
- })
122
- .sort((a, b) => a.startMin - b.startMin || b.endMin - a.endMin)
123
-
124
- const colEnds: number[] = []
125
- for (const item of items) {
126
- let col = colEnds.findIndex((end) => end <= item.startMin)
127
- if (col === -1) {
128
- col = colEnds.length
129
- colEnds.push(item.endMin)
130
- } else {
131
- colEnds[col] = item.endMin
132
- }
133
- item.col = col
134
- }
135
- for (const item of items) {
136
- const overlapping = items.filter((other) => other.startMin < item.endMin && other.endMin > item.startMin)
137
- item.cols = Math.max(1, ...overlapping.map((other) => other.col + 1))
138
- }
139
- return items
140
- }
141
-
142
- type Bar = { event: CalendarEvent; colStart: number; colSpan: number; lane: number }
143
-
144
- function packBars(events: CalendarEvent[], days: Date[]): { bars: Bar[]; lanes: number } {
145
- const origin = startOfDay(days[0] ?? new Date())
146
- const last = addDays(startOfDay(days[days.length - 1] ?? origin), 1)
147
- const candidates = events
148
- .filter(isBarEvent)
149
- .map((event) => {
150
- const start = event.start < origin ? origin : startOfDay(event.start)
151
- const end = event.end > last ? last : event.end
152
- const colStart = differenceInCalendarDays(start, origin)
153
- const colSpan = Math.max(1, differenceInCalendarDays(end, start))
154
- return { event, colStart, colSpan, lane: 0 }
155
- })
156
- .filter((bar) => bar.colStart < days.length && bar.colStart + bar.colSpan > 0)
157
- .sort((a, b) => a.colStart - b.colStart || b.colSpan - a.colSpan)
158
-
159
- const laneEnds: number[] = []
160
- for (const bar of candidates) {
161
- const start = Math.max(0, bar.colStart)
162
- const end = Math.min(days.length, bar.colStart + bar.colSpan)
163
- bar.colStart = start
164
- bar.colSpan = Math.max(1, end - start)
165
- let lane = laneEnds.findIndex((occupied) => occupied <= bar.colStart)
166
- if (lane === -1) {
167
- lane = laneEnds.length
168
- laneEnds.push(bar.colStart + bar.colSpan)
169
- } else {
170
- laneEnds[lane] = bar.colStart + bar.colSpan
171
- }
172
- bar.lane = lane
173
- }
174
- return { bars: candidates, lanes: Math.max(1, laneEnds.length) }
175
- }
176
-
177
- function EventChip({
178
- event,
179
- compact,
180
- onClick,
181
- }: {
182
- event: CalendarEvent
183
- compact?: boolean
184
- onClick?: (event: CalendarEvent) => void
185
- }) {
186
- return (
187
- <button
188
- type="button"
189
- data-testid={`calendar-event-${event.id}`}
190
- className={cn(
191
- "w-full truncate rounded-sm bg-primary/15 px-1 text-left text-[11px] text-primary",
192
- compact ? "h-5 leading-5" : "py-0.5"
193
- )}
194
- onClick={(e) => {
195
- e.stopPropagation()
196
- onClick?.(event)
197
- }}
198
- >
199
- {event.allDay ? event.title : `${format(event.start, "HH:mm")} ${event.title}`}
200
- </button>
201
- )
202
- }
5
+ import { CalendarBody } from "./full-calendar/calendar-body"
6
+ import { CalendarProvider } from "./full-calendar/contexts/calendar-context"
7
+ import { DndProvider } from "./full-calendar/contexts/dnd-context"
8
+ import { CalendarHeader } from "./full-calendar/header/calendar-header"
9
+ import type { IEvent, IUser } from "./full-calendar/interfaces"
10
+ import type { TCalendarView, TEventColor } from "./full-calendar/types"
203
11
 
204
- function EventDialog({
205
- draft,
206
- onOpenChange,
207
- onSave,
208
- onDelete,
209
- }: {
210
- draft: Draft | null
211
- onOpenChange: (open: boolean) => void
212
- onSave: (next: { title: string; start: Date; end: Date; allDay: boolean }) => void
213
- onDelete?: () => void
214
- }) {
215
- const t = useLabels("eventCalendar")
216
- const [title, setTitle] = React.useState("")
217
- const [allDay, setAllDay] = React.useState(false)
218
- const [range, setRange] = React.useState<{ start?: Date; end?: Date }>({})
219
-
220
- React.useEffect(() => {
221
- if (!draft) return
222
- setTitle(draft.event?.title ?? "")
223
- setAllDay(draft.allDay)
224
- setRange({
225
- start: draft.start,
226
- end: draft.allDay ? inclusiveAllDayEnd(draft.end) : draft.end,
227
- })
228
- }, [draft])
229
-
230
- const setAllDayLinked = (next: boolean) => {
231
- setAllDay(next)
232
- if (!range.start) return
233
- if (next) {
234
- setRange({
235
- start: startOfDay(range.start),
236
- end: startOfDay(range.end ?? range.start),
237
- })
238
- return
239
- }
240
- const start = applyTime(range.start, "09:00")
241
- let end = applyTime(range.end ?? range.start, "10:00")
242
- if (end <= start) end = addMinutes(start, 60)
243
- setRange({ start, end })
244
- }
245
-
246
- const save = () => {
247
- if (!range.start || !title.trim()) return
248
- if (allDay) {
249
- const start = startOfDay(range.start)
250
- const last = range.end ?? range.start
251
- const end = exclusiveAllDayEnd(last < start ? start : last)
252
- onSave({ title: title.trim(), start, end, allDay: true })
253
- return
254
- }
255
- const start = range.start
256
- let end = range.end ?? addMinutes(start, 30)
257
- if (end <= start) end = addMinutes(start, 30)
258
- onSave({ title: title.trim(), start, end, allDay: false })
259
- }
260
-
261
- return (
262
- <Dialog open={draft != null} onOpenChange={onOpenChange}>
263
- <DialogContent className="sm:max-w-xl" data-testid="event-dialog">
264
- <DialogHeader>
265
- <DialogTitle>{draft?.event ? t.editEvent : t.newEvent}</DialogTitle>
266
- </DialogHeader>
267
- <div className="flex flex-col gap-3">
268
- <Input
269
- data-testid="event-title-input"
270
- placeholder={t.eventTitle}
271
- value={title}
272
- onChange={(event) => setTitle(event.target.value)}
273
- onKeyDown={(event) => {
274
- if (event.key === "Enter") save()
275
- }}
276
- />
277
- <div className="flex items-center gap-2">
278
- <Switch
279
- id="event-all-day"
280
- checked={allDay}
281
- onCheckedChange={(checked) => setAllDayLinked(checked === true)}
282
- />
283
- <Label htmlFor="event-all-day" id="event-all-day-label">
284
- {t.allDay}
285
- </Label>
286
- </div>
287
- <DateTimeRangePicker value={range} onChange={setRange} allDay={allDay} />
288
- </div>
289
- <DialogFooter className={draft?.event ? "sm:justify-between" : undefined}>
290
- {draft?.event && onDelete ? (
291
- <Button variant="destructive" onClick={onDelete}>
292
- {t.delete}
293
- </Button>
294
- ) : (
295
- <span />
296
- )}
297
- <Button data-testid="event-save" onClick={save}>
298
- {t.save}
299
- </Button>
300
- </DialogFooter>
301
- </DialogContent>
302
- </Dialog>
303
- )
304
- }
305
-
306
- function monthDayKey(day: Date) {
307
- return format(day, "yyyy-MM-dd")
308
- }
309
-
310
- function orderedDays(a: Date, b: Date) {
311
- return a.getTime() <= b.getTime() ? [a, b] : [b, a]
312
- }
313
-
314
- function MonthView({
315
- current,
316
- events,
317
- onSelectRange,
318
- onEventClick,
319
- }: {
320
- current: Date
321
- events: CalendarEvent[]
322
- onSelectRange: (range: { start: Date; end: Date; allDay: boolean }) => void
323
- onEventClick: (event: CalendarEvent) => void
324
- }) {
325
- const t = useLabels("eventCalendar")
326
- const monthStart = startOfWeek(startOfMonth(current), { weekStartsOn: WEEK_STARTS_ON })
327
- const monthEnd = endOfWeek(endOfMonth(current), { weekStartsOn: WEEK_STARTS_ON })
328
- const days: Date[] = []
329
- for (let day = monthStart; day <= monthEnd; day = addDays(day, 1)) days.push(day)
330
- const [draft, setDraft] = React.useState<{ start: Date; end: Date } | null>(null)
331
- const draftRef = React.useRef(draft)
332
- draftRef.current = draft
333
-
334
- const dayFromPoint = (clientX: number, clientY: number) => {
335
- const node = document.elementFromPoint(clientX, clientY)?.closest("[data-month-day]")
336
- const key = node?.getAttribute("data-month-day")
337
- return key ? startOfDay(new Date(`${key}T00:00:00`)) : null
338
- }
339
-
340
- const commit = () => {
341
- const next = draftRef.current
342
- if (!next) return
343
- const [start, end] = orderedDays(next.start, next.end)
344
- onSelectRange({ start, end: addDays(end, 1), allDay: true })
345
- setDraft(null)
346
- }
347
-
348
- return (
349
- <>
350
- <div className="grid grid-cols-7 text-center text-[11px] text-muted-foreground">
351
- {t.weekdays.map((label) => (
352
- <div key={label} className="py-1">
353
- {label}
354
- </div>
355
- ))}
356
- </div>
357
- <div
358
- className="grid grid-cols-7"
359
- data-testid="calendar-month-grid"
360
- onPointerMove={(event) => {
361
- if (!draftRef.current || !event.currentTarget.hasPointerCapture(event.pointerId)) return
362
- const day = dayFromPoint(event.clientX, event.clientY)
363
- if (day) setDraft({ ...draftRef.current, end: day })
364
- }}
365
- onPointerUp={commit}
366
- >
367
- {days.map((day) => {
368
- const dayEvents = events.filter((event) => eventTouchesDay(event, day))
369
- const visible = dayEvents.slice(0, MONTH_MAX)
370
- const overflow = dayEvents.length - visible.length
371
- const selected =
372
- draft &&
373
- day >= orderedDays(draft.start, draft.end)[0] &&
374
- day <= orderedDays(draft.start, draft.end)[1]
375
- return (
376
- <div
377
- key={day.toISOString()}
378
- role="button"
379
- tabIndex={0}
380
- data-month-day={monthDayKey(day)}
381
- data-testid={`calendar-month-day-${monthDayKey(day)}`}
382
- onPointerDown={(event) => {
383
- if ((event.target as HTMLElement).closest("button")) return
384
- event.currentTarget.parentElement?.setPointerCapture?.(event.pointerId)
385
- setDraft({ start: startOfDay(day), end: startOfDay(day) })
386
- }}
387
- onKeyDown={(event) => {
388
- if (event.key === "Enter" || event.key === " ") {
389
- onSelectRange({
390
- start: startOfDay(day),
391
- end: addDays(startOfDay(day), 1),
392
- allDay: true,
393
- })
394
- }
395
- }}
396
- className={cn(
397
- "min-h-24 border-t border-r p-1 text-left text-xs last:border-r-0",
398
- !isSameMonth(day, current) && "bg-muted/30 text-muted-foreground",
399
- isToday(day) && "bg-primary/5",
400
- selected && "bg-primary/15"
401
- )}
402
- >
403
- <div className={cn("mb-1 font-medium", isToday(day) && "text-primary")}>{format(day, "d")}</div>
404
- <div className="flex flex-col gap-0.5">
405
- {visible.map((event) => (
406
- <EventChip key={event.id} event={event} compact onClick={onEventClick} />
407
- ))}
408
- {overflow > 0 ? (
409
- <Popover>
410
- <PopoverTrigger
411
- render={
412
- <Button
413
- variant="ghost"
414
- size="sm"
415
- data-testid="calendar-more"
416
- className="text-muted-foreground h-auto px-1 text-[11px]"
417
- onClick={(e) => e.stopPropagation()}
418
- />
419
- }
420
- >
421
- {t.more(overflow)}
422
- </PopoverTrigger>
423
- <PopoverContent align="start" className="w-56 p-2">
424
- <div className="mb-1 text-xs font-medium">{format(day, "EEE MMM d")}</div>
425
- <div className="flex flex-col gap-1">
426
- {dayEvents.map((event) => (
427
- <EventChip key={event.id} event={event} onClick={onEventClick} />
428
- ))}
429
- </div>
430
- </PopoverContent>
431
- </Popover>
432
- ) : null}
433
- </div>
434
- </div>
435
- )
436
- })}
437
- </div>
438
- </>
439
- )
440
- }
441
-
442
- function TimeGrid({
443
- days,
444
- events,
445
- onSelectRange,
446
- onEventClick,
447
- }: {
448
- days: Date[]
449
- events: CalendarEvent[]
450
- onSelectRange: (range: { start: Date; end: Date; allDay: boolean }) => void
451
- onEventClick: (event: CalendarEvent) => void
452
- }) {
453
- const t = useLabels("eventCalendar")
454
- const scrollerRef = React.useRef<HTMLDivElement>(null)
455
- const [draft, setDraft] = React.useState<{ day: Date; startMin: number; endMin: number } | null>(null)
456
- const draftRef = React.useRef(draft)
457
- draftRef.current = draft
458
- const [barDraft, setBarDraft] = React.useState<{ start: number; end: number } | null>(null)
459
- const barDraftRef = React.useRef(barDraft)
460
- barDraftRef.current = barDraft
461
- const { bars, lanes } = packBars(events, days)
462
-
463
- React.useEffect(() => {
464
- scrollerRef.current?.scrollTo?.({ top: 8 * HOUR_HEIGHT })
465
- }, [days[0]?.toDateString()])
466
-
467
- const minutesFromPointer = (event: React.PointerEvent, column: HTMLElement) => {
468
- const rect = column.getBoundingClientRect()
469
- const y = event.clientY - rect.top
470
- return snapMinutes((y / (24 * HOUR_HEIGHT)) * 24 * 60)
471
- }
472
-
473
- const commitTimed = () => {
474
- const next = draftRef.current
475
- if (!next) return
476
- const startMin = Math.min(next.startMin, next.endMin)
477
- const endMin = Math.max(next.startMin, next.endMin)
478
- const end = endMin === startMin ? startMin + 30 : endMin
479
- onSelectRange({
480
- start: atMinutes(next.day, startMin),
481
- end: atMinutes(next.day, Math.min(24 * 60, end)),
482
- allDay: false,
483
- })
484
- setDraft(null)
485
- }
486
-
487
- const indexFromX = (clientX: number, el: HTMLElement) => {
488
- const rect = el.getBoundingClientRect()
489
- const ratio = (clientX - rect.left) / Math.max(rect.width, 1)
490
- return Math.min(days.length - 1, Math.max(0, Math.floor(ratio * days.length)))
491
- }
492
-
493
- const commitBar = () => {
494
- const next = barDraftRef.current
495
- if (!next) return
496
- const startIndex = Math.min(next.start, next.end)
497
- const endIndex = Math.max(next.start, next.end)
498
- const start = days[startIndex]
499
- const end = days[endIndex]
500
- if (!start || !end) return
501
- onSelectRange({
502
- start: startOfDay(start),
503
- end: addDays(startOfDay(end), 1),
504
- allDay: true,
505
- })
506
- setBarDraft(null)
507
- }
508
-
509
- return (
510
- <div className="flex min-h-0 flex-1 flex-col" data-testid="calendar-week-grid">
511
- <div
512
- className="grid shrink-0 border-b"
513
- style={{ gridTemplateColumns: `3.5rem repeat(${days.length}, minmax(0, 1fr))` }}
514
- >
515
- <div />
516
- {days.map((day) => (
517
- <div
518
- key={day.toISOString()}
519
- className={cn("px-2 py-1.5 text-center text-xs", isToday(day) && "text-primary")}
520
- >
521
- <div className="text-muted-foreground font-medium">{format(day, "EEE")}</div>
522
- <div className={cn("text-sm", isToday(day) && "font-semibold")}>{format(day, "d")}</div>
523
- </div>
524
- ))}
525
- </div>
526
-
527
- <div
528
- className="grid shrink-0 border-b"
529
- data-testid="calendar-all-day"
530
- style={{ gridTemplateColumns: `3.5rem repeat(${days.length}, minmax(0, 1fr))` }}
531
- >
532
- <div className="text-muted-foreground px-1 py-1 text-[10px] leading-4">{t.allDay}</div>
533
- <div
534
- className="relative"
535
- style={{ gridColumn: `2 / span ${days.length}`, minHeight: lanes * 22 + 8 }}
536
- onPointerDown={(event) => {
537
- if ((event.target as HTMLElement).closest("[data-event-block]")) return
538
- event.currentTarget.setPointerCapture?.(event.pointerId)
539
- const index = indexFromX(event.clientX, event.currentTarget)
540
- setBarDraft({ start: index, end: index })
541
- }}
542
- onPointerMove={(event) => {
543
- if (!barDraftRef.current || !event.currentTarget.hasPointerCapture(event.pointerId)) return
544
- setBarDraft({
545
- ...barDraftRef.current,
546
- end: indexFromX(event.clientX, event.currentTarget),
547
- })
548
- }}
549
- onPointerUp={commitBar}
550
- >
551
- <div
552
- className="absolute inset-0 grid"
553
- style={{ gridTemplateColumns: `repeat(${days.length}, minmax(0, 1fr))` }}
554
- >
555
- {days.map((day, index) => {
556
- const selected =
557
- barDraft &&
558
- index >= Math.min(barDraft.start, barDraft.end) &&
559
- index <= Math.max(barDraft.start, barDraft.end)
560
- return (
561
- <div
562
- key={day.toISOString()}
563
- className={cn("border-l", selected ? "bg-primary/15" : "hover:bg-muted/30")}
564
- />
565
- )
566
- })}
567
- </div>
568
- {bars.map((bar) => (
569
- <button
570
- key={bar.event.id}
571
- type="button"
572
- data-event-block
573
- data-testid={`calendar-event-${bar.event.id}`}
574
- className="absolute truncate rounded-sm bg-primary/20 px-1 text-left text-[11px] text-primary"
575
- style={{
576
- top: 4 + bar.lane * 22,
577
- height: 20,
578
- left: `calc(${(bar.colStart / days.length) * 100}% + 2px)`,
579
- width: `calc(${(bar.colSpan / days.length) * 100}% - 4px)`,
580
- }}
581
- onPointerDown={(event) => event.stopPropagation()}
582
- onClick={(e) => {
583
- e.stopPropagation()
584
- onEventClick(bar.event)
585
- }}
586
- >
587
- {bar.event.title}
588
- </button>
589
- ))}
590
- </div>
591
- </div>
592
-
593
- <div ref={scrollerRef} className="min-h-0 flex-1 overflow-auto">
594
- <div
595
- className="grid"
596
- style={{
597
- gridTemplateColumns: `3.5rem repeat(${days.length}, minmax(0, 1fr))`,
598
- height: 24 * HOUR_HEIGHT,
599
- }}
600
- >
601
- <div className="relative">
602
- {Array.from({ length: 24 }, (_, hour) => (
603
- <div
604
- key={hour}
605
- className="text-muted-foreground -mt-2 pr-2 text-right text-[10px]"
606
- style={{ height: HOUR_HEIGHT }}
607
- >
608
- {hour === 0 ? "" : `${String(hour).padStart(2, "0")}:00`}
609
- </div>
610
- ))}
611
- </div>
612
- {days.map((day) => {
613
- const packed = packDay(events, day)
614
- const now = new Date()
615
- const showNow = isToday(day)
616
- return (
617
- <div
618
- key={day.toISOString()}
619
- className="relative border-l"
620
- data-testid={`calendar-day-col-${format(day, "yyyy-MM-dd")}`}
621
- onPointerDown={(event) => {
622
- if ((event.target as HTMLElement).closest("[data-event-block]")) return
623
- const column = event.currentTarget
624
- column.setPointerCapture?.(event.pointerId)
625
- const minutes = minutesFromPointer(event, column)
626
- setDraft({ day, startMin: minutes, endMin: minutes })
627
- }}
628
- onPointerMove={(event) => {
629
- if (!draftRef.current || !event.currentTarget.hasPointerCapture(event.pointerId)) return
630
- const minutes = minutesFromPointer(event, event.currentTarget)
631
- setDraft({ ...draftRef.current, endMin: minutes })
632
- }}
633
- onPointerUp={() => commitTimed()}
634
- >
635
- {Array.from({ length: 24 }, (_, hour) => (
636
- <div key={hour} className="border-t border-border/60" style={{ height: HOUR_HEIGHT }} />
637
- ))}
638
- {packed.map((item) => (
639
- <button
640
- key={item.id}
641
- type="button"
642
- data-event-block
643
- data-testid={`calendar-event-${item.id}`}
644
- className="absolute overflow-hidden rounded-sm bg-primary/20 px-1 py-0.5 text-left text-[11px] text-primary ring-1 ring-primary/20"
645
- style={{
646
- top: (item.startMin / 60) * HOUR_HEIGHT,
647
- height: Math.max(16, ((item.endMin - item.startMin) / 60) * HOUR_HEIGHT),
648
- left: `calc(${(item.col / item.cols) * 100}% + 2px)`,
649
- width: `calc(${(1 / item.cols) * 100}% - 4px)`,
650
- }}
651
- onClick={(e) => {
652
- e.stopPropagation()
653
- onEventClick(item)
654
- }}
655
- onPointerDown={(e) => e.stopPropagation()}
656
- >
657
- <div className="truncate font-medium">{item.title}</div>
658
- {item.endMin - item.startMin >= 45 ? (
659
- <div className="text-primary/80 truncate">
660
- {format(item.start, "HH:mm")}–{format(item.end, "HH:mm")}
661
- </div>
662
- ) : null}
663
- </button>
664
- ))}
665
- {draft && isSameDay(draft.day, day) ? (
666
- <div
667
- className="pointer-events-none absolute left-1 right-1 rounded-sm bg-primary/25 ring-1 ring-primary/40"
668
- style={{
669
- top: (Math.min(draft.startMin, draft.endMin) / 60) * HOUR_HEIGHT,
670
- height: Math.max(
671
- HOUR_HEIGHT / 4,
672
- (Math.abs(draft.endMin - draft.startMin) / 60) * HOUR_HEIGHT
673
- ),
674
- }}
675
- />
676
- ) : null}
677
- {showNow ? (
678
- <div
679
- className="pointer-events-none absolute right-0 left-0 z-10 flex items-center"
680
- style={{ top: (minutesOfDay(now) / 60) * HOUR_HEIGHT }}
681
- >
682
- <span className="bg-destructive size-1.5 rounded-full" />
683
- <span className="bg-destructive h-px flex-1" />
684
- </div>
685
- ) : null}
686
- </div>
687
- )
688
- })}
689
- </div>
690
- </div>
691
- </div>
692
- )
693
- }
12
+ export type CalendarView = TCalendarView
13
+ export type CalendarEvent = IEvent
14
+ export type CalendarUser = IUser
15
+ export type CalendarEventColor = TEventColor
694
16
 
695
17
  /**
696
- * Month / week / day calendar. Event shape: { id, title, start, end, allDay? }.
697
- * Drag empty slots to create; click event to edit. Week/day have all-day row.
18
+ * Full calendar (day / week / month / year / agenda). Event shape matches
19
+ * yassir-jeraidi/full-calendar: { id, title, startDate, endDate, color, description, user }.
698
20
  * @when Scheduling, on-call, release windows
699
21
  * @example
700
22
  * <EventCalendar events={events} onEventsChange={setEvents} view="week" />
23
+ * @family Calendar & date
701
24
  */
702
25
  export function EventCalendar({
703
- value,
704
- onValueChange,
705
- view = "month",
706
- onViewChange,
707
26
  events,
27
+ users = [],
28
+ view = "month",
29
+ date,
708
30
  onEventsChange,
709
- onDayClick,
710
- onEventClick,
711
31
  className,
712
32
  }: {
713
- value?: Date
714
- onValueChange?: (date: Date) => void
715
- view?: CalendarView
716
- onViewChange?: (view: CalendarView) => void
717
- events: CalendarEvent[]
718
- onEventsChange?: (events: CalendarEvent[]) => void
719
- onDayClick?: (date: Date) => void
720
- onEventClick?: (event: CalendarEvent) => void
33
+ events: IEvent[]
34
+ users?: IUser[]
35
+ view?: TCalendarView
36
+ date?: Date
37
+ onEventsChange?: (events: IEvent[]) => void
721
38
  className?: string
722
39
  }) {
723
- const [cursor, setCursor] = React.useState(value ?? new Date())
724
- const current = value ?? cursor
725
- const setCurrent = (date: Date) => {
726
- setCursor(date)
727
- onValueChange?.(date)
728
- }
729
- const [innerView, setInnerView] = React.useState(view)
730
- React.useEffect(() => setInnerView(view), [view])
731
- const currentView = innerView
732
- const setView = (next: CalendarView) => {
733
- setInnerView(next)
734
- onViewChange?.(next)
735
- }
736
- const [draft, setDraft] = React.useState<Draft | null>(null)
737
- const t = useLabels("eventCalendar")
738
- const dateLocale = useDateLocale()
739
-
740
- const openCreate = (range: { start: Date; end: Date; allDay: boolean }) => {
741
- onDayClick?.(range.start)
742
- if (!onEventsChange) return
743
- setDraft(range)
744
- }
745
-
746
- const openEvent = (event: CalendarEvent) => {
747
- onEventClick?.(event)
748
- if (!onEventsChange) return
749
- setDraft({ start: event.start, end: event.end, allDay: Boolean(event.allDay), event })
750
- }
751
-
752
- const weekStart = startOfWeek(current, { weekStartsOn: WEEK_STARTS_ON })
753
- const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
754
- const dayDays = [startOfDay(current)]
755
-
756
- const heading =
757
- currentView === "day"
758
- ? format(current, "EEEE, MMM d", { locale: dateLocale })
759
- : currentView === "week"
760
- ? `${format(weekStart, "MMM d", { locale: dateLocale })} – ${format(addDays(weekStart, 6), "MMM d", { locale: dateLocale })}`
761
- : format(current, "MMMM yyyy", { locale: dateLocale })
762
-
763
- const shift = (dir: number) => {
764
- if (currentView === "month") setCurrent(addMonths(current, dir))
765
- else if (currentView === "week") setCurrent(addWeeks(current, dir))
766
- else setCurrent(addDays(current, dir))
767
- }
768
-
769
40
  return (
770
- <div
771
- data-testid="event-calendar"
772
- className={cn(
773
- "flex flex-col overflow-hidden rounded-xl border",
774
- currentView === "month" ? "" : "h-[620px]",
775
- className
776
- )}
777
- >
778
- <div className="flex shrink-0 flex-wrap items-center gap-2 border-b px-3 py-2">
779
- <div className="text-sm font-medium">{heading}</div>
780
- <div className="ml-auto flex flex-wrap items-center gap-1">
781
- <Button size="sm" variant="outline" onClick={() => setCurrent(new Date())}>
782
- {t.today}
783
- </Button>
784
- <Button size="sm" variant="outline" onClick={() => shift(-1)}>
785
- {t.prev}
786
- </Button>
787
- <Button size="sm" variant="outline" onClick={() => shift(1)}>
788
- {t.next}
789
- </Button>
790
- {(["month", "week", "day"] as const).map((item) => (
791
- <Button
792
- key={item}
793
- size="sm"
794
- variant={currentView === item ? "default" : "outline"}
795
- data-testid={`calendar-view-${item}`}
796
- onClick={() => setView(item)}
797
- >
798
- {t[item]}
799
- </Button>
800
- ))}
801
- </div>
802
- </div>
803
-
804
- {currentView === "month" ? (
805
- <MonthView
806
- current={current}
807
- events={events}
808
- onSelectRange={openCreate}
809
- onEventClick={openEvent}
810
- />
811
- ) : (
812
- <TimeGrid
813
- days={currentView === "week" ? weekDays : dayDays}
814
- events={events}
815
- onSelectRange={openCreate}
816
- onEventClick={openEvent}
817
- />
818
- )}
819
-
820
- <EventDialog
821
- draft={draft}
822
- onOpenChange={(open) => !open && setDraft(null)}
823
- onSave={(next) => {
824
- if (!onEventsChange) return
825
- if (draft?.event) {
826
- onEventsChange(
827
- events.map((event) => (event.id === draft.event?.id ? { ...event, ...next } : event))
828
- )
829
- } else {
830
- onEventsChange([...events, { id: `evt-${Date.now()}`, ...next }])
831
- }
832
- setDraft(null)
833
- }}
834
- onDelete={
835
- draft?.event
836
- ? () => {
837
- onEventsChange?.(events.filter((event) => event.id !== draft.event?.id))
838
- setDraft(null)
839
- }
840
- : undefined
841
- }
842
- />
41
+ <div data-testid="event-calendar" className={cn("w-full overflow-hidden rounded-xl border bg-card", className)}>
42
+ <CalendarProvider events={events} users={users} view={view} date={date} onEventsChange={onEventsChange}>
43
+ <DndProvider>
44
+ <CalendarHeader />
45
+ <CalendarBody />
46
+ </DndProvider>
47
+ </CalendarProvider>
843
48
  </div>
844
49
  )
845
50
  }