@lichta/svelte 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,14 @@
1
1
  # @lichta/svelte
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@lichta/svelte.svg)](https://www.npmjs.com/package/@lichta/svelte)
3
+ [![@lichta/svelte](https://img.shields.io/npm/v/@lichta/svelte.svg?label=%40lichta%2Fsvelte)](https://www.npmjs.com/package/@lichta/svelte)
4
+ [![@lichta/core](https://img.shields.io/npm/v/@lichta/core.svg?label=%40lichta%2Fcore)](https://www.npmjs.com/package/@lichta/core)
5
+
6
+ 🚀 **[Demo trực tiếp](https://lichta.zeneo.app/)**
4
7
 
5
8
  Component `Calendar`/`Formatter` (lịch tháng có ngày âm lịch) cho Svelte 5, đóng gói trên [`@lichta/core`](https://www.npmjs.com/package/@lichta/core).
6
9
 
10
+ Package này gồm 3 component: `Calendar` (lịch tháng), `Formatter` (hiển thị ngày âm cho 1 ngày cụ thể), `DatePicker` (input + popover chọn ngày, dựng trên `Calendar`).
11
+
7
12
  Khác với `@lichta/react`/`@lichta/vue`, package này **re-export toàn bộ API của `@lichta/core`** (`LichTa`, `getYearDetails`, `formatTraditional`, `t`, ...) — bạn không cần cài `@lichta/core` riêng, chỉ cần import thẳng từ `@lichta/svelte`.
8
13
 
9
14
  ## Cài đặt
@@ -58,8 +63,11 @@ Bạn cần cài `svelte` (peer dependency, `^5.0.0`) nếu dự án chưa có.
58
63
  | `selectedDate` | `Date \| null` | `null` | Ngày được chọn (controlled) |
59
64
  | `showLunar` | `boolean` | `true` | Hiện ngày âm lịch dưới ngày dương |
60
65
  | `locale` | `'vi' \| 'en' \| 'ja' \| 'ko'` | `'vi'` | Ngôn ngữ nhãn thứ trong tuần |
66
+ | `firstDayOfWeek` | `0 \| 1` | `0` | Ngày bắt đầu tuần: `0` = Chủ Nhật, `1` = Thứ Hai |
67
+ | `showWeekNumber` | `boolean` | `false` | Hiện số tuần (ISO-8601) ở đầu mỗi hàng |
61
68
  | `theme` | `'classic' \| 'glass'` | `'classic'` | Giao diện lịch |
62
69
  | `onSelect` | `(date: Date, lunar: LunarDate) => void` | — | Callback khi chọn ngày |
70
+ | `onMonthChange` | `(month: number, year: number) => void` | — | Callback khi điều hướng tháng qua nút prev/next trong header |
63
71
  | `dayCell` | `Snippet<[DayCellData]>` | — | Custom snippet cho mỗi ô ngày |
64
72
  | `children` | `Snippet` | — | Nội dung footer tùy chỉnh |
65
73
 
@@ -70,6 +78,37 @@ Bạn cần cài `svelte` (peer dependency, `^5.0.0`) nếu dự án chưa có.
70
78
  | `date` | `Date` | `new Date()` | Ngày dương lịch cần hiển thị |
71
79
  | `children` | `Snippet` | — | Nội dung tùy chỉnh (render prop) |
72
80
 
81
+ ### `DatePicker`
82
+
83
+ ```svelte
84
+ <script>
85
+ import { DatePicker } from '@lichta/svelte';
86
+ import '@lichta/core/styles/calendar-base.css';
87
+ import '@lichta/core/styles/calendar-glass.css'; // theme "glass" (tùy chọn)
88
+ import '@lichta/core/styles/datepicker-base.css';
89
+ import '@lichta/core/styles/datepicker-glass.css'; // theme "glass" (tùy chọn)
90
+ </script>
91
+
92
+ <DatePicker theme="glass" locale="vi" onSelect={(date, lunar) => console.log(date, lunar)} />
93
+ ```
94
+
95
+ > ⚠️ Cần import cả CSS của `Calendar` (dùng trong popover) lẫn `DatePicker` (input + popover chrome) — thiếu 1 trong 2 sẽ hiển thị thiếu style.
96
+
97
+ | Prop | Kiểu | Mặc định | Mô tả |
98
+ | ----------- | ----------------------------------------- | -------------- | ----------------------------------------------------------------------- |
99
+ | `value` | `Date \| null` | `null` | Ngày đang chọn (uncontrolled — component tự quản lý sau lần mount đầu) |
100
+ | `onSelect` | `(date: Date, lunar: LunarDate) => void` | — | Callback khi chọn ngày trong popover |
101
+ | `onMonthChange` | `(month: number, year: number) => void` | — | Callback khi điều hướng tháng qua nút prev/next trong popover |
102
+ | `placeholder` | `string` | `'Chọn ngày'` | Placeholder khi chưa chọn ngày |
103
+ | `locale` | `'vi' \| 'en' \| 'ja' \| 'ko'` | `'vi'` | Ngôn ngữ hiển thị trong popover |
104
+ | `firstDayOfWeek` | `0 \| 1` | `0` | Ngày bắt đầu tuần: `0` = Chủ Nhật, `1` = Thứ Hai |
105
+ | `theme` | `'classic' \| 'glass'` | `'classic'` | Giao diện input + popover |
106
+ | `showLunar` | `boolean` | `true` | Hiện ngày âm lịch trong popover |
107
+ | `format` | `(date: Date) => string` | `dd/MM/yyyy` | Hàm format ngày hiển thị trên input |
108
+ | `disabled` | `boolean` | `false` | Vô hiệu hoá input |
109
+
110
+ Popover tự đóng khi: chọn xong 1 ngày, click ra ngoài, hoặc nhấn phím `Escape`.
111
+
73
112
  ## Dùng logic thuần (không cần component)
74
113
 
75
114
  Vì `@lichta/svelte` re-export toàn bộ `@lichta/core`, bạn có thể import thẳng logic tính toán từ đây thay vì cài thêm `@lichta/core`:
@@ -1,21 +1,15 @@
1
1
  <script lang="ts">
2
- import { LichTa, getYearDetails } from '@lichta/core';
3
- import type { LunarDate } from '@lichta/core';
2
+ import { getYearDetails, getCalendarGrid, getWeekDayLabels, t } from '@lichta/core';
3
+ import type { LunarDate, CalendarDayCell } from '@lichta/core';
4
4
  import type { Snippet } from 'svelte';
5
5
  import { untrack } from 'svelte';
6
6
 
7
- import type { Locale } from '@lichta/core';
7
+ import type { Locale, FirstDayOfWeek } from '@lichta/core';
8
8
 
9
9
  /**
10
10
  * Dữ liệu cho mỗi ô ngày trong lưới lịch
11
11
  */
12
- interface DayCellData {
13
- solar: Date;
14
- lunar: LunarDate;
15
- isToday: boolean;
16
- isSelected: boolean;
17
- isCurrentMonth: boolean;
18
- }
12
+ type DayCellData = CalendarDayCell;
19
13
 
20
14
  interface Props {
21
15
  /** Tháng hiển thị (1-12). Mặc định: tháng hiện tại */
@@ -26,10 +20,16 @@
26
20
  selectedDate?: Date | null;
27
21
  /** Callback khi chọn ngày */
28
22
  onSelect?: (date: Date, lunar: LunarDate) => void;
23
+ /** Callback khi user điều hướng tháng qua nút prev/next trong header */
24
+ onMonthChange?: (month: number, year: number) => void;
29
25
  /** Hiển thị ngày âm lịch bên dưới ngày dương */
30
26
  showLunar?: boolean;
31
27
  /** Locale (vi | en | ja | ko) */
32
28
  locale?: Locale;
29
+ /** Ngày bắt đầu tuần: 0 = Chủ Nhật (mặc định), 1 = Thứ Hai */
30
+ firstDayOfWeek?: FirstDayOfWeek;
31
+ /** Hiển thị số tuần (ISO-8601) ở đầu mỗi hàng */
32
+ showWeekNumber?: boolean;
33
33
  /** Custom snippet cho mỗi ô ngày */
34
34
  dayCell?: Snippet<[DayCellData]>;
35
35
  children?: Snippet;
@@ -42,8 +42,11 @@
42
42
  year = new Date().getFullYear(),
43
43
  selectedDate = null,
44
44
  onSelect,
45
+ onMonthChange,
45
46
  showLunar = true,
46
47
  locale = 'vi',
48
+ firstDayOfWeek = 0,
49
+ showWeekNumber = false,
47
50
  dayCell,
48
51
  children,
49
52
  theme = 'classic'
@@ -66,127 +69,32 @@
66
69
  });
67
70
 
68
71
  // Tên thứ trong tuần
69
- const weekDayLabels = $derived(
70
- locale === 'vi'
71
- ? ['CN', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7']
72
- : ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
73
- );
72
+ const weekDayLabels = $derived(getWeekDayLabels(locale, firstDayOfWeek));
74
73
 
75
- // Tên tháng hiển thị
76
- const monthNames = [
77
- 'Tháng 1',
78
- 'Tháng 2',
79
- 'Tháng 3',
80
- 'Tháng 4',
81
- 'Tháng 5',
82
- 'Tháng 6',
83
- 'Tháng 7',
84
- 'Tháng 8',
85
- 'Tháng 9',
86
- 'Tháng 10',
87
- 'Tháng 11',
88
- 'Tháng 12'
89
- ];
74
+ // Tên tháng dương lịch cho phần header điều hướng
75
+ const monthNames = $derived(t(locale).solarMonthNames);
90
76
 
91
77
  // Can Chi năm hiển thị
92
78
  let yearInfo = $derived(getYearDetails(currentYear));
93
79
 
94
- // Ngày hôm nay
95
- const today = new Date();
96
- const todayStr = `${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}`;
97
-
98
80
  // Tính grid lịch 6x7 = 42 ô
99
- let calendarGrid = $derived.by((): DayCellData[] => {
100
- const grid: DayCellData[] = [];
101
-
102
- // Ngày đầu tiên của tháng
103
- const firstDay = new Date(currentYear, currentMonth - 1, 1);
104
- // Thứ của ngày đầu tiên (0=CN, 1=T2, ...)
105
- const startDayOfWeek = firstDay.getDay();
106
- // Số ngày trong tháng
107
- const daysInMonth = new Date(currentYear, currentMonth, 0).getDate();
108
-
109
- // Padding ngày tháng trước
110
- const prevMonthDays = new Date(currentYear, currentMonth - 1, 0).getDate();
111
- for (let i = startDayOfWeek - 1; i >= 0; i--) {
112
- const d = prevMonthDays - i;
113
- const m = currentMonth - 1 <= 0 ? 12 : currentMonth - 1;
114
- const y = currentMonth - 1 <= 0 ? currentYear - 1 : currentYear;
115
- const solar = new Date(y, m - 1, d);
116
- const lunar = LichTa.toLunar(d, m, y);
117
- const dateStr = `${y}-${m}-${d}`;
118
- const selStr = selectedDate
119
- ? `${selectedDate.getFullYear()}-${selectedDate.getMonth() + 1}-${selectedDate.getDate()}`
120
- : '';
121
-
122
- grid.push({
123
- solar,
124
- lunar,
125
- isToday: dateStr === todayStr,
126
- isSelected: dateStr === selStr,
127
- isCurrentMonth: false
128
- });
129
- }
130
-
131
- // Ngày trong tháng hiện tại
132
- for (let d = 1; d <= daysInMonth; d++) {
133
- const solar = new Date(currentYear, currentMonth - 1, d);
134
- const lunar = LichTa.toLunar(d, currentMonth, currentYear);
135
- const dateStr = `${currentYear}-${currentMonth}-${d}`;
136
- const selStr = selectedDate
137
- ? `${selectedDate.getFullYear()}-${selectedDate.getMonth() + 1}-${selectedDate.getDate()}`
138
- : '';
139
-
140
- grid.push({
141
- solar,
142
- lunar,
143
- isToday: dateStr === todayStr,
144
- isSelected: dateStr === selStr,
145
- isCurrentMonth: true
146
- });
147
- }
148
-
149
- // Padding ngày tháng sau
150
- const remaining = 42 - grid.length;
151
- for (let d = 1; d <= remaining; d++) {
152
- const m = currentMonth + 1 > 12 ? 1 : currentMonth + 1;
153
- const y = currentMonth + 1 > 12 ? currentYear + 1 : currentYear;
154
- const solar = new Date(y, m - 1, d);
155
- const lunar = LichTa.toLunar(d, m, y);
156
- const dateStr = `${y}-${m}-${d}`;
157
- const selStr = selectedDate
158
- ? `${selectedDate.getFullYear()}-${selectedDate.getMonth() + 1}-${selectedDate.getDate()}`
159
- : '';
160
-
161
- grid.push({
162
- solar,
163
- lunar,
164
- isToday: dateStr === todayStr,
165
- isSelected: dateStr === selStr,
166
- isCurrentMonth: false
167
- });
168
- }
169
-
170
- return grid;
171
- });
81
+ let calendarGrid = $derived(getCalendarGrid(currentMonth, currentYear, selectedDate, firstDayOfWeek));
172
82
 
173
83
  // Navigation
174
84
  function prevMonth() {
175
- if (currentMonth === 1) {
176
- currentMonth = 12;
177
- currentYear -= 1;
178
- } else {
179
- currentMonth -= 1;
180
- }
85
+ const newMonth = currentMonth === 1 ? 12 : currentMonth - 1;
86
+ const newYear = currentMonth === 1 ? currentYear - 1 : currentYear;
87
+ currentMonth = newMonth;
88
+ currentYear = newYear;
89
+ onMonthChange?.(newMonth, newYear);
181
90
  }
182
91
 
183
92
  function nextMonth() {
184
- if (currentMonth === 12) {
185
- currentMonth = 1;
186
- currentYear += 1;
187
- } else {
188
- currentMonth += 1;
189
- }
93
+ const newMonth = currentMonth === 12 ? 1 : currentMonth + 1;
94
+ const newYear = currentMonth === 12 ? currentYear + 1 : currentYear;
95
+ currentMonth = newMonth;
96
+ currentYear = newYear;
97
+ onMonthChange?.(newMonth, newYear);
190
98
  }
191
99
 
192
100
  function handleDayClick(cell: DayCellData) {
@@ -201,7 +109,7 @@
201
109
  }
202
110
  </script>
203
111
 
204
- <div class={['lich-ta-calendar', `lichta-theme-${theme}`].join(' ')}>
112
+ <div class={['lich-ta-calendar', `lichta-theme-${theme}`, showWeekNumber ? 'lich-ta-calendar--with-week-number' : ''].filter(Boolean).join(' ')}>
205
113
  <!-- Header: Navigation + Tháng/Năm -->
206
114
  <div class="lich-ta-calendar-header">
207
115
  <button class="lich-ta-calendar-nav" onclick={prevMonth} aria-label="Tháng trước">◀</button>
@@ -219,6 +127,9 @@
219
127
 
220
128
  <!-- Tên thứ -->
221
129
  <div class="lich-ta-calendar-weekdays">
130
+ {#if showWeekNumber}
131
+ <div class="lich-ta-calendar-week-number" aria-hidden="true"></div>
132
+ {/if}
222
133
  {#each weekDayLabels as label (label)}
223
134
  <div class="lich-ta-calendar-weekday">{label}</div>
224
135
  {/each}
@@ -226,7 +137,10 @@
226
137
 
227
138
  <!-- Grid ngày -->
228
139
  <div class="lich-ta-calendar-grid">
229
- {#each calendarGrid as cell (cell.solar.getTime())}
140
+ {#each calendarGrid as cell, idx (cell.solar.getTime())}
141
+ {#if showWeekNumber && idx % 7 === 0}
142
+ <div class="lich-ta-calendar-week-number">{cell.weekNumber}</div>
143
+ {/if}
230
144
  {#if dayCell}
231
145
  {@render dayCell(cell)}
232
146
  {:else}
@@ -262,202 +176,3 @@
262
176
  </div>
263
177
  {/if}
264
178
  </div>
265
-
266
- <style>
267
- .lich-ta-calendar {
268
- --lichta-primary: #d4a373;
269
- --lichta-primary-light: #f5e6d3;
270
- --lichta-bg: #fffcf7;
271
- --lichta-surface: #ffffff;
272
- --lichta-text: #2c1810;
273
- --lichta-text-muted: #8b7355;
274
- --lichta-text-dim: #c4b5a0;
275
- --lichta-border: #e8ddd0;
276
- --lichta-today-bg: #d4a373;
277
- --lichta-today-text: #ffffff;
278
- --lichta-selected-bg: #a0522d;
279
- --lichta-selected-text: #ffffff;
280
- --lichta-hover-bg: #f5e6d3;
281
- --lichta-lunar-text: #b08968;
282
- --lichta-first-lunar: #c2185b;
283
- --lichta-radius: 8px;
284
- --lichta-font: inherit;
285
-
286
- font-family: var(--lichta-font);
287
- background: var(--lichta-bg);
288
- border: 1px solid var(--lichta-border);
289
- border-radius: calc(var(--lichta-radius) * 2);
290
- padding: 16px;
291
- max-width: 420px;
292
- width: 100%;
293
- box-sizing: border-box;
294
- user-select: none;
295
- }
296
-
297
- /* Header */
298
- .lich-ta-calendar-header {
299
- display: flex;
300
- align-items: center;
301
- justify-content: space-between;
302
- margin-bottom: 16px;
303
- }
304
-
305
- .lich-ta-calendar-nav {
306
- background: none;
307
- border: 1px solid var(--lichta-border);
308
- border-radius: var(--lichta-radius);
309
- width: 36px;
310
- height: 36px;
311
- display: flex;
312
- align-items: center;
313
- justify-content: center;
314
- cursor: pointer;
315
- color: var(--lichta-text);
316
- font-size: 0.85rem;
317
- transition: all 0.15s ease;
318
- }
319
-
320
- .lich-ta-calendar-nav:hover {
321
- background: var(--lichta-hover-bg);
322
- border-color: var(--lichta-primary);
323
- }
324
-
325
- .lich-ta-calendar-title {
326
- text-align: center;
327
- }
328
-
329
- .lich-ta-calendar-month-year {
330
- display: block;
331
- font-size: 1.1rem;
332
- font-weight: 600;
333
- color: var(--lichta-text);
334
- }
335
-
336
- .lich-ta-calendar-canchi {
337
- display: block;
338
- font-size: 0.8rem;
339
- color: var(--lichta-text-muted);
340
- margin-top: 2px;
341
- }
342
-
343
- /* Weekdays */
344
- .lich-ta-calendar-weekdays {
345
- display: grid;
346
- grid-template-columns: repeat(7, 1fr);
347
- margin-bottom: 4px;
348
- }
349
-
350
- .lich-ta-calendar-weekday {
351
- text-align: center;
352
- font-size: 0.75rem;
353
- font-weight: 600;
354
- color: var(--lichta-text-muted);
355
- padding: 6px 0;
356
- text-transform: uppercase;
357
- letter-spacing: 0.05em;
358
- }
359
-
360
- /* Grid */
361
- .lich-ta-calendar-grid {
362
- display: grid;
363
- grid-template-columns: repeat(7, 1fr);
364
- gap: 2px;
365
- }
366
-
367
- .lich-ta-calendar-day {
368
- display: flex;
369
- flex-direction: column;
370
- align-items: center;
371
- justify-content: center;
372
- padding: 6px 2px;
373
- min-height: 48px;
374
- border: none;
375
- border-radius: var(--lichta-radius);
376
- background: transparent;
377
- cursor: pointer;
378
- transition: all 0.15s ease;
379
- font-family: inherit;
380
- }
381
-
382
- .lich-ta-calendar-day:hover {
383
- background: var(--lichta-hover-bg);
384
- }
385
-
386
- .lich-ta-calendar-day:focus-visible {
387
- outline: 2px solid var(--lichta-primary);
388
- outline-offset: 1px;
389
- }
390
-
391
- .solar-day {
392
- font-size: 0.95rem;
393
- font-weight: 500;
394
- color: var(--lichta-text);
395
- line-height: 1.2;
396
- }
397
-
398
- .lunar-day {
399
- font-size: 0.65rem;
400
- color: var(--lichta-lunar-text);
401
- line-height: 1.2;
402
- margin-top: 1px;
403
- }
404
-
405
- /* States */
406
- .is-other-month .solar-day {
407
- color: var(--lichta-text-dim);
408
- }
409
-
410
- .is-other-month .lunar-day {
411
- color: var(--lichta-text-dim);
412
- }
413
-
414
- .is-today {
415
- background: var(--lichta-today-bg);
416
- }
417
-
418
- .is-today .solar-day {
419
- color: var(--lichta-today-text);
420
- font-weight: 700;
421
- }
422
-
423
- .is-today .lunar-day {
424
- color: var(--lichta-today-text);
425
- opacity: 0.85;
426
- }
427
-
428
- .is-today:hover {
429
- background: var(--lichta-today-bg);
430
- opacity: 0.9;
431
- }
432
-
433
- .is-selected {
434
- background: var(--lichta-selected-bg);
435
- }
436
-
437
- .is-selected .solar-day {
438
- color: var(--lichta-selected-text);
439
- font-weight: 700;
440
- }
441
-
442
- .is-selected .lunar-day {
443
- color: var(--lichta-selected-text);
444
- opacity: 0.85;
445
- }
446
-
447
- .is-first-lunar .lunar-day {
448
- color: var(--lichta-first-lunar);
449
- font-weight: 600;
450
- }
451
-
452
- .is-today.is-first-lunar .lunar-day,
453
- .is-selected.is-first-lunar .lunar-day {
454
- color: var(--lichta-today-text);
455
- }
456
-
457
- /* Footer */
458
- .lich-ta-calendar-footer {
459
- margin-top: 12px;
460
- padding-top: 12px;
461
- border-top: 1px solid var(--lichta-border);
462
- }
463
- </style>
@@ -1,16 +1,10 @@
1
- import type { LunarDate } from '@lichta/core';
1
+ import type { LunarDate, CalendarDayCell } from '@lichta/core';
2
2
  import type { Snippet } from 'svelte';
3
- import type { Locale } from '@lichta/core';
3
+ import type { Locale, FirstDayOfWeek } from '@lichta/core';
4
4
  /**
5
5
  * Dữ liệu cho mỗi ô ngày trong lưới lịch
6
6
  */
7
- interface DayCellData {
8
- solar: Date;
9
- lunar: LunarDate;
10
- isToday: boolean;
11
- isSelected: boolean;
12
- isCurrentMonth: boolean;
13
- }
7
+ type DayCellData = CalendarDayCell;
14
8
  interface Props {
15
9
  /** Tháng hiển thị (1-12). Mặc định: tháng hiện tại */
16
10
  month?: number;
@@ -20,10 +14,16 @@ interface Props {
20
14
  selectedDate?: Date | null;
21
15
  /** Callback khi chọn ngày */
22
16
  onSelect?: (date: Date, lunar: LunarDate) => void;
17
+ /** Callback khi user điều hướng tháng qua nút prev/next trong header */
18
+ onMonthChange?: (month: number, year: number) => void;
23
19
  /** Hiển thị ngày âm lịch bên dưới ngày dương */
24
20
  showLunar?: boolean;
25
21
  /** Locale (vi | en | ja | ko) */
26
22
  locale?: Locale;
23
+ /** Ngày bắt đầu tuần: 0 = Chủ Nhật (mặc định), 1 = Thứ Hai */
24
+ firstDayOfWeek?: FirstDayOfWeek;
25
+ /** Hiển thị số tuần (ISO-8601) ở đầu mỗi hàng */
26
+ showWeekNumber?: boolean;
27
27
  /** Custom snippet cho mỗi ô ngày */
28
28
  dayCell?: Snippet<[DayCellData]>;
29
29
  children?: Snippet;
@@ -0,0 +1,188 @@
1
+ <script lang="ts">
2
+ import { getYearDetails, getCalendarGrid, getWeekDayLabels, t } from '@lichta/core';
3
+ import type { LunarDate, Locale, CalendarDayCell, FirstDayOfWeek } from '@lichta/core';
4
+
5
+ interface Props {
6
+ /** Ngày đang chọn (uncontrolled — component tự quản lý sau lần mount đầu) */
7
+ value?: Date | null;
8
+ /** Gọi khi người dùng chọn 1 ngày trong popover */
9
+ onSelect?: (date: Date, lunar: LunarDate) => void;
10
+ /** Callback khi user điều hướng tháng qua nút prev/next trong popover */
11
+ onMonthChange?: (month: number, year: number) => void;
12
+ placeholder?: string;
13
+ locale?: Locale;
14
+ /** Ngày bắt đầu tuần: 0 = Chủ Nhật (mặc định), 1 = Thứ Hai */
15
+ firstDayOfWeek?: FirstDayOfWeek;
16
+ theme?: 'classic' | 'glass';
17
+ showLunar?: boolean;
18
+ format?: (date: Date) => string;
19
+ disabled?: boolean;
20
+ }
21
+
22
+ function defaultFormat(date: Date): string {
23
+ const dd = String(date.getDate()).padStart(2, '0');
24
+ const mm = String(date.getMonth() + 1).padStart(2, '0');
25
+ return `${dd}/${mm}/${date.getFullYear()}`;
26
+ }
27
+
28
+ let {
29
+ value = null,
30
+ onSelect,
31
+ onMonthChange,
32
+ placeholder = 'Chọn ngày',
33
+ locale = 'vi',
34
+ firstDayOfWeek = 0,
35
+ theme = 'classic',
36
+ showLunar = true,
37
+ format = defaultFormat,
38
+ disabled = false
39
+ }: Props = $props();
40
+
41
+ // Writable $derived (Svelte 5): mặc định bám theo prop `value`, nhưng có thể
42
+ // gán lại trực tiếp (xem handleDaySelect) — giá trị gán tay giữ nguyên cho tới
43
+ // khi `value` đổi thì tính lại từ đầu.
44
+ let selected = $derived<Date | null>(value ?? null);
45
+ let isOpen = $state(false);
46
+ let rootEl: HTMLElement | undefined = $state();
47
+
48
+ // State điều hướng tháng/năm hiển thị trong popover, tách khỏi `selected` để
49
+ // prevMonth()/nextMonth() điều hướng được độc lập (không phụ thuộc ngày đã chọn).
50
+ const initialAnchor = value ?? new Date();
51
+ let viewMonth = $state(initialAnchor.getMonth() + 1);
52
+ let viewYear = $state(initialAnchor.getFullYear());
53
+
54
+ // Đồng bộ lại view khi component cha đổi prop `value` từ bên ngoài.
55
+ $effect(() => {
56
+ if (value) {
57
+ viewMonth = value.getMonth() + 1;
58
+ viewYear = value.getFullYear();
59
+ }
60
+ });
61
+
62
+ const weekDayLabels = $derived(getWeekDayLabels(locale, firstDayOfWeek));
63
+ const monthNames = $derived(t(locale).solarMonthNames);
64
+ let yearInfo = $derived(getYearDetails(viewYear));
65
+ let grid = $derived(getCalendarGrid(viewMonth, viewYear, selected, firstDayOfWeek));
66
+ const displayValue = $derived(selected ? format(selected) : '');
67
+
68
+ function toggleOpen() {
69
+ if (!disabled) isOpen = !isOpen;
70
+ }
71
+
72
+ function prevMonth() {
73
+ const newMonth = viewMonth === 1 ? 12 : viewMonth - 1;
74
+ const newYear = viewMonth === 1 ? viewYear - 1 : viewYear;
75
+ viewMonth = newMonth;
76
+ viewYear = newYear;
77
+ onMonthChange?.(newMonth, newYear);
78
+ }
79
+
80
+ function nextMonth() {
81
+ const newMonth = viewMonth === 12 ? 1 : viewMonth + 1;
82
+ const newYear = viewMonth === 12 ? viewYear + 1 : viewYear;
83
+ viewMonth = newMonth;
84
+ viewYear = newYear;
85
+ onMonthChange?.(newMonth, newYear);
86
+ }
87
+
88
+ function handleDaySelect(cell: CalendarDayCell) {
89
+ selected = cell.solar;
90
+ isOpen = false;
91
+ onSelect?.(cell.solar, cell.lunar);
92
+ }
93
+
94
+ function handlePointerDown(event: MouseEvent) {
95
+ if (rootEl && !rootEl.contains(event.target as Node)) {
96
+ isOpen = false;
97
+ }
98
+ }
99
+
100
+ function handleKeydown(event: KeyboardEvent) {
101
+ if (event.key === 'Escape') isOpen = false;
102
+ }
103
+
104
+ $effect(() => {
105
+ if (!isOpen) return;
106
+ document.addEventListener('mousedown', handlePointerDown);
107
+ document.addEventListener('keydown', handleKeydown);
108
+ return () => {
109
+ document.removeEventListener('mousedown', handlePointerDown);
110
+ document.removeEventListener('keydown', handleKeydown);
111
+ };
112
+ });
113
+ </script>
114
+
115
+ <div bind:this={rootEl} class={['lich-ta-datepicker', `lichta-theme-${theme}`].join(' ')}>
116
+ <input
117
+ type="text"
118
+ class="lich-ta-datepicker-input"
119
+ readonly
120
+ {disabled}
121
+ {placeholder}
122
+ value={displayValue}
123
+ aria-haspopup="dialog"
124
+ aria-expanded={isOpen}
125
+ onclick={toggleOpen}
126
+ />
127
+ {#if isOpen}
128
+ <div class="lich-ta-datepicker-popover" role="dialog">
129
+ <div class="lich-ta-datepicker-calendar">
130
+ <div class="lich-ta-datepicker-calendar-header">
131
+ <button
132
+ type="button"
133
+ class="lich-ta-datepicker-calendar-nav"
134
+ onclick={prevMonth}
135
+ aria-label="Tháng trước">◀</button
136
+ >
137
+ <div class="lich-ta-datepicker-calendar-title">
138
+ <span class="lich-ta-datepicker-calendar-month-year">
139
+ {monthNames[viewMonth - 1]}, {viewYear}
140
+ </span>
141
+ <span class="lich-ta-datepicker-calendar-canchi">
142
+ {yearInfo.can}
143
+ {yearInfo.chi}
144
+ </span>
145
+ </div>
146
+ <button
147
+ type="button"
148
+ class="lich-ta-datepicker-calendar-nav"
149
+ onclick={nextMonth}
150
+ aria-label="Tháng sau">▶</button
151
+ >
152
+ </div>
153
+
154
+ <div class="lich-ta-datepicker-calendar-weekdays">
155
+ {#each weekDayLabels as label (label)}
156
+ <div class="lich-ta-datepicker-calendar-weekday">{label}</div>
157
+ {/each}
158
+ </div>
159
+
160
+ <div class="lich-ta-datepicker-calendar-grid">
161
+ {#each grid as cell (cell.solar.getTime())}
162
+ <button
163
+ type="button"
164
+ class="lich-ta-datepicker-calendar-day"
165
+ class:is-today={cell.isToday}
166
+ class:is-selected={cell.isSelected}
167
+ class:is-other-month={!cell.isCurrentMonth}
168
+ class:is-first-lunar={cell.lunar.day === 1}
169
+ tabindex={cell.isCurrentMonth ? 0 : -1}
170
+ onclick={() => handleDaySelect(cell)}
171
+ >
172
+ <span class="solar-day">{cell.solar.getDate()}</span>
173
+ {#if showLunar}
174
+ <span class="lunar-day">
175
+ {#if cell.lunar.day === 1}
176
+ {cell.lunar.day}/{cell.lunar.month}{cell.lunar.isLeap ? '*' : ''}
177
+ {:else}
178
+ {cell.lunar.day}
179
+ {/if}
180
+ </span>
181
+ {/if}
182
+ </button>
183
+ {/each}
184
+ </div>
185
+ </div>
186
+ </div>
187
+ {/if}
188
+ </div>
@@ -0,0 +1,20 @@
1
+ import type { LunarDate, Locale, FirstDayOfWeek } from '@lichta/core';
2
+ interface Props {
3
+ /** Ngày đang chọn (uncontrolled — component tự quản lý sau lần mount đầu) */
4
+ value?: Date | null;
5
+ /** Gọi khi người dùng chọn 1 ngày trong popover */
6
+ onSelect?: (date: Date, lunar: LunarDate) => void;
7
+ /** Callback khi user điều hướng tháng qua nút prev/next trong popover */
8
+ onMonthChange?: (month: number, year: number) => void;
9
+ placeholder?: string;
10
+ locale?: Locale;
11
+ /** Ngày bắt đầu tuần: 0 = Chủ Nhật (mặc định), 1 = Thứ Hai */
12
+ firstDayOfWeek?: FirstDayOfWeek;
13
+ theme?: 'classic' | 'glass';
14
+ showLunar?: boolean;
15
+ format?: (date: Date) => string;
16
+ disabled?: boolean;
17
+ }
18
+ declare const DatePicker: import("svelte").Component<Props, {}, "">;
19
+ type DatePicker = ReturnType<typeof DatePicker>;
20
+ export default DatePicker;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export { LichTa, jdFromDate, jdToDate, getYearDetails, getDayCanChi, getMonthCanChi, getHourCanChi, getAuspiciousHours, formatLunarDate, formatTraditional, getMonthName, getDayName, t, getZodiacAnimal } from '@lichta/core';
2
- export type { LunarDate, SolarDate, Locale } from '@lichta/core';
1
+ export { LichTa, jdFromDate, jdToDate, getYearDetails, getDayCanChi, getMonthCanChi, getHourCanChi, getAuspiciousHours, formatLunarDate, formatTraditional, getMonthName, getDayName, getCalendarGrid, getISOWeekNumber, t, getZodiacAnimal } from '@lichta/core';
2
+ export type { LunarDate, SolarDate, Locale, CalendarDayCell, FirstDayOfWeek } from '@lichta/core';
3
3
  export { default as Formatter } from './components/Formatter.svelte';
4
4
  export { default as Calendar } from './components/Calendar.svelte';
5
+ export { default as DatePicker } from './components/DatePicker.svelte';
package/dist/index.js CHANGED
@@ -2,7 +2,8 @@
2
2
  // Lichta Svelte — Public API (Root Entry Point)
3
3
  // ============================================================
4
4
  // Re-export phần lõi từ @lichta/core
5
- export { LichTa, jdFromDate, jdToDate, getYearDetails, getDayCanChi, getMonthCanChi, getHourCanChi, getAuspiciousHours, formatLunarDate, formatTraditional, getMonthName, getDayName, t, getZodiacAnimal } from '@lichta/core';
5
+ export { LichTa, jdFromDate, jdToDate, getYearDetails, getDayCanChi, getMonthCanChi, getHourCanChi, getAuspiciousHours, formatLunarDate, formatTraditional, getMonthName, getDayName, getCalendarGrid, getISOWeekNumber, t, getZodiacAnimal } from '@lichta/core';
6
6
  // Xuất bản phần Svelte Component
7
7
  export { default as Formatter } from './components/Formatter.svelte';
8
8
  export { default as Calendar } from './components/Calendar.svelte';
9
+ export { default as DatePicker } from './components/DatePicker.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lichta/svelte",
3
- "version": "2.0.1",
3
+ "version": "2.2.0",
4
4
  "description": "Svelte components for LichTa",
5
5
  "author": "Zeforc Labs | Stridev",
6
6
  "license": "MIT",
@@ -52,7 +52,7 @@
52
52
  "vite": "^8.0.16"
53
53
  },
54
54
  "dependencies": {
55
- "@lichta/core": "2.0.1"
55
+ "@lichta/core": "2.2.0"
56
56
  },
57
57
  "repository": {
58
58
  "type": "git",