@weft-js/warp 0.0.0-stage → 0.1.1

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,87 @@
1
+ import { at, asTime, dateOf, fmt, minuteOf, minutes, type CalEvent, type IsoDate, type IsoTime } from '@weft-js/warp-core'
2
+ import { derived, flush, state, untrack, type State } from '@weft-js/core'
3
+ import { Button } from '../Button'
4
+ import { Switch } from '../Choice'
5
+ import { DateField } from '../DateField'
6
+ import { Drawer } from '../Drawer'
7
+ import { Input } from '../Field'
8
+ import { RecurrenceField } from '../RecurrenceField'
9
+ import { TimeZoneSelect } from '../RichSelect'
10
+ import { Select } from '../Select'
11
+ import { TimeField } from '../TimeField'
12
+ import './bits'
13
+
14
+ /** A calendar an event can live in. */
15
+ export type CalendarSource = { id: number; name: string }
16
+ /** Props for EventEditor. */
17
+ export type EventEditorProps = {
18
+ open: State<boolean>
19
+ /** What it edits; rebuild the fields by passing a new object. */
20
+ event: CalEvent | null
21
+ isNew?: boolean | undefined
22
+ calendars?: CalendarSource[] | undefined
23
+ /** The IANA zone the times are edited in, such as `Europe/Berlin`. */
24
+ timeZone?: string | undefined
25
+ locale?: string | undefined
26
+ /** Pins today's date, for server rendering and tests. The browser's clock otherwise. */
27
+ today?: string | undefined
28
+ onSave: (e: CalEvent) => void
29
+ onCancel?: (() => void) | undefined
30
+ }
31
+
32
+ /** The event editor (07 §2.1, `#ev-sheet`): a Sheet, right on wide screens and bottom on a phone; the fields path for every drag. */
33
+ export function EventEditor(p: EventEditorProps) {
34
+ let form: HTMLFormElement | undefined
35
+ const label = derived(() => (p.isNew ? 'New event' : 'Edit event'))
36
+ const save = () => form?.requestSubmit()
37
+ const cancel = () => { p.open.value = false; p.onCancel?.() }
38
+ return (
39
+ <Drawer open={p.open} title={label.value} description={p.event ? fmt(dateOf(p.event.start), { weekday: 'long', month: 'short', day: 'numeric' }, p.locale ?? 'en-US') : undefined} footer={<><Button emphasis="outline" tone="neutral" onClick={cancel}>Cancel</Button><Button onClick={save}>Save</Button></>}>
40
+ {p.event && p.open.value && <Fields key={p.event.id} draft={p.event} props={p} form={(n: HTMLFormElement) => (form = n)} />}
41
+ </Drawer>
42
+ )
43
+ }
44
+
45
+ function Fields({ draft, props: p, form }: { draft: CalEvent; props: EventEditorProps; form: (n: HTMLFormElement) => void }) {
46
+ const e = untrack(() => draft)
47
+ const title = state(e.title)
48
+ const date = state<IsoDate | null>(dateOf(e.start))
49
+ const start = state<IsoTime | null>(asTime(minuteOf(e.start)))
50
+ const end = state<IsoTime | null>(asTime(minuteOf(e.end)))
51
+ const repeats = state(!!e.recurrence)
52
+ const rule = state<string | null>(e.recurrence ?? 'FREQ=WEEKLY')
53
+ const zone = state<string | null>(e.tz ?? untrack(() => p.timeZone) ?? null)
54
+ const place = state(e.location ?? '')
55
+ const cal = state(String(e.cal ?? 1))
56
+ // '' not undefined: Field only builds its message row for an error it knows about at setup
57
+ const titleError = state('')
58
+ const timeError = state('')
59
+ const sources = derived(() => (p.calendars ?? [{ id: 1, name: 'Calendar' }]).map(c => ({ value: String(c.id), label: c.name })))
60
+ let box: HTMLFormElement | undefined
61
+ const submit = (ev: Event) => {
62
+ ev.preventDefault()
63
+ const s = minutes(start.value ?? '00:00'), z = minutes(end.value ?? '00:00')
64
+ titleError.value = title.value.trim() ? '' : 'Give it a title.'
65
+ timeError.value = e.allDay || z > s ? '' : 'Ends before it starts'
66
+ if (titleError.value || timeError.value) {
67
+ flush()
68
+ box?.querySelector<HTMLElement>('[aria-invalid="true"], [aria-invalid="true"] input')?.focus()
69
+ return
70
+ }
71
+ const d = date.value ?? dateOf(e.start)
72
+ p.open.value = false
73
+ p.onSave({ ...e, title: title.value.trim(), start: e.allDay ? d : at(d, s), end: e.allDay ? e.end : at(d, z), recurrence: repeats.value ? rule.value : null, tz: zone.value && zone.value !== p.timeZone ? zone.value : e.tz, location: place.value || undefined, cal: +cal.value })
74
+ }
75
+ return (
76
+ <form ref={n => { box = n; form(n) }} class="stack" onSubmit={submit} noValidate>
77
+ <Input label="Title" value={title.value} error={titleError.value} required autofocus onInput={i => (title.value = i.currentTarget.value)} />
78
+ <DateField label="Date" value={date} today={p.today} locale={p.locale} />
79
+ {!e.allDay && <div class="row"><TimeField label="Starts" value={start} locale={p.locale} hourCycle="h23" /><TimeField label="Ends" value={end} locale={p.locale} hourCycle="h23" error={timeError.value} /></div>}
80
+ <Switch checked={repeats.value} onChange={(i: Event) => (repeats.value = (i.currentTarget as HTMLInputElement).checked)}>Repeats</Switch>
81
+ {repeats.value && <RecurrenceField label="Repeats" value={rule} dtstart={date.value ?? dateOf(e.start)} locale={p.locale} />}
82
+ <TimeZoneSelect label="Time zone" value={zone} referenceDate={date.value ?? undefined} today={p.today} locale={p.locale} />
83
+ <Input label="Location or link" value={place.value} onInput={i => (place.value = i.currentTarget.value)} />
84
+ <Select label="Calendar" options={sources.value} value={cal.value} onChange={(i: Event) => (cal.value = (i.currentTarget as HTMLInputElement).value)} />
85
+ </form>
86
+ )
87
+ }
@@ -0,0 +1,65 @@
1
+ import { ClockIcon, InfoIcon, PinIcon, RepeatIcon, UsersIcon, VideoIcon } from '@weft-js/warp-icons'
2
+ import { clockRange, dateOf, fmt, longDuration, popover, ruleSentence, parseRule, slotOf, type Align, type CalEvent, type Side } from '@weft-js/warp-core'
3
+ import { derived, type State } from '@weft-js/core'
4
+ import { Avatar } from '../parts'
5
+ import { Badge } from '../Badge'
6
+ import { Button } from '../Button'
7
+ import { use } from '@weft-js/warp-core/weft'
8
+ import '@weft-js/warp-core/styles/components/popover.css'
9
+ import '@weft-js/warp-core/styles/app/avatar-stack.css'
10
+ import '@weft-js/warp-core/styles/app/event-card.css'
11
+
12
+ /** Props for EventPopover. */
13
+ export type EventPopoverProps = {
14
+ event: CalEvent | null
15
+ open: State<boolean>
16
+ /** The element it hangs from; focus returns there. */
17
+ anchor: () => HTMLElement | undefined
18
+ /** "New York (EDT)". */
19
+ zoneLabel?: string | undefined
20
+ locale?: string | undefined
21
+ /** `h12` for AM and PM, `h23` for a 24-hour clock. The locale's otherwise. */
22
+ hourCycle?: string | undefined
23
+ side?: Side | undefined
24
+ align?: Align | undefined
25
+ onEdit?: ((e: CalEvent) => void) | undefined
26
+ onDelete?: ((e: CalEvent) => void) | undefined
27
+ onJoin?: ((e: CalEvent) => void) | undefined
28
+ }
29
+
30
+ /** The event card (07 §2.1, `#ev-pop`): a non-modal dialog on the event; Escape returns to it. */
31
+ export function EventPopover(p: EventPopoverProps) {
32
+ let panel: HTMLDivElement | undefined
33
+ const locale = derived(() => p.locale ?? 'en-US')
34
+ const when = derived(() => {
35
+ const e = p.event
36
+ if (!e) return null
37
+ const s = slotOf(e), l = locale.value
38
+ return { date: fmt(dateOf(e.start), { weekday: 'long', month: 'short', day: 'numeric' }, l), time: e.allDay ? 'All day' : clockRange(s.s, s.e, l, p.hourCycle), length: e.allDay ? undefined : longDuration(s.e - s.s) }
39
+ })
40
+ const close = () => (p.open.value = false)
41
+ use(popover, () => panel, () => ({ open: p.open.value, trigger: p.anchor()!, side: p.side ?? 'right', align: p.align ?? 'start', onClose: close }))
42
+ const act = (f?: (e: CalEvent) => void) => () => { const e = p.event!; close(); f?.(e) }
43
+ return (
44
+ <div ref={n => (panel = n)} data-scope="popover" data-part="content" popover="manual" role="dialog" aria-label="Event details">
45
+ {p.event && (
46
+ <div data-scope="event-card" data-cal={p.event.cal ?? 1}>
47
+ <h4 data-part="title">{p.event.title}</h4>
48
+ <dl>
49
+ <dt><ClockIcon /></dt>
50
+ <dd>{when.value!.date} · {when.value!.time}{(when.value!.length || p.zoneLabel) && <><br /><span class="muted">{[when.value!.length, p.zoneLabel].filter(Boolean).join(' · ')}</span></>}</dd>
51
+ {p.event.recurrence && <><dt><RepeatIcon /></dt><dd>{ruleSentence(parseRule(p.event.recurrence), dateOf(p.event.start), locale.value)}</dd></>}
52
+ {p.event.location && <><dt><PinIcon /></dt><dd>{p.event.location}</dd></>}
53
+ {p.event.attendees?.length ? <><dt><UsersIcon /></dt><dd><span data-scope="avatar-stack">{p.event.attendees.slice(0, 5).map(n => <Avatar key={n} name={n} size="sm" />)}</span> <span class="muted">{p.event.attendees.length} going</span></dd></> : null}
54
+ {p.event.tentative && <><dt><InfoIcon /></dt><dd><Badge tone="caution">Tentative</Badge></dd></>}
55
+ </dl>
56
+ <div data-part="actions">
57
+ {!p.event.locked && p.onDelete && <Button emphasis="ghost" tone="critical" size="sm" onClick={act(p.onDelete)}>Delete</Button>}
58
+ {!p.event.locked && p.onEdit && <Button emphasis="outline" tone="neutral" size="sm" onClick={act(p.onEdit)}>Edit</Button>}
59
+ {p.event.video && <Button size="sm" onClick={act(p.onJoin)}><VideoIcon />Join</Button>}
60
+ </div>
61
+ </div>
62
+ )}
63
+ </div>
64
+ )
65
+ }
@@ -0,0 +1,214 @@
1
+ import { ClockIcon, SendIcon, XIcon } from '@weft-js/warp-icons'
2
+ import { announce, asTime, clock, clockRange, fmt, minutes, sharedFree, suggestSlots, uid, withinHours, zoneCity, zoneMinute, type Attendee, type IsoTime, type Range } from '@weft-js/warp-core'
3
+ import { derived, flush, state, untrack, watch } from '@weft-js/core'
4
+ import { Badge } from '../Badge'
5
+ import { Button } from '../Button'
6
+ import { Card } from '../Card'
7
+ import { Segmented } from '../Choice'
8
+ import { ComboField } from '../Combobox'
9
+ import { Input } from '../Field'
10
+ import { Avatar } from '../parts'
11
+ import { usePickerEnv } from '../Picker'
12
+ import { TimeField } from '../TimeField'
13
+ import { SweepParts, confirmSweep } from './bits'
14
+ import '@weft-js/warp-core/styles/app/booking.css'
15
+ import '@weft-js/warp-core/styles/app/calendar.css'
16
+
17
+ /** What the planner sends: who, when and about what. */
18
+ export type MeetingDetails = { attendees: Attendee[]; title: string; link: string; tags: string[]; date: string; s: number; e: number }
19
+ /** Props for MeetingPlanner. */
20
+ export type MeetingPlannerProps = {
21
+ date: string
22
+ attendees?: Attendee[] | undefined
23
+ /** Search for people to add. */
24
+ loadAttendees?: ((q: string, signal: AbortSignal) => Promise<Attendee[]>) | undefined
25
+ /** The app's own suggestions; else they come from everyone's `busy`. */
26
+ loadSlots?: ((input: { attendees: Attendee[]; date: string; duration: number }) => number[] | Promise<number[]>) | undefined
27
+ /** The meeting's starting length, in minutes. Default 30. */
28
+ duration?: number | undefined
29
+ durations?: number[] | undefined
30
+ title?: string | undefined
31
+ /** The meeting link the form starts with, such as a video call URL. */
32
+ link?: string | undefined
33
+ tags?: string[] | undefined
34
+ /** The strip's hours, minutes since midnight. */
35
+ from?: number | undefined
36
+ /** Where the strip's hours end, in minutes since midnight. */
37
+ to?: number | undefined
38
+ /** The IANA zone times are shown in, such as `Europe/Berlin`. */
39
+ timeZone?: string | undefined
40
+ /** Pins today's date, for server rendering and tests. The browser's clock otherwise. */
41
+ today?: string | undefined
42
+ locale?: string | undefined
43
+ /** `h12` for AM and PM, `h23` for a 24-hour clock. The locale's otherwise. */
44
+ hourCycle?: string | undefined
45
+ onSubmit: (m: MeetingDetails) => void | Promise<unknown>
46
+ onUndo?: (() => void) | undefined
47
+ }
48
+
49
+ const BUSY_BAR = 'position:absolute;inset-block:7px;border-radius:3px;background:color-mix(in oklab,var(--warp-color-text) 22%,transparent)'
50
+ const FREE_BAR = 'position:absolute;inset-block:6px;background:var(--warp-color-positive-soft);border-block:1px solid var(--warp-color-positive-border)'
51
+ const PICK_BAR = 'position:absolute;inset-block:0;background:color-mix(in oklab,var(--warp-color-sweep) 18%,transparent);border-inline:var(--warp-border-emphasis) solid var(--warp-color-sweep)'
52
+
53
+ /**
54
+ * MeetingPlanner (07 §2.2, `#planner`): attendees, details, an availability table with an Everyone row, three suggested
55
+ * times as a radiogroup, a manual time, Send. Everything is drawn from `busy`, so it works from a query or a signal.
56
+ */
57
+ export function MeetingPlanner(p: MeetingPlannerProps) {
58
+ const id = uid('plan')
59
+ const env = usePickerEnv(() => p.locale, () => p.today)
60
+ const people = state<Attendee[]>(untrack(() => p.attendees ?? []))
61
+ const len = state(untrack(() => p.duration ?? 30))
62
+ const lens = derived(() => (p.durations ?? [15, 30, 45, 60]).map(m => ({ value: String(m), label: `${m} min` })))
63
+ const title = state(untrack(() => p.title ?? ''))
64
+ const link = state(untrack(() => p.link ?? ''))
65
+ const query = state('')
66
+ const manual = state(false)
67
+ const typed = state<IsoTime | null>(null)
68
+ const sent = state(false)
69
+ const busy = state(false)
70
+ const failed = state<string | null>(null)
71
+ const remote = state<number[] | null>(null)
72
+ const from = derived(() => p.from ?? 480)
73
+ const to = derived(() => p.to ?? 1080)
74
+ const width = derived(() => to.value - from.value)
75
+ const ticks = derived(() => Array.from({ length: Math.round(width.value / 60) }, (_, i) => from.value + i * 60))
76
+ const busyLists = derived(() => people.value.map(a => a.busy ?? []))
77
+ const slots = derived(() => remote.value ?? suggestSlots(people.value, p.date, { len: len.value, from: from.value, to: to.value, zone: p.timeZone }))
78
+ const shared = derived(() => sharedFree(busyLists.value, from.value, to.value))
79
+ const pick = state<number | null>(null)
80
+ const chosen = derived(() => (manual.value ? (typed.value ? minutes(typed.value) : null) : pick.value !== null && slots.value.includes(pick.value) ? pick.value : slots.value[0] ?? null))
81
+ const time = (m: number) => clock(m, env.locale.value, p.hourCycle)
82
+ const span = (a: number, b: number) => clockRange(a, b, env.locale.value, p.hourCycle)
83
+ const left = (m: number) => ((m - from.value) / width.value) * 100
84
+ const size = (m: number) => (m / width.value) * 100
85
+ let root: HTMLDivElement | undefined
86
+ let group: HTMLDivElement | undefined
87
+ const say = (t: string, loud = false) => root && announce(root.ownerDocument, t, loud)
88
+ watch(() => [people.value, len.value] as const, ([a, l]) => {
89
+ if (!p.loadSlots) return
90
+ const r = p.loadSlots({ attendees: a, date: p.date, duration: l })
91
+ if (Array.isArray(r)) remote.value = r
92
+ else void r.then(v => (remote.value = v))
93
+ })
94
+ const fit = derived(() => { const s = chosen.value; return s === null ? 0 : withinHours(people.value, p.date, s, len.value, p.timeZone) })
95
+ const elsewhere = derived(() => { const s = chosen.value; if (s === null) return []; return [...new Set(people.value.map(a => a.zone).filter(z => z && z !== p.timeZone))].slice(0, 2).map(z => `${zoneCity(z!)} ${time(zoneMinute(p.date, s, p.timeZone, z))}`) })
96
+ const summary = derived(() => (chosen.value === null ? 'No common time today' : `${fmt(p.date, { weekday: 'short', month: 'short', day: 'numeric' }, env.locale.value)} · ${span(chosen.value, chosen.value + len.value)}`))
97
+ const add = (a: Attendee) => {
98
+ if (!people.value.some(x => x.id === a.id)) people.value = [...people.value, a]
99
+ query.value = ''
100
+ say(`${a.name} added. ${slots.value.length} times work for everyone.`)
101
+ }
102
+ const drop = (a: Attendee) => { people.value = people.value.filter(x => x.id !== a.id); say(`${a.name} removed`) }
103
+ const setLen = (v: string) => { len.value = +v; flush(); say(`${v} minutes. ${slots.value.length} times work for everyone.`) }
104
+ const onRadio = (e: KeyboardEvent) => {
105
+ const d = ({ ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 } as Record<string, number>)[e.key]
106
+ const list = slots.value
107
+ const i = list.indexOf(chosen.value!)
108
+ const n = d ? (i + d + list.length) % list.length : e.key === 'Home' ? 0 : e.key === 'End' ? list.length - 1 : -1
109
+ if (n < 0) return
110
+ e.preventDefault()
111
+ pick.value = list[n]!
112
+ flush()
113
+ group?.querySelector<HTMLElement>('[aria-checked="true"]')?.focus()
114
+ }
115
+ const send = async () => {
116
+ const s = chosen.value
117
+ if (s === null || busy.value) return
118
+ busy.value = true
119
+ failed.value = null
120
+ try {
121
+ await p.onSubmit({ attendees: people.value, title: title.value, link: link.value, tags: p.tags ?? [], date: p.date, s, e: s + len.value })
122
+ sent.value = true
123
+ say(`Invite sent for ${summary.value}`)
124
+ flush()
125
+ confirmSweep(root?.querySelector('[data-arrive]'))
126
+ } catch (err) {
127
+ failed.value = (err as Error | undefined)?.message ?? 'Try again.'
128
+ say(`Couldn't send the invite: ${failed.value}`, true)
129
+ } finally { busy.value = false }
130
+ }
131
+ return (
132
+ <div ref={n => (root = n)} class="stack">
133
+ <div class="row">
134
+ <div><span data-part="eyebrow">MeetingPlanner</span><h3>Find a time</h3></div>
135
+ <span style="flex:1"></span>
136
+ <Segmented label="Duration" size="sm" value={String(len.value)} onChange={setLen} options={lens.value} />
137
+ </div>
138
+ <Card sweep={false}>
139
+ <div class="stack">
140
+ <h4 id={`${id}-att`}>Attendees <span class="muted">{people.value.length}</span></h4>
141
+ {p.loadAttendees && <ComboField label="Add people" text={() => query.value} onText={t => (query.value = t)} find={p.loadAttendees} row={(a: Attendee) => ({ main: a.name, sub: a.zone && zoneCity(a.zone) })} onPick={add} />}
142
+ <ul class="stack" aria-labelledby={`${id}-att`}>
143
+ {people.value.map(a => (
144
+ <li key={a.id} class="row">
145
+ <Avatar name={a.name} size="sm" />
146
+ <span>{a.name}{a.organizer && <span class="muted"> (organizer)</span>}</span>
147
+ <span style="flex:1"></span>
148
+ <span class="muted">{a.zone ? zoneCity(a.zone) : ''}</span>
149
+ {!a.organizer && <Button emphasis="ghost" tone="neutral" size="sm" icon={XIcon} label={`Remove ${a.name}`} onClick={() => drop(a)} />}
150
+ </li>
151
+ ))}
152
+ </ul>
153
+ </div>
154
+ </Card>
155
+ <Card sweep={false}>
156
+ <div class="stack">
157
+ <h4>Details</h4>
158
+ <Input label="Title" value={title.value} onInput={e => (title.value = e.currentTarget.value)} />
159
+ <Input label="Video link" value={link.value} onInput={e => (link.value = e.currentTarget.value)} />
160
+ {p.tags && <div class="row">{p.tags.map(t => <Badge key={t} tone="neutral">{t}</Badge>)}</div>}
161
+ </div>
162
+ </Card>
163
+ <Card sweep={false}>
164
+ <div class="stack">
165
+ <h4 id={`${id}-grid`}>{fmt(p.date, { weekday: 'long', month: 'short', day: 'numeric' }, env.locale.value)} {p.timeZone && <span class="muted">· {zoneCity(p.timeZone)}</span>}</h4>
166
+ <div data-scope="calendar">
167
+ <div data-part="timeline" role="table" aria-labelledby={`${id}-grid`}>
168
+ <span aria-hidden="true"></span>
169
+ <div class="muted" aria-hidden="true" style={`display:grid;grid-template-columns:repeat(${ticks.value.length},1fr)`}>{ticks.value.map((m, n) => <span key={m}>{n % 2 ? '' : time(m)}</span>)}</div>
170
+ {people.value.map(a => (
171
+ <div key={a.id} role="row">
172
+ <div data-part="lane-label" role="rowheader"><Avatar name={a.name} size="sm" /><span>{a.name.split(' ')[0]}</span></div>
173
+ <div data-part="lane" role="cell" style={`--_slots:${ticks.value.length}`}>
174
+ <span class="sr">{(a.busy ?? []).length ? `Busy ${(a.busy as Range[]).map(r => span(r[0], r[1])).join(', ')}` : 'Free all day'}</span>
175
+ {(a.busy ?? []).map(r => <span key={r[0]} aria-hidden="true" style={`${BUSY_BAR};inset-inline-start:${left(r[0])}%;inline-size:${size(r[1] - r[0])}%`}></span>)}
176
+ {chosen.value !== null && <span aria-hidden="true" style={`${PICK_BAR};inset-inline-start:${left(chosen.value)}%;inline-size:${size(len.value)}%`}></span>}
177
+ </div>
178
+ </div>
179
+ ))}
180
+ <div role="row">
181
+ <div data-part="lane-label" role="rowheader">Everyone</div>
182
+ <div data-part="lane" role="cell" style={`--_slots:${ticks.value.length}`}>
183
+ <span class="sr">Everyone free: {shared.value.map(r => span(r[0], r[1])).join(', ') || 'no shared time'}</span>
184
+ {shared.value.map(r => <span key={r[0]} aria-hidden="true" style={`${FREE_BAR};inset-inline-start:${left(r[0])}%;inline-size:${size(r[1] - r[0])}%`}></span>)}
185
+ </div>
186
+ </div>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ </Card>
191
+ <Card sweep={false}>
192
+ {sent.value
193
+ ? <div data-arrive="" data-sweep="" class="row" role="status"><div><b>Invite sent · {summary.value}</b><p class="muted">{people.value.length} people · added to your calendar · we'll tell you when they answer.</p></div>{p.onUndo && <Button emphasis="ghost" tone="neutral" size="sm" onClick={() => { sent.value = false; p.onUndo?.() }}>Undo</Button>}</div>
194
+ : <div class="stack">
195
+ <div class="row"><h4 id={`${id}-slots`}>Suggested times</h4><span style="flex:1"></span><span class="muted">{slots.value.length ? `Everyone is free · within working hours for ${fit.value} of ${people.value.length}` : 'No time works for everyone'}</span></div>
196
+ {manual.value
197
+ ? <TimeField label="Start" value={typed} locale={env.locale.value} hourCycle="h23" />
198
+ : <div data-scope="booking" data-part="root">
199
+ <div ref={n => (group = n)} data-part="slots" role="radiogroup" aria-labelledby={`${id}-slots`} onKeyDown={onRadio}>
200
+ {slots.value.map(m => <button key={m} type="button" data-part="slot" data-sweep="" role="radio" aria-checked={m === chosen.value ? 'true' : 'false'} data-hold={m === chosen.value ? '' : undefined} tabindex={m === chosen.value ? '0' : '-1'} onClick={() => (pick.value = m)}><SweepParts />{time(m)}</button>)}
201
+ </div>
202
+ </div>}
203
+ <div class="row">
204
+ <span><b>{summary.value}</b> {elsewhere.value.length > 0 && <span class="muted">· {elsewhere.value.join(' · ')}</span>}</span>
205
+ <span style="flex:1"></span>
206
+ <Button emphasis="outline" tone="neutral" size="sm" icon={ClockIcon} onClick={() => { typed.value = chosen.value !== null ? asTime(chosen.value) : null; manual.value = !manual.value }}>{manual.value ? 'Suggested times' : 'Pick manually'}</Button>
207
+ <Button loading={busy.value} disabled={chosen.value === null} onClick={send}><SendIcon />Send invite</Button>
208
+ </div>
209
+ {failed.value && <p role="alert" class="muted">Couldn't send the invite: {failed.value}</p>}
210
+ </div>}
211
+ </Card>
212
+ </div>
213
+ )
214
+ }
@@ -0,0 +1,78 @@
1
+ import { LeftIcon, RightIcon } from '@weft-js/warp-icons'
2
+ import { clock, clockRange, dateOf, fmt, longDuration, moveSlots, slotOf, weekDates, type CalEvent, type Slot } from '@weft-js/warp-core'
3
+ import { derived, state, untrack, type State } from '@weft-js/core'
4
+ import { Alert } from '../Alert'
5
+ import { Button } from '../Button'
6
+ import { Chip } from '../Chip'
7
+ import { Drawer } from '../Drawer'
8
+ import { SweepParts, confirmSweep } from './bits'
9
+ import '@weft-js/warp-core/styles/app/booking.css'
10
+
11
+ /** Props for MoveSheet. */
12
+ export type MoveSheetProps = {
13
+ open: State<boolean>
14
+ event: CalEvent | null
15
+ /** Everything else on the calendar: a start that collides is disabled. */
16
+ events: CalEvent[]
17
+ /** The day the week strip starts on, 0 for Sunday to 6. Default 1, Monday. */
18
+ firstDayOfWeek?: number | undefined
19
+ locale?: string | undefined
20
+ /** `h12` for AM and PM, `h23` for a 24-hour clock. The locale's otherwise. */
21
+ hourCycle?: string | undefined
22
+ step?: number | undefined
23
+ onMove: (e: CalEvent, to: Slot) => void
24
+ onClose?: (() => void) | undefined
25
+ }
26
+
27
+ /** The move sheet (07 §2.2, `#move-sheet`): a bottom Sheet with day, start and duration, the touch path for a drag. */
28
+ export function MoveSheet(p: MoveSheetProps) {
29
+ const title = derived(() => (p.event ? `Move “${p.event.title}”` : 'Move event'))
30
+ const hold = state<(() => void) | null>(null)
31
+ const go = () => hold.value?.()
32
+ const cancel = () => { p.open.value = false; p.onClose?.() }
33
+ return (
34
+ <Drawer open={p.open} side="bottom" title={title.value} description={p.event ? `${longDuration(slotOf(p.event).e - slotOf(p.event).s)} · now ${fmt(dateOf(p.event.start), { weekday: 'short' }, p.locale ?? 'en-US')} ${clockRange(slotOf(p.event).s, slotOf(p.event).e, p.locale ?? 'en-US', p.hourCycle)}` : undefined} footer={<><Button emphasis="outline" tone="neutral" onClick={cancel}>Cancel</Button><Button onClick={go}>Move event</Button></>}>
35
+ {p.event && p.open.value && <Body key={p.event.id} event={p.event} props={p} bind={f => (hold.value = f)} />}
36
+ </Drawer>
37
+ )
38
+ }
39
+
40
+ function Body({ event, props: p, bind }: { event: CalEvent; props: MoveSheetProps; bind: (go: () => void) => void }) {
41
+ const e = untrack(() => event)
42
+ const origin = slotOf(e)
43
+ const day = state<string>(origin.date)
44
+ const start = state(origin.s)
45
+ const len = state(origin.e - origin.s)
46
+ const locale = derived(() => p.locale ?? 'en-US')
47
+ const week = derived(() => weekDates(origin.date, p.firstDayOfWeek ?? 1))
48
+ const slots = derived(() => moveSlots(p.events, e, day.value, len.value))
49
+ const stepBy = untrack(() => p.step ?? 15)
50
+ const go = () => {
51
+ const to = { date: day.value, s: start.value, e: start.value + len.value, resource: origin.resource }
52
+ p.open.value = false
53
+ p.onMove(e, to)
54
+ p.onClose?.()
55
+ }
56
+ untrack(() => bind(go))
57
+ const pick = (s: number, ev: MouseEvent) => { start.value = s; confirmSweep(ev.currentTarget as Element) }
58
+ return (
59
+ <div class="stack">
60
+ <div class="chips" role="group" aria-label="Day">
61
+ {week.value.map(d => <Chip key={d} pressed={d === day.value} onClick={() => (day.value = d)}>{fmt(d, { weekday: 'short', day: 'numeric' }, locale.value)}</Chip>)}
62
+ </div>
63
+ <div data-scope="booking" data-part="root">
64
+ <div data-part="slots" role="group" aria-label="Start time">
65
+ {slots.value.map(x => (
66
+ <button key={x.s} type="button" data-part="slot" data-sweep="" aria-pressed={x.s === start.value ? 'true' : 'false'} disabled={x.busy} onClick={(ev: MouseEvent) => pick(x.s, ev)}><SweepParts />{clock(x.s, locale.value, p.hourCycle)}</button>
67
+ ))}
68
+ </div>
69
+ </div>
70
+ <div class="row" role="group" aria-label="Duration">
71
+ <Button emphasis="outline" tone="neutral" size="sm" label="Shorter" icon={LeftIcon} disabled={len.value <= stepBy} onClick={() => (len.value = Math.max(stepBy, len.value - stepBy))} />
72
+ <output aria-live="polite">{longDuration(len.value)}</output>
73
+ <Button emphasis="outline" tone="neutral" size="sm" label="Longer" icon={RightIcon} onClick={() => (len.value += stepBy)} />
74
+ </div>
75
+ {e.recurrence && <Alert tone="info" title="This event repeats">Moving changes this one only. Use Edit for the series.</Alert>}
76
+ </div>
77
+ )
78
+ }
@@ -0,0 +1,163 @@
1
+ import { LeftIcon, RightIcon } from '@weft-js/warp-icons'
2
+ import { addMonths, announce, clock, eventsOn, fmt, minuteOf, monthStart, monthSummary, monthWeeks, motionOff, uid, weekStart, weekdays, ymd, yearKey, yearLevel, yearMonths, yearTotal, type CalEvent, type IsoDate } from '@weft-js/warp-core'
3
+ import { derived, flush, state, untrack } from '@weft-js/core'
4
+ import { Button } from '../Button'
5
+ import { usePickerEnv } from '../Picker'
6
+ import './bits'
7
+ import '@weft-js/warp-core/styles/app/year.css'
8
+
9
+ /** Props for YearView. */
10
+ export type YearViewProps = {
11
+ /** `{ [isoDay]: n }`: a year's counts only (07 §2.7). */
12
+ counts: Record<string, number>
13
+ /** Names the section when the year alone won't do: two on a page. */
14
+ label?: string | undefined
15
+ /** Events for the picked day's detail: from `events`, or fetched by the app when it is picked. */
16
+ events?: CalEvent[] | undefined
17
+ year?: number | undefined
18
+ /** Pins today's date, for server rendering and tests. The browser's clock otherwise. */
19
+ today?: string | undefined
20
+ locale?: string | undefined
21
+ /** `h12` for AM and PM, `h23` for a 24-hour clock. The locale's otherwise. */
22
+ hourCycle?: string | undefined
23
+ /** The day each month's rows start on, 0 for Sunday to 6. The locale's otherwise. */
24
+ firstDayOfWeek?: number | undefined
25
+ /** Inside a Calendar: return true when the day or month opened there, and the picker stays out of the way. */
26
+ openDay?: ((iso: IsoDate) => boolean | void) | undefined
27
+ openMonth?: ((iso: IsoDate) => boolean | void) | undefined
28
+ /** Open straight away; off, a pick shows the detail and offers the jump. */
29
+ direct?: boolean | undefined
30
+ /** Called with the new year when you page. */
31
+ onYearChange?: ((y: number) => void) | undefined
32
+ }
33
+
34
+ const events = (n: number) => (n ? `${n} event${n > 1 ? 's' : ''}` : 'no events')
35
+
36
+ /**
37
+ * The year (07 §2.7): twelve mini months with density dots, one roving stop across all twelve grids, a polite detail
38
+ * for the picked day or month. Columns follow the container. Enter opens the day, `M` its month, `T` today.
39
+ */
40
+ export function YearView(p: YearViewProps) {
41
+ const id = uid('year')
42
+ const env = usePickerEnv(() => p.locale, () => p.today)
43
+ const first = derived(() => p.firstDayOfWeek ?? weekStart(env.locale.value))
44
+ const heads = derived(() => weekdays(first.value, env.locale.value))
45
+ const focus = state<string>(untrack(() => (p.year && p.year !== ymd(env.today.value)[0] ? `${p.year}-01-01` : env.today.value)))
46
+ const year = derived(() => ymd(focus.value)[0])
47
+ const picked = state<{ iso: string; kind: 'day' | 'month' } | null>(null)
48
+ const swapping = state(false)
49
+ let months: HTMLDivElement | undefined
50
+ const say = (t: string) => months && announce(months.ownerDocument, t)
51
+ const long = (iso: string) => fmt(iso, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }, env.locale.value)
52
+ const list = derived(() => yearMonths(year.value).map(month => {
53
+ const { total } = monthSummary(p.counts, month)
54
+ return {
55
+ month, total, name: fmt(month, { month: 'long' }, env.locale.value), current: month === monthStart(env.today.value),
56
+ weeks: monthWeeks(month, first.value).map((days, r) => ({ key: `${month}-${r}`, cells: days.map((iso, c) => ({ key: iso ?? `${month}-${r}-${c}`, iso, count: iso ? p.counts[iso] ?? 0 : 0, weekend: (first.value + c) % 7 === 0 || (first.value + c) % 7 === 6 })) })),
57
+ }
58
+ }))
59
+ const toCell = () => { flush(); months?.querySelector<HTMLElement>('[data-day][tabindex="0"]')?.focus() }
60
+ const go = (iso: string, move = true) => {
61
+ const was = year.value
62
+ const swap = ymd(iso)[0] !== was
63
+ const apply = () => {
64
+ focus.value = iso
65
+ swapping.value = false
66
+ if (move) toCell()
67
+ if (swap) { say(`${ymd(iso)[0]}, ${yearTotal(p.counts, ymd(iso)[0]).toLocaleString(env.locale.value)} events`); p.onYearChange?.(ymd(iso)[0]) }
68
+ }
69
+ if (!swap || !months || motionOff(months)) return apply()
70
+ swapping.value = true
71
+ setTimeout(apply, 140)
72
+ }
73
+ const dayName = (iso: string) => `${long(iso)}, ${events(p.counts[iso] ?? 0)}`
74
+ const day = (iso: string) => {
75
+ if (p.direct !== false && p.openDay?.(iso as IsoDate)) return say(`Day view, ${long(iso)}`)
76
+ focus.value = iso
77
+ picked.value = { iso, kind: 'day' }
78
+ say(dayName(iso))
79
+ }
80
+ const month = (iso: string) => {
81
+ const m = monthStart(iso)
82
+ if (p.direct !== false && p.openMonth?.(m)) return say(`Month view, ${fmt(m, { month: 'long', year: 'numeric' }, env.locale.value)}`)
83
+ focus.value = m
84
+ picked.value = { iso: m, kind: 'month' }
85
+ toCell()
86
+ const s = monthSummary(p.counts, m)
87
+ say(`${fmt(m, { month: 'long', year: 'numeric' }, env.locale.value)}, ${s.total} events`)
88
+ }
89
+ const onKey = (e: KeyboardEvent) => {
90
+ const c = (e.target as Element).closest?.<HTMLElement>('[data-day]')
91
+ if (!c || e.metaKey || e.ctrlKey) return
92
+ const to = yearKey(e.key, e.shiftKey, c.dataset.day!, first.value, getComputedStyle(c).direction === 'rtl')
93
+ if (!to) return
94
+ e.preventDefault()
95
+ if (to === 'pick') day(c.dataset.day!)
96
+ else if (to === 'month') month(c.dataset.day!)
97
+ else if (to === 'today') go(env.today.value)
98
+ else go(to)
99
+ }
100
+ const onClick = (e: MouseEvent) => {
101
+ const t = e.target as Element
102
+ const title = t.closest?.<HTMLElement>('[data-part="month-title"]')
103
+ if (title) return month(title.dataset.month!)
104
+ const c = t.closest?.<HTMLElement>('[data-day]')
105
+ if (c) day(c.dataset.day!)
106
+ }
107
+ const detail = derived(() => {
108
+ const at = picked.value
109
+ if (!at || ymd(at.iso)[0] !== year.value) return null
110
+ if (at.kind === 'month') {
111
+ const s = monthSummary(p.counts, at.iso)
112
+ return { kind: 'month' as const, iso: at.iso, head: `${fmt(at.iso, { month: 'long', year: 'numeric' }, env.locale.value)} · ${s.total} events`, busiest: s.busiest ? `Busiest day: ${long(s.busiest)}, ${s.most} events.` : undefined }
113
+ }
114
+ const n = p.counts[at.iso] ?? 0
115
+ const rows = p.events ? eventsOn(p.events, at.iso) : []
116
+ return { kind: 'day' as const, iso: at.iso, head: `${long(at.iso)} · ${n ? events(n) : 'nothing booked'}`, rows: rows.slice(0, 4), more: Math.max(0, rows.length - 4) }
117
+ })
118
+ return (
119
+ <section data-scope="year" aria-label={p.label} aria-labelledby={p.label ? undefined : `${id}-t`}>
120
+ <div data-part="toolbar">
121
+ <Button emphasis="ghost" tone="neutral" size="sm" icon={LeftIcon} label="Previous year" onClick={() => go(addMonths(focus.value, -12), false)} />
122
+ <h3 data-part="title" id={`${id}-t`} aria-live="polite">{year.value}</h3>
123
+ <Button emphasis="ghost" tone="neutral" size="sm" icon={RightIcon} label="Next year" onClick={() => go(addMonths(focus.value, 12), false)} />
124
+ <Button emphasis="outline" tone="neutral" size="sm" onClick={() => go(env.today.value)}>Today</Button>
125
+ <span data-part="legend" aria-hidden="true"><span><i data-part="dots" data-zero=""></i>none</span><span><i data-part="dots" style="--_n:1"></i>1–2</span><span><i data-part="dots" style="--_n:2"></i>3–4</span><span><i data-part="dots" style="--_n:3"></i>5+</span></span>
126
+ </div>
127
+ <div ref={n => (months = n)} data-part="months" role="group" aria-labelledby={`${id}-t`} aria-describedby={`${id}-h`} data-swapping={swapping.value ? '' : undefined} onKeyDown={onKey} onClick={onClick}>
128
+ {list.value.map(m => (
129
+ <div key={m.month} data-part="year-month" data-current={m.current ? '' : undefined}>
130
+ <button type="button" data-part="month-title" id={`${id}-${m.month}`} data-month={m.month} tabindex="-1">{m.name} <small>{m.total}<span class="sr"> events</span></small></button>
131
+ <div role="grid" aria-labelledby={`${id}-${m.month}`}>
132
+ <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>
133
+ {m.weeks.map(w => (
134
+ <div key={w.key} role="row">
135
+ {w.cells.map(c => (c.iso
136
+ ? <span key={c.key} role="gridcell" data-day={c.iso} tabindex={c.iso === focus.value ? '0' : '-1'} aria-selected={picked.value?.iso === c.iso && picked.value.kind === 'day' ? 'true' : 'false'} aria-current={c.iso === env.today.value ? 'date' : undefined} data-weekend={c.weekend ? '' : undefined} aria-label={`${dayName(c.iso)}${c.iso === env.today.value ? ', today' : ''}`}><span data-part="num" aria-hidden="true">{ymd(c.iso)[2]}</span><i data-part="dots" style={`--_n:${yearLevel(c.count)}`} aria-hidden="true"></i></span>
137
+ : <span key={c.key} role="gridcell" data-outside="" aria-hidden="true"></span>))}
138
+ </div>
139
+ ))}
140
+ </div>
141
+ </div>
142
+ ))}
143
+ </div>
144
+ <span class="sr" id={`${id}-h`}>One stop for the whole year. Arrow keys move by day and week, Home and End to the week's ends, Page Up and Page Down by month, with Shift by year. Enter opens the day, M opens its month, T goes to today.</span>
145
+ <div data-part="detail" role="status" aria-live="polite">
146
+ {detail.value
147
+ ? <>
148
+ <div class="row"><b>{detail.value.head}</b>
149
+ {detail.value.kind === 'day' && p.openDay && <Button emphasis="outline" tone="neutral" size="sm" onClick={() => p.openDay!(picked.value!.iso as IsoDate)}>Open in Day view</Button>}
150
+ {p.openMonth && <Button emphasis="ghost" tone="neutral" size="sm" onClick={() => p.openMonth!(monthStart(picked.value!.iso))}>Open in Month view</Button>}
151
+ </div>
152
+ {detail.value.kind === 'month'
153
+ ? detail.value.busiest && <span class="muted">{detail.value.busiest}</span>
154
+ : detail.value.rows.length > 0 && <ul>
155
+ {detail.value.rows.map(e => <li key={e.id}><i data-cal={e.cal ?? 1}></i><span>{e.allDay ? 'All day' : clock(minuteOf(e.start), env.locale.value, p.hourCycle)}</span>{e.title}</li>)}
156
+ {detail.value.more > 0 && <li>+{detail.value.more} more</li>}
157
+ </ul>}
158
+ </>
159
+ : <span class="muted">Pick a day to see what's on it. More dots, busier day: one for 1–2 events, two for 3–4, three for 5 or more.</span>}
160
+ </div>
161
+ </section>
162
+ )
163
+ }