@lumen-design/date-time-picker 0.0.2

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.
@@ -0,0 +1,387 @@
1
+ <script lang="ts">
2
+ import { createDropdownTransition } from '../../utils'
3
+ import { onDestroy, onMount } from 'svelte'
4
+ import Icon from '@lumen-design/icon'
5
+ import { Calendar, ChevronLeft, ChevronRight, X, ChevronDown, ChevronsLeft, ChevronsRight } from 'lucide'
6
+
7
+ import type { DateTimePickerProps } from './types'
8
+
9
+ const pad2 = (n: number): string => String(n).padStart(2, '0')
10
+
11
+ const parseTimeParts = (val: string): { h: string; m: string; s: string } => {
12
+ if (!val) return { h: '00', m: '00', s: '00' }
13
+ const [h = '00', m = '00', s = '00'] = val.split(':')
14
+ return {
15
+ h: h.padStart(2, '0').slice(-2),
16
+ m: m.padStart(2, '0').slice(-2),
17
+ s: s.padStart(2, '0').slice(-2),
18
+ }
19
+ }
20
+
21
+ const formatDate = (date: Date, fmt: string): string => {
22
+ const y = date.getFullYear()
23
+ const m = pad2(date.getMonth() + 1)
24
+ const d = pad2(date.getDate())
25
+ return fmt.replace('YYYY', String(y)).replace('MM', m).replace('DD', d)
26
+ }
27
+
28
+ const formatTime = (date: Date, timeFormat: string): string => {
29
+ const h = pad2(date.getHours())
30
+ const m = pad2(date.getMinutes())
31
+ const s = pad2(date.getSeconds())
32
+ return timeFormat.includes('ss') ? `${h}:${m}:${s}` : `${h}:${m}`
33
+ }
34
+
35
+ const normalizeTimeInput = (input: string, timeFormat: string): string => {
36
+ const raw = (input ?? '').trim()
37
+ if (!raw) return ''
38
+ const wantSeconds = timeFormat.includes('ss')
39
+ const parts = raw.split(':')
40
+ if (parts.length < 2 || parts.length > 3) return ''
41
+
42
+ const h = Number.parseInt(parts[0] ?? '0', 10)
43
+ const m = Number.parseInt(parts[1] ?? '0', 10)
44
+ const s = Number.parseInt(parts[2] ?? '0', 10)
45
+ if (!Number.isFinite(h) || !Number.isFinite(m) || (!wantSeconds && parts.length === 3 && !Number.isFinite(s))) return ''
46
+ if (h < 0 || h > 23 || m < 0 || m > 59) return ''
47
+ if (wantSeconds && (s < 0 || s > 59)) return ''
48
+ return wantSeconds ? `${pad2(h)}:${pad2(m)}:${pad2(s)}` : `${pad2(h)}:${pad2(m)}`
49
+ }
50
+
51
+ const toDate = (val: Date | string): Date => (val instanceof Date ? val : new Date(val))
52
+
53
+ const parseDateInput = (input: string): Date | null => {
54
+ const raw = (input ?? '').trim()
55
+ if (!raw) return null
56
+ const parts = raw.replace(/\//g, '-').split('-')
57
+ if (parts.length !== 3) return null
58
+ const y = Number.parseInt(parts[0] ?? '0', 10)
59
+ const m = Number.parseInt(parts[1] ?? '0', 10)
60
+ const d = Number.parseInt(parts[2] ?? '0', 10)
61
+ if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) return null
62
+ if (y < 1000 || m < 1 || m > 12 || d < 1 || d > 31) return null
63
+ const dt = new Date(y, m - 1, d)
64
+ // reject overflow dates like 2026-02-31
65
+ if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return null
66
+ return dt
67
+ }
68
+
69
+ const combine = (dateOnly: Date, t: { h: string; m: string; s: string }, timeFormat: string): Date => {
70
+ const next = new Date(dateOnly)
71
+ const h = Number.parseInt(t.h, 10)
72
+ const m = Number.parseInt(t.m, 10)
73
+ const s = Number.parseInt(t.s, 10)
74
+ next.setHours(Number.isFinite(h) ? h : 0, Number.isFinite(m) ? m : 0, timeFormat.includes('ss') && Number.isFinite(s) ? s : 0, 0)
75
+ return next
76
+ }
77
+
78
+ let {
79
+ modelValue = $bindable<Date | string | null>(null),
80
+ placeholder = '选择日期时间',
81
+ disabled = false,
82
+ clearable = false,
83
+ readonly = false,
84
+ size = 'default',
85
+ dateFormat = 'YYYY-MM-DD',
86
+ datePlaceholder = '选择日期',
87
+ timeFormat = 'HH:mm:ss',
88
+ defaultTime = '00:00:00',
89
+ timePlaceholder = '选择时间',
90
+ showMenuArrow = true,
91
+ class: cls = '',
92
+ ...attrs
93
+ }: DateTimePickerProps = $props()
94
+
95
+ const showSeconds = $derived(timeFormat.includes('ss'))
96
+
97
+ let visible = $state(false)
98
+ let reduceMotion = $state(false)
99
+ let rootRef: HTMLDivElement | null = $state(null)
100
+
101
+ let currentYear = $state(new Date().getFullYear())
102
+ let currentMonth = $state(new Date().getMonth())
103
+
104
+ let tempDate = $state<Date | null>(null)
105
+ let tempDateText = $state('')
106
+ let tempTime = $state('')
107
+
108
+ const dropdownId = `lm-date-time-picker-dropdown-${Math.random().toString(36).slice(2)}`
109
+
110
+ const selectedCommitted = $derived(modelValue ? toDate(modelValue) : null)
111
+
112
+ const displayValue = $derived.by(() => {
113
+ if (!selectedCommitted) return ''
114
+ const dateStr = formatDate(selectedCommitted, dateFormat)
115
+ const timeStr = formatTime(selectedCommitted, timeFormat)
116
+ return `${dateStr} ${timeStr}`
117
+ })
118
+
119
+ const weekDays = ['日', '一', '二', '三', '四', '五', '六']
120
+
121
+ const daysInMonth = $derived.by(() => {
122
+ const firstDay = new Date(currentYear, currentMonth, 1)
123
+ const lastDay = new Date(currentYear, currentMonth + 1, 0)
124
+ const days: { date: Date; isCurrentMonth: boolean }[] = []
125
+ const startDay = firstDay.getDay()
126
+
127
+ for (let i = startDay - 1; i >= 0; i--) {
128
+ days.push({ date: new Date(currentYear, currentMonth, -i), isCurrentMonth: false })
129
+ }
130
+ for (let i = 1; i <= lastDay.getDate(); i++) {
131
+ days.push({ date: new Date(currentYear, currentMonth, i), isCurrentMonth: true })
132
+ }
133
+ const remaining = 42 - days.length
134
+ for (let i = 1; i <= remaining; i++) {
135
+ days.push({ date: new Date(currentYear, currentMonth + 1, i), isCurrentMonth: false })
136
+ }
137
+ return days
138
+ })
139
+
140
+ const isSelected = (date: Date): boolean => {
141
+ if (!tempDate) return false
142
+ return date.getFullYear() === tempDate.getFullYear() && date.getMonth() === tempDate.getMonth() && date.getDate() === tempDate.getDate()
143
+ }
144
+
145
+ const isToday = (date: Date): boolean => {
146
+ const today = new Date()
147
+ return date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth() && date.getDate() === today.getDate()
148
+ }
149
+
150
+ const openWithCommittedOrNow = (): void => {
151
+ const base = selectedCommitted ?? new Date()
152
+ tempDate = new Date(base.getFullYear(), base.getMonth(), base.getDate())
153
+ tempDateText = formatDate(tempDate, dateFormat)
154
+
155
+ if (selectedCommitted) tempTime = formatTime(selectedCommitted, timeFormat)
156
+ else tempTime = normalizeTimeInput(defaultTime, timeFormat) || (showSeconds ? '00:00:00' : '00:00')
157
+
158
+ currentYear = base.getFullYear()
159
+ currentMonth = base.getMonth()
160
+ }
161
+
162
+ const toggle = (): void => {
163
+ if (disabled || readonly) return
164
+ const next = !visible
165
+ visible = next
166
+ if (next) openWithCommittedOrNow()
167
+ }
168
+
169
+ const closeAndRevert = (): void => {
170
+ visible = false
171
+ // revert temp state next time open
172
+ }
173
+
174
+ const handleKeydown = (e: KeyboardEvent): void => {
175
+ if (e.key === 'Escape') closeAndRevert()
176
+ else if (e.key === 'Enter' || e.key === ' ') toggle()
177
+ }
178
+
179
+ const handleClickOutside = (e: MouseEvent): void => {
180
+ if (visible && rootRef && !rootRef.contains(e.target as Node)) closeAndRevert()
181
+ }
182
+
183
+ const handleClear = (e: MouseEvent): void => {
184
+ e.stopPropagation()
185
+ modelValue = null
186
+ closeAndRevert()
187
+ }
188
+
189
+ const prevMonth = (): void => {
190
+ if (currentMonth === 0) {
191
+ currentMonth = 11
192
+ currentYear--
193
+ } else {
194
+ currentMonth--
195
+ }
196
+ }
197
+
198
+ const prevYear = (): void => {
199
+ currentYear--
200
+ }
201
+
202
+ const nextMonth = (): void => {
203
+ if (currentMonth === 11) {
204
+ currentMonth = 0
205
+ currentYear++
206
+ } else {
207
+ currentMonth++
208
+ }
209
+ }
210
+
211
+ const nextYear = (): void => {
212
+ currentYear++
213
+ }
214
+
215
+ const handleSelectDate = (date: Date): void => {
216
+ tempDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
217
+ tempDateText = formatDate(tempDate, dateFormat)
218
+ }
219
+
220
+ const setNow = (): void => {
221
+ const now = new Date()
222
+ tempDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
223
+ tempDateText = formatDate(tempDate, dateFormat)
224
+
225
+ tempTime = formatTime(now, timeFormat)
226
+
227
+ currentYear = now.getFullYear()
228
+ currentMonth = now.getMonth()
229
+ }
230
+
231
+ const normalizedDate = $derived.by(() => parseDateInput(tempDateText) ?? tempDate)
232
+
233
+ const isDateValid = $derived.by(() => !!normalizedDate)
234
+
235
+ const isTimeValid = $derived.by(() => {
236
+ const normalized = normalizeTimeInput(tempTime || defaultTime, timeFormat)
237
+ return !!normalized
238
+ })
239
+
240
+ const handleConfirm = (): void => {
241
+ const normalized = normalizeTimeInput(tempTime || defaultTime, timeFormat)
242
+ const dateToUse = normalizedDate
243
+ if (!dateToUse || !normalized) return
244
+ modelValue = combine(dateToUse, parseTimeParts(normalized), timeFormat)
245
+ visible = false
246
+ }
247
+
248
+ const dropdownTransition = $derived(createDropdownTransition(reduceMotion))
249
+
250
+ $effect(() => {
251
+ if (visible) document.addEventListener('click', handleClickOutside)
252
+ return () => document.removeEventListener('click', handleClickOutside)
253
+ })
254
+
255
+
256
+ onMount(() => {
257
+ if (typeof window !== 'undefined') {
258
+ reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false
259
+ }
260
+ })
261
+
262
+ onDestroy(() => {
263
+ document.removeEventListener('click', handleClickOutside)
264
+ })
265
+
266
+ const classes = $derived(['lm-date-time-picker', `lm-date-time-picker--${size}`, disabled && 'is-disabled', cls].filter(Boolean).join(' '))
267
+ </script>
268
+
269
+ <div bind:this={rootRef} class={classes} {...attrs}>
270
+ <div
271
+ class="lm-date-time-picker__trigger"
272
+ class:is-focus={visible}
273
+ role="combobox"
274
+ tabindex={disabled ? -1 : 0}
275
+ aria-controls={dropdownId}
276
+ aria-expanded={visible}
277
+ aria-disabled={disabled}
278
+ onclick={toggle}
279
+ onkeydown={handleKeydown}
280
+ >
281
+ <span class="lm-date-time-picker__prefix">
282
+ <Icon icon={Calendar} size={14} />
283
+ </span>
284
+ <span class="lm-date-time-picker__value" class:is-placeholder={!displayValue}>
285
+ {displayValue || placeholder}
286
+ </span>
287
+
288
+ {#if clearable && modelValue && !disabled}
289
+ <button type="button" class="lm-date-time-picker__clear" onclick={handleClear} aria-label="清除">
290
+ <Icon icon={X} size={14} />
291
+ </button>
292
+ {:else}
293
+ <span class="lm-date-time-picker__arrow" class:is-open={visible} aria-hidden="true">
294
+ <Icon icon={ChevronDown} size={14} />
295
+ </span>
296
+ {/if}
297
+ </div>
298
+
299
+ {#if visible}
300
+ <div id={dropdownId} class="lm-date-time-picker__dropdown" role="dialog" transition:dropdownTransition>
301
+ {#if showMenuArrow}
302
+ <div class="lm-date-time-picker__menu-arrow" aria-hidden="true"></div>
303
+ {/if}
304
+
305
+ <div class="lm-date-time-picker__panel">
306
+ <div class="lm-date-time-picker__date-panel">
307
+ <div class="lm-date-time-picker__time-inputs" aria-label="日期时间输入">
308
+ <input
309
+ class="lm-date-time-picker__date-input"
310
+ type="text"
311
+ value={tempDateText}
312
+ placeholder={datePlaceholder}
313
+ oninput={(e: Event) => (tempDateText = (e.currentTarget as HTMLInputElement).value)}
314
+ onblur={() => {
315
+ const parsed = parseDateInput(tempDateText)
316
+ if (parsed) {
317
+ tempDate = parsed
318
+ tempDateText = formatDate(parsed, dateFormat)
319
+ currentYear = parsed.getFullYear()
320
+ currentMonth = parsed.getMonth()
321
+ }
322
+ }}
323
+ />
324
+ <input
325
+ class="lm-date-time-picker__time-input"
326
+ type="text"
327
+ value={tempTime}
328
+ placeholder={timePlaceholder}
329
+ oninput={(e: Event) => (tempTime = (e.currentTarget as HTMLInputElement).value)}
330
+ onblur={() => {
331
+ const normalized = normalizeTimeInput(tempTime, timeFormat)
332
+ if (normalized) tempTime = normalized
333
+ }}
334
+ />
335
+ </div>
336
+
337
+ <div class="lm-date-time-picker__header">
338
+ <div class="lm-date-time-picker__nav-group" aria-hidden="false">
339
+ <button type="button" class="lm-date-time-picker__nav" onclick={prevYear} aria-label="上一年">
340
+ <Icon icon={ChevronsLeft} size={16} />
341
+ </button>
342
+ <button type="button" class="lm-date-time-picker__nav" onclick={prevMonth} aria-label="上个月">
343
+ <Icon icon={ChevronLeft} size={14} />
344
+ </button>
345
+ </div>
346
+ <span class="lm-date-time-picker__title">{currentYear}年 {currentMonth + 1}月</span>
347
+ <div class="lm-date-time-picker__nav-group" aria-hidden="false">
348
+ <button type="button" class="lm-date-time-picker__nav" onclick={nextMonth} aria-label="下个月">
349
+ <Icon icon={ChevronRight} size={14} />
350
+ </button>
351
+ <button type="button" class="lm-date-time-picker__nav" onclick={nextYear} aria-label="下一年">
352
+ <Icon icon={ChevronsRight} size={16} />
353
+ </button>
354
+ </div>
355
+ </div>
356
+
357
+ <div class="lm-date-time-picker__body">
358
+ <div class="lm-date-time-picker__week">
359
+ {#each weekDays as day}
360
+ <span class="lm-date-time-picker__week-day">{day}</span>
361
+ {/each}
362
+ </div>
363
+ <div class="lm-date-time-picker__days">
364
+ {#each daysInMonth as item (item.date.toISOString())}
365
+ <button
366
+ type="button"
367
+ class="lm-date-time-picker__day"
368
+ class:is-other={!item.isCurrentMonth}
369
+ class:is-today={isToday(item.date)}
370
+ class:is-selected={isSelected(item.date)}
371
+ onclick={() => handleSelectDate(item.date)}
372
+ >
373
+ {item.date.getDate()}
374
+ </button>
375
+ {/each}
376
+ </div>
377
+ </div>
378
+ </div>
379
+ </div>
380
+
381
+ <div class="lm-date-time-picker__footer">
382
+ <button type="button" class="lm-date-time-picker__btn lm-date-time-picker__btn--text" onclick={setNow}>此刻</button>
383
+ <button type="button" class="lm-date-time-picker__btn" onclick={handleConfirm} disabled={!isDateValid || !isTimeValid}>确定</button>
384
+ </div>
385
+ </div>
386
+ {/if}
387
+ </div>
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ import DateTimePicker from './DateTimePicker.svelte';
2
+ export default DateTimePicker;
3
+ export { DateTimePicker };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@lumen-design/date-time-picker",
3
+ "version": "0.0.2",
4
+ "description": "DateTimePicker component for Lumen UI",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "svelte": "./dist/index.js",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "scripts": {
20
+ "build": "svelte-package -i src -o dist --types",
21
+ "build:watch": "svelte-package -i src -o dist --types -w"
22
+ },
23
+ "dependencies": {
24
+ "@lumen-design/icon": "0.0.2",
25
+ "lucide": "^0.563.0",
26
+ "@lumen-design/utils": "0.0.2"
27
+ },
28
+ "devDependencies": {
29
+ "@sveltejs/package": "^2.5.7",
30
+ "svelte": "5.48.2"
31
+ },
32
+ "peerDependencies": {
33
+ "svelte": "^5.0.0"
34
+ }
35
+ }