daisy-ui-kit 5.0.0-pre.24 → 5.0.0-pre.25

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.
@@ -1,89 +1,175 @@
1
1
  <script setup lang="ts">
2
- import type { PikadayOptions } from 'pikaday'
3
- import { onMounted, ref } from 'vue'
4
- import { usePikaday } from '../composables/use-pikaday'
5
- import CalendarSkeleton from './CalendarSkeleton.vue'
2
+ import type { CalendarOptions } from '../composables/use-calendar'
3
+ import { computed, watch } from 'vue'
4
+ import { useCalendar } from '../composables/use-calendar'
6
5
 
7
6
  const props = defineProps<{
8
7
  /** Bound value: Date object or ISO string or null */
9
- modelValue: Date | string | null
10
- /** All Pikaday options (except `container` and `bound`) */
11
- options?: Omit<PikadayOptions, 'container' | 'bound'>
8
+ modelValue?: Date | string | null
9
+ /** Calendar options */
10
+ options?: CalendarOptions
12
11
  /** If true, default to today when no value provided */
13
12
  autoDefault?: boolean
14
13
  }>()
14
+
15
15
  const emit = defineEmits<{
16
16
  (e: 'update:modelValue', v: Date | null): void
17
17
  }>()
18
18
 
19
- const containerRef = ref<HTMLElement | null>(null)
20
- const loading = ref(true)
21
-
22
- const defaultOptions: Partial<PikadayOptions> = {
23
- format: 'D MMM YYYY',
19
+ // Parse initial date from modelValue
20
+ function parseDate(value: Date | string | null | undefined): Date | null {
21
+ if (!value) return null
22
+ if (value instanceof Date) return value
23
+ const d = new Date(value)
24
+ return Number.isNaN(d.getTime()) ? null : d
24
25
  }
25
26
 
26
- // Determine the date to use for the skeleton
27
- const skeletonDate = computed(() => {
28
- // Try to use modelValue if it's a Date
29
- if (props.modelValue instanceof Date) {
30
- return props.modelValue
31
- }
32
- // Try to parse modelValue if it's a string
33
- if (typeof props.modelValue === 'string') {
34
- return new Date(props.modelValue)
35
- }
36
- // Use defaultDate from options if provided
37
- if (props.options?.defaultDate instanceof Date) {
38
- return props.options.defaultDate
39
- }
40
- // Fall back to today
41
- return new Date()
27
+ const initialDate = computed(() => {
28
+ const parsed = parseDate(props.modelValue)
29
+ if (parsed) return parsed
30
+ if (props.autoDefault) return new Date()
31
+ return null
42
32
  })
43
33
 
44
- function handleSelect(date: Date) {
34
+ const {
35
+ selectedDate,
36
+ viewMonth,
37
+ viewYear,
38
+ monthName,
39
+ weekdayHeaders,
40
+ weekdayHeadersFull,
41
+ calendarDays,
42
+ prevMonth,
43
+ nextMonth,
44
+ selectDate,
45
+ goToDate,
46
+ } = useCalendar(initialDate.value, props.options)
47
+
48
+ // Sync selectedDate back to modelValue
49
+ watch(selectedDate, date => {
45
50
  emit('update:modelValue', date)
46
- }
51
+ })
47
52
 
48
- const { createPicker } = usePikaday(
49
- {
50
- ...defaultOptions,
51
- ...props.options,
52
- bound: false,
53
+ // Watch for external modelValue changes
54
+ watch(
55
+ () => props.modelValue,
56
+ newValue => {
57
+ const parsed = parseDate(newValue)
58
+ if (parsed && (!selectedDate.value || parsed.getTime() !== selectedDate.value.getTime())) {
59
+ selectDate(parsed)
60
+ goToDate(parsed)
61
+ }
53
62
  },
54
- handleSelect,
55
63
  )
56
64
 
57
- onMounted(async () => {
58
- if (containerRef.value) {
59
- const picker = await createPicker(containerRef.value)
60
- if (picker && picker.el && !containerRef.value.contains(picker.el)) {
61
- containerRef.value.appendChild(picker.el)
62
- }
63
- loading.value = false
64
- }
65
- })
65
+ function handleDayClick(day: (typeof calendarDays.value)[0]) {
66
+ if (day.isDisabled) return
67
+ selectDate(day.date)
68
+ }
66
69
  </script>
67
70
 
68
71
  <template>
69
- <div class="relative w-[270px] h-[270px]">
70
- <div class="absolute inset-0">
71
- <CalendarSkeleton
72
- :number-of-months="options?.numberOfMonths ?? 1"
73
- :date="skeletonDate"
74
- :first-day="options?.firstDay"
75
- :class="loading ? '' : 'opacity-0 pointer-events-none transition-opacity duration-300'"
76
- />
72
+ <div class="pika-single">
73
+ <div class="pika-lendar">
74
+ <!-- Header with navigation -->
75
+ <div class="pika-title">
76
+ <div class="pika-label">
77
+ {{ monthName }}
78
+ <select
79
+ class="pika-select pika-select-month"
80
+ :value="viewMonth"
81
+ @change="
82
+ e => {
83
+ const target = e.target as HTMLSelectElement
84
+ const newMonth = parseInt(target.value, 10)
85
+ const d = new Date(viewYear, newMonth, 1)
86
+ goToDate(d)
87
+ }
88
+ "
89
+ >
90
+ <option
91
+ v-for="(m, i) in [
92
+ 'January',
93
+ 'February',
94
+ 'March',
95
+ 'April',
96
+ 'May',
97
+ 'June',
98
+ 'July',
99
+ 'August',
100
+ 'September',
101
+ 'October',
102
+ 'November',
103
+ 'December',
104
+ ]"
105
+ :key="i"
106
+ :value="i"
107
+ >
108
+ {{ m }}
109
+ </option>
110
+ </select>
111
+ </div>
112
+ <div class="pika-label">
113
+ {{ viewYear }}
114
+ <select
115
+ class="pika-select pika-select-year"
116
+ :value="viewYear"
117
+ @change="
118
+ e => {
119
+ const target = e.target as HTMLSelectElement
120
+ const newYear = parseInt(target.value, 10)
121
+ const d = new Date(newYear, viewMonth, 1)
122
+ goToDate(d)
123
+ }
124
+ "
125
+ >
126
+ <option v-for="y in Array.from({ length: 21 }, (_, i) => viewYear - 10 + i)" :key="y" :value="y">
127
+ {{ y }}
128
+ </option>
129
+ </select>
130
+ </div>
131
+ <button type="button" class="pika-prev" @click="prevMonth">Previous Month</button>
132
+ <button type="button" class="pika-next" @click="nextMonth">Next Month</button>
133
+ </div>
134
+
135
+ <!-- Calendar grid -->
136
+ <table class="pika-table" role="grid">
137
+ <thead>
138
+ <tr>
139
+ <th v-for="(day, i) in weekdayHeaders" :key="i" scope="col">
140
+ <abbr :title="weekdayHeadersFull[i]">{{ day }}</abbr>
141
+ </th>
142
+ </tr>
143
+ </thead>
144
+ <tbody>
145
+ <tr v-for="week in 6" :key="week" class="pika-row">
146
+ <td
147
+ v-for="dayIndex in 7"
148
+ :key="dayIndex"
149
+ :class="{
150
+ 'is-today': calendarDays[(week - 1) * 7 + dayIndex - 1]?.isToday,
151
+ 'is-selected': calendarDays[(week - 1) * 7 + dayIndex - 1]?.isSelected,
152
+ 'is-disabled': calendarDays[(week - 1) * 7 + dayIndex - 1]?.isDisabled,
153
+ 'is-outside-current-month': calendarDays[(week - 1) * 7 + dayIndex - 1]?.isOutsideMonth,
154
+ }"
155
+ :aria-selected="calendarDays[(week - 1) * 7 + dayIndex - 1]?.isSelected"
156
+ :data-day="calendarDays[(week - 1) * 7 + dayIndex - 1]?.day"
157
+ >
158
+ <button
159
+ type="button"
160
+ class="pika-button pika-day"
161
+ :disabled="calendarDays[(week - 1) * 7 + dayIndex - 1]?.isDisabled"
162
+ :data-pika-year="calendarDays[(week - 1) * 7 + dayIndex - 1]?.year"
163
+ :data-pika-month="calendarDays[(week - 1) * 7 + dayIndex - 1]?.month"
164
+ :data-pika-day="calendarDays[(week - 1) * 7 + dayIndex - 1]?.day"
165
+ @click="handleDayClick(calendarDays[(week - 1) * 7 + dayIndex - 1]!)"
166
+ >
167
+ {{ calendarDays[(week - 1) * 7 + dayIndex - 1]?.day }}
168
+ </button>
169
+ </td>
170
+ </tr>
171
+ </tbody>
172
+ </table>
77
173
  </div>
78
- <div ref="containerRef" class="absolute inset-0 inline-block w-full h-full" />
79
- <span class="pika-single hidden" />
80
174
  </div>
81
175
  </template>
82
-
83
- <style>
84
- .has-event {
85
- .pika-button {
86
- color: var(--color-primary);
87
- }
88
- }
89
- </style>
@@ -1,15 +1,14 @@
1
1
  <script setup lang="ts">
2
- import type { PikadayOptions } from 'pikaday'
3
- import type { ComponentPublicInstance } from 'vue'
4
- import { onMounted, ref, watch } from 'vue'
5
-
6
- import { usePikaday } from '../composables/use-pikaday'
2
+ import type { CalendarOptions } from '../composables/use-calendar'
3
+ import { computed, ref, watch } from 'vue'
4
+ import { useCalendar } from '../composables/use-calendar'
5
+ import { randomString } from '../utils/random-string'
7
6
 
8
7
  const props = defineProps<{
9
8
  /** Bound value: Date object or ISO string or null */
10
- modelValue: Date | string | number | null
11
- /** All Pikaday options (except `field`) */
12
- options?: Omit<PikadayOptions, 'field'>
9
+ modelValue?: Date | string | number | null
10
+ /** Calendar options */
11
+ options?: CalendarOptions
13
12
  /** If true, default to today when no value provided */
14
13
  autoDefault?: boolean
15
14
  placeholder?: string
@@ -32,143 +31,245 @@ const props = defineProps<{
32
31
  sm?: boolean
33
32
  xs?: boolean
34
33
  }>()
34
+
35
35
  const emit = defineEmits<{
36
36
  (e: 'update:modelValue', v: Date | null): void
37
37
  (e: 'update:inputValue', v: string | null): void
38
38
  }>()
39
- const inputRef = ref<ComponentPublicInstance | null>(null)
40
- const inputValue = ref<string | null>(null)
41
- const visible = ref(false)
42
39
 
43
- const defaultOptions: Partial<PikadayOptions> = {
44
- format: 'D MMM YYYY',
45
- }
40
+ const popoverId = `calendar-popover-${randomString()}`
41
+ const anchorName = `--calendar-anchor-${randomString()}`
42
+ const inputRef = ref<HTMLInputElement | null>(null)
43
+ const popoverRef = ref<HTMLElement | null>(null)
46
44
 
47
- const { picker, createPicker } = usePikaday(
48
- {
49
- ...defaultOptions,
50
- ...props.options,
51
- bound: true,
52
- field: undefined, // will be set below
53
- },
54
- handleSelect,
55
- handleOpen,
56
- handleClose,
57
- )
58
-
59
- function handleSelect(date: Date) {
60
- emit('update:modelValue', date)
61
- inputValue.value = picker.value?.toString() ?? ''
62
- emit('update:inputValue', inputValue.value)
63
- }
64
- function handleOpen() {
65
- visible.value = true
66
- }
67
- function handleClose() {
68
- visible.value = false
45
+ // Parse date from various input types
46
+ function parseDate(value: Date | string | number | null | undefined): Date | null {
47
+ if (!value) return null
48
+ if (value instanceof Date) return value
49
+ const d = new Date(value)
50
+ return Number.isNaN(d.getTime()) ? null : d
69
51
  }
70
52
 
71
- onMounted(async () => {
72
- const inputEl = inputRef.value as HTMLInputElement | null
73
- // Format and set the initial input value BEFORE initializing Pikaday
74
- if (props.modelValue) {
75
- let d: Date | null = null
76
- if (props.modelValue instanceof Date) {
77
- d = props.modelValue
78
- } else if (typeof props.modelValue === 'string') {
79
- const tmp = new Date(props.modelValue)
80
- if (!Number.isNaN(tmp.getTime())) {
81
- d = tmp
82
- }
83
- }
84
- if (d) {
85
- const day = d.getDate()
86
- const month = d.toLocaleString('en-US', { month: 'short' })
87
- const year = d.getFullYear()
88
- inputValue.value = `${day} ${month} ${year}`
89
- }
90
- }
91
- if (inputEl) {
92
- picker.value = await createPicker(inputEl)
93
- // Do not call setDate here
94
- if (picker.value && (picker.value as any).config?.bound === false) {
95
- picker.value.hide()
96
- }
97
- }
53
+ const initialDate = computed(() => {
54
+ const parsed = parseDate(props.modelValue)
55
+ if (parsed) return parsed
56
+ if (props.autoDefault) return new Date()
57
+ return null
98
58
  })
99
59
 
100
- function onFocus() {
101
- if (picker.value) {
102
- let d: Date | null = null
103
- if (props.modelValue instanceof Date) {
104
- d = props.modelValue
105
- } else if (typeof props.modelValue === 'string') {
106
- const tmp = new Date(props.modelValue)
107
- if (!Number.isNaN(tmp.getTime())) {
108
- d = tmp
109
- }
110
- }
111
- picker.value.setDate(d, true)
112
- }
113
- }
60
+ const {
61
+ selectedDate,
62
+ viewMonth,
63
+ viewYear,
64
+ monthName,
65
+ weekdayHeaders,
66
+ weekdayHeadersFull,
67
+ calendarDays,
68
+ prevMonth,
69
+ nextMonth,
70
+ selectDate,
71
+ goToDate,
72
+ formatDate,
73
+ } = useCalendar(initialDate.value, props.options)
74
+
75
+ // Input display value
76
+ const inputValue = computed(() => formatDate('D MMM YYYY'))
114
77
 
78
+ // Sync selectedDate back to modelValue
79
+ watch(selectedDate, date => {
80
+ emit('update:modelValue', date)
81
+ emit('update:inputValue', formatDate('D MMM YYYY'))
82
+ })
83
+
84
+ // Watch for external modelValue changes
115
85
  watch(
116
86
  () => props.modelValue,
117
- val => {
118
- if (!picker.value) {
119
- return
120
- }
121
- if (!visible.value) {
122
- if (val instanceof Date) {
123
- picker.value.setDate(val, true)
124
- inputValue.value = picker.value.toString()
125
- } else if (typeof val === 'string') {
126
- const d = new Date(val)
127
- if (!Number.isNaN(d.getTime())) {
128
- picker.value.setDate(d, true)
129
- inputValue.value = picker.value.toString()
130
- }
131
- } else {
132
- picker.value.setDate(null, true)
133
- inputValue.value = ''
134
- }
87
+ newValue => {
88
+ const parsed = parseDate(newValue)
89
+ if (parsed && (!selectedDate.value || parsed.getTime() !== selectedDate.value.getTime())) {
90
+ selectDate(parsed)
91
+ goToDate(parsed)
92
+ } else if (!newValue && selectedDate.value) {
93
+ selectedDate.value = null
135
94
  }
136
95
  },
137
96
  )
97
+
98
+ function handleDayClick(day: (typeof calendarDays.value)[0]) {
99
+ if (day.isDisabled) return
100
+ selectDate(day.date)
101
+ popoverRef.value?.hidePopover()
102
+ }
103
+
104
+ function handleClick() {
105
+ // Sync view to selected date when opening
106
+ if (selectedDate.value) {
107
+ goToDate(selectedDate.value)
108
+ }
109
+ popoverRef.value?.togglePopover()
110
+ }
138
111
  </script>
139
112
 
140
113
  <template>
141
- <input
142
- ref="inputRef"
143
- type="text"
144
- :value="inputValue"
145
- :placeholder="props.placeholder"
146
- :disabled="props.disabled"
147
- class="input"
148
- :class="[
149
- { validator: props.validator },
150
- { 'input-primary': props.primary || props.color === 'primary' },
151
- { 'input-secondary': props.secondary || props.color === 'secondary' },
152
- { 'input-accent': props.accent || props.color === 'accent' },
153
- { 'input-info': props.info || props.color === 'info' },
154
- { 'input-success': props.success || props.color === 'success' },
155
- { 'input-warning': props.warning || props.color === 'warning' },
156
- { 'input-error': props.error || props.color === 'error' },
157
- { 'input-ghost': props.ghost },
158
- { 'input-xl': props.xl || props.size === 'xl' },
159
- { 'input-lg': props.lg || props.size === 'lg' },
160
- { 'input-md': props.md || props.size === 'md' },
161
- { 'input-sm': props.sm || props.size === 'sm' },
162
- { 'input-xs': props.xs || props.size === 'xs' },
163
- { 'join-item': props.join },
164
- ]"
165
- v-bind="$attrs"
166
- @focus="onFocus"
167
- @input="
168
- e => {
169
- const val = (e.target as HTMLInputElement).value
170
- emit('update:inputValue', val)
171
- }
172
- "
173
- />
114
+ <div class="relative inline-block">
115
+ <input
116
+ ref="inputRef"
117
+ type="text"
118
+ readonly
119
+ :value="inputValue"
120
+ :placeholder="props.placeholder"
121
+ :disabled="props.disabled"
122
+ class="input cursor-pointer"
123
+ :class="[
124
+ { validator: props.validator },
125
+ { 'input-primary': props.primary || props.color === 'primary' },
126
+ { 'input-secondary': props.secondary || props.color === 'secondary' },
127
+ { 'input-accent': props.accent || props.color === 'accent' },
128
+ { 'input-info': props.info || props.color === 'info' },
129
+ { 'input-success': props.success || props.color === 'success' },
130
+ { 'input-warning': props.warning || props.color === 'warning' },
131
+ { 'input-error': props.error || props.color === 'error' },
132
+ { 'input-ghost': props.ghost },
133
+ { 'input-xl': props.xl || props.size === 'xl' },
134
+ { 'input-lg': props.lg || props.size === 'lg' },
135
+ { 'input-md': props.md || props.size === 'md' },
136
+ { 'input-sm': props.sm || props.size === 'sm' },
137
+ { 'input-xs': props.xs || props.size === 'xs' },
138
+ { 'join-item': props.join },
139
+ ]"
140
+ :style="{ 'anchor-name': anchorName } as any"
141
+ v-bind="$attrs"
142
+ @click="handleClick"
143
+ />
144
+
145
+ <!-- Dropdown calendar using Popover API -->
146
+ <div
147
+ :id="popoverId"
148
+ ref="popoverRef"
149
+ popover="auto"
150
+ class="pika-single calendar-popover"
151
+ :style="{ 'position-anchor': anchorName } as any"
152
+ >
153
+ <div class="pika-lendar">
154
+ <!-- Header with navigation -->
155
+ <div class="pika-title">
156
+ <div class="pika-label">
157
+ {{ monthName }}
158
+ <select
159
+ class="pika-select pika-select-month"
160
+ :value="viewMonth"
161
+ @change="
162
+ e => {
163
+ const target = e.target as HTMLSelectElement
164
+ const newMonth = parseInt(target.value, 10)
165
+ const d = new Date(viewYear, newMonth, 1)
166
+ goToDate(d)
167
+ }
168
+ "
169
+ >
170
+ <option
171
+ v-for="(m, i) in [
172
+ 'January',
173
+ 'February',
174
+ 'March',
175
+ 'April',
176
+ 'May',
177
+ 'June',
178
+ 'July',
179
+ 'August',
180
+ 'September',
181
+ 'October',
182
+ 'November',
183
+ 'December',
184
+ ]"
185
+ :key="i"
186
+ :value="i"
187
+ >
188
+ {{ m }}
189
+ </option>
190
+ </select>
191
+ </div>
192
+ <div class="pika-label">
193
+ {{ viewYear }}
194
+ <select
195
+ class="pika-select pika-select-year"
196
+ :value="viewYear"
197
+ @change="
198
+ e => {
199
+ const target = e.target as HTMLSelectElement
200
+ const newYear = parseInt(target.value, 10)
201
+ const d = new Date(newYear, viewMonth, 1)
202
+ goToDate(d)
203
+ }
204
+ "
205
+ >
206
+ <option v-for="y in Array.from({ length: 21 }, (_, i) => viewYear - 10 + i)" :key="y" :value="y">
207
+ {{ y }}
208
+ </option>
209
+ </select>
210
+ </div>
211
+ <button type="button" class="pika-prev" @click="prevMonth">Previous Month</button>
212
+ <button type="button" class="pika-next" @click="nextMonth">Next Month</button>
213
+ </div>
214
+
215
+ <!-- Calendar grid -->
216
+ <table class="pika-table" role="grid">
217
+ <thead>
218
+ <tr>
219
+ <th v-for="(day, i) in weekdayHeaders" :key="i" scope="col">
220
+ <abbr :title="weekdayHeadersFull[i]">{{ day }}</abbr>
221
+ </th>
222
+ </tr>
223
+ </thead>
224
+ <tbody>
225
+ <tr v-for="week in 6" :key="week" class="pika-row">
226
+ <td
227
+ v-for="dayIndex in 7"
228
+ :key="dayIndex"
229
+ :class="{
230
+ 'is-today': calendarDays[(week - 1) * 7 + dayIndex - 1]?.isToday,
231
+ 'is-selected': calendarDays[(week - 1) * 7 + dayIndex - 1]?.isSelected,
232
+ 'is-disabled': calendarDays[(week - 1) * 7 + dayIndex - 1]?.isDisabled,
233
+ 'is-outside-current-month': calendarDays[(week - 1) * 7 + dayIndex - 1]?.isOutsideMonth,
234
+ }"
235
+ :aria-selected="calendarDays[(week - 1) * 7 + dayIndex - 1]?.isSelected"
236
+ >
237
+ <button
238
+ type="button"
239
+ class="pika-button pika-day"
240
+ :disabled="calendarDays[(week - 1) * 7 + dayIndex - 1]?.isDisabled"
241
+ @click="handleDayClick(calendarDays[(week - 1) * 7 + dayIndex - 1]!)"
242
+ >
243
+ {{ calendarDays[(week - 1) * 7 + dayIndex - 1]?.day }}
244
+ </button>
245
+ </td>
246
+ </tr>
247
+ </tbody>
248
+ </table>
249
+ </div>
250
+ </div>
251
+ </div>
174
252
  </template>
253
+
254
+ <style>
255
+ .calendar-popover[popover] {
256
+ position-area: block-end span-inline-end;
257
+ position-try-fallbacks:
258
+ flip-block,
259
+ flip-inline,
260
+ flip-block flip-inline;
261
+ margin: 0;
262
+ margin-top: 0.25rem;
263
+ /* Reset default popover styles */
264
+ border: none;
265
+ inset: auto;
266
+ }
267
+
268
+ .calendar-popover[popover]:popover-open {
269
+ position: fixed;
270
+ }
271
+
272
+ .calendar-popover[popover]:not(:popover-open) {
273
+ display: none;
274
+ }
275
+ </style>
@@ -0,0 +1,239 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { useCalendar } from '../use-calendar'
3
+
4
+ describe('useCalendar', () => {
5
+ describe('initialization', () => {
6
+ it('initializes with null date by default', () => {
7
+ const { selectedDate } = useCalendar()
8
+ expect(selectedDate.value).toBeNull()
9
+ })
10
+
11
+ it('initializes with provided date', () => {
12
+ const date = new Date(2025, 5, 15) // June 15, 2025
13
+ const { selectedDate } = useCalendar(date)
14
+ expect(selectedDate.value?.getFullYear()).toBe(2025)
15
+ expect(selectedDate.value?.getMonth()).toBe(5)
16
+ expect(selectedDate.value?.getDate()).toBe(15)
17
+ })
18
+
19
+ it('sets viewDate to today when no initial date provided', () => {
20
+ const { viewDate } = useCalendar()
21
+ const today = new Date()
22
+ expect(viewDate.value.getFullYear()).toBe(today.getFullYear())
23
+ expect(viewDate.value.getMonth()).toBe(today.getMonth())
24
+ })
25
+
26
+ it('sets viewDate to initial date when provided', () => {
27
+ const date = new Date(2025, 5, 15)
28
+ const { viewDate } = useCalendar(date)
29
+ expect(viewDate.value.getFullYear()).toBe(2025)
30
+ expect(viewDate.value.getMonth()).toBe(5)
31
+ })
32
+ })
33
+
34
+ describe('weekday headers', () => {
35
+ it('returns weekday headers starting with Sunday by default', () => {
36
+ const { weekdayHeaders } = useCalendar()
37
+ expect(weekdayHeaders.value).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'])
38
+ })
39
+
40
+ it('returns weekday headers starting with Monday when firstDay is 1', () => {
41
+ const { weekdayHeaders } = useCalendar(null, { firstDay: 1 })
42
+ expect(weekdayHeaders.value).toEqual(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'])
43
+ })
44
+
45
+ it('returns full weekday names for accessibility', () => {
46
+ const { weekdayHeadersFull } = useCalendar()
47
+ expect(weekdayHeadersFull.value).toEqual([
48
+ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
49
+ ])
50
+ })
51
+ })
52
+
53
+ describe('month display', () => {
54
+ it('returns correct month name', () => {
55
+ const date = new Date(2025, 5, 15) // June
56
+ const { monthName } = useCalendar(date)
57
+ expect(monthName.value).toBe('June')
58
+ })
59
+
60
+ it('returns correct short month name', () => {
61
+ const date = new Date(2025, 11, 25) // December
62
+ const { monthNameShort } = useCalendar(date)
63
+ expect(monthNameShort.value).toBe('Dec')
64
+ })
65
+ })
66
+
67
+ describe('calendar days', () => {
68
+ it('generates 42 days (6 weeks)', () => {
69
+ const { calendarDays } = useCalendar(new Date(2025, 5, 15))
70
+ expect(calendarDays.value).toHaveLength(42)
71
+ })
72
+
73
+ it('marks days outside current month', () => {
74
+ const { calendarDays } = useCalendar(new Date(2025, 5, 15)) // June 2025
75
+ const outsideDays = calendarDays.value.filter(d => d.isOutsideMonth)
76
+ expect(outsideDays.length).toBeGreaterThan(0)
77
+ })
78
+
79
+ it('marks the selected date', () => {
80
+ const date = new Date(2025, 5, 15)
81
+ const { calendarDays } = useCalendar(date)
82
+ const selectedDays = calendarDays.value.filter(d => d.isSelected)
83
+ expect(selectedDays).toHaveLength(1)
84
+ expect(selectedDays[0]?.day).toBe(15)
85
+ })
86
+
87
+ it('marks today correctly', () => {
88
+ const today = new Date()
89
+ const { calendarDays, goToDate } = useCalendar()
90
+ goToDate(today)
91
+ const todayDays = calendarDays.value.filter(d => d.isToday && !d.isOutsideMonth)
92
+ expect(todayDays).toHaveLength(1)
93
+ expect(todayDays[0]?.day).toBe(today.getDate())
94
+ })
95
+ })
96
+
97
+ describe('navigation', () => {
98
+ it('navigates to next month', () => {
99
+ const { viewMonth, viewYear, nextMonth } = useCalendar(new Date(2025, 5, 15))
100
+ expect(viewMonth.value).toBe(5)
101
+ nextMonth()
102
+ expect(viewMonth.value).toBe(6)
103
+ expect(viewYear.value).toBe(2025)
104
+ })
105
+
106
+ it('navigates to previous month', () => {
107
+ const { viewMonth, viewYear, prevMonth } = useCalendar(new Date(2025, 5, 15))
108
+ expect(viewMonth.value).toBe(5)
109
+ prevMonth()
110
+ expect(viewMonth.value).toBe(4)
111
+ expect(viewYear.value).toBe(2025)
112
+ })
113
+
114
+ it('handles year rollover when navigating forward', () => {
115
+ const { viewMonth, viewYear, nextMonth } = useCalendar(new Date(2025, 11, 15))
116
+ expect(viewMonth.value).toBe(11)
117
+ nextMonth()
118
+ expect(viewMonth.value).toBe(0)
119
+ expect(viewYear.value).toBe(2026)
120
+ })
121
+
122
+ it('handles year rollover when navigating backward', () => {
123
+ const { viewMonth, viewYear, prevMonth } = useCalendar(new Date(2025, 0, 15))
124
+ expect(viewMonth.value).toBe(0)
125
+ prevMonth()
126
+ expect(viewMonth.value).toBe(11)
127
+ expect(viewYear.value).toBe(2024)
128
+ })
129
+
130
+ it('goes to specific month', () => {
131
+ const { viewMonth, goToMonth } = useCalendar(new Date(2025, 5, 15))
132
+ goToMonth(10)
133
+ expect(viewMonth.value).toBe(10)
134
+ })
135
+
136
+ it('goes to specific year', () => {
137
+ const { viewYear, goToYear } = useCalendar(new Date(2025, 5, 15))
138
+ goToYear(2030)
139
+ expect(viewYear.value).toBe(2030)
140
+ })
141
+
142
+ it('goes to today', () => {
143
+ const { viewMonth, viewYear, goToToday } = useCalendar(new Date(2020, 0, 1))
144
+ const today = new Date()
145
+ goToToday()
146
+ expect(viewMonth.value).toBe(today.getMonth())
147
+ expect(viewYear.value).toBe(today.getFullYear())
148
+ })
149
+ })
150
+
151
+ describe('selection', () => {
152
+ it('selects a date', () => {
153
+ const { selectedDate, selectDate } = useCalendar()
154
+ const date = new Date(2025, 5, 20)
155
+ selectDate(date)
156
+ expect(selectedDate.value?.getDate()).toBe(20)
157
+ expect(selectedDate.value?.getMonth()).toBe(5)
158
+ })
159
+
160
+ it('clears selection', () => {
161
+ const { selectedDate, clearSelection } = useCalendar(new Date(2025, 5, 15))
162
+ expect(selectedDate.value).not.toBeNull()
163
+ clearSelection()
164
+ expect(selectedDate.value).toBeNull()
165
+ })
166
+
167
+ it('does not select disabled dates', () => {
168
+ const minDate = new Date(2025, 5, 10)
169
+ const { selectedDate, selectDate } = useCalendar(null, { minDate })
170
+ selectDate(new Date(2025, 5, 5)) // Before minDate
171
+ expect(selectedDate.value).toBeNull()
172
+ })
173
+ })
174
+
175
+ describe('date constraints', () => {
176
+ it('marks dates before minDate as disabled', () => {
177
+ const minDate = new Date(2025, 5, 15)
178
+ const { calendarDays } = useCalendar(new Date(2025, 5, 20), { minDate })
179
+ const june14 = calendarDays.value.find(d => d.day === 14 && d.month === 5)
180
+ const june15 = calendarDays.value.find(d => d.day === 15 && d.month === 5)
181
+ expect(june14?.isDisabled).toBe(true)
182
+ expect(june15?.isDisabled).toBe(false)
183
+ })
184
+
185
+ it('marks dates after maxDate as disabled', () => {
186
+ const maxDate = new Date(2025, 5, 15)
187
+ const { calendarDays } = useCalendar(new Date(2025, 5, 10), { maxDate })
188
+ const june15 = calendarDays.value.find(d => d.day === 15 && d.month === 5)
189
+ const june16 = calendarDays.value.find(d => d.day === 16 && d.month === 5)
190
+ expect(june15?.isDisabled).toBe(false)
191
+ expect(june16?.isDisabled).toBe(true)
192
+ })
193
+ })
194
+
195
+ describe('formatting', () => {
196
+ it('formats date with default format', () => {
197
+ const { formatDate } = useCalendar(new Date(2025, 5, 15))
198
+ expect(formatDate()).toBe('15 Jun 2025')
199
+ })
200
+
201
+ it('formats date with custom format', () => {
202
+ const { formatDate } = useCalendar(new Date(2025, 5, 15))
203
+ expect(formatDate('YYYY-MM-DD')).toBe('2025-06-15')
204
+ })
205
+
206
+ it('formats date with full month name', () => {
207
+ const { formatDate } = useCalendar(new Date(2025, 5, 15))
208
+ expect(formatDate('MMMM D, YYYY')).toBe('June 15, 2025')
209
+ })
210
+
211
+ it('returns empty string when no date selected', () => {
212
+ const { formatDate } = useCalendar()
213
+ expect(formatDate()).toBe('')
214
+ })
215
+ })
216
+
217
+ describe('utility functions', () => {
218
+ it('isSameDay returns true for same day', () => {
219
+ const { isSameDay } = useCalendar()
220
+ const a = new Date(2025, 5, 15, 10, 30)
221
+ const b = new Date(2025, 5, 15, 20, 45)
222
+ expect(isSameDay(a, b)).toBe(true)
223
+ })
224
+
225
+ it('isSameDay returns false for different days', () => {
226
+ const { isSameDay } = useCalendar()
227
+ const a = new Date(2025, 5, 15)
228
+ const b = new Date(2025, 5, 16)
229
+ expect(isSameDay(a, b)).toBe(false)
230
+ })
231
+
232
+ it('isSameDay handles null values', () => {
233
+ const { isSameDay } = useCalendar()
234
+ expect(isSameDay(null, new Date())).toBe(false)
235
+ expect(isSameDay(new Date(), null)).toBe(false)
236
+ expect(isSameDay(null, null)).toBe(false)
237
+ })
238
+ })
239
+ })
@@ -0,0 +1,288 @@
1
+ import { computed, ref } from 'vue'
2
+
3
+ export interface CalendarOptions {
4
+ /** First day of week: 0 = Sunday, 1 = Monday, etc. */
5
+ firstDay?: number
6
+ /** Minimum selectable date */
7
+ minDate?: Date | null
8
+ /** Maximum selectable date */
9
+ maxDate?: Date | null
10
+ /** Locale for formatting (default: 'en-US') */
11
+ locale?: string
12
+ /** Short month names */
13
+ monthsShort?: string[]
14
+ /** Full month names */
15
+ months?: string[]
16
+ /** Short weekday names */
17
+ weekdaysShort?: string[]
18
+ /** Full weekday names */
19
+ weekdays?: string[]
20
+ }
21
+
22
+ export interface CalendarDay {
23
+ date: Date
24
+ day: number
25
+ month: number
26
+ year: number
27
+ isToday: boolean
28
+ isSelected: boolean
29
+ isDisabled: boolean
30
+ isOutsideMonth: boolean
31
+ }
32
+
33
+ const DEFAULT_MONTHS = [
34
+ 'January',
35
+ 'February',
36
+ 'March',
37
+ 'April',
38
+ 'May',
39
+ 'June',
40
+ 'July',
41
+ 'August',
42
+ 'September',
43
+ 'October',
44
+ 'November',
45
+ 'December',
46
+ ]
47
+
48
+ const DEFAULT_MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
49
+
50
+ const DEFAULT_WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
51
+
52
+ const DEFAULT_WEEKDAYS_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
53
+
54
+ export function useCalendar(initialDate: Date | null = null, options: CalendarOptions = {}) {
55
+ const {
56
+ firstDay = 0,
57
+ minDate = null,
58
+ maxDate = null,
59
+ months = DEFAULT_MONTHS,
60
+ monthsShort = DEFAULT_MONTHS_SHORT,
61
+ weekdays = DEFAULT_WEEKDAYS,
62
+ weekdaysShort = DEFAULT_WEEKDAYS_SHORT,
63
+ } = options
64
+
65
+ // The currently selected date
66
+ const selectedDate = ref<Date | null>(initialDate)
67
+
68
+ // The month/year currently being viewed
69
+ const viewDate = ref(initialDate ? new Date(initialDate) : new Date())
70
+
71
+ // Current view month/year
72
+ const viewMonth = computed(() => viewDate.value.getMonth())
73
+ const viewYear = computed(() => viewDate.value.getFullYear())
74
+
75
+ // Month/year display strings
76
+ const monthName = computed(() => months[viewMonth.value])
77
+ const monthNameShort = computed(() => monthsShort[viewMonth.value])
78
+
79
+ // Weekday headers adjusted for firstDay (short names for display)
80
+ const weekdayHeaders = computed(() => {
81
+ const headers: string[] = []
82
+ for (let i = 0; i < 7; i++) {
83
+ headers.push(weekdaysShort[(i + firstDay) % 7] ?? '')
84
+ }
85
+ return headers
86
+ })
87
+
88
+ // Full weekday names for accessibility (title attributes, screen readers)
89
+ const weekdayHeadersFull = computed(() => {
90
+ const headers: string[] = []
91
+ for (let i = 0; i < 7; i++) {
92
+ headers.push(weekdays[(i + firstDay) % 7] ?? '')
93
+ }
94
+ return headers
95
+ })
96
+
97
+ // Get days in a month
98
+ function getDaysInMonth(year: number, month: number): number {
99
+ return new Date(year, month + 1, 0).getDate()
100
+ }
101
+
102
+ // Check if two dates are the same day
103
+ function isSameDay(a: Date | null, b: Date | null): boolean {
104
+ if (!a || !b) return false
105
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
106
+ }
107
+
108
+ // Check if a date is today
109
+ function isToday(date: Date): boolean {
110
+ return isSameDay(date, new Date())
111
+ }
112
+
113
+ // Check if a date is disabled
114
+ function isDisabled(date: Date): boolean {
115
+ if (minDate && date < minDate) return true
116
+ if (maxDate && date > maxDate) return true
117
+ return false
118
+ }
119
+
120
+ // Generate calendar days for the current view
121
+ const calendarDays = computed<CalendarDay[]>(() => {
122
+ const year = viewYear.value
123
+ const month = viewMonth.value
124
+ const days: CalendarDay[] = []
125
+
126
+ // First day of the month
127
+ const firstOfMonth = new Date(year, month, 1)
128
+ const startDayOfWeek = firstOfMonth.getDay()
129
+
130
+ // Calculate offset based on firstDay option
131
+ const offset = (startDayOfWeek - firstDay + 7) % 7
132
+
133
+ // Days from previous month
134
+ const prevMonth = month === 0 ? 11 : month - 1
135
+ const prevYear = month === 0 ? year - 1 : year
136
+ const daysInPrevMonth = getDaysInMonth(prevYear, prevMonth)
137
+
138
+ for (let i = offset - 1; i >= 0; i--) {
139
+ const day = daysInPrevMonth - i
140
+ const date = new Date(prevYear, prevMonth, day)
141
+ days.push({
142
+ date,
143
+ day,
144
+ month: prevMonth,
145
+ year: prevYear,
146
+ isToday: isToday(date),
147
+ isSelected: isSameDay(date, selectedDate.value),
148
+ isDisabled: isDisabled(date),
149
+ isOutsideMonth: true,
150
+ })
151
+ }
152
+
153
+ // Days in current month
154
+ const daysInMonth = getDaysInMonth(year, month)
155
+ for (let day = 1; day <= daysInMonth; day++) {
156
+ const date = new Date(year, month, day)
157
+ days.push({
158
+ date,
159
+ day,
160
+ month,
161
+ year,
162
+ isToday: isToday(date),
163
+ isSelected: isSameDay(date, selectedDate.value),
164
+ isDisabled: isDisabled(date),
165
+ isOutsideMonth: false,
166
+ })
167
+ }
168
+
169
+ // Days from next month to fill the grid (always 6 rows = 42 cells)
170
+ const nextMonth = month === 11 ? 0 : month + 1
171
+ const nextYear = month === 11 ? year + 1 : year
172
+ const remainingDays = 42 - days.length
173
+
174
+ for (let day = 1; day <= remainingDays; day++) {
175
+ const date = new Date(nextYear, nextMonth, day)
176
+ days.push({
177
+ date,
178
+ day,
179
+ month: nextMonth,
180
+ year: nextYear,
181
+ isToday: isToday(date),
182
+ isSelected: isSameDay(date, selectedDate.value),
183
+ isDisabled: isDisabled(date),
184
+ isOutsideMonth: true,
185
+ })
186
+ }
187
+
188
+ return days
189
+ })
190
+
191
+ // Navigation
192
+ function prevMonth() {
193
+ const d = new Date(viewDate.value)
194
+ d.setMonth(d.getMonth() - 1)
195
+ viewDate.value = d
196
+ }
197
+
198
+ function nextMonth() {
199
+ const d = new Date(viewDate.value)
200
+ d.setMonth(d.getMonth() + 1)
201
+ viewDate.value = d
202
+ }
203
+
204
+ function goToMonth(month: number) {
205
+ const d = new Date(viewDate.value)
206
+ d.setMonth(month)
207
+ viewDate.value = d
208
+ }
209
+
210
+ function goToYear(year: number) {
211
+ const d = new Date(viewDate.value)
212
+ d.setFullYear(year)
213
+ viewDate.value = d
214
+ }
215
+
216
+ function goToDate(date: Date) {
217
+ viewDate.value = new Date(date)
218
+ }
219
+
220
+ function goToToday() {
221
+ viewDate.value = new Date()
222
+ }
223
+
224
+ // Selection
225
+ function selectDate(date: Date) {
226
+ if (isDisabled(date)) return
227
+ selectedDate.value = new Date(date)
228
+ }
229
+
230
+ function clearSelection() {
231
+ selectedDate.value = null
232
+ }
233
+
234
+ // Format selected date
235
+ function formatDate(format: string = 'D MMM YYYY'): string {
236
+ if (!selectedDate.value) return ''
237
+
238
+ const d = selectedDate.value
239
+ const day = d.getDate()
240
+ const month = d.getMonth()
241
+ const year = d.getFullYear()
242
+
243
+ return format
244
+ .replace('YYYY', String(year))
245
+ .replace('YY', String(year).slice(-2))
246
+ .replace('MMMM', months[month] ?? '')
247
+ .replace('MMM', monthsShort[month] ?? '')
248
+ .replace('MM', String(month + 1).padStart(2, '0'))
249
+ .replace('M', String(month + 1))
250
+ .replace('DD', String(day).padStart(2, '0'))
251
+ .replace('D', String(day))
252
+ }
253
+
254
+ return {
255
+ // State
256
+ selectedDate,
257
+ viewDate,
258
+ viewMonth,
259
+ viewYear,
260
+
261
+ // Display
262
+ monthName,
263
+ monthNameShort,
264
+ weekdayHeaders,
265
+ weekdayHeadersFull,
266
+ calendarDays,
267
+
268
+ // Navigation
269
+ prevMonth,
270
+ nextMonth,
271
+ goToMonth,
272
+ goToYear,
273
+ goToDate,
274
+ goToToday,
275
+
276
+ // Selection
277
+ selectDate,
278
+ clearSelection,
279
+
280
+ // Formatting
281
+ formatDate,
282
+
283
+ // Utilities
284
+ isSameDay,
285
+ isToday,
286
+ isDisabled,
287
+ }
288
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "daisy-ui-kit",
3
3
  "type": "module",
4
- "version": "5.0.0-pre.24",
4
+ "version": "5.0.0-pre.25",
5
5
  "packageManager": "pnpm@10.10.0",
6
6
  "author": "feathers.dev",
7
7
  "exports": {
@@ -60,7 +60,6 @@
60
60
  "@vueuse/integrations": "^13.1.0",
61
61
  "focus-trap": "^7.6.4",
62
62
  "nuxt": "^4.2.1",
63
- "pikaday": "^1.8.2",
64
63
  "shiki": "^3.3.0",
65
64
  "typescript": "^5.8.3",
66
65
  "vue": "^3.5.13",
@@ -72,11 +71,14 @@
72
71
  "@stylistic/eslint-plugin": "^4.2.0",
73
72
  "@tailwindcss/typography": "^0.5.16",
74
73
  "@tailwindcss/vite": "^4.1.5",
75
- "@types/pikaday": "^1.7.9",
74
+ "@vitejs/plugin-vue": "^6.0.3",
75
+ "@vue/test-utils": "^2.4.6",
76
76
  "daisyui": "^5.5.5",
77
77
  "eslint": "^9.26.0",
78
78
  "eslint-config-prettier": "^10.1.8",
79
79
  "eslint-plugin-vue": "^10.5.1",
80
- "tailwindcss": "^4.1.5"
80
+ "happy-dom": "^20.0.11",
81
+ "tailwindcss": "^4.1.5",
82
+ "vitest": "^4.0.16"
81
83
  }
82
84
  }
@@ -1,35 +0,0 @@
1
- import type Pikaday from 'pikaday'
2
- import type { PikadayOptions } from 'pikaday'
3
- import { onBeforeUnmount, ref } from 'vue'
4
-
5
- export function usePikaday(
6
- options: PikadayOptions,
7
- onSelect: (date: Date) => void,
8
- onOpen?: () => void,
9
- onClose?: () => void,
10
- ) {
11
- const picker = ref<Pikaday | null>(null)
12
-
13
- async function createPicker(fieldOrContainer: HTMLElement) {
14
- const { default: PikadayLib } = await import('pikaday')
15
- picker.value = new PikadayLib({
16
- ...options,
17
- field: options.bound !== false ? fieldOrContainer : undefined,
18
- container: options.bound === false ? fieldOrContainer : undefined,
19
- onSelect,
20
- onOpen,
21
- onClose,
22
- })
23
- return picker.value
24
- }
25
-
26
- onBeforeUnmount(() => {
27
- picker.value?.destroy()
28
- picker.value = null
29
- })
30
-
31
- return {
32
- picker,
33
- createPicker,
34
- }
35
- }