@valentinkolb/cloud 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/package.json +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +113 -10
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +0 -2
- package/src/services/auth-flows/magic-link.ts +3 -2
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +64 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +49 -0
- package/src/shared/redirect.ts +52 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /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;
|