@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
package/src/parts.tsx
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { CheckIcon } from '@weft-js/warp-icons'
|
|
2
|
+
// Small 05 and 02 atoms the app patterns and flows share, each the specimen markup. Internal: not exported from the
|
|
3
|
+
// index, so a pack that needs one pays only for its own CSS.
|
|
4
|
+
import { derived } from '@weft-js/core'
|
|
5
|
+
import type { Tone } from './Button'
|
|
6
|
+
import { Icon, toneIcon } from './Icon'
|
|
7
|
+
import './styles'
|
|
8
|
+
import '@weft-js/warp-core/styles/app/tokens.css'
|
|
9
|
+
import '@weft-js/warp-core/styles/app/avatar.css'
|
|
10
|
+
import '@weft-js/warp-core/styles/components/badge.css' // the status dot
|
|
11
|
+
import '@weft-js/warp-core/styles/components/empty-state.css'
|
|
12
|
+
import '@weft-js/warp-core/styles/components/live-status.css'
|
|
13
|
+
import '@weft-js/warp-core/styles/components/spinner.css'
|
|
14
|
+
import '@weft-js/warp-core/styles/components/steps.css'
|
|
15
|
+
import '@weft-js/warp-core/styles/layout/tokens.css'
|
|
16
|
+
import '@weft-js/warp-core/styles/layout/banner.css'
|
|
17
|
+
|
|
18
|
+
/** One path on the 24 grid, for a glyph the shared Icon set doesn't carry. */
|
|
19
|
+
export const Glyph = ({ d, ...rest }: { d: string; [attr: string]: unknown }) => <svg data-icon viewBox="0 0 24 24" aria-hidden="true" focusable="false" {...rest}><path d={d} /></svg>
|
|
20
|
+
|
|
21
|
+
export const initials = (name: string) => name.split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase()
|
|
22
|
+
|
|
23
|
+
/** Avatar with initials (07 §0); `name` is real text for the row around it, so the circle itself is aria-hidden. */
|
|
24
|
+
export function Avatar({ name, size, status, presence, ...rest }: { name: string; size?: 'sm' | 'md' | 'lg' | 'xl' | undefined; status?: Tone | undefined; presence?: number | undefined; [attr: string]: unknown }) {
|
|
25
|
+
return (
|
|
26
|
+
<span data-scope="avatar" data-size={size === 'md' ? undefined : size} data-presence={presence} aria-hidden="true" {...rest}>
|
|
27
|
+
{initials(name)}
|
|
28
|
+
{status && <span data-scope="status-dot" data-tone={status}></span>}
|
|
29
|
+
</span>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Where a live connection stands. */
|
|
34
|
+
export type LiveState = 'connecting' | 'live' | 'reconnecting' | 'offline'
|
|
35
|
+
const LIVE: Record<LiveState, string> = { connecting: 'Connecting', live: 'Live', reconnecting: 'Reconnecting', offline: 'Offline' }
|
|
36
|
+
const DOT: Partial<Record<LiveState, Tone>> = { live: 'positive', offline: 'critical' }
|
|
37
|
+
|
|
38
|
+
/** The one realtime pill (07 §4.4): quiet while live, loud only in trouble. */
|
|
39
|
+
export function LiveStatus({ state, label, children }: { state: LiveState; label?: string | undefined; children?: unknown }) {
|
|
40
|
+
const dot = derived(() => DOT[state])
|
|
41
|
+
return (
|
|
42
|
+
<span data-scope="live-status" data-state={state} role="status" aria-label={label ? `${label}: ${LIVE[state].toLowerCase()}` : undefined}>
|
|
43
|
+
{dot.value ? <span data-scope="status-dot" data-tone={dot.value}></span> : <span data-scope="spinner" aria-hidden="true"></span>}
|
|
44
|
+
{children ?? LIVE[state]}
|
|
45
|
+
</span>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Page-level message inside main (02 §5). `live` announces it; a Banner that was there on load stays role=note. */
|
|
50
|
+
export function Banner({ tone = 'info', live, actions, children, ...rest }: { tone?: Tone | undefined; live?: 'polite' | 'assertive' | undefined; actions?: unknown; children?: unknown; [attr: string]: unknown }) {
|
|
51
|
+
const icon = derived(() => toneIcon[tone])
|
|
52
|
+
const acts = derived(() => actions) // one build, however often it is read
|
|
53
|
+
return (
|
|
54
|
+
<div data-scope="banner" data-tone={tone === 'info' ? undefined : tone} role={live === 'assertive' ? 'alert' : live ? 'status' : 'note'} {...rest}>
|
|
55
|
+
<span data-part="icon"><Icon name={icon.value} /></span>
|
|
56
|
+
<div data-part="body">{children}</div>
|
|
57
|
+
{acts.value !== undefined && <div data-part="actions">{acts.value}</div>}
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type Step = { id: string; title: unknown; description?: unknown; error?: boolean }
|
|
63
|
+
|
|
64
|
+
/** Steps (05 §4): full labels, the compact count from the CSS. `current` is an index. */
|
|
65
|
+
export function Steps({ label, steps, current }: { label: string; steps: Step[]; current: number }) {
|
|
66
|
+
return (
|
|
67
|
+
<div data-scope="steps">
|
|
68
|
+
<ol aria-label={label}>
|
|
69
|
+
{steps.map((s, i) => (
|
|
70
|
+
<li key={s.id} data-part="item" data-state={s.error ? 'error' : i < current ? 'complete' : i === current ? 'current' : undefined} aria-current={i === current ? 'step' : undefined}>
|
|
71
|
+
<span data-part="indicator"><CheckIcon /></span>
|
|
72
|
+
<span data-part="title">{s.title}</span>
|
|
73
|
+
{s.description !== undefined && <span data-part="description">{s.description}</span>}
|
|
74
|
+
</li>
|
|
75
|
+
))}
|
|
76
|
+
</ol>
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Empty or error state (05 §5): the weave art, a title, one sentence, actions. */
|
|
82
|
+
export function EmptyState({ title, tone, actions, children, ...rest }: { title: unknown; tone?: Tone | undefined; actions?: unknown; children?: unknown; [attr: string]: unknown }) {
|
|
83
|
+
const acts = derived(() => actions)
|
|
84
|
+
const body = derived(() => children)
|
|
85
|
+
return (
|
|
86
|
+
<div data-scope="empty-state" data-tone={tone} {...rest}>
|
|
87
|
+
<svg data-part="art" viewBox="0 0 96 64" aria-hidden="true"><g class="w"><path d="M12 4v56M28 4v56M44 4v56M60 4v56M76 4v56" /></g><path class="f" d="M4 24H92" /><path class="f" d="M4 40H92" style="stroke:var(--warp-color-border-strong)" /></svg>
|
|
88
|
+
<h2 data-part="title">{title}</h2>
|
|
89
|
+
{body.value !== undefined && <p data-part="description">{body.value}</p>}
|
|
90
|
+
{acts.value !== undefined && <div data-part="actions">{acts.value}</div>}
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { PlusIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { addDays, agendaItems, announce, at, clock, dayDots, eventsOn, fmt, longDuration, longPress, slide, swipePages, uid, weekDates, weekStart, ymd, type AgendaItem, type CalEvent, type IsoDate, type Slot } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, flush, state, untrack, type State } from '@weft-js/core'
|
|
4
|
+
import { Button } from '../Button'
|
|
5
|
+
import { bindable, usePickerEnv } from '../Picker'
|
|
6
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
7
|
+
import { RepeatGlyph, tint, useNow } from './bits'
|
|
8
|
+
import { EventEditor, type CalendarSource } from './EventEditor'
|
|
9
|
+
import { MoveSheet } from './MoveSheet'
|
|
10
|
+
import '@weft-js/warp-core/styles/app/agenda.css'
|
|
11
|
+
import '@weft-js/warp-core/styles/components/fab.css'
|
|
12
|
+
|
|
13
|
+
const summary = (e: CalEvent) => [e.location, e.video ? 'Video call' : '', e.attendees?.length ? `${e.attendees.length} people` : ''].filter(Boolean).join(' · ')
|
|
14
|
+
|
|
15
|
+
function Row({ item, hourCycle, locale }: { item: AgendaItem; hourCycle?: string | undefined; locale: string }) {
|
|
16
|
+
const time = (m: number) => clock(m, locale, hourCycle)
|
|
17
|
+
return <>{item.kind === 'now'
|
|
18
|
+
? <div data-part="now" role="listitem" aria-label={`Now, ${time(item.at)}`} aria-current="time"><span aria-hidden="true">{time(item.at)}</span><i></i></div>
|
|
19
|
+
: item.kind === 'free'
|
|
20
|
+
? <div data-part="free" role="listitem"><span></span><span>{longDuration(item.minutes)} free</span></div>
|
|
21
|
+
: item.kind === 'allday'
|
|
22
|
+
? <div data-part="row" data-allday="" data-cal={item.event.cal ?? 1} style={tint(item.event.cal)} role="listitem" data-id={item.event.id} tabindex="0"><time>All day</time><i></i><div><b>{item.event.title}</b></div></div>
|
|
23
|
+
: <div data-part="row" data-cal={item.event.cal ?? 1} style={tint(item.event.cal)} data-id={item.event.id} role="listitem" tabindex="0" aria-label={`${item.event.title}, ${time(item.s)} to ${time(item.e)}`}>
|
|
24
|
+
<time>{time(item.s)}<small>{time(item.e)}</small></time>
|
|
25
|
+
<i></i>
|
|
26
|
+
<div><b>{item.event.recurrence && <RepeatGlyph />}{item.event.recurrence ? ' ' : ''}{item.event.title}</b><span>{summary(item.event) || longDuration(item.e - item.s)}</span></div>
|
|
27
|
+
</div>}</>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One day's list of rows; the Agenda pages and the Calendar's Agenda view both draw it. */
|
|
31
|
+
export function AgendaDay({ events, day, now, locale, hourCycle, label }: { events: CalEvent[]; day: string; now?: string | null | undefined; locale: string; hourCycle?: string | undefined; label: string }) {
|
|
32
|
+
const items = derived(() => agendaItems(events, day, now))
|
|
33
|
+
return <>{items.value.length
|
|
34
|
+
? <div role="list" aria-label={label}>{items.value.map(i => <Row key={i.key} item={i} locale={locale} hourCycle={hourCycle} />)}</div>
|
|
35
|
+
: <div data-scope="empty-state"><p data-part="description">Nothing scheduled. Enjoy it.</p></div>}</>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Props for Agenda. */
|
|
39
|
+
export type AgendaProps = {
|
|
40
|
+
events: CalEvent[]
|
|
41
|
+
/** The day it opens on: a state it writes as you page, or a plain value. */
|
|
42
|
+
date?: State<string> | string | undefined
|
|
43
|
+
defaultDate?: string | undefined
|
|
44
|
+
/** Called with the new day when you page or jump to today. */
|
|
45
|
+
onDateChange?: ((d: string) => void) | undefined
|
|
46
|
+
title?: unknown
|
|
47
|
+
/** The calendars the events belong to, for their colours and names. */
|
|
48
|
+
calendars?: CalendarSource[] | undefined
|
|
49
|
+
/** The IANA zone times are shown in, such as `Europe/Berlin`. The browser's otherwise. */
|
|
50
|
+
timeZone?: string | undefined
|
|
51
|
+
/** Pins today's date, for server rendering and tests. The browser's clock otherwise. */
|
|
52
|
+
today?: string | undefined
|
|
53
|
+
/** Pins the current time, for server rendering and tests. The browser's clock otherwise. */
|
|
54
|
+
now?: string | undefined
|
|
55
|
+
locale?: string | undefined
|
|
56
|
+
/** `h12` for AM and PM, `h23` for a 24-hour clock. The locale's otherwise. */
|
|
57
|
+
hourCycle?: string | undefined
|
|
58
|
+
firstDayOfWeek?: number | undefined
|
|
59
|
+
/** Long press: open your own sheet. Without it the built-in MoveSheet opens and calls `onMove`. */
|
|
60
|
+
onLongPress?: ((e: CalEvent) => void) | undefined
|
|
61
|
+
onMove?: (e: CalEvent, to: { start: string; end: string; resource?: string | undefined }) => void
|
|
62
|
+
onOpen?: ((e: CalEvent) => void) | undefined
|
|
63
|
+
/** The FAB opens the editor; Save calls this. */
|
|
64
|
+
onCreate?: ((e: CalEvent) => void) | undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The phone agenda (07 §2.3, `#cal-phone`): a week strip of APG tabs with dots per source, three day pages that swipe, a
|
|
69
|
+
* now marker, "1 h free" gaps and the FAB. A long press (or Shift+F10) opens the MoveSheet.
|
|
70
|
+
*/
|
|
71
|
+
export function Agenda(p: AgendaProps) {
|
|
72
|
+
const id = uid('agenda')
|
|
73
|
+
const env = usePickerEnv(() => p.locale, () => p.today)
|
|
74
|
+
const date = bindable<string>(() => p.date, () => p.defaultDate, untrack(() => env.today.value), () => p.onDateChange)
|
|
75
|
+
const now = useNow(() => p.now, () => p.timeZone)
|
|
76
|
+
const first = derived(() => p.firstDayOfWeek ?? weekStart(env.locale.value))
|
|
77
|
+
const week = derived(() => weekDates(date.value, first.value))
|
|
78
|
+
const pages = derived(() => [-1, 0, 1].map(d => ({ day: addDays(date.value, d), at: d })))
|
|
79
|
+
const month = derived(() => fmt(date.value, { month: 'long' }, env.locale.value))
|
|
80
|
+
const moving = state<CalEvent | null>(null)
|
|
81
|
+
const sheet = state(false)
|
|
82
|
+
const lifted = state<string | null>(null)
|
|
83
|
+
const draft = state<CalEvent | null>(null)
|
|
84
|
+
const editing = state(false)
|
|
85
|
+
let root: HTMLDivElement | undefined
|
|
86
|
+
let view: HTMLDivElement | undefined
|
|
87
|
+
let track: HTMLDivElement | undefined
|
|
88
|
+
const say = (t: string) => root && announce(root.ownerDocument, t)
|
|
89
|
+
const settle = (d: string, focus?: boolean ) => {
|
|
90
|
+
date.set(d)
|
|
91
|
+
if (track) { track.style.removeProperty('--_dx') }
|
|
92
|
+
say(`${fmt(d, { weekday: 'long' }, env.locale.value)}, ${eventsOn(p.events, d).length} events`)
|
|
93
|
+
if (focus) { flush(); root?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')?.focus() }
|
|
94
|
+
}
|
|
95
|
+
const go = (d: string, focus?: boolean ) => {
|
|
96
|
+
const off = untrack(() => date.value)
|
|
97
|
+
if (d === off) { track?.style.setProperty('--_dx', '0px'); return }
|
|
98
|
+
const near = addDays(off, 1) === d || addDays(off, -1) === d
|
|
99
|
+
if (!track || !near) return settle(d, focus)
|
|
100
|
+
slide(track, `${d > off ? -100 : 100}%`, () => settle(d, focus))
|
|
101
|
+
}
|
|
102
|
+
const byId = (i: string | undefined) => p.events.find(e => e.id === i)
|
|
103
|
+
const open = (e: CalEvent) => {
|
|
104
|
+
if (p.onLongPress) return p.onLongPress(e)
|
|
105
|
+
if (!p.onMove) return
|
|
106
|
+
moving.value = e
|
|
107
|
+
lifted.value = e.id
|
|
108
|
+
sheet.value = true
|
|
109
|
+
}
|
|
110
|
+
use(swipePages, () => view, () => ({ track: () => track, onStep: (d: 1 | -1) => go(addDays(date.value, d)) }))
|
|
111
|
+
use(longPress, () => view, () => ({ selector: '[data-part="row"][data-id]', onPress: (r: HTMLElement) => { const e = byId(r.dataset.id); if (e) open(e) } }))
|
|
112
|
+
const onKey = (e: KeyboardEvent) => {
|
|
113
|
+
const r = (e.target as Element).closest?.<HTMLElement>('[data-part="row"][data-id]')
|
|
114
|
+
if (!r) return
|
|
115
|
+
const hit = byId(r.dataset.id)
|
|
116
|
+
if (!hit) return
|
|
117
|
+
if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); open(hit) }
|
|
118
|
+
else if (e.key === 'Enter') { e.preventDefault(); p.onOpen ? p.onOpen(hit) : open(hit) }
|
|
119
|
+
}
|
|
120
|
+
const onClick = (e: MouseEvent) => { const hit = byId((e.target as Element).closest?.<HTMLElement>('[data-part="row"][data-id]')?.dataset.id); if (hit) p.onOpen?.(hit) }
|
|
121
|
+
const onTabs = (e: KeyboardEvent) => {
|
|
122
|
+
const to = ({ ArrowRight: 1, ArrowLeft: -1 } as Record<string, number>)[e.key]
|
|
123
|
+
const days = week.value
|
|
124
|
+
const i = days.indexOf(date.value as IsoDate)
|
|
125
|
+
if (e.key === 'Home' || e.key === 'End') { e.preventDefault(); return settle(days[e.key === 'Home' ? 0 : 6]!, true) }
|
|
126
|
+
if (!to) return
|
|
127
|
+
e.preventDefault()
|
|
128
|
+
const rtl = getComputedStyle(e.currentTarget as Element).direction === 'rtl'
|
|
129
|
+
settle(days[(i + (rtl ? -to : to) + 7) % 7]!, true)
|
|
130
|
+
}
|
|
131
|
+
const create = () => {
|
|
132
|
+
const d = date.value
|
|
133
|
+
draft.value = { id: uid('new'), title: '', start: `${d}T13:00`, end: `${d}T14:00`, cal: 1 }
|
|
134
|
+
editing.value = true
|
|
135
|
+
}
|
|
136
|
+
const moved = (e: CalEvent, to: Slot) => {
|
|
137
|
+
p.onMove?.(e, { start: at(to.date, to.s), end: at(to.date, to.e), resource: to.resource })
|
|
138
|
+
go(to.date)
|
|
139
|
+
say(`Moved ${e.title} to ${fmt(to.date, { weekday: 'long' }, env.locale.value)} ${clock(to.s, env.locale.value, p.hourCycle)}`)
|
|
140
|
+
}
|
|
141
|
+
return (
|
|
142
|
+
<div ref={n => (root = n)} data-scope="agenda" data-part="root">
|
|
143
|
+
<div data-part="app-bar">
|
|
144
|
+
<h3 data-part="app-title">{p.title ?? month.value}</h3>
|
|
145
|
+
<span data-part="spacer"></span>
|
|
146
|
+
<Button emphasis="outline" tone="neutral" size="sm" onClick={() => go(env.today.value)}>Today</Button>
|
|
147
|
+
</div>
|
|
148
|
+
<div data-part="strip" role="tablist" aria-label={`Week of ${fmt(week.value[0]!, { month: 'short', day: 'numeric' }, env.locale.value)}`} onKeyDown={onTabs}>
|
|
149
|
+
{week.value.map(d => (
|
|
150
|
+
<button key={d} type="button" role="tab" id={`${id}-${d}`} aria-controls={`${id}-panel`} aria-selected={d === date.value ? 'true' : 'false'} tabindex={d === date.value ? '0' : '-1'} data-today={d === env.today.value ? '' : undefined} onClick={() => go(d)}>
|
|
151
|
+
<strong class="sr">{fmt(d, { weekday: 'long', month: 'short', day: 'numeric' }, env.locale.value)}</strong>
|
|
152
|
+
<span aria-hidden="true">{fmt(d, { weekday: 'narrow' }, env.locale.value)}</span>
|
|
153
|
+
<b aria-hidden="true">{ymd(d)[2]}</b>
|
|
154
|
+
<i>{dayDots(p.events, d).map(c => <em key={c} data-cal={c}></em>)}</i>
|
|
155
|
+
</button>
|
|
156
|
+
))}
|
|
157
|
+
</div>
|
|
158
|
+
<div ref={n => (view = n)} data-part="viewport" id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-${date.value}`} onKeyDown={onKey} onClick={onClick}>
|
|
159
|
+
<div ref={n => (track = n)} data-part="track">
|
|
160
|
+
{pages.value.map(g => (
|
|
161
|
+
<div key={g.day} data-part="page" aria-hidden={g.at ? 'true' : undefined} inert={g.at ? true : undefined}>
|
|
162
|
+
<div data-part="day-title"><h4>{fmt(g.day, { weekday: 'long' }, env.locale.value)}</h4><span class="muted">{fmt(g.day, { month: 'short', day: 'numeric' }, env.locale.value)}</span></div>
|
|
163
|
+
<AgendaDay events={p.events} day={g.day} now={now.value} locale={env.locale.value} hourCycle={p.hourCycle} label={fmt(g.day, { weekday: 'long' }, env.locale.value)} />
|
|
164
|
+
</div>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
{p.onCreate && <button data-scope="fab" type="button" aria-label="New event" onClick={create}><PlusIcon /></button>}
|
|
169
|
+
<MoveSheet open={sheet} event={moving.value} events={p.events} firstDayOfWeek={first.value} locale={env.locale.value} hourCycle={p.hourCycle} onMove={moved} onClose={() => (lifted.value = null)} />
|
|
170
|
+
<EventEditor open={editing} event={draft.value} isNew calendars={p.calendars} timeZone={p.timeZone} locale={env.locale.value} today={env.today.value} onSave={e => p.onCreate?.(e)} />
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { AlertIcon, GlobeIcon, PlusIcon, XIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { announce, fmt, hhmm, nextRange, rangeIssues, timeOptions, uid, weekValid, type IsoDate, type Override, type Range, type WeekDay } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, flush, state, untrack } from '@weft-js/core'
|
|
4
|
+
import { Button } from '../Button'
|
|
5
|
+
import { Switch } from '../Choice'
|
|
6
|
+
import { DateField } from '../DateField'
|
|
7
|
+
import { Input } from '../Field'
|
|
8
|
+
import { usePickerEnv } from '../Picker'
|
|
9
|
+
import { Select } from '../Select'
|
|
10
|
+
import { confirmSweep } from './bits'
|
|
11
|
+
import '@weft-js/warp-core/styles/app/availability.css'
|
|
12
|
+
|
|
13
|
+
/** A person's bookable hours: the usual week and its exceptions. */
|
|
14
|
+
export type Availability = { week: WeekDay[]; overrides: Override[] }
|
|
15
|
+
/** Props for AvailabilityEditor. */
|
|
16
|
+
export type AvailabilityEditorProps = {
|
|
17
|
+
defaultValue: Availability
|
|
18
|
+
/** "New York (EDT)". */
|
|
19
|
+
zoneLabel?: string | undefined
|
|
20
|
+
/** Pins today's date, for server rendering and tests. The browser's clock otherwise. */
|
|
21
|
+
today?: string | undefined
|
|
22
|
+
locale?: string | undefined
|
|
23
|
+
/** Times on offer, minutes. */
|
|
24
|
+
from?: number | undefined
|
|
25
|
+
to?: number | undefined
|
|
26
|
+
step?: number | undefined
|
|
27
|
+
/** Rejecting says so and keeps the changes. */
|
|
28
|
+
onSave: (v: Availability) => void | Promise<unknown>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const optionsOf = (from?: number , to?: number , step?: number) => timeOptions(from, to, step).map(m => ({ value: String(m), label: hhmm(m) }))
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* AvailabilityEditor (07 §6.4): a Switch and From/To ranges per weekday, "Copy Monday to weekdays", date overrides. Ranges
|
|
35
|
+
* validate on change; Save stays blocked with focus on the first invalid time. Saves nothing until Save.
|
|
36
|
+
*/
|
|
37
|
+
export function AvailabilityEditor(p: AvailabilityEditorProps) {
|
|
38
|
+
const id = uid('avail')
|
|
39
|
+
const env = usePickerEnv(() => p.locale, () => p.today)
|
|
40
|
+
const seed = untrack(() => p.defaultValue)
|
|
41
|
+
const week = state<WeekDay[]>(seed.week.map(d => ({ ...d, ranges: d.ranges.map(r => [...r] as Range) })))
|
|
42
|
+
const overrides = state<Override[]>(seed.overrides.map(o => ({ ...o })))
|
|
43
|
+
const status = state('No unsaved changes')
|
|
44
|
+
const busy = state(false)
|
|
45
|
+
const adding = state(false)
|
|
46
|
+
const newDate = state<IsoDate | null>(null)
|
|
47
|
+
const newNote = state('')
|
|
48
|
+
const off = state(true)
|
|
49
|
+
const newFrom = state('540')
|
|
50
|
+
const newTo = state('780')
|
|
51
|
+
const options = derived(() => optionsOf(p.from, p.to, p.step))
|
|
52
|
+
const dayName = (d: number) => fmt(`2024-01-${String(7 + d).padStart(2, '0')}`, { weekday: 'long' }, env.locale.value)
|
|
53
|
+
let root: HTMLDivElement | undefined
|
|
54
|
+
let saveBtn: HTMLElement | undefined
|
|
55
|
+
const say = (t: string, loud = false) => root && announce(root.ownerDocument, t, loud)
|
|
56
|
+
const dirty = () => (status.value = 'Unsaved changes')
|
|
57
|
+
const patch = (day: number, f: (d: WeekDay) => WeekDay) => { week.value = week.value.map(d => (d.day === day ? f(d) : d)); dirty() }
|
|
58
|
+
const setTime = (day: number, at: number, side: 0 | 1, v: number) => {
|
|
59
|
+
patch(day, d => ({ ...d, ranges: d.ranges.map((r, i) => (i === at ? (side ? [r[0], v] : [v, r[1]]) : r)) }))
|
|
60
|
+
const err = rangeIssues(week.value.find(d => d.day === day)!.ranges)[at]
|
|
61
|
+
if (err) say(`${dayName(day)}: ${err}`, true)
|
|
62
|
+
}
|
|
63
|
+
const focus = (sel: string) => { flush(); root?.querySelector<HTMLElement>(sel)?.focus() }
|
|
64
|
+
const addRange = (day: number) => { patch(day, d => ({ ...d, ranges: [...d.ranges, nextRange(d.ranges, p.to)] })); say(`Range added on ${dayName(day)}`); focus(`[data-day-of="${day}"] [data-part="range"]:last-child button[data-part="input"]`) }
|
|
65
|
+
const removeRange = (day: number, at: number) => { patch(day, d => ({ ...d, ranges: d.ranges.filter((_, i) => i !== at) })); say(`Range removed from ${dayName(day)}`); focus(`[data-add="${day}"]`) }
|
|
66
|
+
const copyMonday = () => {
|
|
67
|
+
const mon = week.value.find(d => d.day === 1)
|
|
68
|
+
if (!mon) return
|
|
69
|
+
week.value = week.value.map(d => (d.day >= 2 && d.day <= 5 ? { ...d, on: mon.on, ranges: mon.ranges.map(r => [...r] as Range) } : d))
|
|
70
|
+
dirty()
|
|
71
|
+
say(`${dayName(1)}'s hours copied to ${dayName(2)} through ${dayName(5)}`)
|
|
72
|
+
}
|
|
73
|
+
const addOverride = () => {
|
|
74
|
+
if (!newDate.value) return
|
|
75
|
+
const o: Override = { id: uid('ov'), date: newDate.value, ranges: off.value ? null : [[+newFrom.value, +newTo.value]], note: newNote.value || undefined }
|
|
76
|
+
overrides.value = [...overrides.value, o]
|
|
77
|
+
adding.value = false
|
|
78
|
+
newDate.value = null
|
|
79
|
+
newNote.value = ''
|
|
80
|
+
dirty()
|
|
81
|
+
say(`Override added for ${fmt(o.date, { dateStyle: 'long' }, env.locale.value)}`)
|
|
82
|
+
focus(`[data-rov="${o.id}"]`)
|
|
83
|
+
}
|
|
84
|
+
const removeOverride = (o: Override) => { overrides.value = overrides.value.filter(x => x.id !== o.id); dirty(); say(`Override for ${fmt(o.date, { dateStyle: 'long' }, env.locale.value)} removed`); focus('[data-add-ov]') }
|
|
85
|
+
const save = async () => {
|
|
86
|
+
if (!weekValid(week.value)) {
|
|
87
|
+
say('Fix the highlighted ranges before saving.', true)
|
|
88
|
+
root?.querySelector<HTMLElement>('[data-part="range"][data-invalid] button[data-part="input"]')?.focus()
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
busy.value = true
|
|
92
|
+
try {
|
|
93
|
+
await p.onSave({ week: week.value, overrides: overrides.value })
|
|
94
|
+
status.value = 'Saved · booking pages updated'
|
|
95
|
+
confirmSweep(saveBtn)
|
|
96
|
+
say('Availability saved')
|
|
97
|
+
} catch (err) {
|
|
98
|
+
status.value = `Couldn't save: ${(err as Error | undefined)?.message ?? 'try again'}`
|
|
99
|
+
say(status.value, true)
|
|
100
|
+
} finally { busy.value = false }
|
|
101
|
+
}
|
|
102
|
+
const hours = (o: Override) => (o.ranges ? o.ranges.map(r => `${hhmm(r[0])}–${hhmm(r[1])}`).join(', ') : 'Unavailable')
|
|
103
|
+
return (
|
|
104
|
+
<div ref={n => (root = n)} data-scope="availability">
|
|
105
|
+
<div class="row">
|
|
106
|
+
{p.zoneLabel && <span class="muted"><GlobeIcon /> Times in {p.zoneLabel}</span>}
|
|
107
|
+
<span style="flex:1"></span>
|
|
108
|
+
<Button emphasis="ghost" tone="neutral" size="sm" onClick={copyMonday}>Copy Monday to weekdays</Button>
|
|
109
|
+
</div>
|
|
110
|
+
<ul data-part="week" aria-label="Weekly hours">
|
|
111
|
+
{week.value.map(d => <Day key={d.day} day={d} id={id} name={dayName(d.day)} options={options.value} onToggle={on => { patch(d.day, x => ({ ...x, on })); say(`${dayName(d.day)} ${on ? 'available' : 'unavailable'}`) }} onTime={setTime} onAdd={addRange} onRemove={removeRange} />)}
|
|
112
|
+
</ul>
|
|
113
|
+
<div class="stack">
|
|
114
|
+
<span data-part="eyebrow">Date overrides</span>
|
|
115
|
+
<ul data-part="overrides" aria-label="Date overrides">
|
|
116
|
+
{overrides.value.map(o => (
|
|
117
|
+
<li key={o.id}>
|
|
118
|
+
<span><b>{fmt(o.date, { weekday: 'short', month: 'short', day: 'numeric' }, env.locale.value)}</b> · {hours(o)}{o.note && <><br /><small>{o.note}</small></>}</span>
|
|
119
|
+
<Button emphasis="ghost" tone="neutral" size="sm" icon={XIcon} label={`Remove override for ${fmt(o.date, { month: 'short', day: 'numeric' }, env.locale.value)}`} data-rov={o.id} onClick={() => removeOverride(o)} />
|
|
120
|
+
</li>
|
|
121
|
+
))}
|
|
122
|
+
{!overrides.value.length && <li><span class="muted">No overrides. Add one for holidays or a half day.</span></li>}
|
|
123
|
+
</ul>
|
|
124
|
+
{adding.value
|
|
125
|
+
? <div class="stack" role="group" aria-label="New override">
|
|
126
|
+
<DateField label="Date" value={newDate} today={env.today.value} locale={env.locale.value} />
|
|
127
|
+
<Switch checked={off.value} onChange={(e: Event) => (off.value = (e.currentTarget as HTMLInputElement).checked)}>Unavailable all day</Switch>
|
|
128
|
+
{!off.value && <div class="row"><Select label="From" options={options.value} value={newFrom.value} onChange={(e: Event) => (newFrom.value = (e.currentTarget as HTMLInputElement).value)} /><Select label="To" options={options.value} value={newTo.value} onChange={(e: Event) => (newTo.value = (e.currentTarget as HTMLInputElement).value)} /></div>}
|
|
129
|
+
<Input label="Note" value={newNote.value} onInput={e => (newNote.value = e.currentTarget.value)} />
|
|
130
|
+
<div class="row"><Button size="sm" disabled={!newDate.value} onClick={addOverride}>Add</Button><Button emphasis="ghost" tone="neutral" size="sm" onClick={() => (adding.value = false)}>Cancel</Button></div>
|
|
131
|
+
</div>
|
|
132
|
+
: <div class="row"><Button emphasis="outline" tone="neutral" size="sm" data-add-ov="" onClick={() => (adding.value = true)}>Add an override</Button></div>}
|
|
133
|
+
</div>
|
|
134
|
+
<div class="row">
|
|
135
|
+
<span style="flex:1"></span>
|
|
136
|
+
<span class="muted" role="status">{status.value}</span>
|
|
137
|
+
<Button size="sm" loading={busy.value} ref={(n: HTMLElement) => (saveBtn = n)} onClick={save}>Save availability</Button>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function Day({ day, id, name, options, onToggle, onTime, onAdd, onRemove }: { day: WeekDay; id: string; name: string; options: { value: string; label: string }[]; onToggle: (on: boolean) => void; onTime: (d: number, at: number, side: 0 | 1, v: number) => void; onAdd: (d: number) => void; onRemove: (d: number, at: number) => void }) {
|
|
144
|
+
const issues = derived(() => rangeIssues(day.ranges))
|
|
145
|
+
const rows = derived(() => day.ranges.map((r, at) => ({ r, at, k: `${day.day}-${at}`, error: issues.value[at] })))
|
|
146
|
+
return (
|
|
147
|
+
<li data-part="day" data-day-of={day.day}>
|
|
148
|
+
<Switch checked={day.on} onChange={(e: Event) => onToggle((e.currentTarget as HTMLInputElement).checked)}>{name}</Switch>
|
|
149
|
+
<div data-part="ranges">
|
|
150
|
+
{day.on
|
|
151
|
+
? rows.value.map(x => (
|
|
152
|
+
<div key={x.k} data-part="range" data-invalid={x.error ? '' : undefined} role="group" aria-label={`${name}, range ${x.at + 1}`}>
|
|
153
|
+
<Select label="From" size="sm" options={options} value={String(x.r[0])} aria-invalid={x.error ? 'true' : undefined} aria-describedby={x.error ? `${id}-${x.k}-e` : undefined} hold={!!x.error} onChange={(e: Event) => onTime(day.day, x.at, 0, +(e.currentTarget as HTMLInputElement).value)} />
|
|
154
|
+
<Select label="To" size="sm" options={options} value={String(x.r[1])} aria-invalid={x.error ? 'true' : undefined} aria-describedby={x.error ? `${id}-${x.k}-e` : undefined} hold={!!x.error} onChange={(e: Event) => onTime(day.day, x.at, 1, +(e.currentTarget as HTMLInputElement).value)} />
|
|
155
|
+
{day.ranges.length > 1 && <Button emphasis="ghost" tone="neutral" size="sm" icon={XIcon} label={`Remove ${name} range ${x.at + 1}`} onClick={() => onRemove(day.day, x.at)} />}
|
|
156
|
+
{x.at === day.ranges.length - 1 && <Button emphasis="ghost" tone="neutral" size="sm" label={`Add a range on ${name}`} data-icon-only="" data-add={day.day} onClick={() => onAdd(day.day)}><PlusIcon /></Button>}
|
|
157
|
+
{x.error && <span data-part="error" id={`${id}-${x.k}-e`}><AlertIcon /> {x.error}</span>}
|
|
158
|
+
</div>
|
|
159
|
+
))
|
|
160
|
+
: <span data-part="off">Unavailable</span>}
|
|
161
|
+
</div>
|
|
162
|
+
</li>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { announce, clock, fmt, longDuration, minutes, uid, 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 { InlineCalendar } from '../Calendar'
|
|
5
|
+
import { Alert } from '../Alert'
|
|
6
|
+
import { usePickerEnv } from '../Picker'
|
|
7
|
+
import { Skeleton } from '../Skeleton'
|
|
8
|
+
import { TimeField } from '../TimeField'
|
|
9
|
+
import { SweepParts, confirmSweep } from './bits'
|
|
10
|
+
import '@weft-js/warp-core/styles/app/booking.css'
|
|
11
|
+
import '@weft-js/warp-core/styles/rich/calendar-grid.css'
|
|
12
|
+
|
|
13
|
+
/** A start in minutes; `disabled` shows it taken rather than hiding it. */
|
|
14
|
+
export type BookingSlot = { s: number; disabled?: boolean | undefined }
|
|
15
|
+
/** The slot being booked, in minutes from midnight; manual when typed rather than picked. */
|
|
16
|
+
export type BookingDetails = { date: string; s: number; e: number; manual: boolean }
|
|
17
|
+
/** Props for BookingCalendar. */
|
|
18
|
+
export type BookingCalendarProps = {
|
|
19
|
+
/** The times for a day: a list, or read from the date. */
|
|
20
|
+
slots: BookingSlot[] | ((date: string) => BookingSlot[])
|
|
21
|
+
/** The picked day: a state it writes, or a plain value with `onDateChange`. */
|
|
22
|
+
date?: State<IsoDate | null> | IsoDate | undefined
|
|
23
|
+
/** The day picked at the start, when the calendar keeps its own state. */
|
|
24
|
+
defaultDate?: IsoDate | undefined
|
|
25
|
+
/** Called with the day picked. Load that day's slots here. */
|
|
26
|
+
onDateChange?: ((d: IsoDate) => void) | undefined
|
|
27
|
+
duration?: number | undefined
|
|
28
|
+
min?: string | undefined
|
|
29
|
+
max?: string | undefined
|
|
30
|
+
isDateUnavailable?: ((d: IsoDate) => boolean) | undefined
|
|
31
|
+
loading?: boolean | undefined
|
|
32
|
+
error?: unknown
|
|
33
|
+
/** Pins today's date, for server rendering and tests. The browser's clock otherwise. */
|
|
34
|
+
today?: string | undefined
|
|
35
|
+
locale?: string | undefined
|
|
36
|
+
/** `h12` for AM and PM, `h23` for a 24-hour clock. The locale's otherwise. */
|
|
37
|
+
hourCycle?: string | undefined
|
|
38
|
+
/** Rejecting keeps the confirm step and says why. */
|
|
39
|
+
onBook: (b: BookingDetails) => void | Promise<unknown>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* BookingCalendar (07 §2.2): InlineCalendar plus slot buttons (`aria-pressed`), a confirm step with a manual time as the
|
|
44
|
+
* fallback field path, and the arrival sweep on the confirmation card.
|
|
45
|
+
*/
|
|
46
|
+
export function BookingCalendar(p: BookingCalendarProps) {
|
|
47
|
+
const id = uid('book')
|
|
48
|
+
const env = usePickerEnv(() => p.locale, () => p.today)
|
|
49
|
+
const own = state<IsoDate | null>(untrack(() => (typeof p.date === 'string' ? p.date : p.defaultDate) ?? null))
|
|
50
|
+
const date = derived(() => (p.date && typeof p.date === 'object' ? p.date.value : typeof p.date === 'string' ? p.date : own.value))
|
|
51
|
+
const pick = state<number | null>(null)
|
|
52
|
+
const manual = state(false)
|
|
53
|
+
const typed = state<IsoTime | null>(null)
|
|
54
|
+
const stage = state<'pick' | 'confirm' | 'done'>('pick')
|
|
55
|
+
const busy = state(false)
|
|
56
|
+
const failed = state<string | null>(null)
|
|
57
|
+
const len = derived(() => p.duration ?? 30)
|
|
58
|
+
const list = derived(() => { const d = date.value; if (!d) return []; return typeof p.slots === 'function' ? p.slots(d) : p.slots })
|
|
59
|
+
const chosen = derived(() => (manual.value ? (typed.value ? minutes(typed.value) : null) : pick.value))
|
|
60
|
+
const when = derived(() => { const d = date.value, s = chosen.value; return d && s !== null ? `${fmt(d, { weekday: 'short', month: 'short', day: 'numeric' }, env.locale.value)} · ${clock(s, env.locale.value, p.hourCycle)}–${clock(s + len.value, env.locale.value, p.hourCycle)}` : '' })
|
|
61
|
+
let root: HTMLDivElement | undefined
|
|
62
|
+
const setDate = (d: IsoDate) => {
|
|
63
|
+
if (p.date && typeof p.date === 'object') p.date.value = d
|
|
64
|
+
else own.value = d
|
|
65
|
+
p.onDateChange?.(d)
|
|
66
|
+
pick.value = null
|
|
67
|
+
stage.value = 'pick'
|
|
68
|
+
}
|
|
69
|
+
const onSlots = (e: KeyboardEvent) => {
|
|
70
|
+
const d = ({ ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 } as Record<string, number>)[e.key]
|
|
71
|
+
const on = [...(e.currentTarget as HTMLElement).querySelectorAll<HTMLButtonElement>('button:not(:disabled)')]
|
|
72
|
+
const i = on.indexOf(e.target as HTMLButtonElement)
|
|
73
|
+
if (d && i >= 0) { e.preventDefault(); on[(i + d + on.length) % on.length]!.focus() }
|
|
74
|
+
}
|
|
75
|
+
const toConfirm = () => {
|
|
76
|
+
stage.value = 'confirm'
|
|
77
|
+
flush()
|
|
78
|
+
root?.querySelector<HTMLElement>('[data-confirm-step] button:last-child')?.focus()
|
|
79
|
+
}
|
|
80
|
+
const book = async () => {
|
|
81
|
+
const d = date.value, s = chosen.value
|
|
82
|
+
if (!d || s === null || busy.value) return
|
|
83
|
+
busy.value = true
|
|
84
|
+
failed.value = null
|
|
85
|
+
try {
|
|
86
|
+
await p.onBook({ date: d, s, e: s + len.value, manual: manual.value })
|
|
87
|
+
stage.value = 'done'
|
|
88
|
+
flush()
|
|
89
|
+
if (root) { confirmSweep(root.querySelector('[data-part="slot"][aria-pressed="true"]')); announce(root.ownerDocument, `Booked ${when.value}`) }
|
|
90
|
+
} catch (err) {
|
|
91
|
+
failed.value = (err as Error | undefined)?.message ?? 'That time was just taken.'
|
|
92
|
+
if (root) announce(root.ownerDocument, `Couldn't book: ${failed.value}`, true)
|
|
93
|
+
} finally { busy.value = false }
|
|
94
|
+
}
|
|
95
|
+
const empty = derived(() => !list.value.some(s => !s.disabled))
|
|
96
|
+
return (
|
|
97
|
+
<div ref={n => (root = n)} data-scope="booking" data-part="root">
|
|
98
|
+
<InlineCalendar aria-label="Choose a date" value={date.value} onValueChange={d => setDate(d as IsoDate)} today={env.today.value} locale={env.locale.value} min={p.min} max={p.max} isDateUnavailable={p.isDateUnavailable} />
|
|
99
|
+
{stage.value === 'done'
|
|
100
|
+
? <div data-arrive="" class="row" role="status"><div><b>Booked · {when.value}</b><p class="muted">{longDuration(len.value)}. We've sent the details.</p></div><Button emphasis="ghost" tone="neutral" size="sm" onClick={() => { stage.value = 'pick'; pick.value = null }}>Book another</Button></div>
|
|
101
|
+
: <>
|
|
102
|
+
{!date.value && <p class="muted">Pick a date to see the times.</p>}
|
|
103
|
+
{date.value && p.loading && <div data-part="slots" aria-busy="true"><Skeleton /><Skeleton /><Skeleton /></div>}
|
|
104
|
+
{date.value && !p.loading && p.error && <Alert tone="critical" title="Couldn't load the times" live>{p.error}</Alert>}
|
|
105
|
+
{date.value && !p.loading && !p.error && !manual.value && (
|
|
106
|
+
<>
|
|
107
|
+
<div data-part="slots" role="group" aria-label={`Times on ${fmt(date.value, { dateStyle: 'full' }, env.locale.value)}`} onKeyDown={onSlots}>
|
|
108
|
+
{list.value.map(x => <button key={x.s} type="button" data-part="slot" data-sweep="" aria-pressed={x.s === pick.value ? 'true' : 'false'} disabled={x.disabled} onClick={() => { pick.value = x.s; stage.value = 'pick' }}><SweepParts />{clock(x.s, env.locale.value, p.hourCycle)}</button>)}
|
|
109
|
+
</div>
|
|
110
|
+
{empty.value && <p class="muted">No times left on this day.</p>}
|
|
111
|
+
</>
|
|
112
|
+
)}
|
|
113
|
+
{date.value && manual.value && <TimeField id={`${id}-t`} label="Time" value={typed} locale={env.locale.value} hourCycle="h23" hint="Type any time." />}
|
|
114
|
+
{date.value && !p.error && (
|
|
115
|
+
<div class="row">
|
|
116
|
+
<Button emphasis="ghost" tone="neutral" size="sm" onClick={() => { manual.value = !manual.value; stage.value = 'pick' }}>{manual.value ? 'Show suggested times' : 'Enter a time'}</Button>
|
|
117
|
+
<span style="flex:1"></span>
|
|
118
|
+
{stage.value === 'pick' && <Button disabled={chosen.value === null} onClick={toConfirm}>Continue</Button>}
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
{stage.value === 'confirm' && (
|
|
122
|
+
<div class="row" data-confirm-step="">
|
|
123
|
+
<span><b>{when.value}</b> <span class="muted">{longDuration(len.value)}</span></span>
|
|
124
|
+
<Button emphasis="outline" tone="neutral" size="sm" onClick={() => (stage.value = 'pick')}>Back</Button>
|
|
125
|
+
<Button size="sm" loading={busy.value} onClick={book}>Confirm</Button>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
{failed.value && <Alert tone="critical" title="Couldn't book" live>{failed.value}</Alert>}
|
|
129
|
+
</>}
|
|
130
|
+
</div>
|
|
131
|
+
)
|
|
132
|
+
}
|