@nexxtmove/ui 0.1.21 → 0.1.23
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/dist/index.d.ts +75 -8
- package/dist/index.js +2415 -252
- package/dist/nuxt.d.ts +1 -0
- package/dist/nuxt.js +49 -0
- package/package.json +23 -13
- package/src/assets/images/Template_aanvragen.jpg +0 -0
- package/src/assets/svg/type_post.svg +1 -0
- package/src/assets/svg/type_reel.svg +54 -0
- package/src/assets/svg/type_story.svg +46 -0
- package/src/assets/svg/type_tiktok.svg +1 -0
- package/src/assets/video/Template_aanvragen.mp4 +0 -0
- package/src/components/AnimatedNumber/AnimatedNumber.stories.ts +15 -0
- package/src/components/AnimatedNumber/AnimatedNumber.test.ts +56 -0
- package/src/components/AnimatedNumber/AnimatedNumber.vue +61 -0
- package/src/components/Button/Button.stories.ts +212 -0
- package/src/components/Button/Button.test.ts +318 -0
- package/src/components/Button/Button.vue +67 -0
- package/src/components/Calendar/Calendar.stories.ts +91 -0
- package/src/components/Calendar/Calendar.test.ts +269 -0
- package/src/components/Calendar/Calendar.vue +221 -0
- package/src/components/Calendar/_CalendarDayView.test.ts +145 -0
- package/src/components/Calendar/_CalendarDayView.vue +156 -0
- package/src/components/Calendar/_CalendarHeader.test.ts +86 -0
- package/src/components/Calendar/_CalendarHeader.vue +123 -0
- package/src/components/Calendar/_CalendarMonthView.test.ts +68 -0
- package/src/components/Calendar/_CalendarMonthView.vue +70 -0
- package/src/components/Calendar/_CalendarYearView.vue +77 -0
- package/src/components/Calendar/calendar.types.ts +10 -0
- package/src/components/Chip/Chip.stories.ts +42 -0
- package/src/components/Chip/Chip.test.ts +51 -0
- package/src/components/Chip/Chip.vue +37 -0
- package/src/components/DatePicker/DatePicker.stories.ts +149 -0
- package/src/components/DatePicker/DatePicker.test.ts +191 -0
- package/src/components/DatePicker/DatePicker.vue +142 -0
- package/src/components/Header/Header.stories.ts +48 -0
- package/src/components/Header/Header.test.ts +169 -0
- package/src/components/Header/Header.vue +42 -0
- package/src/components/Icon/Icon.stories.ts +50 -0
- package/src/components/Icon/Icon.test.ts +73 -0
- package/src/components/Icon/Icon.vue +20 -0
- package/src/components/InfoBlock/InfoBlock.stories.ts +90 -0
- package/src/components/InfoBlock/InfoBlock.test.ts +101 -0
- package/src/components/InfoBlock/InfoBlock.vue +70 -0
- package/src/components/ProgressBar/ProgressBar.stories.ts +30 -0
- package/src/components/ProgressBar/ProgressBar.test.ts +314 -0
- package/src/components/ProgressBar/ProgressBar.vue +102 -0
- package/src/components/SocialIcons/SocialIcons.stories.ts +34 -0
- package/src/components/SocialIcons/SocialIcons.test.ts +58 -0
- package/src/components/SocialIcons/SocialIcons.vue +58 -0
- package/src/components/SocialMediaCustomTemplate/SocialMediaCustomTemplate.stories.ts +11 -0
- package/src/components/SocialMediaCustomTemplate/SocialMediaCustomTemplate.test.ts +131 -0
- package/src/components/SocialMediaCustomTemplate/SocialMediaCustomTemplate.vue +55 -0
- package/src/components/SocialMediaTemplate/SocialMediaTemplate.stories.ts +71 -0
- package/src/components/SocialMediaTemplate/SocialMediaTemplate.test.ts +466 -0
- package/src/components/SocialMediaTemplate/SocialMediaTemplate.vue +130 -0
- package/src/components/SocialMediaType/SocialMediaType.stories.ts +43 -0
- package/src/components/SocialMediaType/SocialMediaType.test.ts +126 -0
- package/src/components/SocialMediaType/SocialMediaType.vue +117 -0
- package/src/components/StepperHeader/StepperHeader.stories.ts +47 -0
- package/src/components/StepperHeader/StepperHeader.test.ts +244 -0
- package/src/components/StepperHeader/StepperHeader.vue +37 -0
- package/src/components.json +16 -0
- package/src/env.d.ts +23 -0
- package/src/index.css +2 -0
- package/src/index.ts +15 -0
- package/src/nuxt.ts +50 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import { nextTick } from 'vue'
|
|
4
|
+
import Calendar from './Calendar.vue'
|
|
5
|
+
|
|
6
|
+
// Fixed reference date used across tests
|
|
7
|
+
const YEAR = 2026
|
|
8
|
+
const MONTH = 2 // March (0-indexed)
|
|
9
|
+
const TODAY = new Date(YEAR, MONTH, 20)
|
|
10
|
+
|
|
11
|
+
const mountCalendar = (props = {}) => mount(Calendar, { props })
|
|
12
|
+
|
|
13
|
+
describe('Calendar', () => {
|
|
14
|
+
// ── Rendering ─────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
it('renders the calendar widget', () => {
|
|
17
|
+
const wrapper = mountCalendar({ modelValue: TODAY })
|
|
18
|
+
expect(wrapper.find('[class*="rounded-xl"]').exists()).toBe(true)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('shows 7 weekday header columns', () => {
|
|
22
|
+
const wrapper = mountCalendar({ modelValue: TODAY })
|
|
23
|
+
const headers = wrapper.findAll('[class*="grid-cols-7"] > div').slice(0, 7)
|
|
24
|
+
expect(headers.length).toBe(7)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// ── Day selection ──────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
it('emits update:modelValue and select when a day is clicked', async () => {
|
|
30
|
+
const wrapper = mountCalendar({ modelValue: TODAY })
|
|
31
|
+
|
|
32
|
+
const dayButtons = wrapper
|
|
33
|
+
.findAll('button[type="button"]')
|
|
34
|
+
.filter((b) => /^\d{1,2}$/.test(b.text().trim()))
|
|
35
|
+
await dayButtons[10]!.trigger('click')
|
|
36
|
+
await nextTick()
|
|
37
|
+
|
|
38
|
+
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
|
39
|
+
expect(wrapper.emitted('update:modelValue')![0]![0]).toBeInstanceOf(Date)
|
|
40
|
+
expect(wrapper.emitted('select')).toBeTruthy()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// ── Month navigation ───────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
it('navigates to next month', async () => {
|
|
46
|
+
const wrapper = mountCalendar({ modelValue: new Date(YEAR, MONTH, 1) })
|
|
47
|
+
|
|
48
|
+
const labelBefore = wrapper.find('[class*="cornflower-blue-600"]').text()
|
|
49
|
+
const nextBtn = wrapper
|
|
50
|
+
.findAll('button[aria-label]')
|
|
51
|
+
.find((b) => b.attributes('aria-label') === 'Volgende maand')
|
|
52
|
+
await nextBtn!.trigger('click')
|
|
53
|
+
await nextTick()
|
|
54
|
+
|
|
55
|
+
const labelAfter = wrapper.find('[class*="cornflower-blue-600"]').text()
|
|
56
|
+
expect(labelAfter).not.toBe(labelBefore)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('navigates to previous month', async () => {
|
|
60
|
+
const wrapper = mountCalendar({ modelValue: new Date(YEAR, MONTH, 1) })
|
|
61
|
+
|
|
62
|
+
const labelBefore = wrapper.find('[class*="cornflower-blue-600"]').text()
|
|
63
|
+
const prevBtn = wrapper
|
|
64
|
+
.findAll('button[aria-label]')
|
|
65
|
+
.find((b) => b.attributes('aria-label') === 'Vorige maand')
|
|
66
|
+
await prevBtn!.trigger('click')
|
|
67
|
+
await nextTick()
|
|
68
|
+
|
|
69
|
+
const labelAfter = wrapper.find('[class*="cornflower-blue-600"]').text()
|
|
70
|
+
expect(labelAfter).not.toBe(labelBefore)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// ── Marked dates ───────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
it('renders a dot for marked dates', () => {
|
|
76
|
+
const markedDates = [new Date(YEAR, MONTH, 10)]
|
|
77
|
+
const wrapper = mountCalendar({ modelValue: new Date(YEAR, MONTH, 1), markedDates })
|
|
78
|
+
|
|
79
|
+
expect(wrapper.find('[class*="rounded-full"][class*="bg-cornflower"]').exists()).toBe(true)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// ── Disabled dates ─────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
it('disables specified dates', () => {
|
|
85
|
+
const disabledDates = [new Date(YEAR, MONTH, 15)]
|
|
86
|
+
const wrapper = mountCalendar({ modelValue: new Date(YEAR, MONTH, 1), disabledDates })
|
|
87
|
+
|
|
88
|
+
const disabled = wrapper
|
|
89
|
+
.findAll('button[disabled]')
|
|
90
|
+
.filter((b) => /^\d{1,2}$/.test(b.text().trim()))
|
|
91
|
+
expect(disabled.length).toBeGreaterThan(0)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('does not emit when clicking a disabled date', async () => {
|
|
95
|
+
const disabledDates = [new Date(YEAR, MONTH, 15)]
|
|
96
|
+
const wrapper = mountCalendar({ modelValue: new Date(YEAR, MONTH, 1), disabledDates })
|
|
97
|
+
|
|
98
|
+
const disabled = wrapper
|
|
99
|
+
.findAll('button[disabled]')
|
|
100
|
+
.find((b) => /^\d{1,2}$/.test(b.text().trim()))
|
|
101
|
+
await disabled?.trigger('click')
|
|
102
|
+
await nextTick()
|
|
103
|
+
|
|
104
|
+
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// ── minDate / maxDate ──────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
it('disables dates before minDate', () => {
|
|
110
|
+
const minDate = new Date(YEAR, MONTH, 10)
|
|
111
|
+
const wrapper = mountCalendar({ modelValue: new Date(YEAR, MONTH, 15), minDate })
|
|
112
|
+
|
|
113
|
+
const disabled = wrapper
|
|
114
|
+
.findAll('button[disabled]')
|
|
115
|
+
.filter((b) => /^\d{1,2}$/.test(b.text().trim()))
|
|
116
|
+
expect(disabled.length).toBeGreaterThan(0)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('disables dates after maxDate', () => {
|
|
120
|
+
const maxDate = new Date(YEAR, MONTH, 20)
|
|
121
|
+
const wrapper = mountCalendar({ modelValue: new Date(YEAR, MONTH, 15), maxDate })
|
|
122
|
+
|
|
123
|
+
const disabled = wrapper
|
|
124
|
+
.findAll('button[disabled]')
|
|
125
|
+
.filter((b) => /^\d{1,2}$/.test(b.text().trim()))
|
|
126
|
+
expect(disabled.length).toBeGreaterThan(0)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('disables prev nav when already at minDate month', () => {
|
|
130
|
+
const minDate = new Date(YEAR, MONTH, 1)
|
|
131
|
+
const wrapper = mountCalendar({ modelValue: new Date(YEAR, MONTH, 15), minDate })
|
|
132
|
+
|
|
133
|
+
const prevBtn = wrapper.findAll('[class*="rounded-full"][type="button"]')[0]!
|
|
134
|
+
expect(prevBtn.attributes('disabled')).toBeDefined()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('disables next nav when already at maxDate month', () => {
|
|
138
|
+
const maxDate = new Date(YEAR, MONTH, 28)
|
|
139
|
+
const wrapper = mountCalendar({ modelValue: new Date(YEAR, MONTH, 15), maxDate })
|
|
140
|
+
|
|
141
|
+
const navButtons = wrapper.findAll('[class*="rounded-full"][type="button"]')
|
|
142
|
+
const nextBtn = navButtons[navButtons.length - 1]!
|
|
143
|
+
expect(nextBtn.attributes('disabled')).toBeDefined()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('does not navigate prev when at minDate month', async () => {
|
|
147
|
+
const minDate = new Date(YEAR, MONTH, 1)
|
|
148
|
+
const wrapper = mountCalendar({ modelValue: new Date(YEAR, MONTH, 15), minDate })
|
|
149
|
+
|
|
150
|
+
const prevBtn = wrapper.find('button[aria-label="Vorige maand"]')
|
|
151
|
+
await prevBtn.trigger('click')
|
|
152
|
+
await nextTick()
|
|
153
|
+
|
|
154
|
+
const label = wrapper.find('[class*="cornflower-blue-600"]').text()
|
|
155
|
+
expect(label).toContain('Mrt')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('does not navigate next when at maxDate month', async () => {
|
|
159
|
+
const maxDate = new Date(YEAR, MONTH, 28)
|
|
160
|
+
const wrapper = mountCalendar({ modelValue: new Date(YEAR, MONTH, 15), maxDate })
|
|
161
|
+
|
|
162
|
+
const nextBtn = wrapper.find('button[aria-label="Volgende maand"]')
|
|
163
|
+
await nextBtn.trigger('click')
|
|
164
|
+
await nextTick()
|
|
165
|
+
|
|
166
|
+
const label = wrapper.find('[class*="cornflower-blue-600"]').text()
|
|
167
|
+
expect(label).toContain('Mrt')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// ── monthYearPicker ────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
it('month name is not a button without monthYearPicker prop', () => {
|
|
173
|
+
const wrapper = mountCalendar({ modelValue: TODAY })
|
|
174
|
+
|
|
175
|
+
const header = wrapper.find('[class*="cornflower-blue-600"]')
|
|
176
|
+
expect(header.find('button').exists()).toBe(false)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('month name is a button with monthYearPicker prop', () => {
|
|
180
|
+
const wrapper = mountCalendar({ modelValue: TODAY, monthYearPicker: true })
|
|
181
|
+
|
|
182
|
+
const header = wrapper.find('[class*="cornflower-blue-600"]')
|
|
183
|
+
expect(header.find('button').exists()).toBe(true)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('opens month picker when month name is clicked', async () => {
|
|
187
|
+
const wrapper = mountCalendar({ modelValue: TODAY, monthYearPicker: true })
|
|
188
|
+
|
|
189
|
+
const header = wrapper.find('[class*="cornflower-blue-600"]')
|
|
190
|
+
await header.find('button').trigger('click')
|
|
191
|
+
await nextTick()
|
|
192
|
+
|
|
193
|
+
expect(wrapper.find('[class*="grid-cols-3"]').exists()).toBe(true)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('opens year picker when year is clicked', async () => {
|
|
197
|
+
const wrapper = mountCalendar({ modelValue: TODAY, monthYearPicker: true })
|
|
198
|
+
|
|
199
|
+
const header = wrapper.find('[class*="cornflower-blue-600"]')
|
|
200
|
+
const buttons = header.findAll('button')
|
|
201
|
+
await buttons[1]!.trigger('click')
|
|
202
|
+
await nextTick()
|
|
203
|
+
|
|
204
|
+
expect(wrapper.find('[class*="snap-y"]').exists()).toBe(true)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('returns to days view after selecting a month', async () => {
|
|
208
|
+
const wrapper = mountCalendar({ modelValue: TODAY, monthYearPicker: true })
|
|
209
|
+
|
|
210
|
+
const header = wrapper.find('[class*="cornflower-blue-600"]')
|
|
211
|
+
await header.find('button').trigger('click')
|
|
212
|
+
await nextTick()
|
|
213
|
+
|
|
214
|
+
const monthButtons = wrapper.findAll('[class*="grid-cols-3"] button')
|
|
215
|
+
await monthButtons[0]!.trigger('click')
|
|
216
|
+
await nextTick()
|
|
217
|
+
|
|
218
|
+
expect(wrapper.find('[class*="grid-cols-7"]').exists()).toBe(true)
|
|
219
|
+
expect(wrapper.find('[class*="grid-cols-3"]').exists()).toBe(false)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('returns to months view after selecting a year', async () => {
|
|
223
|
+
const wrapper = mountCalendar({ modelValue: TODAY, monthYearPicker: true })
|
|
224
|
+
|
|
225
|
+
const header = wrapper.find('[class*="cornflower-blue-600"]')
|
|
226
|
+
await header.findAll('button')[1]!.trigger('click')
|
|
227
|
+
await nextTick()
|
|
228
|
+
|
|
229
|
+
const yearButtons = wrapper.findAll('[class*="snap-center"]')
|
|
230
|
+
await yearButtons[0]!.trigger('click')
|
|
231
|
+
await nextTick()
|
|
232
|
+
|
|
233
|
+
expect(wrapper.find('[class*="grid-cols-3"]').exists()).toBe(true)
|
|
234
|
+
expect(wrapper.find('[class*="snap-y"]').exists()).toBe(false)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('selects correct date from month picker', async () => {
|
|
238
|
+
const wrapper = mountCalendar({ modelValue: TODAY, monthYearPicker: true })
|
|
239
|
+
|
|
240
|
+
const header = wrapper.find('[class*="cornflower-blue-600"]')
|
|
241
|
+
await header.find('button').trigger('click')
|
|
242
|
+
await nextTick()
|
|
243
|
+
|
|
244
|
+
const monthButtons = wrapper.findAll('[class*="grid-cols-3"] button')
|
|
245
|
+
await monthButtons[0].trigger('click')
|
|
246
|
+
await nextTick()
|
|
247
|
+
|
|
248
|
+
const labelAfter = wrapper.find('[class*="cornflower-blue-600"]').text()
|
|
249
|
+
expect(labelAfter).toContain('Jan')
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('selects correct year from year drum', async () => {
|
|
253
|
+
const wrapper = mountCalendar({ modelValue: TODAY, monthYearPicker: true })
|
|
254
|
+
|
|
255
|
+
const header = wrapper.find('[class*="cornflower-blue-600"]')
|
|
256
|
+
const headerButtons = header.findAll('button')
|
|
257
|
+
await headerButtons[1].trigger('click')
|
|
258
|
+
await nextTick()
|
|
259
|
+
|
|
260
|
+
const yearButtons = wrapper.findAll('[class*="snap-center"]')
|
|
261
|
+
const targetYear = (YEAR - 1).toString()
|
|
262
|
+
const targetBtn = yearButtons.find((btn) => btn.text().includes(targetYear))
|
|
263
|
+
await targetBtn!.trigger('click')
|
|
264
|
+
await nextTick()
|
|
265
|
+
|
|
266
|
+
const labelAfter = wrapper.find('[class*="cornflower-blue-600"]').text()
|
|
267
|
+
expect(labelAfter).toContain(targetYear)
|
|
268
|
+
})
|
|
269
|
+
})
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { ref, computed, watch } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
startOfMonth,
|
|
5
|
+
endOfMonth,
|
|
6
|
+
startOfWeek,
|
|
7
|
+
endOfWeek,
|
|
8
|
+
eachDayOfInterval,
|
|
9
|
+
isSameDay,
|
|
10
|
+
isSameMonth,
|
|
11
|
+
addMonths,
|
|
12
|
+
subMonths,
|
|
13
|
+
isBefore,
|
|
14
|
+
isAfter,
|
|
15
|
+
startOfDay,
|
|
16
|
+
endOfDay,
|
|
17
|
+
} from 'date-fns'
|
|
18
|
+
import { nl } from 'date-fns/locale'
|
|
19
|
+
import CalendarHeader from './_CalendarHeader.vue'
|
|
20
|
+
import CalendarDayView from './_CalendarDayView.vue'
|
|
21
|
+
import CalendarMonthView from './_CalendarMonthView.vue'
|
|
22
|
+
import CalendarYearView from './_CalendarYearView.vue'
|
|
23
|
+
import type { NexxtCalendarProps } from './calendar.types'
|
|
24
|
+
|
|
25
|
+
export type { NexxtCalendarProps }
|
|
26
|
+
|
|
27
|
+
type PickerView = 'days' | 'months' | 'years'
|
|
28
|
+
type DayTransition = 'slide-next' | 'slide-prev' | 'fade'
|
|
29
|
+
|
|
30
|
+
defineOptions({ name: 'NexxtCalendar' })
|
|
31
|
+
|
|
32
|
+
const model = defineModel<Date | null>({ default: null })
|
|
33
|
+
|
|
34
|
+
const emit = defineEmits<{
|
|
35
|
+
(e: 'select', day: Date): void
|
|
36
|
+
}>()
|
|
37
|
+
|
|
38
|
+
const {
|
|
39
|
+
locale = nl,
|
|
40
|
+
markedDates = [],
|
|
41
|
+
disabledDates = [],
|
|
42
|
+
minDate,
|
|
43
|
+
maxDate,
|
|
44
|
+
monthYearPicker = false,
|
|
45
|
+
} = defineProps<NexxtCalendarProps>()
|
|
46
|
+
|
|
47
|
+
// ── State ─────────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
const viewDate = ref<Date>(model.value ? new Date(model.value) : new Date())
|
|
50
|
+
const pickerView = ref<PickerView>('days')
|
|
51
|
+
const dayViewRef = ref<InstanceType<typeof CalendarDayView> | null>(null)
|
|
52
|
+
const dayTransition = ref<DayTransition>('fade')
|
|
53
|
+
|
|
54
|
+
// ── Watchers ──────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
watch(model, (val) => {
|
|
57
|
+
if (val) viewDate.value = new Date(val)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// ── Logic ─────────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
const calendarDays = computed(() => {
|
|
63
|
+
const start = startOfWeek(startOfMonth(viewDate.value), { weekStartsOn: 1 })
|
|
64
|
+
const end = endOfWeek(endOfMonth(viewDate.value), { weekStartsOn: 1 })
|
|
65
|
+
return eachDayOfInterval({ start, end })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const isDisabled = (day: Date): boolean => {
|
|
69
|
+
if (disabledDates.some((d) => isSameDay(d, day))) return true
|
|
70
|
+
if (minDate && isBefore(day, startOfDay(minDate))) return true
|
|
71
|
+
if (maxDate && isAfter(day, endOfDay(maxDate))) return true
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Views ─────────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
const handleSelectDay = (day: Date) => {
|
|
78
|
+
if (isDisabled(day)) return
|
|
79
|
+
if (!isSameMonth(day, viewDate.value)) viewDate.value = startOfMonth(day)
|
|
80
|
+
model.value = day
|
|
81
|
+
emit('select', day)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const handleSelectMonth = (monthIndex: number) => {
|
|
85
|
+
dayTransition.value = 'fade'
|
|
86
|
+
viewDate.value = new Date(viewDate.value.getFullYear(), monthIndex, 1)
|
|
87
|
+
pickerView.value = 'days'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const handleSelectYear = (year: number) => {
|
|
91
|
+
viewDate.value = new Date(year, viewDate.value.getMonth(), 1)
|
|
92
|
+
pickerView.value = 'months'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Navigation ────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
const canGoPrev = computed(() => {
|
|
98
|
+
if (pickerView.value !== 'days') return false
|
|
99
|
+
if (!minDate) return true
|
|
100
|
+
return !isBefore(endOfMonth(subMonths(viewDate.value, 1)), startOfDay(minDate))
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const canGoNext = computed(() => {
|
|
104
|
+
if (pickerView.value !== 'days') return false
|
|
105
|
+
if (!maxDate) return true
|
|
106
|
+
return !isAfter(startOfMonth(addMonths(viewDate.value, 1)), endOfDay(maxDate))
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const handlePrev = () => {
|
|
110
|
+
if (!canGoPrev.value) return
|
|
111
|
+
dayTransition.value = 'slide-prev'
|
|
112
|
+
viewDate.value = subMonths(viewDate.value, 1)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const handleNext = () => {
|
|
116
|
+
if (!canGoNext.value) return
|
|
117
|
+
dayTransition.value = 'slide-next'
|
|
118
|
+
viewDate.value = addMonths(viewDate.value, 1)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const openMonthPicker = () => {
|
|
122
|
+
dayTransition.value = 'fade'
|
|
123
|
+
pickerView.value = 'months'
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const openYearPicker = () => {
|
|
127
|
+
dayTransition.value = 'fade'
|
|
128
|
+
pickerView.value = 'years'
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const closePicker = () => {
|
|
132
|
+
pickerView.value = 'months'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Transitions ───────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
const fadeTransition = {
|
|
138
|
+
enterActiveClass: 'transition-opacity duration-200 ease-out',
|
|
139
|
+
enterFromClass: 'opacity-0',
|
|
140
|
+
leaveActiveClass: 'transition-opacity duration-150 ease-in',
|
|
141
|
+
leaveToClass: 'opacity-0',
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const slideTransitions: Record<'slide-next' | 'slide-prev', object> = {
|
|
145
|
+
'slide-next': {
|
|
146
|
+
enterActiveClass: 'transition-all duration-200 ease-out',
|
|
147
|
+
enterFromClass: 'translate-x-full opacity-0',
|
|
148
|
+
leaveActiveClass: 'transition-all duration-200 ease-in absolute inset-0',
|
|
149
|
+
leaveToClass: '-translate-x-full opacity-0',
|
|
150
|
+
},
|
|
151
|
+
'slide-prev': {
|
|
152
|
+
enterActiveClass: 'transition-all duration-200 ease-out',
|
|
153
|
+
enterFromClass: '-translate-x-full opacity-0',
|
|
154
|
+
leaveActiveClass: 'transition-all duration-200 ease-in absolute inset-0',
|
|
155
|
+
leaveToClass: 'translate-x-full opacity-0',
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const currentDayTransition = computed(() =>
|
|
160
|
+
dayTransition.value === 'fade' ? fadeTransition : slideTransitions[dayTransition.value],
|
|
161
|
+
)
|
|
162
|
+
</script>
|
|
163
|
+
|
|
164
|
+
<template>
|
|
165
|
+
<div class="w-72 rounded-xl border border-gray-200 bg-white p-4">
|
|
166
|
+
<CalendarHeader
|
|
167
|
+
:view-date="viewDate"
|
|
168
|
+
:picker-view="pickerView"
|
|
169
|
+
:month-year-picker="monthYearPicker"
|
|
170
|
+
:locale="locale"
|
|
171
|
+
:can-go-prev="canGoPrev"
|
|
172
|
+
:can-go-next="canGoNext"
|
|
173
|
+
@prev="handlePrev"
|
|
174
|
+
@next="handleNext"
|
|
175
|
+
@open-month-picker="openMonthPicker"
|
|
176
|
+
@open-year-picker="openYearPicker"
|
|
177
|
+
@close-picker="closePicker"
|
|
178
|
+
/>
|
|
179
|
+
|
|
180
|
+
<div :class="['relative', pickerView === 'days' && 'overflow-hidden']">
|
|
181
|
+
<Transition
|
|
182
|
+
v-bind="currentDayTransition"
|
|
183
|
+
:mode="dayTransition === 'fade' ? 'out-in' : undefined"
|
|
184
|
+
>
|
|
185
|
+
<CalendarDayView
|
|
186
|
+
v-if="pickerView === 'days'"
|
|
187
|
+
ref="dayViewRef"
|
|
188
|
+
:key="`days-${viewDate.getFullYear()}-${viewDate.getMonth()}`"
|
|
189
|
+
:view-date="viewDate"
|
|
190
|
+
:calendar-days="calendarDays"
|
|
191
|
+
:model-value="model"
|
|
192
|
+
:marked-dates="markedDates"
|
|
193
|
+
:locale="locale"
|
|
194
|
+
:is-disabled="isDisabled"
|
|
195
|
+
@select="handleSelectDay"
|
|
196
|
+
/>
|
|
197
|
+
|
|
198
|
+
<CalendarMonthView
|
|
199
|
+
v-else-if="pickerView === 'months'"
|
|
200
|
+
key="months"
|
|
201
|
+
:view-year="viewDate.getFullYear()"
|
|
202
|
+
:model-value="model"
|
|
203
|
+
:current-month="viewDate.getMonth()"
|
|
204
|
+
:min-date="minDate"
|
|
205
|
+
:max-date="maxDate"
|
|
206
|
+
:locale="locale"
|
|
207
|
+
@select="handleSelectMonth"
|
|
208
|
+
/>
|
|
209
|
+
|
|
210
|
+
<CalendarYearView
|
|
211
|
+
v-else-if="pickerView === 'years'"
|
|
212
|
+
key="years"
|
|
213
|
+
:view-year="viewDate.getFullYear()"
|
|
214
|
+
:min-date="minDate"
|
|
215
|
+
:max-date="maxDate"
|
|
216
|
+
@select="handleSelectYear"
|
|
217
|
+
/>
|
|
218
|
+
</Transition>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
</template>
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import CalendarDayView from './_CalendarDayView.vue'
|
|
4
|
+
import { nl } from 'date-fns/locale'
|
|
5
|
+
import { startOfMonth, endOfMonth, startOfWeek, endOfWeek, eachDayOfInterval } from 'date-fns'
|
|
6
|
+
|
|
7
|
+
const VIEW_DATE = new Date(2026, 2, 20) // March 20, 2026
|
|
8
|
+
const start = startOfWeek(startOfMonth(VIEW_DATE), { weekStartsOn: 1 })
|
|
9
|
+
const end = endOfWeek(endOfMonth(VIEW_DATE), { weekStartsOn: 1 })
|
|
10
|
+
const DAYS = eachDayOfInterval({ start, end })
|
|
11
|
+
|
|
12
|
+
describe('CalendarDayView', () => {
|
|
13
|
+
it('renders the correct number of days', () => {
|
|
14
|
+
const wrapper = mount(CalendarDayView, {
|
|
15
|
+
props: {
|
|
16
|
+
viewDate: VIEW_DATE,
|
|
17
|
+
calendarDays: DAYS,
|
|
18
|
+
modelValue: null,
|
|
19
|
+
markedDates: [],
|
|
20
|
+
locale: nl,
|
|
21
|
+
isDisabled: () => false,
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
const dayButtons = wrapper.findAll('button')
|
|
25
|
+
expect(dayButtons.length).toBe(DAYS.length)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('highlights the selected day', () => {
|
|
29
|
+
const selectedDate = new Date(2026, 2, 15)
|
|
30
|
+
const wrapper = mount(CalendarDayView, {
|
|
31
|
+
props: {
|
|
32
|
+
viewDate: VIEW_DATE,
|
|
33
|
+
calendarDays: DAYS,
|
|
34
|
+
modelValue: selectedDate,
|
|
35
|
+
markedDates: [],
|
|
36
|
+
locale: nl,
|
|
37
|
+
isDisabled: () => false,
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
const selectedCell = wrapper.find('[role="gridcell"][aria-selected="true"]')
|
|
41
|
+
expect(selectedCell.exists()).toBe(true)
|
|
42
|
+
expect(selectedCell.text()).toBe('15')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('marks dates correctly', () => {
|
|
46
|
+
const markedDates = [new Date(2026, 2, 10)]
|
|
47
|
+
const wrapper = mount(CalendarDayView, {
|
|
48
|
+
props: {
|
|
49
|
+
viewDate: VIEW_DATE,
|
|
50
|
+
calendarDays: DAYS,
|
|
51
|
+
modelValue: null,
|
|
52
|
+
markedDates,
|
|
53
|
+
locale: nl,
|
|
54
|
+
isDisabled: () => false,
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
// In our component, isMarked(day) is used to show a small dot.
|
|
58
|
+
// The dot is a span with bg-cornflower-blue-500 class.
|
|
59
|
+
expect(wrapper.find('.bg-cornflower-blue-500').exists()).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('disables dates based on isDisabled prop', () => {
|
|
63
|
+
const wrapper = mount(CalendarDayView, {
|
|
64
|
+
props: {
|
|
65
|
+
viewDate: VIEW_DATE,
|
|
66
|
+
calendarDays: DAYS,
|
|
67
|
+
modelValue: null,
|
|
68
|
+
markedDates: [],
|
|
69
|
+
locale: nl,
|
|
70
|
+
isDisabled: (day: Date) => day.getDate() === 15,
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
const disabledButton = wrapper.find('button[disabled]')
|
|
74
|
+
expect(disabledButton.exists()).toBe(true)
|
|
75
|
+
expect(disabledButton.text()).toBe('15')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('emits select event when a day is clicked', async () => {
|
|
79
|
+
const wrapper = mount(CalendarDayView, {
|
|
80
|
+
props: {
|
|
81
|
+
viewDate: VIEW_DATE,
|
|
82
|
+
calendarDays: DAYS,
|
|
83
|
+
modelValue: null,
|
|
84
|
+
markedDates: [],
|
|
85
|
+
locale: nl,
|
|
86
|
+
isDisabled: () => false,
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
await wrapper.findAll('button')[10].trigger('click')
|
|
90
|
+
expect(wrapper.emitted('select')).toBeTruthy()
|
|
91
|
+
expect(wrapper.emitted('select')![0][0]).toBeInstanceOf(Date)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('handles keyboard navigation (ArrowRight)', async () => {
|
|
95
|
+
const wrapper = mount(CalendarDayView, {
|
|
96
|
+
props: {
|
|
97
|
+
viewDate: VIEW_DATE,
|
|
98
|
+
calendarDays: DAYS,
|
|
99
|
+
modelValue: null,
|
|
100
|
+
markedDates: [],
|
|
101
|
+
locale: nl,
|
|
102
|
+
isDisabled: () => false,
|
|
103
|
+
},
|
|
104
|
+
attachTo: document.body,
|
|
105
|
+
})
|
|
106
|
+
const buttons = wrapper.findAll('button')
|
|
107
|
+
await buttons[0].trigger('keydown', { key: 'ArrowRight' })
|
|
108
|
+
// In vitest/jsdom, focus might not move as easily, so we check if the method logic runs
|
|
109
|
+
// For unit tests, we can also check if emit('select') is called on Enter/Space
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('emits select when Enter or Space is pressed', async () => {
|
|
113
|
+
const wrapper = mount(CalendarDayView, {
|
|
114
|
+
props: {
|
|
115
|
+
viewDate: VIEW_DATE,
|
|
116
|
+
calendarDays: DAYS,
|
|
117
|
+
modelValue: null,
|
|
118
|
+
markedDates: [],
|
|
119
|
+
locale: nl,
|
|
120
|
+
isDisabled: () => false,
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
const buttons = wrapper.findAll('button')
|
|
124
|
+
await buttons[0].trigger('keydown', { key: 'Enter' })
|
|
125
|
+
expect(wrapper.emitted('select')).toBeTruthy()
|
|
126
|
+
await buttons[0].trigger('keydown', { key: ' ' })
|
|
127
|
+
expect(wrapper.emitted('select')![1]).toBeTruthy()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('does not emit select for other keys', async () => {
|
|
131
|
+
const wrapper = mount(CalendarDayView, {
|
|
132
|
+
props: {
|
|
133
|
+
viewDate: VIEW_DATE,
|
|
134
|
+
calendarDays: DAYS,
|
|
135
|
+
modelValue: null,
|
|
136
|
+
markedDates: [],
|
|
137
|
+
locale: nl,
|
|
138
|
+
isDisabled: () => false,
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
const buttons = wrapper.findAll('button')
|
|
142
|
+
await buttons[0].trigger('keydown', { key: 'Tab' })
|
|
143
|
+
expect(wrapper.emitted('select')).toBeFalsy()
|
|
144
|
+
})
|
|
145
|
+
})
|