@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.
- 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,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
|
+
}
|