@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.
- package/LICENSE +16 -0
- package/README.md +55 -2
- package/package.json +43 -4
- package/plugin.d.mts +30 -0
- package/plugin.mjs +358 -0
- package/src/Accordion.tsx +58 -0
- package/src/Alert.tsx +25 -0
- package/src/Badge.tsx +17 -0
- package/src/Button.tsx +100 -0
- package/src/Calendar.tsx +162 -0
- package/src/Canvas.tsx +110 -0
- package/src/Card.tsx +29 -0
- package/src/Chart.tsx +133 -0
- package/src/Charts.tsx +69 -0
- package/src/Chip.tsx +27 -0
- package/src/Choice.tsx +105 -0
- package/src/CodeBlock.tsx +65 -0
- package/src/CodeInput.tsx +130 -0
- package/src/Combobox.tsx +113 -0
- package/src/CommandPalette.tsx +348 -0
- package/src/DataGrid.tsx +561 -0
- package/src/DateField.tsx +221 -0
- package/src/Dither.tsx +129 -0
- package/src/Drawer.tsx +41 -0
- package/src/DurationField.tsx +98 -0
- package/src/Feed.tsx +74 -0
- package/src/Field.tsx +147 -0
- package/src/Icon.tsx +8 -0
- package/src/Kbd.tsx +6 -0
- package/src/KeyValueEditor.tsx +114 -0
- package/src/Layout.tsx +109 -0
- package/src/Markdown.tsx +63 -0
- package/src/MentionsInput.tsx +150 -0
- package/src/Menu.tsx +111 -0
- package/src/Modal.tsx +40 -0
- package/src/MonthPicker.tsx +190 -0
- package/src/Nav.tsx +20 -0
- package/src/NumberField.tsx +78 -0
- package/src/Pagination.tsx +122 -0
- package/src/PaymentCard.tsx +63 -0
- package/src/Picker.tsx +120 -0
- package/src/PlaceField.tsx +126 -0
- package/src/Popover.tsx +34 -0
- package/src/Progress.tsx +26 -0
- package/src/RecurrenceField.tsx +126 -0
- package/src/RichSelect.tsx +243 -0
- package/src/RichTextEditor.tsx +207 -0
- package/src/ScrollGrid.tsx +190 -0
- package/src/Segments.tsx +70 -0
- package/src/Select.tsx +192 -0
- package/src/Shape.tsx +20 -0
- package/src/Shell.tsx +308 -0
- package/src/SideNav.tsx +147 -0
- package/src/SignaturePad.tsx +120 -0
- package/src/Skeleton.tsx +14 -0
- package/src/Slider.tsx +42 -0
- package/src/Spinner.tsx +7 -0
- package/src/Table.tsx +528 -0
- package/src/Tabs.tsx +67 -0
- package/src/Tag.tsx +15 -0
- package/src/Ticker.tsx +36 -0
- package/src/TimeField.tsx +237 -0
- package/src/Toast.tsx +56 -0
- package/src/Toggle.tsx +42 -0
- package/src/Tooltip.tsx +32 -0
- package/src/Type.tsx +46 -0
- package/src/VirtualList.tsx +386 -0
- package/src/admin/ApiKeys.tsx +136 -0
- package/src/admin/Audit.tsx +93 -0
- package/src/admin/Billing.tsx +123 -0
- package/src/admin/Cart.tsx +96 -0
- package/src/admin/Checkout.tsx +300 -0
- package/src/admin/CsvImport.tsx +174 -0
- package/src/admin/Danger.tsx +110 -0
- package/src/admin/Integrations.tsx +200 -0
- package/src/admin/InviteDialog.tsx +64 -0
- package/src/admin/Members.tsx +186 -0
- package/src/admin/Order.tsx +63 -0
- package/src/admin/PlainTable.tsx +22 -0
- package/src/admin/Plans.tsx +167 -0
- package/src/admin/SaveBar.tsx +112 -0
- package/src/admin/Secret.tsx +37 -0
- package/src/admin/Settings.tsx +76 -0
- package/src/admin/StepBar.tsx +35 -0
- package/src/admin/TagsInput.tsx +79 -0
- package/src/admin/atoms.tsx +33 -0
- package/src/admin/index.ts +16 -0
- package/src/agent/Actions.tsx +85 -0
- package/src/agent/AgentPlan.tsx +49 -0
- package/src/agent/ArtifactPanel.tsx +80 -0
- package/src/agent/Assistant.tsx +96 -0
- package/src/agent/Checkpoint.tsx +14 -0
- package/src/agent/Cite.tsx +39 -0
- package/src/agent/ContextMeter.tsx +31 -0
- package/src/agent/Conversation.tsx +34 -0
- package/src/agent/Message.tsx +53 -0
- package/src/agent/ModelPicker.tsx +65 -0
- package/src/agent/Prompt.tsx +197 -0
- package/src/agent/Prose.tsx +54 -0
- package/src/agent/Question.tsx +52 -0
- package/src/agent/Reasoning.tsx +19 -0
- package/src/agent/Shimmer.tsx +7 -0
- package/src/agent/Sources.tsx +23 -0
- package/src/agent/SpeechInput.tsx +102 -0
- package/src/agent/Suggestions.tsx +22 -0
- package/src/agent/ToolCall.tsx +91 -0
- package/src/agent/TraceViewer.tsx +55 -0
- package/src/agent/bits.tsx +40 -0
- package/src/agent/index.ts +22 -0
- package/src/bind.ts +42 -0
- package/src/collab/AutoTrack.tsx +42 -0
- package/src/collab/Comments.tsx +94 -0
- package/src/collab/Composer.tsx +173 -0
- package/src/collab/ConversationList.tsx +73 -0
- package/src/collab/Cursors.tsx +35 -0
- package/src/collab/DataIntegrity.tsx +45 -0
- package/src/collab/Kanban.tsx +185 -0
- package/src/collab/Lock.tsx +23 -0
- package/src/collab/PresenceStack.tsx +67 -0
- package/src/collab/ShareDialog.tsx +118 -0
- package/src/collab/Status.tsx +42 -0
- package/src/collab/SyncedForm.tsx +19 -0
- package/src/collab/Thread.tsx +273 -0
- package/src/collab/VersionHistory.tsx +49 -0
- package/src/collab/ViewportRail.tsx +49 -0
- package/src/collab/index.ts +16 -0
- package/src/collab/peer.tsx +16 -0
- package/src/fields.ts +5 -0
- package/src/flow/AuthScreen.tsx +40 -0
- package/src/flow/AuthSteps.tsx +120 -0
- package/src/flow/Checklist.tsx +84 -0
- package/src/flow/ConnBanner.tsx +147 -0
- package/src/flow/Flow.tsx +63 -0
- package/src/flow/MfaSetup.tsx +103 -0
- package/src/flow/Otp.tsx +114 -0
- package/src/flow/ReauthDialog.tsx +47 -0
- package/src/flow/ShortcutsOverlay.tsx +60 -0
- package/src/flow/SignGate.tsx +98 -0
- package/src/flow/SignIn.tsx +80 -0
- package/src/flow/SignUp.tsx +71 -0
- package/src/flow/Sso.tsx +38 -0
- package/src/flow/SystemPage.tsx +247 -0
- package/src/flow/Tour.tsx +108 -0
- package/src/flow/index.ts +17 -0
- package/src/flow/kit.tsx +170 -0
- package/src/index.ts +74 -0
- package/src/layout.css +4 -0
- package/src/links.css +4 -0
- package/src/live/ActivityFeed.tsx +49 -0
- package/src/live/Bell.tsx +62 -0
- package/src/live/Connection.tsx +49 -0
- package/src/live/Inbox.tsx +220 -0
- package/src/live/MeetingRoom.tsx +298 -0
- package/src/live/NotificationPreferences.tsx +193 -0
- package/src/live/Prejoin.tsx +140 -0
- package/src/live/PushSoftAsk.tsx +37 -0
- package/src/live/TransportPicker.tsx +163 -0
- package/src/live/index.ts +12 -0
- package/src/live/notify.tsx +16 -0
- package/src/live/shared.tsx +39 -0
- package/src/parts.tsx +93 -0
- package/src/schedule/Agenda.tsx +173 -0
- package/src/schedule/AvailabilityEditor.tsx +164 -0
- package/src/schedule/BookingCalendar.tsx +132 -0
- package/src/schedule/Calendar.tsx +746 -0
- package/src/schedule/EventEditor.tsx +87 -0
- package/src/schedule/EventPopover.tsx +65 -0
- package/src/schedule/MeetingPlanner.tsx +214 -0
- package/src/schedule/MoveSheet.tsx +78 -0
- package/src/schedule/YearView.tsx +163 -0
- package/src/schedule/bits.tsx +51 -0
- package/src/schedule/index.ts +10 -0
- package/src/skeleton-css.ts +4 -0
- package/src/styles.ts +10 -0
- package/src/tokens.d.ts +2 -0
- package/src/type.css +15 -0
- package/src/ui.ts +26 -0
- package/src/use-ui.ts +10 -0
- package/tailwind.css +38 -0
- 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
|
+
|