@utopia-studio-design/design-system-cli 0.4.0 → 0.5.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 +115 -7
- 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 +2 -1
- package/data/docs/interaction-foundation.md +109 -0
- package/data/docs/observability.md +165 -0
- package/data/docs/quick-start-ai.md +55 -1
- package/data/manifests/blocks.json +392 -0
- package/data/manifests/catalog.json +7 -4
- package/data/manifests/components.json +191 -3
- 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/themes.json +314 -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/lib/telemetry.mjs +74 -0
- package/package.json +4 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Calendar Application
|
|
2
|
+
|
|
3
|
+
Product-first Ceramic scheduling workspace composed from the Shadcn-derived
|
|
4
|
+
Ceramic Calendar, Button, ButtonGroup, IconButton, Select, and form families.
|
|
5
|
+
It includes Month, Week, Day, and Agenda views, overlaps, recurrence, event
|
|
6
|
+
editing, conflict recovery, resources, permissions, timezone switching, RTL,
|
|
7
|
+
dark mode, and mobile day/agenda transformation.
|
|
8
|
+
|
|
9
|
+
The collapsed `Test states` section is a QA lab and is not part of the primary
|
|
10
|
+
calendar workflow.
|