@valentinkolb/cloud 0.3.1 → 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.
Files changed (194) hide show
  1. package/package.json +18 -8
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +119 -47
  4. package/src/_internal/runtime-context.ts +1 -0
  5. package/src/api/accounts-entities.ts +4 -0
  6. package/src/api/admin-core-settings.ts +98 -0
  7. package/src/api/announcements.ts +131 -0
  8. package/src/api/auth/schemas.ts +24 -0
  9. package/src/api/auth.ts +113 -10
  10. package/src/api/index.ts +15 -25
  11. package/src/api/me.ts +203 -14
  12. package/src/api/search/schemas.ts +1 -0
  13. package/src/api/search.ts +62 -8
  14. package/src/config/ssr.ts +2 -9
  15. package/src/contracts/announcements.test.ts +37 -0
  16. package/src/contracts/announcements.ts +121 -0
  17. package/src/contracts/app.ts +4 -0
  18. package/src/contracts/index.ts +3 -2
  19. package/src/contracts/registry.ts +4 -0
  20. package/src/contracts/shared.ts +108 -1
  21. package/src/desktop/index.ts +704 -0
  22. package/src/desktop/solid.tsx +938 -0
  23. package/src/server/api/index.ts +1 -1
  24. package/src/server/api/respond.ts +50 -10
  25. package/src/server/index.ts +44 -38
  26. package/src/server/middleware/auth.ts +98 -9
  27. package/src/server/middleware/index.ts +2 -1
  28. package/src/server/middleware/settings.ts +26 -0
  29. package/src/server/services/access.test.ts +197 -0
  30. package/src/server/services/access.ts +254 -6
  31. package/src/server/services/index.ts +14 -11
  32. package/src/server/services/pagination.ts +22 -0
  33. package/src/server/time.ts +45 -0
  34. package/src/services/account-lifecycle/index.ts +142 -18
  35. package/src/services/accounts/app.ts +658 -170
  36. package/src/services/accounts/authz.test.ts +77 -0
  37. package/src/services/accounts/authz.ts +22 -0
  38. package/src/services/accounts/entities.ts +84 -5
  39. package/src/services/accounts/groups.ts +30 -24
  40. package/src/services/accounts/model.test.ts +30 -0
  41. package/src/services/accounts/switching.test.ts +14 -0
  42. package/src/services/accounts/switching.ts +15 -6
  43. package/src/services/accounts/users.ts +75 -52
  44. package/src/services/announcements/index.test.ts +32 -0
  45. package/src/services/announcements/index.ts +224 -0
  46. package/src/services/audit/index.test.ts +84 -0
  47. package/src/services/audit/index.ts +431 -0
  48. package/src/services/auth-flows/index.ts +9 -2
  49. package/src/services/auth-flows/ipa.ts +0 -2
  50. package/src/services/auth-flows/magic-link.ts +3 -2
  51. package/src/services/auth-flows/password-reset.ts +284 -0
  52. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  53. package/src/services/auth-flows/proxy-return.ts +49 -0
  54. package/src/services/gateway.ts +162 -0
  55. package/src/services/index.ts +44 -2
  56. package/src/services/ipa/effective-groups.test.ts +33 -0
  57. package/src/services/ipa/effective-groups.ts +70 -0
  58. package/src/services/ipa/profile.ts +45 -3
  59. package/src/services/ipa/search.ts +3 -5
  60. package/src/services/ipa/service-account.ts +15 -0
  61. package/src/services/ipa/sync-planning.test.ts +32 -0
  62. package/src/services/ipa/sync-planning.ts +22 -0
  63. package/src/services/ipa/sync.ts +110 -38
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +64 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +49 -0
  92. package/src/shared/redirect.ts +52 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -0,0 +1,846 @@
1
+ import { type DateContext, dates } from "@valentinkolb/stdlib";
2
+ import { createMemo, createSignal, For, type JSX, onCleanup, Show } from "solid-js";
3
+ import { createInputA11y, InputWrapper } from "./util";
4
+
5
+ export type DateRangeValue = {
6
+ start: string | null;
7
+ end: string | null;
8
+ };
9
+
10
+ export type DatePreset<T> = {
11
+ label: string;
12
+ value: T;
13
+ };
14
+
15
+ export type DurationPreset = {
16
+ label: string;
17
+ minutes: number;
18
+ };
19
+
20
+ type BasePickerProps<T> = {
21
+ label?: string;
22
+ description?: string | JSX.Element;
23
+ placeholder?: string;
24
+ value: () => T;
25
+ onChange: (value: T) => void;
26
+ presets?: DatePreset<T>[];
27
+ dateConfig?: DateContext;
28
+ clearable?: boolean;
29
+ disabled?: boolean;
30
+ required?: boolean;
31
+ error?: () => string | undefined;
32
+ };
33
+
34
+ export type DatePickerProps = BasePickerProps<string | null>;
35
+ export type DateTimePickerProps = BasePickerProps<string | null>;
36
+ export type DateRangePickerProps = BasePickerProps<DateRangeValue> & {
37
+ withTime?: boolean;
38
+ datePresets?: DatePreset<string | null>[];
39
+ durationPresets?: DurationPreset[];
40
+ };
41
+
42
+ type PanelView = "days" | "months";
43
+
44
+ const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
45
+
46
+ const hasInstantOffset = (value: string) => /[T\s].*([zZ]|[+-]\d{2}:?\d{2})$/.test(value);
47
+
48
+ const pickerContext = (context?: DateContext): DateContext => ({
49
+ weekStartsOn: 1,
50
+ ...context,
51
+ });
52
+
53
+ const dateKey = (date: Date | string, context?: DateContext): string => dates.formatDateKey(date, pickerContext(context));
54
+
55
+ const yearMonth = (date: Date, context?: DateContext): { year: number; month: number } => {
56
+ const [year = "1970", month = "1"] = dateKey(date, context).split("-");
57
+ return { year: Number(year), month: Number(month) - 1 };
58
+ };
59
+
60
+ const monthDate = (year: number, month: number, context?: DateContext): Date => {
61
+ const value = `${year}-${String(month + 1).padStart(2, "0")}-01T12:00`;
62
+ if (context?.timeZone) {
63
+ return new Date(zonedDateTimeToInstant(value, context.timeZone));
64
+ }
65
+ return new Date(year, month, 1, 12);
66
+ };
67
+
68
+ const parseDateValue = (value: string | null | undefined, context?: DateContext): Date => {
69
+ if (!value) return dates.today(pickerContext(context));
70
+ return dates.parseCalendarDate(value.slice(0, 10), pickerContext(context));
71
+ };
72
+
73
+ const displayDate = (value: string | null | undefined, context?: DateContext): string => {
74
+ if (!value) return "";
75
+ return dates.formatDate(parseDateValue(value, context), pickerContext(context));
76
+ };
77
+
78
+ const localDateTimeInput = (value: string): string => {
79
+ if (!hasInstantOffset(value)) return value.slice(0, 16);
80
+ const date = new Date(value);
81
+ const year = date.getFullYear();
82
+ const month = String(date.getMonth() + 1).padStart(2, "0");
83
+ const day = String(date.getDate()).padStart(2, "0");
84
+ const hours = String(date.getHours()).padStart(2, "0");
85
+ const minutes = String(date.getMinutes()).padStart(2, "0");
86
+ return `${year}-${month}-${day}T${hours}:${minutes}`;
87
+ };
88
+
89
+ const instantToZonedInput = (value: string, timeZone: string): string => {
90
+ if (typeof dates.instantToZonedInput === "function") return dates.instantToZonedInput(value, timeZone);
91
+ return localDateTimeInput(value);
92
+ };
93
+
94
+ const zonedDateTimeToInstant = (value: string, timeZone: string): string => {
95
+ if (typeof dates.zonedDateTimeToInstant === "function") {
96
+ return dates.zonedDateTimeToInstant(value, timeZone, { disambiguation: "compatible" });
97
+ }
98
+ return value;
99
+ };
100
+
101
+ const dateTimeInput = (value: string | null | undefined, context?: DateContext): string => {
102
+ if (!value) return "";
103
+ if (context?.timeZone && hasInstantOffset(value)) return instantToZonedInput(value, context.timeZone);
104
+ return localDateTimeInput(value);
105
+ };
106
+
107
+ const splitDateTime = (value: string | null | undefined, context?: DateContext): { date: string; time: string } => {
108
+ const input = dateTimeInput(value, context);
109
+ const [date = "", time = ""] = input.split("T");
110
+ return { date, time: time.slice(0, 5) };
111
+ };
112
+
113
+ const toDateTimeValue = (date: string, time: string, context?: DateContext): string | null => {
114
+ if (!date) return null;
115
+ const local = `${date}T${time || "00:00"}`;
116
+ if (context?.timeZone) return zonedDateTimeToInstant(local, context.timeZone);
117
+ return local;
118
+ };
119
+
120
+ const isCompleteTime = (value: string): boolean => /^\d{2}:\d{2}$/.test(value);
121
+
122
+ const formatDateTimeValue = (value: string | null | undefined, context?: DateContext): string => {
123
+ if (!value) return "";
124
+ return dates.formatDateTime(value, pickerContext(context));
125
+ };
126
+
127
+ const compareDateKey = (a: string | null | undefined, b: string | null | undefined): number => (a ?? "").localeCompare(b ?? "");
128
+
129
+ const inRange = (day: string, range: DateRangeValue): boolean =>
130
+ Boolean(range.start && range.end && day >= range.start && day <= range.end);
131
+
132
+ const isRangeEdge = (day: string, range: DateRangeValue): boolean => day === range.start || day === range.end;
133
+
134
+ const timezoneLabel = (context?: DateContext): string | undefined => context?.timeZone;
135
+
136
+ const formatDateOnlyRangeDuration = (range: DateRangeValue, context?: DateContext): string => {
137
+ if (!range.start || !range.end) return "";
138
+ const start = parseDateValue(range.start, context);
139
+ const end = parseDateValue(range.end, context);
140
+ const days = Math.floor(Math.abs(end.getTime() - start.getTime()) / 86_400_000) + 1;
141
+ return `${days} ${days === 1 ? "day" : "days"}`;
142
+ };
143
+
144
+ function PickerShell<T>(props: {
145
+ owner: BasePickerProps<T>;
146
+ icon: string;
147
+ activeIcon: string;
148
+ valueLabel: () => string;
149
+ valueContent?: () => JSX.Element;
150
+ children: (close: () => void) => JSX.Element;
151
+ clearValue: T;
152
+ timezoneInfo?: boolean;
153
+ footerMeta?: () => JSX.Element | string | undefined;
154
+ onOpen?: () => void;
155
+ wide?: boolean;
156
+ }) {
157
+ const disabled = () => props.owner.disabled ?? false;
158
+ const clearable = () => props.owner.clearable ?? false;
159
+ const [isOpen, setIsOpen] = createSignal(false);
160
+ const [isDarkTheme, setIsDarkTheme] = createSignal(false);
161
+ const a11y = createInputA11y({ description: props.owner.description, error: props.owner.error });
162
+
163
+ let triggerRef: HTMLDivElement | undefined;
164
+ let dialogRef: HTMLDialogElement | undefined;
165
+
166
+ const syncTheme = () => {
167
+ if (typeof document === "undefined") return;
168
+ setIsDarkTheme(document.documentElement.classList.contains("dark") || document.body.classList.contains("dark"));
169
+ };
170
+
171
+ const close = () => {
172
+ setIsOpen(false);
173
+ dialogRef?.close();
174
+ };
175
+
176
+ const toggle = (open: boolean) => {
177
+ if (disabled()) return;
178
+ if (!open) {
179
+ close();
180
+ return;
181
+ }
182
+ syncTheme();
183
+ props.onOpen?.();
184
+ setIsOpen(true);
185
+ if (dialogRef && triggerRef) {
186
+ const rect = triggerRef.getBoundingClientRect();
187
+ const width = props.owner.presets?.length || props.wide ? 400 : props.timezoneInfo ? 380 : 320;
188
+ const availableWidth = Math.max(280, window.innerWidth - 24);
189
+ const panelWidth = Math.min(width, availableWidth);
190
+ const estimatedHeight = props.owner.presets?.length || props.wide ? 380 : 340;
191
+ const maxLeft = window.innerWidth - panelWidth - 12;
192
+ const spaceBelow = window.innerHeight - rect.bottom;
193
+ const spaceAbove = rect.top;
194
+ dialogRef.style.left = `${Math.max(12, Math.min(rect.left, maxLeft))}px`;
195
+ if (spaceBelow < estimatedHeight && spaceAbove > spaceBelow) {
196
+ dialogRef.style.top = "auto";
197
+ dialogRef.style.bottom = `${Math.max(12, window.innerHeight - rect.top + 8)}px`;
198
+ } else {
199
+ dialogRef.style.bottom = "auto";
200
+ dialogRef.style.top = `${rect.bottom + 8}px`;
201
+ }
202
+ dialogRef.style.right = "auto";
203
+ dialogRef.style.margin = "0";
204
+ dialogRef.style.minWidth = "0";
205
+ dialogRef.style.boxSizing = "border-box";
206
+ dialogRef.style.inlineSize = `${panelWidth}px`;
207
+ dialogRef.style.maxInlineSize = "calc(100vw - 24px)";
208
+ dialogRef.style.width = `${panelWidth}px`;
209
+ dialogRef.style.maxWidth = "calc(100vw - 24px)";
210
+ dialogRef.showModal();
211
+ }
212
+ };
213
+
214
+ const handleKeyDown = (event: KeyboardEvent) => {
215
+ if (event.key === "Escape" && isOpen()) {
216
+ event.preventDefault();
217
+ close();
218
+ }
219
+ if ((event.key === "Enter" || event.key === " ") && !isOpen()) {
220
+ event.preventDefault();
221
+ toggle(true);
222
+ }
223
+ };
224
+
225
+ onCleanup(() => dialogRef?.close());
226
+
227
+ return (
228
+ <InputWrapper
229
+ label={props.owner.label}
230
+ description={props.owner.description}
231
+ error={props.owner.error?.()}
232
+ required={props.owner.required}
233
+ inputId={a11y.inputId}
234
+ descriptionId={a11y.descriptionId}
235
+ errorId={a11y.errorId}
236
+ >
237
+ <div class="relative">
238
+ <div class="group relative flex-1">
239
+ <div class="pointer-events-none absolute inset-y-0 left-2 z-10 flex items-center text-zinc-500">
240
+ <i class={`${isOpen() ? props.activeIcon : props.icon} ${isOpen() ? "text-blue-500" : ""}`} />
241
+ </div>
242
+ <div
243
+ ref={triggerRef}
244
+ id={a11y.inputId}
245
+ class={`input w-full pl-9 pr-8 ${isOpen() ? "!border-blue-500 !bg-white dark:!border-blue-400 dark:!bg-zinc-900" : ""} ${
246
+ disabled() ? "cursor-not-allowed opacity-50" : "cursor-pointer"
247
+ }`}
248
+ onClick={() => toggle(!isOpen())}
249
+ onKeyDown={handleKeyDown}
250
+ tabIndex={disabled() ? -1 : 0}
251
+ role="combobox"
252
+ aria-expanded={isOpen()}
253
+ aria-haspopup="dialog"
254
+ aria-label={!props.owner.label ? (props.owner.placeholder ?? "Pick date") : undefined}
255
+ aria-describedby={a11y.ariaDescribedBy()}
256
+ aria-invalid={!!props.owner.error?.()}
257
+ aria-required={props.owner.required}
258
+ aria-disabled={disabled()}
259
+ >
260
+ <Show
261
+ when={props.valueLabel()}
262
+ fallback={<span class="block truncate text-zinc-400 dark:text-zinc-500">{props.owner.placeholder ?? "Pick date"}</span>}
263
+ >
264
+ <span class="block truncate text-zinc-700 dark:text-zinc-300">{props.valueContent?.() ?? props.valueLabel()}</span>
265
+ </Show>
266
+ </div>
267
+
268
+ <Show when={clearable() && props.valueLabel() && !disabled()}>
269
+ <button
270
+ type="button"
271
+ class="absolute inset-y-0 right-2 flex items-center px-1 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
272
+ onClick={(event) => {
273
+ event.stopPropagation();
274
+ props.owner.onChange(props.clearValue);
275
+ triggerRef?.focus();
276
+ }}
277
+ tabIndex={-1}
278
+ aria-label="Clear date"
279
+ >
280
+ <i class="ti ti-x text-sm" />
281
+ </button>
282
+ </Show>
283
+ </div>
284
+
285
+ <dialog
286
+ ref={dialogRef}
287
+ class="popup overflow-hidden p-0 backdrop:bg-transparent"
288
+ classList={{ dark: isDarkTheme() }}
289
+ onClick={(event) => {
290
+ if (event.target === dialogRef) close();
291
+ }}
292
+ onKeyDown={handleKeyDown}
293
+ aria-label={props.owner.label ?? "Date picker"}
294
+ >
295
+ {props.children(close)}
296
+ <Show when={props.footerMeta !== undefined || (props.timezoneInfo && timezoneLabel(props.owner.dateConfig))}>
297
+ <div class="mx-2.5 mb-2 flex min-h-5 min-w-0 items-center justify-between gap-3 px-1 text-xs text-dimmed">
298
+ <div class="min-w-0 truncate">{props.footerMeta?.()}</div>
299
+ <Show when={props.timezoneInfo && timezoneLabel(props.owner.dateConfig)}>
300
+ <div class="ml-auto inline-flex shrink-0 items-center gap-1">
301
+ <i class="ti ti-world" />
302
+ <span>{timezoneLabel(props.owner.dateConfig)}</span>
303
+ </div>
304
+ </Show>
305
+ </div>
306
+ </Show>
307
+ </dialog>
308
+ </div>
309
+ </InputWrapper>
310
+ );
311
+ }
312
+
313
+ function PresetRail<T>(props: { presets?: DatePreset<T>[]; onSelect: (value: T) => void }) {
314
+ return (
315
+ <Show when={props.presets?.length}>
316
+ <div class="m-2 mr-0 flex w-28 shrink-0 self-stretch flex-col gap-1 overflow-y-auto rounded-md bg-zinc-50 p-1.5 dark:bg-zinc-900/70">
317
+ <For each={props.presets}>
318
+ {(preset) => (
319
+ <button
320
+ type="button"
321
+ class="rounded px-2 py-1.5 text-left text-xs text-zinc-700 transition-colors hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
322
+ onClick={() => props.onSelect(preset.value)}
323
+ >
324
+ {preset.label}
325
+ </button>
326
+ )}
327
+ </For>
328
+ </div>
329
+ </Show>
330
+ );
331
+ }
332
+
333
+ function DatePickerPanel(props: {
334
+ visibleMonth: () => Date;
335
+ setVisibleMonth: (date: Date) => void;
336
+ selected?: () => string | null | undefined;
337
+ range?: () => DateRangeValue;
338
+ onSelect: (date: string) => void;
339
+ onDayPreview?: (date: string | null) => void;
340
+ dateConfig?: DateContext;
341
+ }) {
342
+ const [view, setView] = createSignal<PanelView>("days");
343
+ const context = () => pickerContext(props.dateConfig);
344
+ const month = () => yearMonth(props.visibleMonth(), context());
345
+ const weeks = () => dates.getMonthGrid(month().year, month().month, context());
346
+ const weekdays = () => dates.weekdays(context());
347
+
348
+ const moveMonth = (delta: number) => props.setVisibleMonth(dates.addMonths(props.visibleMonth(), delta, context()));
349
+ const moveYear = (delta: number) => props.setVisibleMonth(monthDate(month().year + delta, month().month, context()));
350
+
351
+ return (
352
+ <div class="min-w-0 flex-1 p-2">
353
+ <div class="mx-auto w-full max-w-64">
354
+ <div class="mb-2 flex items-center justify-between">
355
+ <button
356
+ type="button"
357
+ class="icon-btn h-7 w-7"
358
+ onClick={() => (view() === "days" ? moveMonth(-1) : moveYear(-1))}
359
+ aria-label="Previous"
360
+ >
361
+ <i class="ti ti-chevron-left" />
362
+ </button>
363
+ <button
364
+ type="button"
365
+ class="rounded-md px-2 py-1 text-sm font-semibold text-primary transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800"
366
+ onClick={() => setView(view() === "days" ? "months" : "days")}
367
+ >
368
+ <Show when={view() === "days"} fallback={month().year}>
369
+ {dates.formatMonthYear(props.visibleMonth(), context())}
370
+ </Show>
371
+ </button>
372
+ <button type="button" class="icon-btn h-7 w-7" onClick={() => (view() === "days" ? moveMonth(1) : moveYear(1))} aria-label="Next">
373
+ <i class="ti ti-chevron-right" />
374
+ </button>
375
+ </div>
376
+
377
+ <Show
378
+ when={view() === "days"}
379
+ fallback={
380
+ <div class="grid grid-cols-3 gap-1">
381
+ <For each={monthNames}>
382
+ {(name, index) => {
383
+ const active = () => index() === month().month;
384
+ return (
385
+ <button
386
+ type="button"
387
+ class={`h-8 rounded-md px-2 text-sm transition-colors ${
388
+ active() ? "bg-blue-500 text-white" : "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
389
+ }`}
390
+ onClick={() => {
391
+ props.setVisibleMonth(monthDate(month().year, index(), context()));
392
+ setView("days");
393
+ }}
394
+ >
395
+ {name}
396
+ </button>
397
+ );
398
+ }}
399
+ </For>
400
+ </div>
401
+ }
402
+ >
403
+ <div class="grid grid-cols-7 gap-1 text-center text-xs text-dimmed">
404
+ <For each={weekdays()}>{(day) => <div class="py-0.5">{day}</div>}</For>
405
+ </div>
406
+ <div class="mt-1 grid grid-cols-7 gap-1">
407
+ <For each={weeks().flat()}>
408
+ {(day) => {
409
+ const key = () => dateKey(day, context());
410
+ const selected = () => props.selected?.() === key();
411
+ const range = () => props.range?.() ?? { start: null, end: null };
412
+ const active = () => selected() || isRangeEdge(key(), range());
413
+ const muted = () => !dates.isSameMonth(day, props.visibleMonth(), context());
414
+ return (
415
+ <button
416
+ type="button"
417
+ class={`h-8 rounded-md text-sm transition-colors ${
418
+ active()
419
+ ? "bg-blue-500 text-white"
420
+ : inRange(key(), range())
421
+ ? "bg-blue-50 text-blue-700 dark:bg-blue-500/15 dark:text-blue-200"
422
+ : muted()
423
+ ? "text-zinc-300 hover:bg-zinc-100 dark:text-zinc-700 dark:hover:bg-zinc-800"
424
+ : "text-zinc-800 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800"
425
+ }`}
426
+ onClick={() => props.onSelect(key())}
427
+ onBlur={() => props.onDayPreview?.(null)}
428
+ onFocus={() => props.onDayPreview?.(key())}
429
+ onPointerEnter={() => props.onDayPreview?.(key())}
430
+ onPointerLeave={() => props.onDayPreview?.(null)}
431
+ aria-pressed={active()}
432
+ >
433
+ {dates.formatDayNumber(day, context())}
434
+ </button>
435
+ );
436
+ }}
437
+ </For>
438
+ </div>
439
+ </Show>
440
+ </div>
441
+ </div>
442
+ );
443
+ }
444
+
445
+ function TimeRow(props: { time: string; onChange: (time: string) => void; label?: string }) {
446
+ const normalizeTime = (value: string): string => {
447
+ const [hours = "", minutes = ""] = value.split(":");
448
+ const h = Math.max(0, Math.min(23, Number(hours || 0)));
449
+ const m = Math.max(0, Math.min(59, Number(minutes || 0)));
450
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
451
+ };
452
+
453
+ const inputTime = (value: string): string => {
454
+ const digits = value.replace(/\D/g, "").slice(0, 4);
455
+ if (digits.length <= 2) return digits;
456
+ return `${digits.slice(0, 2)}:${digits.slice(2)}`;
457
+ };
458
+
459
+ return (
460
+ <label class={`grid min-w-0 items-center gap-2 ${props.label ? "grid-cols-[auto_minmax(0,1fr)]" : "grid-cols-1"}`}>
461
+ <Show when={props.label}>
462
+ <span class="min-w-0 text-[11px] text-dimmed">{props.label}</span>
463
+ </Show>
464
+ <div class="relative min-w-0 flex-1">
465
+ <input
466
+ type="text"
467
+ inputMode="numeric"
468
+ value={props.time}
469
+ placeholder="09:00"
470
+ class="input h-8 w-full min-w-0 pr-8 text-sm tabular-nums"
471
+ onInput={(event) => props.onChange(inputTime(event.currentTarget.value))}
472
+ onBlur={() => props.onChange(normalizeTime(props.time))}
473
+ aria-label={props.label ? `${props.label} time` : "Time"}
474
+ />
475
+ <i class="ti ti-clock pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-xs text-zinc-500" />
476
+ </div>
477
+ </label>
478
+ );
479
+ }
480
+
481
+ export function DatePicker(props: DatePickerProps) {
482
+ const [visibleMonth, setVisibleMonth] = createSignal(parseDateValue(props.value(), props.dateConfig));
483
+ const valueLabel = () => displayDate(props.value(), props.dateConfig);
484
+
485
+ return (
486
+ <PickerShell
487
+ owner={props}
488
+ icon="ti ti-calendar"
489
+ activeIcon="ti ti-calendar-event"
490
+ valueLabel={valueLabel}
491
+ clearValue={null}
492
+ onOpen={() => setVisibleMonth(parseDateValue(props.value(), props.dateConfig))}
493
+ >
494
+ {(close) => (
495
+ <div class="flex">
496
+ <PresetRail
497
+ presets={props.presets}
498
+ onSelect={(value) => {
499
+ props.onChange(value);
500
+ if (value) setVisibleMonth(parseDateValue(value, props.dateConfig));
501
+ close();
502
+ }}
503
+ />
504
+ <DatePickerPanel
505
+ visibleMonth={visibleMonth}
506
+ setVisibleMonth={setVisibleMonth}
507
+ selected={props.value}
508
+ onSelect={(value) => {
509
+ props.onChange(value);
510
+ close();
511
+ }}
512
+ dateConfig={props.dateConfig}
513
+ />
514
+ </div>
515
+ )}
516
+ </PickerShell>
517
+ );
518
+ }
519
+
520
+ export function DateTimePicker(props: DateTimePickerProps) {
521
+ const current = () => splitDateTime(props.value(), props.dateConfig);
522
+ const [visibleMonth, setVisibleMonth] = createSignal(parseDateValue(current().date, props.dateConfig));
523
+ const [draftDate, setDraftDate] = createSignal(current().date);
524
+ const [draftTime, setDraftTime] = createSignal(current().time || "09:00");
525
+ const valueLabel = () => formatDateTimeValue(props.value(), props.dateConfig);
526
+
527
+ const syncDraft = () => {
528
+ const next = current();
529
+ setDraftDate(next.date);
530
+ setDraftTime(next.time || "09:00");
531
+ if (next.date) setVisibleMonth(parseDateValue(next.date, props.dateConfig));
532
+ };
533
+
534
+ const apply = (close?: () => void) => {
535
+ props.onChange(toDateTimeValue(draftDate(), draftTime(), props.dateConfig));
536
+ close?.();
537
+ };
538
+
539
+ return (
540
+ <PickerShell
541
+ owner={props}
542
+ icon="ti ti-calendar-time"
543
+ activeIcon="ti ti-calendar-event"
544
+ valueLabel={valueLabel}
545
+ clearValue={null}
546
+ timezoneInfo
547
+ onOpen={syncDraft}
548
+ >
549
+ {(close) => (
550
+ <div>
551
+ <div class="flex">
552
+ <PresetRail
553
+ presets={props.presets}
554
+ onSelect={(value) => {
555
+ props.onChange(value);
556
+ const next = splitDateTime(value, props.dateConfig);
557
+ setDraftDate(next.date);
558
+ setDraftTime(next.time || "09:00");
559
+ if (next.date) setVisibleMonth(parseDateValue(next.date, props.dateConfig));
560
+ close();
561
+ }}
562
+ />
563
+ <DatePickerPanel
564
+ visibleMonth={visibleMonth}
565
+ setVisibleMonth={setVisibleMonth}
566
+ selected={draftDate}
567
+ onSelect={(value) => {
568
+ setDraftDate(value);
569
+ setVisibleMonth(parseDateValue(value, props.dateConfig));
570
+ }}
571
+ dateConfig={props.dateConfig}
572
+ />
573
+ </div>
574
+ <div class="flex items-center gap-2 px-3 pb-3">
575
+ <TimeRow time={draftTime()} onChange={setDraftTime} />
576
+ <button type="button" class="btn-primary btn-sm h-8" onClick={() => apply(close)} aria-label="Apply date and time">
577
+ <i class="ti ti-check" />
578
+ </button>
579
+ </div>
580
+ </div>
581
+ )}
582
+ </PickerShell>
583
+ );
584
+ }
585
+
586
+ export function DateRangePicker(props: DateRangePickerProps) {
587
+ const withTime = () => props.withTime ?? false;
588
+ const durationPresets = () => props.durationPresets ?? [];
589
+ const range = () => props.value();
590
+ const startParts = () => (withTime() ? splitDateTime(range().start, props.dateConfig) : { date: range().start ?? "", time: "09:00" });
591
+ const endParts = () => (withTime() ? splitDateTime(range().end, props.dateConfig) : { date: range().end ?? "", time: "10:00" });
592
+ const [visibleMonth, setVisibleMonth] = createSignal(parseDateValue(startParts().date || endParts().date, props.dateConfig));
593
+ const [draftRange, setDraftRange] = createSignal<DateRangeValue>({ start: startParts().date || null, end: endParts().date || null });
594
+ const [previewDate, setPreviewDate] = createSignal<string | null>(null);
595
+ const [startTime, setStartTime] = createSignal(startParts().time || "09:00");
596
+ const [endTime, setEndTime] = createSignal(endParts().time || "10:00");
597
+
598
+ const syncDraft = () => {
599
+ const start = startParts();
600
+ const end = endParts();
601
+ setDraftRange({ start: start.date || null, end: end.date || null });
602
+ setPreviewDate(null);
603
+ setStartTime(start.time || "09:00");
604
+ setEndTime(end.time || "10:00");
605
+ if (start.date || end.date) setVisibleMonth(parseDateValue(start.date || end.date, props.dateConfig));
606
+ };
607
+
608
+ const valueLabel = () => {
609
+ const value = range();
610
+ if (!value.start && !value.end) return "";
611
+ if (withTime()) {
612
+ const start = value.start ? formatDateTimeValue(value.start, props.dateConfig) : "Start";
613
+ const end = value.end ? formatDateTimeValue(value.end, props.dateConfig) : "End";
614
+ return `${start} to ${end}`;
615
+ }
616
+ const start = value.start ? displayDate(value.start, props.dateConfig) : "Start";
617
+ const end = value.end ? displayDate(value.end, props.dateConfig) : "End";
618
+ return `${start} to ${end}`;
619
+ };
620
+
621
+ const valueContent = () => {
622
+ const value = range();
623
+ const start = withTime()
624
+ ? value.start
625
+ ? formatDateTimeValue(value.start, props.dateConfig)
626
+ : "Start"
627
+ : value.start
628
+ ? displayDate(value.start, props.dateConfig)
629
+ : "Start";
630
+ const end = withTime()
631
+ ? value.end
632
+ ? formatDateTimeValue(value.end, props.dateConfig)
633
+ : "End"
634
+ : value.end
635
+ ? displayDate(value.end, props.dateConfig)
636
+ : "End";
637
+ return (
638
+ <span class="inline-flex min-w-0 items-center gap-1.5">
639
+ <span class="min-w-0 truncate">{start}</span>
640
+ <i class="ti ti-arrow-narrow-right shrink-0 text-sm text-zinc-400" aria-hidden="true" />
641
+ <span class="min-w-0 truncate">{end}</span>
642
+ </span>
643
+ );
644
+ };
645
+
646
+ const commitRange = (close?: () => void) => {
647
+ const draft = draftRange();
648
+ if (!withTime()) {
649
+ props.onChange(draft);
650
+ close?.();
651
+ return;
652
+ }
653
+ props.onChange({
654
+ start: draft.start ? toDateTimeValue(draft.start, startTime(), props.dateConfig) : null,
655
+ end: draft.end ? toDateTimeValue(draft.end, endTime(), props.dateConfig) : null,
656
+ });
657
+ close?.();
658
+ };
659
+
660
+ const selectDatePreset = (value: string | null) => {
661
+ if (!value) {
662
+ setDraftRange({ start: null, end: null });
663
+ setPreviewDate(null);
664
+ return;
665
+ }
666
+ setDraftRange({ start: value, end: value });
667
+ setPreviewDate(null);
668
+ setVisibleMonth(parseDateValue(value, props.dateConfig));
669
+ };
670
+
671
+ const selectDate = (value: string) => {
672
+ const current = draftRange();
673
+ if (!current.start || current.end) {
674
+ setDraftRange({ start: value, end: null });
675
+ setPreviewDate(null);
676
+ return;
677
+ }
678
+ setPreviewDate(null);
679
+ setDraftRange(compareDateKey(value, current.start) < 0 ? { start: value, end: current.start } : { start: current.start, end: value });
680
+ };
681
+
682
+ const displayRange = () => {
683
+ const current = draftRange();
684
+ const preview = previewDate();
685
+ if (!current.start || current.end || !preview) return current;
686
+ return compareDateKey(preview, current.start) < 0 ? { start: preview, end: current.start } : { start: current.start, end: preview };
687
+ };
688
+
689
+ const durationPreview = () => {
690
+ const draft = displayRange();
691
+ if (!draft.start || !draft.end) return "";
692
+ if (!withTime()) return formatDateOnlyRangeDuration(draft, props.dateConfig);
693
+ if (!isCompleteTime(startTime()) || !isCompleteTime(endTime())) return "";
694
+ const start = toDateTimeValue(draft.start, startTime(), props.dateConfig);
695
+ const end = toDateTimeValue(draft.end, endTime(), props.dateConfig);
696
+ if (!start || !end) return "";
697
+ return dates.formatDuration(start, end);
698
+ };
699
+
700
+ const currentDurationMinutes = createMemo(() => {
701
+ const draft = displayRange();
702
+ if (!draft.start || !draft.end || !isCompleteTime(startTime()) || !isCompleteTime(endTime())) return null;
703
+ const start = toDateTimeValue(draft.start, startTime(), props.dateConfig);
704
+ const end = toDateTimeValue(draft.end, endTime(), props.dateConfig);
705
+ if (!start || !end) return null;
706
+ return Math.round((new Date(end).getTime() - new Date(start).getTime()) / 60_000);
707
+ });
708
+
709
+ const applyDuration = (minutes: number) => {
710
+ const draft = draftRange();
711
+ if (!draft.start || !isCompleteTime(startTime())) return;
712
+ const start = toDateTimeValue(draft.start, startTime(), props.dateConfig);
713
+ if (!start) return;
714
+ const end = new Date(new Date(start).getTime() + minutes * 60_000).toISOString();
715
+ const next = splitDateTime(end, props.dateConfig);
716
+ setDraftRange({ start: draft.start, end: next.date || draft.end || draft.start });
717
+ setEndTime(next.time || endTime());
718
+ setPreviewDate(null);
719
+ };
720
+
721
+ return (
722
+ <PickerShell
723
+ owner={props}
724
+ icon="ti ti-calendar-stats"
725
+ activeIcon="ti ti-calendar-event"
726
+ valueLabel={valueLabel}
727
+ valueContent={valueContent}
728
+ clearValue={{ start: null, end: null }}
729
+ timezoneInfo={withTime()}
730
+ footerMeta={() => {
731
+ const duration = durationPreview();
732
+ if (!duration) return undefined;
733
+ return (
734
+ <span class="inline-flex items-center gap-1">
735
+ <i class="ti ti-hourglass-low" />
736
+ <span>{duration}</span>
737
+ </span>
738
+ );
739
+ }}
740
+ onOpen={syncDraft}
741
+ wide={!!(props.datePresets?.length || props.presets?.length)}
742
+ >
743
+ {(close) => (
744
+ <div>
745
+ <div class="flex">
746
+ <Show
747
+ when={props.datePresets?.length}
748
+ fallback={
749
+ <PresetRail
750
+ presets={props.presets}
751
+ onSelect={(value) => {
752
+ props.onChange(value);
753
+ const start = withTime()
754
+ ? splitDateTime(value.start, props.dateConfig)
755
+ : { date: value.start ?? "", time: startTime() };
756
+ const end = withTime() ? splitDateTime(value.end, props.dateConfig) : { date: value.end ?? "", time: endTime() };
757
+ setDraftRange({ start: start.date || null, end: end.date || null });
758
+ setPreviewDate(null);
759
+ setStartTime(start.time || "09:00");
760
+ setEndTime(end.time || "10:00");
761
+ if (start.date || end.date) setVisibleMonth(parseDateValue(start.date || end.date, props.dateConfig));
762
+ close();
763
+ }}
764
+ />
765
+ }
766
+ >
767
+ <PresetRail presets={props.datePresets} onSelect={selectDatePreset} />
768
+ </Show>
769
+ <DatePickerPanel
770
+ visibleMonth={visibleMonth}
771
+ setVisibleMonth={setVisibleMonth}
772
+ range={displayRange}
773
+ onSelect={selectDate}
774
+ onDayPreview={(date) => {
775
+ const current = draftRange();
776
+ setPreviewDate(current.start && !current.end ? date : null);
777
+ }}
778
+ dateConfig={props.dateConfig}
779
+ />
780
+ </div>
781
+ <Show when={withTime()}>
782
+ <div class={`flex min-w-0 items-end gap-2 px-2.5 ${durationPresets().length > 0 ? "pb-1.5" : "pb-2.5"}`}>
783
+ <div class="min-w-0 flex-1">
784
+ <TimeRow label="Start" time={startTime()} onChange={setStartTime} />
785
+ </div>
786
+ <div class="min-w-0 flex-1">
787
+ <TimeRow label="End" time={endTime()} onChange={setEndTime} />
788
+ </div>
789
+ <button
790
+ type="button"
791
+ class="btn-primary btn-sm h-8 w-9 shrink-0 px-0"
792
+ onClick={() => commitRange(close)}
793
+ aria-label="Apply date range"
794
+ >
795
+ <i class="ti ti-check" />
796
+ </button>
797
+ </div>
798
+ <Show when={durationPresets().length > 0}>
799
+ <div class="flex min-w-0 items-center gap-1.5 px-2.5 pb-2">
800
+ <div class="flex shrink-0 items-center gap-1 text-[11px] text-dimmed">
801
+ <i class="ti ti-clock-hour-3" />
802
+ <span>Duration</span>
803
+ </div>
804
+ <div class="flex min-w-0 flex-wrap gap-1">
805
+ <For each={durationPresets()}>
806
+ {(preset) => {
807
+ const active = () => currentDurationMinutes() === preset.minutes;
808
+ return (
809
+ <button
810
+ type="button"
811
+ class={`btn-segment h-6 rounded-md px-2 text-[11px] ${
812
+ active() ? "bg-blue-50 text-blue-600 dark:bg-blue-500/15 dark:text-blue-300" : ""
813
+ }`}
814
+ aria-pressed={active()}
815
+ onPointerDown={(event) => {
816
+ event.preventDefault();
817
+ applyDuration(preset.minutes);
818
+ }}
819
+ onClick={(event) => {
820
+ if (event.detail > 0) return;
821
+ applyDuration(preset.minutes);
822
+ }}
823
+ >
824
+ {preset.label}
825
+ </button>
826
+ );
827
+ }}
828
+ </For>
829
+ </div>
830
+ </div>
831
+ </Show>
832
+ </Show>
833
+ <Show when={!withTime()}>
834
+ <div class="flex justify-end px-3 pb-3">
835
+ <button type="button" class="btn-primary btn-sm" onClick={() => commitRange(close)}>
836
+ Apply
837
+ </button>
838
+ </div>
839
+ </Show>
840
+ </div>
841
+ )}
842
+ </PickerShell>
843
+ );
844
+ }
845
+
846
+ export default DatePicker;