@weft-js/warp 0.0.0-stage → 0.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.
Files changed (180) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +55 -2
  3. package/package.json +43 -4
  4. package/plugin.d.mts +30 -0
  5. package/plugin.mjs +358 -0
  6. package/src/Accordion.tsx +58 -0
  7. package/src/Alert.tsx +25 -0
  8. package/src/Badge.tsx +17 -0
  9. package/src/Button.tsx +100 -0
  10. package/src/Calendar.tsx +162 -0
  11. package/src/Canvas.tsx +110 -0
  12. package/src/Card.tsx +29 -0
  13. package/src/Chart.tsx +133 -0
  14. package/src/Charts.tsx +69 -0
  15. package/src/Chip.tsx +27 -0
  16. package/src/Choice.tsx +105 -0
  17. package/src/CodeBlock.tsx +65 -0
  18. package/src/CodeInput.tsx +130 -0
  19. package/src/Combobox.tsx +113 -0
  20. package/src/CommandPalette.tsx +348 -0
  21. package/src/DataGrid.tsx +561 -0
  22. package/src/DateField.tsx +221 -0
  23. package/src/Dither.tsx +129 -0
  24. package/src/Drawer.tsx +41 -0
  25. package/src/DurationField.tsx +98 -0
  26. package/src/Feed.tsx +74 -0
  27. package/src/Field.tsx +147 -0
  28. package/src/Icon.tsx +8 -0
  29. package/src/Kbd.tsx +6 -0
  30. package/src/KeyValueEditor.tsx +114 -0
  31. package/src/Layout.tsx +109 -0
  32. package/src/Markdown.tsx +63 -0
  33. package/src/MentionsInput.tsx +150 -0
  34. package/src/Menu.tsx +111 -0
  35. package/src/Modal.tsx +40 -0
  36. package/src/MonthPicker.tsx +190 -0
  37. package/src/Nav.tsx +20 -0
  38. package/src/NumberField.tsx +78 -0
  39. package/src/Pagination.tsx +122 -0
  40. package/src/PaymentCard.tsx +63 -0
  41. package/src/Picker.tsx +120 -0
  42. package/src/PlaceField.tsx +126 -0
  43. package/src/Popover.tsx +34 -0
  44. package/src/Progress.tsx +26 -0
  45. package/src/RecurrenceField.tsx +126 -0
  46. package/src/RichSelect.tsx +243 -0
  47. package/src/RichTextEditor.tsx +207 -0
  48. package/src/ScrollGrid.tsx +190 -0
  49. package/src/Segments.tsx +70 -0
  50. package/src/Select.tsx +192 -0
  51. package/src/Shape.tsx +20 -0
  52. package/src/Shell.tsx +308 -0
  53. package/src/SideNav.tsx +147 -0
  54. package/src/SignaturePad.tsx +120 -0
  55. package/src/Skeleton.tsx +14 -0
  56. package/src/Slider.tsx +42 -0
  57. package/src/Spinner.tsx +7 -0
  58. package/src/Table.tsx +528 -0
  59. package/src/Tabs.tsx +67 -0
  60. package/src/Tag.tsx +15 -0
  61. package/src/Ticker.tsx +36 -0
  62. package/src/TimeField.tsx +237 -0
  63. package/src/Toast.tsx +56 -0
  64. package/src/Toggle.tsx +42 -0
  65. package/src/Tooltip.tsx +32 -0
  66. package/src/Type.tsx +46 -0
  67. package/src/VirtualList.tsx +386 -0
  68. package/src/admin/ApiKeys.tsx +136 -0
  69. package/src/admin/Audit.tsx +93 -0
  70. package/src/admin/Billing.tsx +123 -0
  71. package/src/admin/Cart.tsx +96 -0
  72. package/src/admin/Checkout.tsx +300 -0
  73. package/src/admin/CsvImport.tsx +174 -0
  74. package/src/admin/Danger.tsx +110 -0
  75. package/src/admin/Integrations.tsx +200 -0
  76. package/src/admin/InviteDialog.tsx +64 -0
  77. package/src/admin/Members.tsx +186 -0
  78. package/src/admin/Order.tsx +63 -0
  79. package/src/admin/PlainTable.tsx +22 -0
  80. package/src/admin/Plans.tsx +167 -0
  81. package/src/admin/SaveBar.tsx +112 -0
  82. package/src/admin/Secret.tsx +37 -0
  83. package/src/admin/Settings.tsx +76 -0
  84. package/src/admin/StepBar.tsx +35 -0
  85. package/src/admin/TagsInput.tsx +79 -0
  86. package/src/admin/atoms.tsx +33 -0
  87. package/src/admin/index.ts +16 -0
  88. package/src/agent/Actions.tsx +85 -0
  89. package/src/agent/AgentPlan.tsx +49 -0
  90. package/src/agent/ArtifactPanel.tsx +80 -0
  91. package/src/agent/Assistant.tsx +96 -0
  92. package/src/agent/Checkpoint.tsx +14 -0
  93. package/src/agent/Cite.tsx +39 -0
  94. package/src/agent/ContextMeter.tsx +31 -0
  95. package/src/agent/Conversation.tsx +34 -0
  96. package/src/agent/Message.tsx +53 -0
  97. package/src/agent/ModelPicker.tsx +65 -0
  98. package/src/agent/Prompt.tsx +197 -0
  99. package/src/agent/Prose.tsx +54 -0
  100. package/src/agent/Question.tsx +52 -0
  101. package/src/agent/Reasoning.tsx +19 -0
  102. package/src/agent/Shimmer.tsx +7 -0
  103. package/src/agent/Sources.tsx +23 -0
  104. package/src/agent/SpeechInput.tsx +102 -0
  105. package/src/agent/Suggestions.tsx +22 -0
  106. package/src/agent/ToolCall.tsx +91 -0
  107. package/src/agent/TraceViewer.tsx +55 -0
  108. package/src/agent/bits.tsx +40 -0
  109. package/src/agent/index.ts +22 -0
  110. package/src/bind.ts +42 -0
  111. package/src/collab/AutoTrack.tsx +42 -0
  112. package/src/collab/Comments.tsx +94 -0
  113. package/src/collab/Composer.tsx +173 -0
  114. package/src/collab/ConversationList.tsx +73 -0
  115. package/src/collab/Cursors.tsx +35 -0
  116. package/src/collab/DataIntegrity.tsx +45 -0
  117. package/src/collab/Kanban.tsx +185 -0
  118. package/src/collab/Lock.tsx +23 -0
  119. package/src/collab/PresenceStack.tsx +67 -0
  120. package/src/collab/ShareDialog.tsx +118 -0
  121. package/src/collab/Status.tsx +42 -0
  122. package/src/collab/SyncedForm.tsx +19 -0
  123. package/src/collab/Thread.tsx +273 -0
  124. package/src/collab/VersionHistory.tsx +49 -0
  125. package/src/collab/ViewportRail.tsx +49 -0
  126. package/src/collab/index.ts +16 -0
  127. package/src/collab/peer.tsx +16 -0
  128. package/src/fields.ts +5 -0
  129. package/src/flow/AuthScreen.tsx +40 -0
  130. package/src/flow/AuthSteps.tsx +120 -0
  131. package/src/flow/Checklist.tsx +84 -0
  132. package/src/flow/ConnBanner.tsx +147 -0
  133. package/src/flow/Flow.tsx +63 -0
  134. package/src/flow/MfaSetup.tsx +103 -0
  135. package/src/flow/Otp.tsx +114 -0
  136. package/src/flow/ReauthDialog.tsx +47 -0
  137. package/src/flow/ShortcutsOverlay.tsx +60 -0
  138. package/src/flow/SignGate.tsx +98 -0
  139. package/src/flow/SignIn.tsx +80 -0
  140. package/src/flow/SignUp.tsx +71 -0
  141. package/src/flow/Sso.tsx +38 -0
  142. package/src/flow/SystemPage.tsx +247 -0
  143. package/src/flow/Tour.tsx +108 -0
  144. package/src/flow/index.ts +17 -0
  145. package/src/flow/kit.tsx +170 -0
  146. package/src/index.ts +74 -0
  147. package/src/layout.css +4 -0
  148. package/src/links.css +4 -0
  149. package/src/live/ActivityFeed.tsx +49 -0
  150. package/src/live/Bell.tsx +62 -0
  151. package/src/live/Connection.tsx +49 -0
  152. package/src/live/Inbox.tsx +220 -0
  153. package/src/live/MeetingRoom.tsx +298 -0
  154. package/src/live/NotificationPreferences.tsx +193 -0
  155. package/src/live/Prejoin.tsx +140 -0
  156. package/src/live/PushSoftAsk.tsx +37 -0
  157. package/src/live/TransportPicker.tsx +163 -0
  158. package/src/live/index.ts +12 -0
  159. package/src/live/notify.tsx +16 -0
  160. package/src/live/shared.tsx +39 -0
  161. package/src/parts.tsx +93 -0
  162. package/src/schedule/Agenda.tsx +173 -0
  163. package/src/schedule/AvailabilityEditor.tsx +164 -0
  164. package/src/schedule/BookingCalendar.tsx +132 -0
  165. package/src/schedule/Calendar.tsx +746 -0
  166. package/src/schedule/EventEditor.tsx +87 -0
  167. package/src/schedule/EventPopover.tsx +65 -0
  168. package/src/schedule/MeetingPlanner.tsx +214 -0
  169. package/src/schedule/MoveSheet.tsx +78 -0
  170. package/src/schedule/YearView.tsx +163 -0
  171. package/src/schedule/bits.tsx +51 -0
  172. package/src/schedule/index.ts +10 -0
  173. package/src/skeleton-css.ts +4 -0
  174. package/src/styles.ts +10 -0
  175. package/src/tokens.d.ts +2 -0
  176. package/src/type.css +15 -0
  177. package/src/ui.ts +26 -0
  178. package/src/use-ui.ts +10 -0
  179. package/tailwind.css +38 -0
  180. package/tokens/color.mjs +66 -0
@@ -0,0 +1,746 @@
1
+ import { GlobeIcon, LeftIcon, PlusIcon, RightIcon } from '@weft-js/warp-icons'
2
+ import { allDayBars, announce, at, edges, calendarDrag, clock, clockRange, conflicts, countByDay, dateOf, daySegments, dayState, dow, dstShift, eventKey, eventName, eventsOn, fmt, gridKey, indicator, isoWeek, isWeekend, laneKey, layoutDay, longDuration, minuteOf, monthCells, monthStart, monthWeeks, moveKey, nextIndex, overflow, popover, shownIn, slotOf, slotTimes, stepDate, toZone, uid, viewDays, weekStart, weekdays, ymd, zoneCity, type CalEvent, type CalView, type DragPreview, type IsoDate, type DaySegment, type Slot } from '@weft-js/warp-core'
3
+ import { derived, flush, onCleanup, onMount, state, untrack, watch, type State } from '@weft-js/core'
4
+ import { Button } from '../Button'
5
+ import { Modal } from '../Modal'
6
+ import { LiveStatus, type LiveState } from '../parts'
7
+ import { bindable, usePickerEnv } from '../Picker'
8
+ import { Popover } from '../Popover'
9
+ import { TimeZoneSelect } from '../RichSelect'
10
+ import { Segmented } from '../Choice'
11
+ import { Tooltip } from '../Tooltip'
12
+ import { use } from '@weft-js/warp-core/weft'
13
+ import { AgendaDay } from './Agenda'
14
+ import { RepeatGlyph, SweepParts, confirmSweep, tint, useNow, useWidth } from './bits'
15
+ import { EventEditor, type CalendarSource } from './EventEditor'
16
+ import { EventPopover } from './EventPopover'
17
+ import { MoveSheet } from './MoveSheet'
18
+ import { YearView } from './YearView'
19
+ import '@weft-js/warp-core/styles/components/tabs.css'
20
+ import '@weft-js/warp-core/styles/app/calendar.css'
21
+ import '@weft-js/warp-core/styles/layout/edges.css'
22
+
23
+ /** Which occurrences of a repeating event a change covers. */
24
+ export type RecurrenceScope = 'this' | 'following' | 'all'
25
+ /** Where an edited event lands: its new times, its timeline row, and which occurrences of a repeat it covers. */
26
+ export type EventChange = { start: string; end: string; resource?: string | undefined; scope?: RecurrenceScope | undefined }
27
+ /** A timeline row: a room or a person. `meta` is the quiet line under its name ("8 seats"). */
28
+ export type Resource = { id: string; name: string; meta?: string }
29
+ type Reason = 'pointer' | 'keyboard' | 'touch'
30
+
31
+ /** Props for Calendar. */
32
+ export type CalendarProps = {
33
+ /** The events to show, in every view. Times are wall-clock ISO strings (`2026-11-04T09:30`), or dates for all-day ones. */
34
+ events: CalEvent[]
35
+ /** The calendars events belong to (an event's `cal`): the rail lists them with a checkbox each, and the editor offers them. */
36
+ calendars?: CalendarSource[] | undefined
37
+ /** The view showing: a State to share it, or a value. */
38
+ view?: State<CalView> | CalView | undefined
39
+ /** The view it opens on when `view` isn't given. Week without either. */
40
+ defaultView?: CalView | undefined
41
+ /** Hears every change of view, by tab, key or the mini month. */
42
+ onViewChange?: ((v: CalView) => void) | undefined
43
+ /** The views offered as tabs, in this order. All six without it. */
44
+ views?: CalView[] | undefined
45
+ /** The date the view is on (`2026-11-04`): a State to share it, or a value. */
46
+ date?: State<string> | string | undefined
47
+ /** The date it opens on when `date` isn't given. Today without either. */
48
+ defaultDate?: string | undefined
49
+ /** Hears the new date when the arrows, Today, the mini month or a drill-in move it. */
50
+ onDateChange?: ((d: string) => void) | undefined
51
+ /** The toolbar chip's zone. Without one, wall times show as they are. */
52
+ timeZone?: State<string | null> | string | undefined
53
+ /** Hears a zone picked from the toolbar chip, or null for wall times. */
54
+ onTimeZoneChange?: ((z: string | null) => void) | undefined
55
+ /** Secondary zones in the rail. */
56
+ zones?: { label: string; zone: string }[] | undefined
57
+ /** Today's date (`2026-11-02`), marked in every view. The browser's date after mount without it, so hydration writes nothing. */
58
+ today?: string | undefined
59
+ /** The now line's wall time; the browser's clock after mount without it. */
60
+ now?: string | undefined
61
+ /** The locale for day and month names, times and the first day of the week. The browser's without it. */
62
+ locale?: string | undefined
63
+ /** `h12` or `h23` to force a clock; the locale's without it. */
64
+ hourCycle?: string | undefined
65
+ /** The week's first day, 0 for Sunday to 6 for Saturday. The locale's without it. */
66
+ firstDayOfWeek?: number | undefined
67
+ /** Minutes a drag or resize snaps to. 15 without it. */
68
+ snap?: number | undefined
69
+ /**
70
+ * Working hours as minutes from midnight, `[start, end]`: `[540, 1020]` is 09:00 to 17:00 (not `[9, 17]`). The rest of
71
+ * the day is banded, and Week and Day open scrolled to just before `start`. 08:00 to 18:00 without it.
72
+ */
73
+ workingHours?: [number, number] | undefined
74
+ /** The toolbar LiveStatus. */
75
+ live?: LiveState | undefined
76
+ /** Timeline rows; events point at one with `resource`. */
77
+ resources?: Resource[] | undefined
78
+ /** The timeline's span, in minutes from midnight like `workingHours`: `[480, 1080]` (08:00 to 18:00) without it. */
79
+ timeline?: [number, number] | undefined
80
+ /** Year view's counts; from `events` without it. */
81
+ counts?: Record<string, number> | undefined
82
+ /** Turns off dragging, resizing, creating and the keyboard move mode. Events still open their popover. */
83
+ readOnly?: boolean | undefined
84
+ /** A rejected promise puts the event back and announces why (07 §2.6). */
85
+ onMove?: ((e: CalEvent, to: EventChange, reason: Reason) => void | Promise<unknown>) | undefined
86
+ /** An event's edge dragged to a new time. A rejected promise puts it back and announces why. */
87
+ onResize?: ((e: CalEvent, to: EventChange, reason: Reason) => void | Promise<unknown>) | undefined
88
+ /** A new event saved from the editor, after a drag over empty time, a press on a slot or New event. */
89
+ onCreate?: ((e: CalEvent) => void) | undefined
90
+ /** An event saved from the editor; `scope` says which of a repeating event's occurrences. */
91
+ onEdit?: ((e: CalEvent, scope?: RecurrenceScope) => void) | undefined
92
+ /** An event deleted after its confirmation; `scope` says which of a repeating event's occurrences. */
93
+ onDelete?: ((e: CalEvent, scope?: RecurrenceScope) => void) | undefined
94
+ /** An event's popover opened, by press or Enter. */
95
+ onOpen?: ((e: CalEvent) => void) | undefined
96
+ /** Join pressed in the popover of an event with a `video` link. */
97
+ onJoin?: ((e: CalEvent) => void) | undefined
98
+ }
99
+
100
+ const LABEL: Record<CalView, string> = { month: 'Month', week: 'Week', day: 'Day', agenda: 'Agenda', timeline: 'Timeline', year: 'Year' }
101
+ const ALL: CalView[] = ['month', 'week', 'day', 'agenda', 'timeline', 'year']
102
+ const hours = (m: number) => `calc(var(--warp-cal-hour) * ${m / 60})`
103
+
104
+ type Ctx = ReturnType<typeof useCalendar>
105
+
106
+ function useCalendar(p: CalendarProps) {
107
+ const env = usePickerEnv(() => p.locale, () => p.today)
108
+ const view = bindable<CalView>(() => p.view, () => p.defaultView, 'week', () => p.onViewChange)
109
+ const date = bindable<string>(() => p.date, () => p.defaultDate, untrack(() => env.today.value), () => p.onDateChange)
110
+ const zone = bindable<string | null>(() => p.timeZone, () => null, null, () => p.onTimeZoneChange)
111
+ const tz = () => zone.value ?? undefined
112
+ const now = useNow(() => p.now, tz)
113
+ const first = derived(() => p.firstDayOfWeek ?? weekStart(env.locale.value))
114
+ const off = state<number[]>([])
115
+ const overlay = state<Record<string, Slot>>({})
116
+ const waiting = new Set<string>()
117
+ const drag = state<DragPreview | null>(null)
118
+ const moving = state<{ id: string; origin: Slot; slot: Slot } | null>(null)
119
+ const stop = state<string | null>(null)
120
+ watch(() => p.events, () => {
121
+ const keep: Record<string, Slot> = {}
122
+ for (const id of waiting) if (overlay.value[id]) keep[id] = overlay.value[id]
123
+ overlay.value = keep
124
+ })
125
+ const all = derived(() => p.events.map(e => { const v = shownIn(e, tz()), o = overlay.value[e.id]; return o ? { ...v, ...slotTimes(o), resource: o.resource ?? v.resource } : v }))
126
+ const visible = derived(() => all.value.filter(e => !off.value.includes(e.cal ?? 1)))
127
+ const preview = derived<DragPreview | null>(() => { const m = moving.value; return m ? { ...m.slot, mode: 'move', id: m.id, origin: m.origin } : drag.value })
128
+ const counts = derived(() => p.counts ?? countByDay(all.value))
129
+ const raw = (id: string) => p.events.find(e => e.id === id)
130
+ const shown = (id: string) => all.value.find(e => e.id === id)
131
+ let root: HTMLElement | undefined
132
+ const say = (t: string, loud = false) => root && announce(root.ownerDocument, t, loud)
133
+ const zoneLabel = derived(() => {
134
+ const z = zone.value
135
+ if (!z) return undefined
136
+ const short = new Intl.DateTimeFormat(env.locale.value, { timeZone: z, timeZoneName: 'short' }).formatToParts(new Date(`${date.value}T12:00:00Z`)).find(x => x.type === 'timeZoneName')?.value
137
+ return `${zoneCity(z)} · ${short}`
138
+ })
139
+ const nodeOf = (id: string) => root?.querySelector<HTMLElement>(`[data-id="${CSS.escape(id)}"]`)
140
+ const focusEvent = (id: string) => { stop.value = id; flush(); nodeOf(id)?.focus({ preventScroll: false }) }
141
+ // overlays and dialogs
142
+ const anchor = state<HTMLElement | undefined>(undefined)
143
+ const popped = state<string | null>(null)
144
+ const popOpen = state(false)
145
+ const editing = state(false)
146
+ const draft = state<{ event: CalEvent; isNew: boolean } | null>(null)
147
+ const sheet = state(false)
148
+ const sheetFor = state<CalEvent | null>(null)
149
+ const doomed = state<CalEvent | null>(null)
150
+ const askDelete = state(false)
151
+ const scopeOpen = state(false)
152
+ const scope = state<RecurrenceScope>('this')
153
+ const pendingScope = state<{ run: (s: RecurrenceScope) => void; undo?: (() => void) | undefined; verb: string } | null>(null)
154
+ const askScope = (verb: string, run: (s: RecurrenceScope) => void, undo?: (() => void) ) => { pendingScope.value = { run, undo, verb }; scope.value = 'this'; scopeOpen.value = true }
155
+
156
+ const settle = (e: CalEvent, slot: Slot, verb: 'move' | 'resize', reason: Reason, chosen?: RecurrenceScope) => {
157
+ const from = e.tz && tz() && e.tz !== tz() ? { start: toZone(at(slot.date, slot.s), tz()!, e.tz), end: toZone(at(slot.date, slot.e), tz()!, e.tz) } : slotTimes(slot)
158
+ overlay.value = { ...overlay.value, [e.id]: slot }
159
+ waiting.add(e.id)
160
+ const put = () => { waiting.delete(e.id) }
161
+ const back = (err: unknown) => {
162
+ put()
163
+ const { [e.id]: _, ...rest } = overlay.value
164
+ overlay.value = rest
165
+ say(`Couldn't ${verb} ${e.title}: ${(err as Error | undefined)?.message ?? 'try again'}`, true)
166
+ }
167
+ let r: PromiseLike<unknown> | undefined
168
+ try { r = (verb === 'move' ? p.onMove : p.onResize)?.(e, { ...from, resource: slot.resource, scope: chosen }, reason) as PromiseLike<unknown> | undefined } catch (err) { back(err); return }
169
+ if (r?.then) r.then(put, back)
170
+ else put()
171
+ const hit = conflicts(all.value, e, slot)
172
+ const day = fmt(slot.date, { weekday: 'long' }, env.locale.value)
173
+ say(`${verb === 'move' ? `Moved ${e.title} to ${day} ${clock(slot.s, env.locale.value, p.hourCycle)}` : `${e.title} now ends at ${clock(slot.e, env.locale.value, p.hourCycle)}`}${hit.length ? `. Overlaps ${hit.map(x => x.title).join(', ')}` : ''}`)
174
+ flush()
175
+ focusEvent(e.id)
176
+ confirmSweep(nodeOf(e.id))
177
+ }
178
+ const change = (id: string, slot: Slot, verb: 'move' | 'resize', reason: Reason) => {
179
+ const e = raw(id)
180
+ if (!e) return
181
+ if (!e.recurrence) return settle(e, slot, verb, reason)
182
+ overlay.value = { ...overlay.value, [e.id]: slot }
183
+ askScope(verb === 'move' ? 'Move' : 'Change', s => settle(e, slot, verb, reason, s), () => { const { [e.id]: _, ...rest } = overlay.value; overlay.value = rest })
184
+ }
185
+ const newEvent = (start: string, end: string, resource?: string ) => {
186
+ draft.value = { event: { id: uid('new'), title: '', start, end, cal: p.calendars?.[0]?.id ?? 1, resource }, isNew: true }
187
+ editing.value = true
188
+ }
189
+ const openPopover = (id: string, el: HTMLElement) => {
190
+ stop.value = id
191
+ anchor.value = el
192
+ popped.value = id
193
+ popOpen.value = true
194
+ const e = raw(id)
195
+ if (e) p.onOpen?.(e)
196
+ }
197
+ const edit = (e: CalEvent) => { draft.value = { event: shown(e.id) ?? e, isNew: false }; editing.value = true }
198
+ const remove = (e: CalEvent) => {
199
+ doomed.value = e
200
+ if (e.recurrence) return askScope('Delete', s => { p.onDelete?.(e, s); say(`Deleted ${e.title}`) })
201
+ askDelete.value = true
202
+ }
203
+ const openSheet = (id: string) => { const e = shown(id); if (e && !e.locked) { sheetFor.value = e; sheet.value = true } }
204
+ const dragOpts = () => ({
205
+ step: p.snap ?? 15,
206
+ readOnly: p.readOnly,
207
+ slot: (id: string) => { const e = shown(id); return e && !e.locked ? slotOf(e) : null },
208
+ onPreview: (v: DragPreview | null) => (drag.value = v),
209
+ onDrop: (v: DragPreview) => {
210
+ if (v.mode === 'create') return newEvent(at(v.date, v.s), at(v.date, v.e), v.resource)
211
+ change(v.id!, { date: v.date, s: v.s, e: v.e, resource: v.resource }, v.mode === 'move' ? 'move' : 'resize', 'pointer')
212
+ },
213
+ onPress: openPopover,
214
+ onLongPress: p.readOnly ? undefined : openSheet,
215
+ onSlot: (d: string, m: number, r?: string) => newEvent(at(d || date.value, m), at(d || date.value, m + 30), r),
216
+ })
217
+ /** Keys shared by the grid and the timeline: the roving stop, Enter, M, Delete, and move mode. */
218
+ const eventKeys = (e: KeyboardEvent, opts: { days?: string[]; resources?: string[]; next: (key: string, id: string) => string | null }) => {
219
+ const el = (e.target as Element).closest?.<HTMLElement>('[data-id]')
220
+ if (!el) return
221
+ const id = el.dataset.id!
222
+ const rtl = getComputedStyle(el).direction === 'rtl'
223
+ const m = moving.value
224
+ if (m && m.id === id) {
225
+ if (e.key === 'Tab') { moving.value = null; return }
226
+ const to = moveKey(e.key, e.shiftKey, m.slot, { step: p.snap ?? 15, days: opts.days, rtl, resources: opts.resources })
227
+ if (to === null) return
228
+ e.preventDefault()
229
+ if (to === 'cancel') { moving.value = null; say('Move cancelled'); focusEvent(id); return }
230
+ if (to === 'drop') {
231
+ const changed = m.slot
232
+ moving.value = null
233
+ if (changed.date === m.origin.date && changed.s === m.origin.s && changed.e === m.origin.e && changed.resource === m.origin.resource) return focusEvent(id)
234
+ return change(id, changed, changed.e - changed.s === m.origin.e - m.origin.s ? 'move' : 'resize', 'keyboard')
235
+ }
236
+ moving.value = { ...m, slot: to }
237
+ say(`${fmt(to.date, { weekday: 'short' }, env.locale.value)} ${clockRange(to.s, to.e, env.locale.value, p.hourCycle)}`)
238
+ focusEvent(id)
239
+ return
240
+ }
241
+ const ev = shown(id)
242
+ if (!ev) return
243
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPopover(id, el); return }
244
+ const editable = !p.readOnly && !ev.locked
245
+ if ((e.key === 'm' || e.key === 'M') && !e.metaKey && !e.ctrlKey && editable && !ev.allDay) {
246
+ e.preventDefault()
247
+ const slot = slotOf(ev)
248
+ moving.value = { id, origin: slot, slot }
249
+ say(`Moving ${ev.title}. Arrows move by ${p.snap ?? 15} minutes or a day, Shift with arrows resizes, Enter drops, Escape cancels.`, true)
250
+ return
251
+ }
252
+ if (e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && editable && !ev.allDay && !opts.resources) {
253
+ e.preventDefault()
254
+ const to = moveKey(e.key, true, slotOf(ev), { step: p.snap ?? 15 })
255
+ if (to && typeof to === 'object') change(id, to, 'resize', 'keyboard')
256
+ return
257
+ }
258
+ if ((e.key === 'Delete' || e.key === 'Backspace') && editable && p.onDelete) { e.preventDefault(); remove(raw(id)!); return }
259
+ const to = opts.next(e.key, id)
260
+ if (to) { e.preventDefault(); focusEvent(to) }
261
+ }
262
+ return {
263
+ env, view, date, zone, tz, now, first, off, drag, moving, stop, all, visible, preview, counts, raw, shown, say, zoneLabel, nodeOf, focusEvent,
264
+ anchor, popped, popOpen, editing, draft, sheet, sheetFor, doomed, askDelete, scopeOpen, scope, pendingScope,
265
+ p, dragOpts, eventKeys, change, settle, newEvent, openPopover, edit, remove, bindRoot: (n: HTMLElement) => (root = n),
266
+ time: (m: number) => clock(m, env.locale.value, p.hourCycle), span: (a: number, b: number) => clockRange(a, b, env.locale.value, p.hourCycle),
267
+ name: (e: CalEvent) => eventName(e, env.locale.value, p.hourCycle),
268
+ }
269
+ }
270
+
271
+ /** The event block (07 §2.1): a button with `--_top`, `--_h` and its lane. */
272
+ function EventBlock({ seg, lane, c, stop }: { seg: DaySegment; lane: { col: number; cols: number; span: number }; c: Ctx; stop: boolean }) {
273
+ const e = derived(() => seg.event)
274
+ const len = derived(() => seg.e - seg.s)
275
+ const state_ = derived(() => (c.moving.value?.id === seg.id ? 'moving' : c.drag.value?.id === seg.id ? 'dragging' : undefined))
276
+ const shift = derived(() => dstShift(e.value, c.tz()))
277
+ const style = derived(() => `${tint(e.value.cal)};--_top:${hours(seg.s)};--_h:${hours(len.value)}${state_.value ? '' : `;--_col:${lane.col};--_cols:${lane.cols};--_span:${lane.span}`}`)
278
+ return (
279
+ <div data-part="event" data-sweep="" data-cal={e.value.cal ?? 1} data-id={seg.id} role="button" tabindex={stop ? '0' : '-1'} aria-roledescription="event" aria-haspopup="dialog" aria-expanded={c.popOpen.value && c.popped.value === seg.id ? 'true' : undefined} aria-keyshortcuts="Enter M Delete" data-tentative={e.value.tentative ? '' : undefined} data-declined={e.value.declined ? '' : undefined} data-locked={e.value.locked ? '' : undefined} data-short={len.value <= 30 ? '' : undefined} data-state={state_.value} style={style.value}>
280
+ <SweepParts />
281
+ <span class="sr">{c.name(e.value)}{shift.value ? '. Clocks change during this event; times are wall-clock' : ''}</span>
282
+ <span data-part="event-title" aria-hidden="true">{e.value.recurrence && <RepeatGlyph />}{e.value.recurrence ? ' ' : ''}{e.value.title}</span>
283
+ <span data-part="event-time" aria-hidden="true">{c.span(seg.s, seg.e)}{shift.value ? ' · clocks change' : ''}</span>
284
+ {len.value >= 90 && e.value.location && <span data-part="event-meta" aria-hidden="true">{e.value.location}</span>}
285
+ {!e.value.locked && !c.p.readOnly && <span data-part="resize" aria-hidden="true"></span>}
286
+ </div>
287
+ )
288
+ }
289
+
290
+ function Column({ day, c, stop, weekend }: { day: string; c: Ctx; stop: string | null; weekend: boolean }) {
291
+ const hrs = derived(() => c.p.workingHours ?? [480, 1080])
292
+ const segs = derived(() => {
293
+ const v = c.preview.value
294
+ return daySegments(c.visible.value.map(e => (v?.id === e.id && !e.allDay ? { ...e, ...slotTimes(v) } : e)), day)
295
+ })
296
+ const lanes = derived(() => layoutDay(segs.value.filter(s => s.id !== c.preview.value?.id)))
297
+ const today = derived(() => day === c.env.today.value)
298
+ const nowAt = derived(() => { const n = c.now.value; return n && dateOf(n) === day ? minuteOf(n) : null })
299
+ const ghost = derived(() => { const v = c.drag.value; return v?.origin && v.origin.date === day ? v.origin : null })
300
+ const draft = derived(() => { const v = c.drag.value; return v?.mode === 'create' && v.date === day ? v : null })
301
+ const read = derived(() => {
302
+ const v = c.preview.value
303
+ if (!v || v.date !== day) return null
304
+ return { top: hours(v.s), text: v.mode === 'resize' ? `${c.span(v.s, v.e)} · ${longDuration(v.e - v.s)}` : `${fmt(v.date, { weekday: 'short' }, c.env.locale.value)} ${c.span(v.s, v.e)}` }
305
+ })
306
+ return (
307
+ <div data-part="col" role="gridcell" data-date={day} data-weekend={weekend ? '' : undefined} aria-current={today.value ? 'date' : undefined}>
308
+ <span data-part="band" style={`--_top:0px;--_h:${hours(hrs.value[0]!)}`}></span>
309
+ <span data-part="band" style={`--_top:${hours(hrs.value[1]!)};--_h:${hours(1440 - hrs.value[1]!)}`}></span>
310
+ {ghost.value && <span data-part="ghost" style={`--_top:${hours(ghost.value.s)};--_h:${hours(ghost.value.e - ghost.value.s)}`}></span>}
311
+ {segs.value.map(s => <EventBlock key={s.id} seg={s} lane={lanes.value.get(s.id) ?? { col: 0, cols: 1, span: 1 }} c={c} stop={stop === s.id} />)}
312
+ {draft.value && <span data-part="draft" style={`--_top:${hours(draft.value.s)};--_h:${hours(draft.value.e - draft.value.s)}`}>New event · {c.span(draft.value.s, draft.value.e)}</span>}
313
+ {read.value && <span data-part="readout" role="status" style={`inset-block-start:${read.value.top}`}>{read.value.text}</span>}
314
+ {nowAt.value !== null && <span data-part="now" style={`--_top:${hours(nowAt.value)}`} aria-hidden="true"></span>}
315
+ </div>
316
+ )
317
+ }
318
+
319
+ /** Week and Day (07 §2.5): an APG grid, day columns as columnheaders, events inside gridcells. */
320
+ function TimeGrid({ c, count }: { c: Ctx; count: number }) {
321
+ const days = derived(() => viewDays(c.view.value, c.date.value, c.first.value, count) as string[])
322
+ const list = derived(() => c.visible.value.filter(e => days.value.some(d => dateOf(e.start) <= d && dateOf(e.end) >= d)))
323
+ const stop = derived(() => { const id = c.stop.value; return (id && list.value.find(e => e.id === id)?.id) || [...list.value].sort((a, b) => (a.start < b.start ? -1 : 1))[0]?.id || null })
324
+ const bars = derived(() => allDayBars(c.visible.value, days.value))
325
+ const nowAt = derived(() => { const n = c.now.value; return n && days.value.includes(dateOf(n)) ? minuteOf(n) : null })
326
+ // a day's least width grows by half for each event that runs beside another (up to four), so side-by-side events
327
+ // still read; past the box's width the week scrolls sideways (calendar.css)
328
+ const widths = derived(() => days.value.map(d => {
329
+ const lanes = Math.max(1, ...[...layoutDay(daySegments(c.visible.value, d)).values()].map(l => l.cols))
330
+ return 1 + (Math.min(lanes, 4) - 1) / 2
331
+ }))
332
+ const tracks = derived(() => `--_tracks:var(--warp-cal-gutter) ${widths.value.map(f => `minmax(calc(var(--warp-cal-day-min) * ${f}), 1fr)`).join(' ')};--_week-w:calc(var(--warp-cal-gutter) + var(--warp-cal-day-min) * ${widths.value.reduce((x, y) => x + y, 0)})`)
333
+ let cols: HTMLDivElement | undefined
334
+ let scroller: HTMLDivElement | undefined
335
+ use(calendarDrag, () => cols, () => c.dragOpts())
336
+ use(edges, () => scroller) // a week wider than its box shades the side with more, past the hours
337
+ // open half an hour before the working day, and sideways at the shown date when the week is wider than its box. The root
338
+ // bounds its own height (calendar.css), so this scrolls even when no parent gives the calendar a height
339
+ onMount(() => {
340
+ if (!scroller || !cols) return
341
+ scroller.scrollTop = (cols.offsetHeight / 1440) * Math.max(0, (c.p.workingHours ?? [480, 1080])[0] - 30)
342
+ const col = cols.querySelector<HTMLElement>(`[data-part="col"][data-date="${untrack(() => c.date.value)}"]`)
343
+ const gutter = cols.querySelector<HTMLElement>('[data-part="gutter"]')?.offsetWidth ?? 0
344
+ if (col && scroller.scrollWidth > scroller.clientWidth) scroller.scrollLeft = Math.max(0, col.offsetLeft - gutter)
345
+ })
346
+ const onKey = (e: KeyboardEvent) => c.eventKeys(e, { days: days.value, next: (k, id) => eventKey(k, id, list.value, getComputedStyle(e.target as Element).direction === 'rtl') })
347
+ const onChip = (e: MouseEvent) => { const el = (e.target as Element).closest?.<HTMLElement>('[data-id]'); if (el) c.openPopover(el.dataset.id!, el) }
348
+ return (
349
+ <div data-scope="edges" data-part="grid" data-view="time" role="grid" aria-label={fmt(days.value[0]!, { month: 'long', year: 'numeric' }, c.env.locale.value)} style={`--_days:${days.value.length};--warp-edges-start:var(--warp-cal-gutter);${tracks.value}`} onKeyDown={onKey}>
350
+ <div ref={(n: HTMLDivElement) => (scroller = n)} data-part="scroller">
351
+ <div data-part="top">
352
+ <div data-part="head" role="row">
353
+ <span data-part="corner" aria-hidden="true"></span>
354
+ {days.value.map(d => <div key={d} data-part="dayhead" role="columnheader" aria-label={fmt(d, { weekday: 'long', month: 'short', day: 'numeric' }, c.env.locale.value)} aria-current={d === c.env.today.value ? 'date' : undefined}><span>{fmt(d, { weekday: 'short' }, c.env.locale.value)}</span><b>{ymd(d)[2]}</b></div>)}
355
+ </div>
356
+ <div data-part="allday" role="row" onClick={onChip}>
357
+ <span data-part="gutter-label" role="rowheader">All day</span>
358
+ {days.value.map((d, i) => <div key={d} data-part="allday-cell" style={`grid-column:${i + 2};grid-row:1 / span ${Math.max(1, ...bars.value.map(b => b.row + 1))}`}></div>)}
359
+ {bars.value.map(b => <div key={b.id} data-part="chip" data-cal={b.event.cal ?? 1} data-id={b.id} role="gridcell" tabindex={stop.value === b.id ? '0' : '-1'} aria-label={c.name(b.event)} style={`${tint(b.event.cal)};grid-column:${b.col + 2} / span ${b.span};grid-row:${b.row + 1}`}>{b.event.title}</div>)}
360
+ </div>
361
+ </div>
362
+ <div ref={(n: HTMLDivElement) => (cols = n)} data-part="cols" role="row">
363
+ <div data-part="gutter" aria-hidden="true">
364
+ {Array.from({ length: 23 }, (_, i) => i + 1).map(h => <span key={h} data-part="hour" style={`top:${hours(h * 60)}`}>{c.time(h * 60)}</span>)}
365
+ {nowAt.value !== null && <span data-part="now-label" style={`--_top:${hours(nowAt.value)}`}>{c.time(nowAt.value)}</span>}
366
+ </div>
367
+ {days.value.map(d => <Column key={d} day={d} c={c} stop={stop.value} weekend={isWeekend(dow(d), c.env.locale.value)} />)}
368
+ </div>
369
+ </div>
370
+ </div>
371
+ )
372
+ }
373
+
374
+ function MonthGrid({ c }: { c: Ctx }) {
375
+ const hint = uid('month')
376
+ const weeks = derived(() => monthCells(c.date.value, c.first.value))
377
+ const onDay = (iso: string) => eventsOn(c.visible.value, iso)
378
+ const heads = derived(() => weekdays(c.first.value, c.env.locale.value))
379
+ const cursor = state(untrack(() => c.date.value))
380
+ watch(() => c.date.value, d => { cursor.value = d })
381
+ let grid: HTMLDivElement | undefined
382
+ const more = state<{ iso: string; list: CalEvent[] } | null>(null)
383
+ const moreOpen = state(false)
384
+ const moreAt = state<HTMLElement | undefined>(undefined)
385
+ let panel: HTMLDivElement | undefined
386
+ use(popover, () => panel, () => ({ open: moreOpen.value, trigger: moreAt.value!, side: 'bottom' as const, align: 'start' as const, onClose: () => (moreOpen.value = false) }))
387
+ // The weeks share the height the calendar has (calendar.css), so a whole month is in view: a day shows the chips its
388
+ // row has room for, and "+N more" for the rest. Until measured, three and "+N more" (fit 4), as the server renders.
389
+ const fit = state(4)
390
+ onMount(() => {
391
+ const win = grid?.ownerDocument.defaultView
392
+ if (!grid || !win || typeof win.ResizeObserver !== 'function') return
393
+ const measure = () => {
394
+ const cell = grid!.querySelector<HTMLElement>('[role="gridcell"]')
395
+ if (!cell) return
396
+ const cs = win.getComputedStyle(cell)
397
+ // a phone's days draw dots that wrap in a row (calendar.css): three and "+N more", as before
398
+ if (cs.display !== 'grid' || cs.gridTemplateColumns.split(' ').length > 1) { fit.value = 4; return }
399
+ const chip = 22 // 20px chips, 2px gap
400
+ const room = cell.clientHeight - parseFloat(cs.paddingBlockStart) - parseFloat(cs.paddingBlockEnd) - (cell.querySelector<HTMLElement>('[aria-hidden="true"]:not([data-part])')?.offsetHeight ?? 18) - 2
401
+ fit.value = Math.max(1, Math.floor((room + 2) / chip))
402
+ }
403
+ const ro = new win.ResizeObserver(measure)
404
+ ro.observe(grid)
405
+ onCleanup(() => ro.disconnect())
406
+ })
407
+ /** The chips a day shows, and how many go behind "+N more" (which takes a chip's room). */
408
+ const shownOn = (list: CalEvent[]) => { const n = list.length <= fit.value ? list.length : Math.max(0, fit.value - 1); return { chips: list.slice(0, n), more: list.length - n } }
409
+ const openDay = (iso: string) => { c.date.set(iso); c.view.set('day'); c.say(`Day view, ${fmt(iso, { dateStyle: 'full' }, c.env.locale.value)}`) }
410
+ const onKey = (e: KeyboardEvent) => {
411
+ const cell = (e.target as Element).closest?.<HTMLElement>('[role="gridcell"]')
412
+ if (!cell) return
413
+ const to = gridKey(e.key, e.shiftKey, cursor.value, c.first.value, getComputedStyle(cell).direction === 'rtl')
414
+ if (!to) return
415
+ e.preventDefault()
416
+ if (to === 'pick') return openDay(cursor.value)
417
+ cursor.value = to
418
+ if (monthStart(to) !== monthStart(c.date.value)) c.date.set(to)
419
+ flush()
420
+ grid?.querySelector<HTMLElement>('[role="gridcell"][tabindex="0"]')?.focus()
421
+ }
422
+ const onClick = (e: MouseEvent) => {
423
+ const t = e.target as Element
424
+ const btn = t.closest?.<HTMLElement>('[data-part="more"]')
425
+ const cell = t.closest?.<HTMLElement>('[role="gridcell"]')
426
+ if (!cell) return
427
+ if (btn) {
428
+ more.value = { iso: cell.dataset.date!, list: eventsOn(c.visible.value, cell.dataset.date!) }
429
+ moreAt.value = btn
430
+ moreOpen.value = true
431
+ return
432
+ }
433
+ openDay(cell.dataset.date!)
434
+ }
435
+ const label = (iso: string, list: CalEvent[]) => `${fmt(iso, { weekday: 'long', month: 'long', day: 'numeric' }, c.env.locale.value)}, ${list.length ? `${list.length} events: ${list.map(e => e.title).join(', ')}` : 'no events'}${iso === c.env.today.value ? ', today' : ''}`
436
+ return (
437
+ <div data-part="grid" style="--warp-cal-gutter:0px;--_days:7">
438
+ <div data-part="head" aria-hidden="true"><span></span>{heads.value.map(h => <div key={h[0]} data-part="dayhead"><span>{h[1]}</span></div>)}</div>
439
+ <span aria-hidden="true"></span>
440
+ <div data-part="scroller">
441
+ <div ref={n => (grid = n)} data-part="month" role="grid" aria-label={fmt(c.date.value, { month: 'long', year: 'numeric' }, c.env.locale.value)} aria-describedby={hint} onKeyDown={onKey} onClick={onClick}>
442
+ {weeks.value.map(w => (
443
+ <div key={w.key} role="row">
444
+ {w.cells.map(x => (
445
+ <div key={x.iso} data-part="day-cell" role="gridcell" tabindex={x.iso === cursor.value ? '0' : '-1'} data-date={x.iso} data-outside={x.outside ? '' : undefined} aria-current={x.iso === c.env.today.value ? 'date' : undefined}>
446
+ <span class="sr">{label(x.iso, onDay(x.iso))}</span>
447
+ <span aria-hidden="true">{ymd(x.iso)[2]}</span>
448
+ {shownOn(onDay(x.iso)).chips.map(e => <span key={e.id} data-part="chip" data-cal={e.cal ?? 1} style={tint(e.cal)} aria-hidden="true">{e.title}</span>)}
449
+ {shownOn(onDay(x.iso)).more > 0 && <button type="button" data-part="more" tabindex="-1" aria-hidden="true">+{shownOn(onDay(x.iso)).more} more</button>}
450
+ </div>
451
+ ))}
452
+ </div>
453
+ ))}
454
+ </div>
455
+ </div>
456
+ <span class="sr" id={hint}>Arrow keys move between days, Home and End to the week's ends, Page Up and Page Down by month, Enter opens the day.</span>
457
+ <div ref={n => (panel = n)} data-scope="popover" data-part="content" popover="manual" role="dialog" aria-label={more.value ? fmt(more.value.iso, { month: 'long', day: 'numeric' }, c.env.locale.value) : 'Events'}>
458
+ {more.value && <div class="stack">{more.value.list.map(e => <Button key={e.id} emphasis="ghost" tone="neutral" size="sm" onClick={() => { moreOpen.value = false; c.say(c.name(e)); openDay(dateOf(e.start)) }}>{e.title}</Button>)}</div>}
459
+ </div>
460
+ </div>
461
+ )
462
+ }
463
+
464
+ function AgendaView({ c }: { c: Ctx }) {
465
+ const days = derived(() => viewDays('week', c.date.value, c.first.value))
466
+ return (
467
+ <div data-part="scroller">
468
+ <div data-scope="agenda">
469
+ <div data-part="page" onClick={(e: MouseEvent) => { const el = (e.target as Element).closest?.<HTMLElement>('[data-id]'); if (el) c.openPopover(el.dataset.id!, el) }} onKeyDown={(e: KeyboardEvent) => { const el = (e.target as Element).closest?.<HTMLElement>('[data-id]'); if (el && e.key === 'Enter') c.openPopover(el.dataset.id!, el) }}>
470
+ {days.value.map(d => (
471
+ <section key={d}>
472
+ <div data-part="day-title"><h4>{fmt(d, { weekday: 'long' }, c.env.locale.value)}</h4><span class="muted">{fmt(d, { month: 'short', day: 'numeric' }, c.env.locale.value)}</span></div>
473
+ <AgendaDay events={c.visible.value} day={d} now={c.now.value} locale={c.env.locale.value} hourCycle={c.p.hourCycle} label={fmt(d, { weekday: 'long' }, c.env.locale.value)} />
474
+ </section>
475
+ ))}
476
+ </div>
477
+ </div>
478
+ </div>
479
+ )
480
+ }
481
+
482
+ const STEP = 5 // a timeline lane's column, in minutes
483
+
484
+ /** Timeline (07 §2.5): rows of rooms or people over hours, one roving stop, the same drag on the x axis. */
485
+ function Timeline({ c }: { c: Ctx }) {
486
+ const hint = uid('tl')
487
+ const range = derived((): [number, number] => c.p.timeline ?? [480, 1080])
488
+ const width = derived(() => range.value[1] - range.value[0])
489
+ const slots = derived(() => Math.round(width.value / 60))
490
+ const marks = derived(() => Array.from({ length: slots.value }, (_, i) => range.value[0] + i * 60))
491
+ const day = derived(() => c.date.value)
492
+ const rows = derived(() => c.p.resources ?? [])
493
+ const segs = derived(() => {
494
+ const pv = c.preview.value
495
+ return daySegments(c.visible.value.map(e => (pv?.id === e.id ? { ...e, ...slotTimes(pv), resource: pv.resource } : e)), day.value)
496
+ })
497
+ const lane = (id: string) => segs.value.filter(s => s.event.resource === id)
498
+ const order = derived(() => rows.value.map(r => lane(r.id).map(s => ({ id: s.id, s: s.s }))))
499
+ const stop = derived(() => { const id = c.stop.value; return (id && order.value.flat().find(x => x.id === id)?.id) || order.value.flat()[0]?.id || null })
500
+ const nowAt = derived(() => { const n = c.now.value; return n && dateOf(n) === day.value ? minuteOf(n) : null })
501
+ let box: HTMLDivElement | undefined
502
+ let scroller: HTMLDivElement | undefined
503
+ use(calendarDrag, () => box, () => ({ ...c.dragOpts(), axis: 'x' as const, from: range.value[0], to: range.value[1] }))
504
+ // an hour keeps a readable width (calendar.css) and the rows scroll sideways under their sticky names: a shadow says so
505
+ use(edges, () => scroller)
506
+ // a lane is a grid of five-minute columns: a booking spans its minutes, and the row grows to hold its whole name
507
+ const cols = derived(() => Math.max(1, Math.round(width.value / STEP)))
508
+ const place = (s: DaySegment) => {
509
+ const [from, to] = range.value
510
+ const a = Math.min(Math.max(s.s, from), to - STEP), b = Math.max(Math.min(s.e, to), a + STEP)
511
+ return `grid-column:${Math.round((a - from) / STEP) + 1} / span ${Math.max(1, Math.round((b - a) / STEP))}`
512
+ }
513
+ // names wrap between words, never inside one: when a word is wider than its booking, every hour widens a step
514
+ // (--_fit) until each word fits, and the rows scroll sideways for it rather than cut a name
515
+ const fit = () => {
516
+ if (!box || untrack(() => c.drag.value || c.moving.value)) return
517
+ const doc = box.ownerDocument
518
+ const titles = () => [...box!.querySelectorAll<HTMLElement>('[data-part="lane"] [data-part="event-title"]')].filter(t => t.offsetWidth)
519
+ const cut = () => titles().some(t => { const r = doc.createRange(); r.selectNodeContents(t); const w = r.getBoundingClientRect(), b = t.getBoundingClientRect(); return w.right > b.right + 0.5 || w.left < b.left - 0.5 })
520
+ box.style.removeProperty('--_fit')
521
+ const first = box.querySelector<HTMLElement>('[data-part="lane"]')
522
+ if (!first) return
523
+ let hour = first.getBoundingClientRect().width / Math.max(1, untrack(() => slots.value))
524
+ for (let i = 0; i < 24 && cut(); i++) box.style.setProperty('--_fit', `${(hour += 8)}px`)
525
+ }
526
+ let frame = 0
527
+ const refit = () => { if (typeof requestAnimationFrame === 'undefined') return; cancelAnimationFrame(frame); frame = requestAnimationFrame(fit) }
528
+ watch(() => [segs.value, rows.value, range.value], refit)
529
+ onMount(() => {
530
+ refit()
531
+ void box?.ownerDocument.fonts?.ready.then(refit)
532
+ if (typeof ResizeObserver === 'undefined' || !scroller) return
533
+ let w = 0
534
+ const ro = new ResizeObserver(r => { const n = Math.round(r[0]!.contentRect.width); if (n !== w) { w = n; refit() } })
535
+ ro.observe(scroller)
536
+ onCleanup(() => { ro.disconnect(); cancelAnimationFrame(frame) })
537
+ })
538
+ const onKey = (e: KeyboardEvent) => c.eventKeys(e, { resources: (c.p.resources ?? []).map(r => r.id), next: (k, id) => laneKey(k, id, order.value) })
539
+ // a booking carries its name, times and start; which show is its own width (calendar.css): its whole name with its
540
+ // times under it, its whole name, or only the start time when it is too narrow for a word
541
+ return (
542
+ <div data-scope="edges" data-part="timeline-edges">
543
+ <div ref={n => (scroller = n)} data-part="scroller">
544
+ <div ref={n => (box = n)} data-part="timeline" role="grid" aria-label={`${(c.p.resources ?? []).length ? 'Resources' : 'Timeline'}, ${fmt(day.value, { weekday: 'long', month: 'short', day: 'numeric' }, c.env.locale.value)}`} aria-describedby={hint} onKeyDown={onKey} style={`--_slots:${slots.value}`}>
545
+ <span data-part="corner" aria-hidden="true"></span>
546
+ <div data-part="marks" class="muted" aria-hidden="true" style={`display:grid;grid-template-columns:repeat(${marks.value.length},1fr)`}>{marks.value.map(m => <span key={m}>{c.time(m)}</span>)}</div>
547
+ {rows.value.map(r => (
548
+ <div key={r.id} role="row">
549
+ <div data-part="lane-label" role="rowheader"><b>{r.name}</b>{r.meta && <span class="muted">{r.meta}</span>}</div>
550
+ <div data-part="lane" data-resource={r.id} data-date={day.value} style={`--_slots:${slots.value};--_cols:${cols.value}`}>
551
+ {lane(r.id).map(s => (
552
+ <div key={s.id} data-part="event" data-cal={s.event.cal ?? 1} data-short="" data-id={s.id} role="gridcell" tabindex={stop.value === s.id ? '0' : '-1'} data-state={c.moving.value?.id === s.id ? 'moving' : c.drag.value?.id === s.id ? 'dragging' : undefined} style={`${tint(s.event.cal)};${place(s)}`}>
553
+ <span class="sr">{`${s.event.title}, ${r.name}, ${c.span(s.s, s.e)}`}</span>
554
+ <span data-part="event-title" aria-hidden="true">{s.event.title}</span>
555
+ <span data-part="event-time" aria-hidden="true">{c.span(s.s, s.e)}</span>
556
+ <span data-part="event-start" aria-hidden="true">{c.time(s.s)}</span>
557
+ </div>
558
+ ))}
559
+ {nowAt.value !== null && nowAt.value >= range.value[0] && nowAt.value <= range.value[1] && <span data-part="now" aria-hidden="true" style={`--_top:0;inset-block:0;inset-inline-start:${((nowAt.value - range.value[0]) / width.value) * 100}%;inline-size:0;block-size:auto;border:0;border-inline-start:var(--warp-border-emphasis) solid var(--warp-color-sweep)`}></span>}
560
+ </div>
561
+ </div>
562
+ ))}
563
+ </div>
564
+ <span class="sr" id={hint}>Left and right move along a row, up and down to the nearest booking in the next row. M moves a booking.</span>
565
+ </div>
566
+ </div>
567
+ )
568
+ }
569
+
570
+ /** The rail's navigator (07 §2.5): an APG date grid, one stop on the focused day. Enter or a press moves the main view. */
571
+ function MiniMonth({ c }: { c: Ctx }) {
572
+ const id = uid('mini')
573
+ const cursor = state(untrack(() => c.date.value))
574
+ const picked = state(untrack(() => c.date.value))
575
+ const note = state('')
576
+ watch(() => c.date.value, d => { cursor.value = d; picked.value = d })
577
+ const weeks = derived(() => monthWeeks(cursor.value, c.first.value).map((days, r) => ({ key: `${cursor.value.slice(0, 7)}-${r}`, days: days.map((iso, k) => ({ iso, k: iso ?? `${cursor.value.slice(0, 7)}-${r}-${k}` })) })))
578
+ const heads = derived(() => weekdays(c.first.value, c.env.locale.value))
579
+ const inWeek = (iso: string) => viewDays('week', c.date.value, c.first.value).includes(iso as IsoDate)
580
+ let grid: HTMLDivElement | undefined
581
+ const pick = (iso: string) => {
582
+ picked.value = iso
583
+ if (inWeek(iso)) { note.value = ''; c.date.set(iso); c.view.set('day'); c.say(`Day view, ${fmt(iso, { dateStyle: 'full' }, c.env.locale.value)}`) }
584
+ else { c.date.set(iso); note.value = `Moved to ${fmt(iso, { month: 'short', day: 'numeric' }, c.env.locale.value)}.` }
585
+ }
586
+ const onKey = (e: KeyboardEvent) => {
587
+ const to = gridKey(e.key, e.shiftKey, cursor.value, c.first.value, getComputedStyle(e.currentTarget as Element).direction === 'rtl')
588
+ if (!to) return
589
+ e.preventDefault()
590
+ if (to === 'pick') return pick(cursor.value)
591
+ cursor.value = to
592
+ flush()
593
+ grid?.querySelector<HTMLElement>('[data-day][tabindex="0"]')?.focus()
594
+ }
595
+ const name = (iso: string) => `${dayState(iso as IsoDate, { mode: 'single', value: null, today: c.env.today.value, locale: c.env.locale.value }).name}${inWeek(iso) ? ', in the shown week' : ''}`
596
+ return (
597
+ <>
598
+ <div class="row">
599
+ <b id={`${id}-t`} aria-live="polite">{fmt(cursor.value, { month: 'long', year: 'numeric' }, c.env.locale.value)}</b>
600
+ <span style="flex:1"></span>
601
+ <Button emphasis="ghost" tone="neutral" size="sm" icon={LeftIcon} label="Previous month" onClick={() => (cursor.value = stepDate('month', cursor.value, -1))} />
602
+ <Button emphasis="ghost" tone="neutral" size="sm" icon={RightIcon} label="Next month" onClick={() => (cursor.value = stepDate('month', cursor.value, 1))} />
603
+ </div>
604
+ <div ref={n => (grid = n)} data-part="mini" role="grid" aria-labelledby={`${id}-t`} aria-describedby={`${id}-h`} onKeyDown={onKey} onClick={(e: MouseEvent) => { const d = (e.target as Element).closest?.<HTMLElement>('[data-day]')?.dataset.day; if (d) { cursor.value = d; pick(d) } }}>
605
+ <div role="row">{heads.value.map(h => <span key={h[0]} role="columnheader"><span aria-hidden="true">{h[0][0]}</span><span class="sr">{h[0]}</span></span>)}</div>
606
+ {weeks.value.map(w => (
607
+ <div key={w.key} role="row">
608
+ {w.days.map(d => (d.iso
609
+ ? <span key={d.k} role="gridcell" data-day={d.iso} tabindex={d.iso === cursor.value ? '0' : '-1'} aria-selected={d.iso === picked.value ? 'true' : 'false'} aria-current={d.iso === c.env.today.value ? 'date' : undefined} data-in-range={inWeek(d.iso) ? '' : undefined} aria-label={name(d.iso)}>{ymd(d.iso)[2]}</span>
610
+ : <span key={d.k} role="gridcell" data-outside=""></span>))}
611
+ </div>
612
+ ))}
613
+ </div>
614
+ <span class="sr" id={`${id}-h`}>Arrow keys move by day and week, Page Up and Page Down by month, Enter opens the day.</span>
615
+ <p data-part="mini-note" aria-live="polite">{note.value}</p>
616
+ </>
617
+ )
618
+ }
619
+
620
+ /** The toolbar's zone chip: TimeZoneSelect (04 §2) in a popover. */
621
+ function ZoneChip({ c }: { c: Ctx }) {
622
+ const id = uid('zone')
623
+ const open = state(false)
624
+ const built = state(false)
625
+ let chip: HTMLButtonElement | undefined
626
+ let panel: HTMLDivElement | undefined
627
+ use(popover, () => panel, () => ({ open: open.value, trigger: chip!, side: 'bottom' as const, align: 'end' as const, onClose: () => (open.value = false) }))
628
+ return (
629
+ <>
630
+ <Tooltip content={`Times shown in ${c.zone.value}`}><button ref={n => (chip = n)} type="button" data-part="tz" aria-haspopup="dialog" aria-expanded={open.value ? 'true' : 'false'} aria-controls={id} onClick={() => { built.value = true; open.value = !open.value }}><GlobeIcon />{c.zoneLabel.value}</button></Tooltip>
631
+ <div ref={n => (panel = n)} id={id} data-scope="popover" data-part="content" popover="manual" role="dialog" aria-label="Time zone">
632
+ {built.value && <TimeZoneSelect label="Time zone" value={c.zone.value} referenceDate={c.date.value} today={c.env.today.value} locale={c.env.locale.value} onValueChange={(z: string | null) => { c.zone.set(z); open.value = false; c.say(`Times shown in ${z}`) }} />}
633
+ </div>
634
+ </>
635
+ )
636
+ }
637
+
638
+ /**
639
+ * Calendar (07 §2, `#cal-desk`): toolbar, rail, and Month, Week, Day, Agenda, Timeline and Year behind APG tabs. Drag
640
+ * moves, resizes and creates on a 15-minute snap; `M` is the keyboard move mode, a long press opens the MoveSheet, and
641
+ * the editor's fields do it all without either. `today` and `now` are props or arrive after mount, so hydration writes
642
+ * nothing. A week keeps all seven days at a readable width and scrolls sideways under its hours when its box is narrower,
643
+ * and below 1000px the mini month moves into the title's popover.
644
+ */
645
+ export function Calendar(p: CalendarProps) {
646
+ const id = uid('cal')
647
+ const c = useCalendar(untrack(() => p))
648
+ const views = derived(() => p.views ?? ALL)
649
+ let root: HTMLDivElement | undefined
650
+ let tabs: HTMLDivElement | undefined
651
+ const width = useWidth(() => root)
652
+ const tablet = derived(() => width.value > 0 && width.value < 1000)
653
+ use(indicator, () => tabs, () => ({ selected: '[aria-selected="true"]', x: '--_ix', w: '--_iw', at: c.view.value }))
654
+ use(overflow, () => tabs, () => tabs?.querySelector<HTMLElement>('[aria-selected="true"]') ?? null)
655
+ const title = derived(() => {
656
+ const v = c.view.value, d = c.date.value, l = c.env.locale.value
657
+ if (v === 'year') return ymd(d)[0].toString()
658
+ if (v === 'day' || v === 'timeline') return fmt(d, { month: 'long', day: 'numeric', year: 'numeric' }, l)
659
+ return fmt(d, { month: 'long', year: 'numeric' }, l)
660
+ })
661
+ const unit = derived(() => ({ month: 'month', week: 'week', day: 'day', agenda: 'week', timeline: 'day', year: 'year' })[c.view.value])
662
+ const week = derived(() => (c.view.value === 'week' ? `Week ${isoWeek(c.date.value).week}` : ''))
663
+ const step = (d: number) => c.date.set(stepDate(c.view.value, c.date.value, d))
664
+ const setView = (v: CalView) => { if (v !== c.view.value) { c.view.set(v); c.say(`${LABEL[v]} view`) } }
665
+ const onTabKey = (e: KeyboardEvent) => {
666
+ const list = views.value
667
+ const to = nextIndex(e.key, list.indexOf(c.view.value), list.length, { rtl: getComputedStyle(e.currentTarget as Element).direction === 'rtl' })
668
+ if (to < 0) return
669
+ e.preventDefault()
670
+ setView(list[to]!)
671
+ flush()
672
+ tabs?.querySelector<HTMLElement>('[aria-selected="true"]')?.focus()
673
+ }
674
+ const createNow = () => { const d = c.date.value; c.newEvent(`${d}T13:00`, `${d}T14:00`) }
675
+ const openDay = (iso: IsoDate) => { c.date.set(iso); c.view.set('day'); return true }
676
+ const openMonth = (iso: IsoDate) => { c.date.set(iso); c.view.set('month'); return true }
677
+ const toggle = (cal: number, on: boolean) => { c.off.value = on ? c.off.value.filter(x => x !== cal) : [...c.off.value, cal] }
678
+ const confirmDelete = () => { const e = c.doomed.value; c.askDelete.value = false; if (e) { c.p.onDelete?.(e); c.say(`Deleted ${e.title}`) } }
679
+ const applyScope = () => { const a = c.pendingScope.value; c.scopeOpen.value = false; c.pendingScope.value = null; a?.run(c.scope.value) }
680
+ const dropScope = () => { const a = c.pendingScope.value; c.scopeOpen.value = false; c.pendingScope.value = null; a?.undo?.() }
681
+ const saved = (e: CalEvent) => {
682
+ const d = c.draft.value
683
+ if (d?.isNew) { p.onCreate?.(e); c.say(`Created ${e.title}`); return }
684
+ const run = (s?: RecurrenceScope) => { p.onEdit?.(e, s); c.say(`Saved ${e.title}`) }
685
+ if (d?.event.recurrence) { c.scope.value = 'this'; c.pendingScope.value = { run, verb: 'Save' }; c.scopeOpen.value = true } else run()
686
+ }
687
+ const zoneNow = (z: string) => { const n = c.now.value; return n ? c.time(minuteOf(c.tz() ? toZone(n, c.tz()!, z) : n)) : '' }
688
+ return (
689
+ <div ref={n => { root = n; c.bindRoot(n) }} data-scope="calendar" data-part="root">
690
+ <div data-part="app-bar" class="row">
691
+ <Button emphasis="ghost" tone="neutral" size="sm" icon={LeftIcon} label={`Previous ${unit.value}`} onClick={() => step(-1)} />
692
+ <Button emphasis="ghost" tone="neutral" size="sm" icon={RightIcon} label={`Next ${unit.value}`} onClick={() => step(1)} />
693
+ <Button emphasis="outline" tone="neutral" size="sm" onClick={() => c.date.set(c.env.today.value)}>Today</Button>
694
+ {tablet.value
695
+ ? <Popover label={title.value} title="Go to a date" trigger={{ emphasis: 'ghost', tone: 'neutral', size: 'sm' }}><span data-scope="calendar"><MiniMonth c={c} /></span></Popover>
696
+ : <h3 data-part="app-title"><span data-part="range-label">{title.value} <small>{week.value && `· ${week.value}`}</small></span></h3>}
697
+ <span data-part="spacer"></span>
698
+ {p.live && <LiveStatus state={p.live} label="Calendar sync">{p.live === 'live' ? 'Synced' : undefined}</LiveStatus>}
699
+ {c.zone.value && <ZoneChip c={c} />}
700
+ <div data-scope="tabs" data-variant="pill" data-part="root">
701
+ <div ref={n => (tabs = n)} data-part="list" role="tablist" aria-label="Calendar view" onKeyDown={onTabKey}>
702
+ {views.value.map(v => <button key={v} type="button" data-part="trigger" role="tab" id={`${id}-${v}`} aria-controls={`${id}-main`} aria-selected={v === c.view.value ? 'true' : 'false'} tabindex={v === c.view.value ? undefined : '-1'} onClick={() => setView(v)}>{LABEL[v]}</button>)}
703
+ <span data-part="indicator" aria-hidden="true" data-instant=""></span>
704
+ </div>
705
+ </div>
706
+ {!p.readOnly && p.onCreate && <Button size="sm" onClick={createNow}><PlusIcon />New event</Button>}
707
+ </div>
708
+ <div data-part="layout" style="min-block-size:0">
709
+ <aside data-part="rail" aria-label="Calendars">
710
+ {!tablet.value && <MiniMonth c={c} />}
711
+ {p.calendars && (
712
+ <div class="stack">
713
+ <span data-part="eyebrow">My calendars</span>
714
+ <ul data-part="sources">{p.calendars.map(s => <li key={s.id}><label data-cal={s.id}><input type="checkbox" checked={!c.off.value.includes(s.id)} onChange={(e: Event) => toggle(s.id, (e.currentTarget as HTMLInputElement).checked)} />{s.name}</label></li>)}</ul>
715
+ </div>
716
+ )}
717
+ {p.zones && (
718
+ <div class="stack">
719
+ <span data-part="eyebrow">Time zones</span>
720
+ {p.zones.map(z => <span key={z.zone} class="row"><span>{z.label}</span><span style="flex:1"></span><span class="muted">{zoneNow(z.zone)}</span></span>)}
721
+ </div>
722
+ )}
723
+ </aside>
724
+ <div data-part="main" id={`${id}-main`} role="tabpanel" aria-labelledby={`${id}-${c.view.value}`} style="min-block-size:0;min-inline-size:0;display:grid;position:relative">
725
+ {c.view.value === 'week' || c.view.value === 'day'
726
+ ? <TimeGrid c={c} count={7} />
727
+ : c.view.value === 'month'
728
+ ? <MonthGrid c={c} />
729
+ : c.view.value === 'agenda'
730
+ ? <AgendaView c={c} />
731
+ : c.view.value === 'timeline'
732
+ ? <Timeline c={c} />
733
+ : <div data-part="year-wrap"><YearView label="Year view" counts={c.counts.value} events={c.all.value} today={c.env.today.value} locale={c.env.locale.value} hourCycle={p.hourCycle} firstDayOfWeek={c.first.value} openDay={openDay} openMonth={openMonth} /></div>}
734
+ </div>
735
+ </div>
736
+ <EventPopover open={c.popOpen} event={c.popped.value ? c.shown(c.popped.value) ?? null : null} anchor={() => c.anchor.value} zoneLabel={c.zoneLabel.value} locale={c.env.locale.value} hourCycle={p.hourCycle} side={c.view.value === 'agenda' ? 'bottom' : 'right'} onEdit={p.onEdit && !p.readOnly ? c.edit : undefined} onDelete={p.onDelete && !p.readOnly ? c.remove : undefined} onJoin={p.onJoin} />
737
+ <EventEditor open={c.editing} event={c.draft.value?.event ?? null} isNew={c.draft.value?.isNew} calendars={p.calendars} timeZone={c.zone.value ?? undefined} locale={c.env.locale.value} today={c.env.today.value} onSave={saved} />
738
+ <MoveSheet open={c.sheet} event={c.sheetFor.value} events={c.all.value} firstDayOfWeek={c.first.value} locale={c.env.locale.value} hourCycle={p.hourCycle} onMove={(e, to) => c.change(e.id, to, 'move', 'touch')} />
739
+ <Modal open={c.askDelete} alert size="sm" tone="critical" title={`Delete ${c.doomed.value?.title ?? 'event'}?`} description="It's removed for everyone on it." footer={<><Button emphasis="outline" tone="neutral" autofocus onClick={() => (c.askDelete.value = false)}>Cancel</Button><Button tone="critical" onClick={confirmDelete}>Delete</Button></>}></Modal>
740
+ <Modal open={c.scopeOpen} size="sm" title={`${c.pendingScope.value?.verb ?? 'Change'} repeating event`} footer={<><Button emphasis="outline" tone="neutral" onClick={dropScope}>Cancel</Button><Button onClick={applyScope}>{c.pendingScope.value?.verb ?? 'Apply'}</Button></>}>
741
+ <Segmented label="Apply to" value={c.scope.value} onChange={v => (c.scope.value = v)} options={[{ value: 'this', label: 'This event' }, { value: 'following', label: 'This and following' }, { value: 'all', label: 'All' }]} />
742
+ </Modal>
743
+ </div>
744
+ )
745
+ }
746
+