@utopia-studio-design/design-system-cli 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/bin/utopia-ds-mcp.mjs +9 -2
- package/bin/utopia-ds.mjs +106 -8
- package/data/blocks/activity-feed/ActivityFeedBlock.tsx +255 -0
- package/data/blocks/activity-feed/README.md +16 -0
- package/data/blocks/activity-feed/activity-feed-contract.ts +96 -0
- package/data/blocks/activity-feed/activity-feed-fixture.ts +22 -0
- package/data/blocks/activity-feed/activity-feed.css +154 -0
- package/data/blocks/agent-status/AgentStatusBlock.tsx +313 -0
- package/data/blocks/agent-status/README.md +15 -0
- package/data/blocks/agent-status/agent-status-contract.ts +133 -0
- package/data/blocks/agent-status/agent-status-fixture.ts +79 -0
- package/data/blocks/agent-status/agent-status.css +216 -0
- package/data/blocks/analytics-dashboard/AnalyticsDashboardBlocks.tsx +107 -0
- package/data/blocks/analytics-dashboard/AnalyticsMetricBlocks.tsx +87 -0
- package/data/blocks/analytics-dashboard/README.md +17 -0
- package/data/blocks/analytics-dashboard/analytics-dashboard.css +92 -0
- package/data/blocks/calendar-application/CalendarApplicationBlocks.tsx +519 -0
- package/data/blocks/calendar-application/README.md +20 -0
- package/data/blocks/calendar-application/calendar-application.css +216 -0
- package/data/blocks/calendar-application/calendar-localization.ts +90 -0
- package/data/blocks/calendar-application/calendar-panel-contract.ts +72 -0
- package/data/blocks/dashboard-composer/CeramicPuckAdapter.tsx +380 -0
- package/data/blocks/dashboard-composer/DashboardComposerBlocks.tsx +337 -0
- package/data/blocks/dashboard-composer/README.md +16 -0
- package/data/blocks/dashboard-composer/RegistryDataBlocks.tsx +238 -0
- package/data/blocks/dashboard-composer/dashboard-composer.css +328 -0
- package/data/blocks/database-workspace/DatabaseWorkspaceBlocks.tsx +240 -0
- package/data/blocks/database-workspace/README.md +9 -0
- package/data/blocks/database-workspace/database-workspace.css +107 -0
- package/data/blocks/interaction-workbench/InteractionWorkbench.tsx +271 -0
- package/data/blocks/interaction-workbench/README.md +9 -0
- package/data/blocks/interaction-workbench/interaction-workbench.css +226 -0
- package/data/docs/calendar.md +164 -0
- package/data/docs/charts.md +95 -0
- package/data/docs/composition-registry.md +174 -0
- package/data/docs/dashboard-composer.md +94 -0
- package/data/docs/database.md +68 -0
- package/data/docs/examples/dashboard-layout.v1.json +65 -0
- package/data/docs/foundations.md +1 -1
- package/data/docs/interaction-foundation.md +109 -0
- package/data/docs/quick-start-ai.md +23 -1
- package/data/manifests/blocks.json +392 -0
- package/data/manifests/catalog.json +7 -4
- package/data/manifests/components.json +179 -0
- package/data/manifests/composition-registry.json +427 -0
- package/data/manifests/motion-profiles.json +1 -1
- package/data/manifests/patterns.json +113 -1
- package/data/manifests/templates.json +346 -0
- package/data/manifests/theme-renacore.json +142 -0
- package/data/manifests/theme-vyapti.json +125 -0
- package/data/manifests/themes.json +400 -28
- package/data/templates/analytics-dashboard/AnalyticsDashboardBlocks.tsx +107 -0
- package/data/templates/analytics-dashboard/AnalyticsMetricBlocks.tsx +87 -0
- package/data/templates/analytics-dashboard/README.md +6 -0
- package/data/templates/analytics-dashboard/analytics-dashboard.css +92 -0
- package/data/templates/analytics-dashboard/index.html +13 -0
- package/data/templates/analytics-dashboard/main.tsx +196 -0
- package/data/templates/analytics-dashboard/styles.css +120 -0
- package/data/templates/analytics-dashboard/template.manifest.json +17 -0
- package/data/templates/calendar-application/CalendarApplicationBlocks.tsx +519 -0
- package/data/templates/calendar-application/README.md +10 -0
- package/data/templates/calendar-application/calendar-application.css +216 -0
- package/data/templates/calendar-application/calendar-localization.ts +90 -0
- package/data/templates/calendar-application/index.html +14 -0
- package/data/templates/calendar-application/main.tsx +409 -0
- package/data/templates/calendar-application/styles.css +248 -0
- package/data/templates/calendar-application/template.manifest.json +27 -0
- package/data/templates/composition-registry-lab/README.md +9 -0
- package/data/templates/composition-registry-lab/index.html +14 -0
- package/data/templates/composition-registry-lab/main.tsx +217 -0
- package/data/templates/composition-registry-lab/sample-layout.json +63 -0
- package/data/templates/composition-registry-lab/styles.css +343 -0
- package/data/templates/composition-registry-lab/template.manifest.json +34 -0
- package/data/templates/dashboard-composer/ActivityFeedBlock.tsx +255 -0
- package/data/templates/dashboard-composer/AgentStatusBlock.tsx +313 -0
- package/data/templates/dashboard-composer/AnalyticsDashboardBlocks.tsx +107 -0
- package/data/templates/dashboard-composer/AnalyticsMetricBlocks.tsx +87 -0
- package/data/templates/dashboard-composer/CalendarApplicationBlocks.tsx +519 -0
- package/data/templates/dashboard-composer/CeramicPuckAdapter.tsx +380 -0
- package/data/templates/dashboard-composer/DashboardComposerBlocks.tsx +337 -0
- package/data/templates/dashboard-composer/DatabaseWorkspaceBlocks.tsx +240 -0
- package/data/templates/dashboard-composer/README.md +53 -0
- package/data/templates/dashboard-composer/RegistryDataBlocks.tsx +238 -0
- package/data/templates/dashboard-composer/activity-feed-contract.ts +96 -0
- package/data/templates/dashboard-composer/activity-feed-fixture.ts +22 -0
- package/data/templates/dashboard-composer/activity-feed.css +154 -0
- package/data/templates/dashboard-composer/agent-status-contract.ts +133 -0
- package/data/templates/dashboard-composer/agent-status-fixture.ts +79 -0
- package/data/templates/dashboard-composer/agent-status-lab/index.html +19 -0
- package/data/templates/dashboard-composer/agent-status-lab/main.tsx +151 -0
- package/data/templates/dashboard-composer/agent-status-lab/styles.css +139 -0
- package/data/templates/dashboard-composer/agent-status.css +216 -0
- package/data/templates/dashboard-composer/analytics-dashboard.css +92 -0
- package/data/templates/dashboard-composer/calendar-application.css +216 -0
- package/data/templates/dashboard-composer/calendar-localization.ts +90 -0
- package/data/templates/dashboard-composer/calendar-panel-contract.ts +72 -0
- package/data/templates/dashboard-composer/dashboard-composer.css +328 -0
- package/data/templates/dashboard-composer/database-workspace.css +107 -0
- package/data/templates/dashboard-composer/index.html +14 -0
- package/data/templates/dashboard-composer/main.tsx +550 -0
- package/data/templates/dashboard-composer/sample-layout.json +147 -0
- package/data/templates/dashboard-composer/styles.css +198 -0
- package/data/templates/dashboard-composer/template.manifest.json +34 -0
- package/data/templates/dashboard-composer/visual-editor/PuckEditorSurface.tsx +132 -0
- package/data/templates/dashboard-composer/visual-editor/VisualEditorApp.tsx +343 -0
- package/data/templates/dashboard-composer/visual-editor/index.html +17 -0
- package/data/templates/dashboard-composer/visual-editor/main.tsx +95 -0
- package/data/templates/dashboard-composer/visual-editor/styles.css +310 -0
- package/data/templates/database-workspace/DatabaseWorkspaceBlocks.tsx +240 -0
- package/data/templates/database-workspace/README.md +5 -0
- package/data/templates/database-workspace/database-workspace.css +107 -0
- package/data/templates/database-workspace/index.html +14 -0
- package/data/templates/database-workspace/main.tsx +247 -0
- package/data/templates/database-workspace/styles.css +121 -0
- package/data/templates/database-workspace/template.manifest.json +19 -0
- package/data/templates/interaction-lab/InteractionWorkbench.tsx +271 -0
- package/data/templates/interaction-lab/README.md +7 -0
- package/data/templates/interaction-lab/index.html +14 -0
- package/data/templates/interaction-lab/interaction-workbench.css +226 -0
- package/data/templates/interaction-lab/main.tsx +104 -0
- package/data/templates/interaction-lab/styles.css +224 -0
- package/data/templates/interaction-lab/template.manifest.json +36 -0
- package/lib/api.mjs +72 -5
- package/lib/composition-runtime.mjs +382 -0
- package/package.json +4 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Badge } from '@utopia-studio-design/design-system/Badge'
|
|
3
|
+
import type { ChartState } from '@utopia-studio-design/design-system/Charts'
|
|
4
|
+
import './analytics-dashboard.css'
|
|
5
|
+
|
|
6
|
+
export interface MetricDefinition {
|
|
7
|
+
change?: number
|
|
8
|
+
changeLabel?: string
|
|
9
|
+
format?: 'number' | 'percent' | 'currency'
|
|
10
|
+
id: string
|
|
11
|
+
label: string
|
|
12
|
+
value: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface KPIGridProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
16
|
+
currency?: string
|
|
17
|
+
density?: 'compact' | 'default' | 'comfortable'
|
|
18
|
+
locale?: string
|
|
19
|
+
metrics: MetricDefinition[]
|
|
20
|
+
state?: ChartState
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function KPIGrid({
|
|
24
|
+
className,
|
|
25
|
+
currency = 'USD',
|
|
26
|
+
density = 'default',
|
|
27
|
+
locale,
|
|
28
|
+
metrics,
|
|
29
|
+
state = metrics.length === 0 ? 'empty' : 'ready',
|
|
30
|
+
...props
|
|
31
|
+
}: KPIGridProps) {
|
|
32
|
+
const formatMetric = (metric: MetricDefinition) => {
|
|
33
|
+
if (metric.format === 'percent') {
|
|
34
|
+
return new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 1 }).format(metric.value)
|
|
35
|
+
}
|
|
36
|
+
if (metric.format === 'currency') {
|
|
37
|
+
return new Intl.NumberFormat(locale, {
|
|
38
|
+
style: 'currency',
|
|
39
|
+
currency,
|
|
40
|
+
notation: 'compact',
|
|
41
|
+
maximumFractionDigits: 1,
|
|
42
|
+
}).format(metric.value)
|
|
43
|
+
}
|
|
44
|
+
return new Intl.NumberFormat(locale, { notation: 'compact', maximumFractionDigits: 1 }).format(metric.value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<section
|
|
49
|
+
aria-busy={state === 'loading'}
|
|
50
|
+
aria-label="Key performance indicators"
|
|
51
|
+
className={['ceramic-kpi-grid', className].filter(Boolean).join(' ')}
|
|
52
|
+
data-density={density}
|
|
53
|
+
data-state={state}
|
|
54
|
+
{...props}
|
|
55
|
+
>
|
|
56
|
+
{state === 'loading' ? (
|
|
57
|
+
<>
|
|
58
|
+
{[0, 1, 2].map((item) => <span aria-hidden="true" className="ceramic-kpi-skeleton" key={item} />)}
|
|
59
|
+
<span className="uds-visually-hidden" role="status">Loading metrics…</span>
|
|
60
|
+
</>
|
|
61
|
+
) : null}
|
|
62
|
+
{state === 'empty' ? (
|
|
63
|
+
<div className="ceramic-kpi-state" role="status">
|
|
64
|
+
<strong>No metrics available</strong>
|
|
65
|
+
<span>Choose another period or data source.</span>
|
|
66
|
+
</div>
|
|
67
|
+
) : null}
|
|
68
|
+
{state === 'error' ? (
|
|
69
|
+
<div className="ceramic-kpi-state" role="alert">
|
|
70
|
+
<strong>Metrics unavailable</strong>
|
|
71
|
+
<span>Reconnect the data source and try again.</span>
|
|
72
|
+
</div>
|
|
73
|
+
) : null}
|
|
74
|
+
{state === 'ready' || state === 'partial' ? metrics.map((metric) => (
|
|
75
|
+
<article className="ceramic-kpi" key={metric.id}>
|
|
76
|
+
<span>{metric.label}</span>
|
|
77
|
+
<strong>{formatMetric(metric)}</strong>
|
|
78
|
+
{metric.change !== undefined ? (
|
|
79
|
+
<Badge variant="outline">
|
|
80
|
+
{metric.change > 0 ? '+' : ''}{metric.change.toFixed(1)}% {metric.changeLabel ?? 'vs previous'}
|
|
81
|
+
</Badge>
|
|
82
|
+
) : <span className="ceramic-kpi-unavailable">No comparison</span>}
|
|
83
|
+
</article>
|
|
84
|
+
)) : null}
|
|
85
|
+
</section>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { X } from '@phosphor-icons/react'
|
|
3
|
+
import { Button } from '@utopia-studio-design/design-system/Button'
|
|
4
|
+
import { Calendar } from '@utopia-studio-design/design-system/DataDisplay'
|
|
5
|
+
import {
|
|
6
|
+
Checkbox,
|
|
7
|
+
Field,
|
|
8
|
+
FieldError,
|
|
9
|
+
FieldLabel,
|
|
10
|
+
NativeSelect,
|
|
11
|
+
NativeSelectOption,
|
|
12
|
+
Select,
|
|
13
|
+
SelectContent,
|
|
14
|
+
SelectItem,
|
|
15
|
+
SelectTrigger,
|
|
16
|
+
SelectValue,
|
|
17
|
+
TextInput,
|
|
18
|
+
} from '@utopia-studio-design/design-system/Forms'
|
|
19
|
+
import { PhosphorIcon } from '@utopia-studio-design/design-system/PhosphorIcon'
|
|
20
|
+
import {
|
|
21
|
+
AgendaView,
|
|
22
|
+
SchedulerRoot,
|
|
23
|
+
addSchedulerDays,
|
|
24
|
+
expandRecurringEvents,
|
|
25
|
+
startOfSchedulerWeek,
|
|
26
|
+
type SchedulerEvent,
|
|
27
|
+
type SchedulerEventEditHandler,
|
|
28
|
+
type SchedulerLabelOverrides,
|
|
29
|
+
type SchedulerResource,
|
|
30
|
+
type SchedulerRootProps,
|
|
31
|
+
type SchedulerWeekDay,
|
|
32
|
+
} from '@utopia-studio-design/design-system/Calendar'
|
|
33
|
+
import './calendar-application.css'
|
|
34
|
+
|
|
35
|
+
export type SchedulerShellProps = SchedulerRootProps
|
|
36
|
+
|
|
37
|
+
export function SchedulerShell(props: SchedulerShellProps) {
|
|
38
|
+
return <SchedulerRoot {...props} />
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CalendarSidebarProps extends React.HTMLAttributes<HTMLElement> {
|
|
42
|
+
date: Date
|
|
43
|
+
labels?: {
|
|
44
|
+
ariaLabel?: string
|
|
45
|
+
jumpToDate?: string
|
|
46
|
+
schedule?: string
|
|
47
|
+
calendars?: string
|
|
48
|
+
}
|
|
49
|
+
locale?: string
|
|
50
|
+
onDateChange?: (date: Date) => void
|
|
51
|
+
resources?: SchedulerResource[]
|
|
52
|
+
selectedResourceIds?: string[]
|
|
53
|
+
onResourceToggle?: (resourceId: string) => void
|
|
54
|
+
weekStartsOn?: SchedulerWeekDay
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function CalendarSidebar({
|
|
58
|
+
date,
|
|
59
|
+
labels = {},
|
|
60
|
+
locale = 'en-US',
|
|
61
|
+
onDateChange,
|
|
62
|
+
onResourceToggle,
|
|
63
|
+
resources = [],
|
|
64
|
+
selectedResourceIds = resources.map((resource) => resource.id),
|
|
65
|
+
weekStartsOn = 1,
|
|
66
|
+
...props
|
|
67
|
+
}: CalendarSidebarProps) {
|
|
68
|
+
return (
|
|
69
|
+
<aside aria-label={labels.ariaLabel ?? 'Calendar sidebar'} className="ceramic-calendar-sidebar" {...props}>
|
|
70
|
+
<div className="ceramic-calendar-sidebar-heading">
|
|
71
|
+
<span>{labels.schedule ?? 'Schedule'}</span>
|
|
72
|
+
<strong>{new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(date)}</strong>
|
|
73
|
+
</div>
|
|
74
|
+
<Calendar
|
|
75
|
+
aria-label={labels.jumpToDate ?? 'Jump to date'}
|
|
76
|
+
captionLayout="label"
|
|
77
|
+
className="ceramic-calendar-mini"
|
|
78
|
+
locale={locale}
|
|
79
|
+
month={date}
|
|
80
|
+
selectedDate={date}
|
|
81
|
+
weekStartsOn={weekStartsOn}
|
|
82
|
+
onMonthChange={onDateChange}
|
|
83
|
+
onSelect={onDateChange}
|
|
84
|
+
/>
|
|
85
|
+
<fieldset>
|
|
86
|
+
<legend>{labels.calendars ?? 'My calendars'}</legend>
|
|
87
|
+
{resources.map((resource) => (
|
|
88
|
+
<label key={resource.id}>
|
|
89
|
+
<Checkbox
|
|
90
|
+
aria-label={resource.label}
|
|
91
|
+
checked={selectedResourceIds.includes(resource.id)}
|
|
92
|
+
onCheckedChange={() => onResourceToggle?.(resource.id)}
|
|
93
|
+
/>
|
|
94
|
+
<span>
|
|
95
|
+
<i aria-hidden="true" data-resource={resource.id} />
|
|
96
|
+
{resource.label}
|
|
97
|
+
</span>
|
|
98
|
+
</label>
|
|
99
|
+
))}
|
|
100
|
+
</fieldset>
|
|
101
|
+
</aside>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface TimezoneSelectorProps {
|
|
106
|
+
className?: string
|
|
107
|
+
label?: string
|
|
108
|
+
onValueChange?: (value: string) => void
|
|
109
|
+
timezones: string[]
|
|
110
|
+
value: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function TimezoneSelector({
|
|
114
|
+
className,
|
|
115
|
+
label = 'Display timezone',
|
|
116
|
+
onValueChange,
|
|
117
|
+
timezones,
|
|
118
|
+
value,
|
|
119
|
+
}: TimezoneSelectorProps) {
|
|
120
|
+
const labelId = React.useId()
|
|
121
|
+
return (
|
|
122
|
+
<Field className={`ceramic-timezone-selector ${className ?? ''}`.trim()}>
|
|
123
|
+
<FieldLabel id={labelId}>{label}</FieldLabel>
|
|
124
|
+
<Select value={value} onValueChange={onValueChange}>
|
|
125
|
+
<SelectTrigger aria-labelledby={labelId}>
|
|
126
|
+
<SelectValue />
|
|
127
|
+
</SelectTrigger>
|
|
128
|
+
<SelectContent>
|
|
129
|
+
{timezones.map((timezone) => <SelectItem key={timezone} value={timezone}>{timezone}</SelectItem>)}
|
|
130
|
+
</SelectContent>
|
|
131
|
+
</Select>
|
|
132
|
+
</Field>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface AgendaPanelProps extends React.HTMLAttributes<HTMLElement> {
|
|
137
|
+
calendarSystem?: string
|
|
138
|
+
date: Date
|
|
139
|
+
events: SchedulerEvent[]
|
|
140
|
+
labels?: SchedulerLabelOverrides
|
|
141
|
+
locale?: string
|
|
142
|
+
numberingSystem?: string
|
|
143
|
+
onEventEdit?: SchedulerEventEditHandler
|
|
144
|
+
timezone: string
|
|
145
|
+
weekendDays?: SchedulerWeekDay[]
|
|
146
|
+
weekStartsOn?: SchedulerWeekDay
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function AgendaPanel({
|
|
150
|
+
calendarSystem,
|
|
151
|
+
date,
|
|
152
|
+
events,
|
|
153
|
+
labels,
|
|
154
|
+
locale,
|
|
155
|
+
numberingSystem,
|
|
156
|
+
onEventEdit,
|
|
157
|
+
timezone,
|
|
158
|
+
weekendDays,
|
|
159
|
+
weekStartsOn,
|
|
160
|
+
...props
|
|
161
|
+
}: AgendaPanelProps) {
|
|
162
|
+
const rangeStart = startOfSchedulerWeek(date, weekStartsOn)
|
|
163
|
+
const expandedEvents = expandRecurringEvents(events, rangeStart, addSchedulerDays(rangeStart, 7))
|
|
164
|
+
return (
|
|
165
|
+
<aside className="ceramic-agenda-panel" {...props}>
|
|
166
|
+
<AgendaView
|
|
167
|
+
calendarSystem={calendarSystem}
|
|
168
|
+
date={date}
|
|
169
|
+
events={expandedEvents}
|
|
170
|
+
labels={labels}
|
|
171
|
+
locale={locale}
|
|
172
|
+
numberingSystem={numberingSystem}
|
|
173
|
+
timezone={timezone}
|
|
174
|
+
weekendDays={weekendDays}
|
|
175
|
+
weekStartsOn={weekStartsOn}
|
|
176
|
+
visible
|
|
177
|
+
onEventEdit={onEventEdit}
|
|
178
|
+
/>
|
|
179
|
+
</aside>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface EventEditorDrawerProps extends Omit<React.HTMLAttributes<HTMLElement>, 'onSave'> {
|
|
184
|
+
canDelete?: boolean
|
|
185
|
+
event: SchedulerEvent | null
|
|
186
|
+
labels?: Partial<EventEditorLabels>
|
|
187
|
+
onClose?: () => void
|
|
188
|
+
onDelete?: (event: SchedulerEvent) => void
|
|
189
|
+
onSave?: (event: SchedulerEvent, scope: 'occurrence' | 'series') => void | Promise<void>
|
|
190
|
+
open: boolean
|
|
191
|
+
returnFocusRef?: React.RefObject<HTMLElement | null>
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface EventEditorLabels {
|
|
195
|
+
ariaLabel: string
|
|
196
|
+
eyebrow: string
|
|
197
|
+
newEvent: string
|
|
198
|
+
close: string
|
|
199
|
+
title: string
|
|
200
|
+
starts: string
|
|
201
|
+
ends: string
|
|
202
|
+
allDay: string
|
|
203
|
+
applyTo: string
|
|
204
|
+
occurrence: string
|
|
205
|
+
series: string
|
|
206
|
+
save: string
|
|
207
|
+
saving: string
|
|
208
|
+
cancel: string
|
|
209
|
+
delete: string
|
|
210
|
+
validationSummary: string
|
|
211
|
+
titleRequired: string
|
|
212
|
+
startRequired: string
|
|
213
|
+
endRequired: string
|
|
214
|
+
invalidDateTime: string
|
|
215
|
+
endAfterStart: string
|
|
216
|
+
saveError: string
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const defaultEditorLabels: EventEditorLabels = {
|
|
220
|
+
ariaLabel: 'Event editor',
|
|
221
|
+
eyebrow: 'Calendar event',
|
|
222
|
+
newEvent: 'New event',
|
|
223
|
+
close: 'Close event editor',
|
|
224
|
+
title: 'Title',
|
|
225
|
+
starts: 'Starts',
|
|
226
|
+
ends: 'Ends',
|
|
227
|
+
allDay: 'All-day event',
|
|
228
|
+
applyTo: 'Apply change to',
|
|
229
|
+
occurrence: 'This occurrence',
|
|
230
|
+
series: 'Entire series',
|
|
231
|
+
save: 'Save event',
|
|
232
|
+
saving: 'Saving event…',
|
|
233
|
+
cancel: 'Cancel',
|
|
234
|
+
delete: 'Delete event',
|
|
235
|
+
validationSummary: 'Check the highlighted fields.',
|
|
236
|
+
titleRequired: 'Enter an event title.',
|
|
237
|
+
startRequired: 'Enter a start date and time.',
|
|
238
|
+
endRequired: 'Enter an end date and time.',
|
|
239
|
+
invalidDateTime: 'Enter a valid date and time.',
|
|
240
|
+
endAfterStart: 'End must be after start.',
|
|
241
|
+
saveError: "The event couldn't be saved. Your changes are still here.",
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function toLocalInput(value: string) {
|
|
245
|
+
const date = new Date(value)
|
|
246
|
+
if (Number.isNaN(date.valueOf())) return ''
|
|
247
|
+
const offset = date.getTimezoneOffset() * 60_000
|
|
248
|
+
return new Date(date.valueOf() - offset).toISOString().slice(0, 16)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function parseLocalInput(value: string) {
|
|
252
|
+
if (!value) return null
|
|
253
|
+
const date = new Date(value)
|
|
254
|
+
return Number.isNaN(date.valueOf()) ? null : date
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
type EventEditorErrors = Partial<Record<'title' | 'start' | 'end', string>>
|
|
258
|
+
|
|
259
|
+
export function EventEditorDrawer({
|
|
260
|
+
canDelete = true,
|
|
261
|
+
event,
|
|
262
|
+
labels: labelOverrides,
|
|
263
|
+
onClose,
|
|
264
|
+
onDelete,
|
|
265
|
+
onSave,
|
|
266
|
+
open,
|
|
267
|
+
returnFocusRef,
|
|
268
|
+
...props
|
|
269
|
+
}: EventEditorDrawerProps) {
|
|
270
|
+
const labels = { ...defaultEditorLabels, ...labelOverrides }
|
|
271
|
+
const drawerRef = React.useRef<HTMLElement>(null)
|
|
272
|
+
const closeRef = React.useRef<HTMLButtonElement>(null)
|
|
273
|
+
const titleRef = React.useRef<HTMLInputElement>(null)
|
|
274
|
+
const startRef = React.useRef<HTMLInputElement>(null)
|
|
275
|
+
const endRef = React.useRef<HTMLInputElement>(null)
|
|
276
|
+
const saveErrorRef = React.useRef<HTMLDivElement>(null)
|
|
277
|
+
const triggerRef = React.useRef<HTMLElement | null>(null)
|
|
278
|
+
const openRef = React.useRef(open)
|
|
279
|
+
openRef.current = open
|
|
280
|
+
const titleId = React.useId()
|
|
281
|
+
const startId = React.useId()
|
|
282
|
+
const endId = React.useId()
|
|
283
|
+
const scopeId = React.useId()
|
|
284
|
+
const [draft, setDraft] = React.useState(event)
|
|
285
|
+
const [titleInput, setTitleInput] = React.useState(event?.title ?? '')
|
|
286
|
+
const [startInput, setStartInput] = React.useState(event ? toLocalInput(event.start) : '')
|
|
287
|
+
const [endInput, setEndInput] = React.useState(event ? toLocalInput(event.end) : '')
|
|
288
|
+
const [scope, setScope] = React.useState<'occurrence' | 'series'>('occurrence')
|
|
289
|
+
const [errors, setErrors] = React.useState<EventEditorErrors>({})
|
|
290
|
+
const [saveError, setSaveError] = React.useState(false)
|
|
291
|
+
const [isSaving, setIsSaving] = React.useState(false)
|
|
292
|
+
React.useEffect(() => {
|
|
293
|
+
setDraft(event)
|
|
294
|
+
setTitleInput(event?.title ?? '')
|
|
295
|
+
setStartInput(event ? toLocalInput(event.start) : '')
|
|
296
|
+
setEndInput(event ? toLocalInput(event.end) : '')
|
|
297
|
+
setScope('occurrence')
|
|
298
|
+
setErrors({})
|
|
299
|
+
setSaveError(false)
|
|
300
|
+
setIsSaving(false)
|
|
301
|
+
}, [event])
|
|
302
|
+
React.useLayoutEffect(() => {
|
|
303
|
+
if (!open) return
|
|
304
|
+
const activeElement = returnFocusRef?.current
|
|
305
|
+
?? (document.activeElement instanceof HTMLElement ? document.activeElement : null)
|
|
306
|
+
if (activeElement && !drawerRef.current?.contains(activeElement)) triggerRef.current = activeElement
|
|
307
|
+
const scrollX = window.scrollX
|
|
308
|
+
const scrollY = window.scrollY
|
|
309
|
+
const bodyStyles = {
|
|
310
|
+
inlineSize: document.body.style.inlineSize,
|
|
311
|
+
inset: document.body.style.inset,
|
|
312
|
+
overflow: document.body.style.overflow,
|
|
313
|
+
position: document.body.style.position,
|
|
314
|
+
}
|
|
315
|
+
document.body.style.position = 'fixed'
|
|
316
|
+
document.body.style.inset = '0'
|
|
317
|
+
document.body.style.inlineSize = '100%'
|
|
318
|
+
document.body.style.overflow = 'hidden'
|
|
319
|
+
return () => {
|
|
320
|
+
document.body.style.position = bodyStyles.position
|
|
321
|
+
document.body.style.inset = bodyStyles.inset
|
|
322
|
+
document.body.style.inlineSize = bodyStyles.inlineSize
|
|
323
|
+
document.body.style.overflow = bodyStyles.overflow
|
|
324
|
+
window.scrollTo(scrollX, scrollY)
|
|
325
|
+
const trigger = triggerRef.current
|
|
326
|
+
window.requestAnimationFrame(() => {
|
|
327
|
+
if (!openRef.current && trigger?.isConnected && !document.querySelector('.ceramic-event-editor[role="dialog"]')) {
|
|
328
|
+
trigger.focus()
|
|
329
|
+
if (triggerRef.current === trigger) triggerRef.current = null
|
|
330
|
+
}
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
}, [open, returnFocusRef])
|
|
334
|
+
React.useLayoutEffect(() => {
|
|
335
|
+
if (open && draft) closeRef.current?.focus()
|
|
336
|
+
}, [draft?.id, open])
|
|
337
|
+
React.useLayoutEffect(() => {
|
|
338
|
+
if (saveError) saveErrorRef.current?.focus()
|
|
339
|
+
}, [saveError])
|
|
340
|
+
if (!open || !draft) return null
|
|
341
|
+
const focusableSelector = 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
342
|
+
const closeEditor = () => {
|
|
343
|
+
if (!isSaving) onClose?.()
|
|
344
|
+
}
|
|
345
|
+
const clearError = (field: keyof EventEditorErrors) => {
|
|
346
|
+
setErrors((current) => {
|
|
347
|
+
if (!current[field]) return current
|
|
348
|
+
const next = { ...current }
|
|
349
|
+
delete next[field]
|
|
350
|
+
return next
|
|
351
|
+
})
|
|
352
|
+
setSaveError(false)
|
|
353
|
+
}
|
|
354
|
+
const handleSubmit = async (submitEvent: React.FormEvent<HTMLFormElement>) => {
|
|
355
|
+
submitEvent.preventDefault()
|
|
356
|
+
if (isSaving) return
|
|
357
|
+
|
|
358
|
+
const nextErrors: EventEditorErrors = {}
|
|
359
|
+
const title = titleInput.trim()
|
|
360
|
+
const start = parseLocalInput(startInput)
|
|
361
|
+
const end = parseLocalInput(endInput)
|
|
362
|
+
if (!title) nextErrors.title = labels.titleRequired
|
|
363
|
+
if (!startInput) nextErrors.start = labels.startRequired
|
|
364
|
+
else if (!start) nextErrors.start = labels.invalidDateTime
|
|
365
|
+
if (!endInput) nextErrors.end = labels.endRequired
|
|
366
|
+
else if (!end) nextErrors.end = labels.invalidDateTime
|
|
367
|
+
else if (start && end <= start) nextErrors.end = labels.endAfterStart
|
|
368
|
+
|
|
369
|
+
setErrors(nextErrors)
|
|
370
|
+
setSaveError(false)
|
|
371
|
+
if (Object.keys(nextErrors).length > 0) {
|
|
372
|
+
window.requestAnimationFrame(() => {
|
|
373
|
+
if (nextErrors.title) titleRef.current?.focus()
|
|
374
|
+
else if (nextErrors.start) startRef.current?.focus()
|
|
375
|
+
else endRef.current?.focus()
|
|
376
|
+
})
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const nextDraft = {
|
|
381
|
+
...draft,
|
|
382
|
+
title,
|
|
383
|
+
start: start!.toISOString(),
|
|
384
|
+
end: end!.toISOString(),
|
|
385
|
+
}
|
|
386
|
+
setDraft(nextDraft)
|
|
387
|
+
setIsSaving(true)
|
|
388
|
+
try {
|
|
389
|
+
await onSave?.(nextDraft, scope)
|
|
390
|
+
} catch {
|
|
391
|
+
setSaveError(true)
|
|
392
|
+
} finally {
|
|
393
|
+
setIsSaving(false)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return (
|
|
397
|
+
<>
|
|
398
|
+
<div aria-hidden="true" className="ceramic-event-editor-backdrop" onClick={closeEditor} />
|
|
399
|
+
<aside
|
|
400
|
+
aria-label={labels.ariaLabel}
|
|
401
|
+
aria-modal="true"
|
|
402
|
+
aria-busy={isSaving || undefined}
|
|
403
|
+
className="ceramic-event-editor"
|
|
404
|
+
ref={drawerRef}
|
|
405
|
+
role="dialog"
|
|
406
|
+
onKeyDown={(keyboardEvent) => {
|
|
407
|
+
if (keyboardEvent.key === 'Escape') {
|
|
408
|
+
keyboardEvent.preventDefault()
|
|
409
|
+
closeEditor()
|
|
410
|
+
}
|
|
411
|
+
if (keyboardEvent.key !== 'Tab') return
|
|
412
|
+
const focusable = [...(drawerRef.current?.querySelectorAll<HTMLElement>(focusableSelector) ?? [])]
|
|
413
|
+
const first = focusable[0]
|
|
414
|
+
const last = focusable.at(-1)
|
|
415
|
+
if (keyboardEvent.shiftKey && document.activeElement === first) {
|
|
416
|
+
keyboardEvent.preventDefault()
|
|
417
|
+
last?.focus()
|
|
418
|
+
} else if (!keyboardEvent.shiftKey && document.activeElement === last) {
|
|
419
|
+
keyboardEvent.preventDefault()
|
|
420
|
+
first?.focus()
|
|
421
|
+
}
|
|
422
|
+
}}
|
|
423
|
+
{...props}
|
|
424
|
+
>
|
|
425
|
+
<header>
|
|
426
|
+
<div><span>{labels.eyebrow}</span><h2><bdi>{draft.title || labels.newEvent}</bdi></h2></div>
|
|
427
|
+
<Button ref={closeRef} aria-label={labels.close} disabled={isSaving} isIconOnly variant="ghost" onClick={closeEditor}>
|
|
428
|
+
<PhosphorIcon decorative icon={X} size="sm" />
|
|
429
|
+
</Button>
|
|
430
|
+
</header>
|
|
431
|
+
<form aria-busy={isSaving || undefined} noValidate onSubmit={handleSubmit}>
|
|
432
|
+
{Object.keys(errors).length > 0 ? (
|
|
433
|
+
<div className="ceramic-event-editor-alert" role="alert">{labels.validationSummary}</div>
|
|
434
|
+
) : null}
|
|
435
|
+
{saveError ? (
|
|
436
|
+
<div className="ceramic-event-editor-alert" ref={saveErrorRef} role="alert" tabIndex={-1}>
|
|
437
|
+
{labels.saveError}
|
|
438
|
+
</div>
|
|
439
|
+
) : null}
|
|
440
|
+
<Field>
|
|
441
|
+
<FieldLabel htmlFor={titleId}>{labels.title}</FieldLabel>
|
|
442
|
+
<TextInput
|
|
443
|
+
aria-describedby={errors.title ? `${titleId}-error` : undefined}
|
|
444
|
+
aria-invalid={Boolean(errors.title)}
|
|
445
|
+
disabled={isSaving}
|
|
446
|
+
id={titleId}
|
|
447
|
+
ref={titleRef}
|
|
448
|
+
required
|
|
449
|
+
value={titleInput}
|
|
450
|
+
onChange={(changeEvent) => {
|
|
451
|
+
setTitleInput(changeEvent.target.value)
|
|
452
|
+
clearError('title')
|
|
453
|
+
}}
|
|
454
|
+
/>
|
|
455
|
+
<FieldError id={`${titleId}-error`}>{errors.title}</FieldError>
|
|
456
|
+
</Field>
|
|
457
|
+
<Field>
|
|
458
|
+
<FieldLabel htmlFor={startId}>{labels.starts}</FieldLabel>
|
|
459
|
+
<TextInput
|
|
460
|
+
aria-describedby={errors.start ? `${startId}-error` : undefined}
|
|
461
|
+
aria-invalid={Boolean(errors.start)}
|
|
462
|
+
disabled={isSaving}
|
|
463
|
+
id={startId}
|
|
464
|
+
ref={startRef}
|
|
465
|
+
required
|
|
466
|
+
type="datetime-local"
|
|
467
|
+
value={startInput}
|
|
468
|
+
onChange={(changeEvent) => {
|
|
469
|
+
setStartInput(changeEvent.target.value)
|
|
470
|
+
clearError('start')
|
|
471
|
+
}}
|
|
472
|
+
/>
|
|
473
|
+
<FieldError id={`${startId}-error`}>{errors.start}</FieldError>
|
|
474
|
+
</Field>
|
|
475
|
+
<Field>
|
|
476
|
+
<FieldLabel htmlFor={endId}>{labels.ends}</FieldLabel>
|
|
477
|
+
<TextInput
|
|
478
|
+
aria-describedby={errors.end ? `${endId}-error` : undefined}
|
|
479
|
+
aria-invalid={Boolean(errors.end)}
|
|
480
|
+
disabled={isSaving}
|
|
481
|
+
id={endId}
|
|
482
|
+
ref={endRef}
|
|
483
|
+
required
|
|
484
|
+
type="datetime-local"
|
|
485
|
+
value={endInput}
|
|
486
|
+
onChange={(changeEvent) => {
|
|
487
|
+
setEndInput(changeEvent.target.value)
|
|
488
|
+
clearError('end')
|
|
489
|
+
}}
|
|
490
|
+
/>
|
|
491
|
+
<FieldError id={`${endId}-error`}>{errors.end}</FieldError>
|
|
492
|
+
</Field>
|
|
493
|
+
<label className="ceramic-event-checkbox">
|
|
494
|
+
<Checkbox disabled={isSaving} checked={Boolean(draft.allDay)} onCheckedChange={(checked) => setDraft({ ...draft, allDay: checked === true })} />
|
|
495
|
+
<span>{labels.allDay}</span>
|
|
496
|
+
</label>
|
|
497
|
+
{draft.recurrenceId || draft.recurrence ? (
|
|
498
|
+
<Field>
|
|
499
|
+
<FieldLabel htmlFor={scopeId}>{labels.applyTo}</FieldLabel>
|
|
500
|
+
<NativeSelect disabled={isSaving} id={scopeId} value={scope} onChange={(changeEvent) => setScope(changeEvent.target.value as 'occurrence' | 'series')}>
|
|
501
|
+
<NativeSelectOption value="occurrence">{labels.occurrence}</NativeSelectOption>
|
|
502
|
+
<NativeSelectOption value="series">{labels.series}</NativeSelectOption>
|
|
503
|
+
</NativeSelect>
|
|
504
|
+
</Field>
|
|
505
|
+
) : null}
|
|
506
|
+
<div className="ceramic-event-editor-actions">
|
|
507
|
+
<div>
|
|
508
|
+
<Button loading={isSaving} loadingText={labels.saving} type="submit">{labels.save}</Button>
|
|
509
|
+
<Button disabled={isSaving} type="button" variant="outline" onClick={closeEditor}>{labels.cancel}</Button>
|
|
510
|
+
</div>
|
|
511
|
+
{canDelete ? (
|
|
512
|
+
<Button disabled={isSaving} type="button" variant="destructive" onClick={() => onDelete?.(draft)}>{labels.delete}</Button>
|
|
513
|
+
) : null}
|
|
514
|
+
</div>
|
|
515
|
+
</form>
|
|
516
|
+
</aside>
|
|
517
|
+
</>
|
|
518
|
+
)
|
|
519
|
+
}
|