@nexxtmove/ui 0.1.23 → 0.1.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.
- package/dist/index.d.ts +75 -0
- package/dist/index.js +1910 -1130
- package/dist/nuxt.js +20 -17
- package/package.json +2 -1
- package/src/components/Calendar/Calendar.test.ts +54 -44
- package/src/components/Calendar/Calendar.vue +31 -13
- package/src/components/Calendar/_CalendarDayView.test.ts +48 -89
- package/src/components/Calendar/_CalendarDayView.vue +19 -6
- package/src/components/Calendar/_CalendarMonthView.test.ts +24 -40
- package/src/components/Calendar/_CalendarMonthView.vue +17 -8
- package/src/components/Calendar/_CalendarYearView.test.ts +56 -0
- package/src/components/Calendar/_CalendarYearView.vue +8 -2
- package/src/components/Calendar/calendar.types.ts +1 -0
- package/src/components/DatePicker/DatePicker.vue +9 -6
- package/src/components/TimelineEvent/TimelineEvent.stories.ts +291 -0
- package/src/components/TimelineEvent/TimelineEvent.test.ts +166 -0
- package/src/components/TimelineEvent/TimelineEvent.vue +111 -0
- package/src/components/TimelineEvent/TimelineTypes.ts +236 -0
- package/src/components/TimelinePhaseblock/TimelinePhaseblock.stories.ts +198 -0
- package/src/components/TimelinePhaseblock/TimelinePhaseblock.test.ts +283 -0
- package/src/components/TimelinePhaseblock/TimelinePhaseblock.vue +154 -0
- package/src/components/Tooltip/Tooltip.stories.ts +146 -0
- package/src/components/Tooltip/Tooltip.test.ts +122 -0
- package/src/components/Tooltip/Tooltip.vue +45 -0
- package/src/components.json +4 -1
- package/src/index.ts +3 -0
package/dist/nuxt.js
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
|
-
import { defineNuxtModule as
|
|
2
|
-
const
|
|
3
|
-
NexxtAnimatedNumber:
|
|
1
|
+
import { defineNuxtModule as c, addComponent as s, addTemplate as i } from "@nuxt/kit";
|
|
2
|
+
const a = "components/AnimatedNumber/AnimatedNumber.vue", m = "components/Button/Button.vue", x = "components/Calendar/Calendar.vue", p = "components/Chip/Chip.vue", l = "components/DatePicker/DatePicker.vue", u = "components/Header/Header.vue", d = "components/Icon/Icon.vue", r = "components/InfoBlock/InfoBlock.vue", v = "components/ProgressBar/ProgressBar.vue", N = "components/SocialIcons/SocialIcons.vue", S = "components/SocialMediaCustomTemplate/SocialMediaCustomTemplate.vue", T = "components/SocialMediaTemplate/SocialMediaTemplate.vue", f = "components/SocialMediaType/SocialMediaType.vue", C = "components/StepperHeader/StepperHeader.vue", h = "components/TimelineEvent/TimelineEvent.vue", M = "components/TimelinePhaseblock/TimelinePhaseblock.vue", P = "components/Tooltip/Tooltip.vue", b = {
|
|
3
|
+
NexxtAnimatedNumber: a,
|
|
4
4
|
NexxtButton: m,
|
|
5
5
|
NexxtCalendar: x,
|
|
6
6
|
NexxtChip: p,
|
|
7
|
-
NexxtDatePicker:
|
|
8
|
-
NexxtHeader:
|
|
9
|
-
NexxtIcon:
|
|
10
|
-
NexxtInfoBlock:
|
|
7
|
+
NexxtDatePicker: l,
|
|
8
|
+
NexxtHeader: u,
|
|
9
|
+
NexxtIcon: d,
|
|
10
|
+
NexxtInfoBlock: r,
|
|
11
11
|
NexxtProgressBar: v,
|
|
12
|
-
NexxtSocialIcons:
|
|
13
|
-
NexxtSocialMediaCustomTemplate:
|
|
14
|
-
NexxtSocialMediaTemplate:
|
|
15
|
-
NexxtSocialMediaType:
|
|
16
|
-
NexxtStepperHeader:
|
|
17
|
-
|
|
12
|
+
NexxtSocialIcons: N,
|
|
13
|
+
NexxtSocialMediaCustomTemplate: S,
|
|
14
|
+
NexxtSocialMediaTemplate: T,
|
|
15
|
+
NexxtSocialMediaType: f,
|
|
16
|
+
NexxtStepperHeader: C,
|
|
17
|
+
NexxtTimelineEvent: h,
|
|
18
|
+
NexxtTimelinePhaseblock: M,
|
|
19
|
+
NexxtTooltip: P
|
|
20
|
+
}, B = c({
|
|
18
21
|
meta: {
|
|
19
22
|
name: "@nexxtmove/ui",
|
|
20
23
|
configKey: "nexxtmoveUi",
|
|
@@ -26,13 +29,13 @@ const i = "components/AnimatedNumber/AnimatedNumber.vue", m = "components/Button
|
|
|
26
29
|
addCSS: !0
|
|
27
30
|
},
|
|
28
31
|
setup(e, o) {
|
|
29
|
-
for (const [t, n] of Object.entries(
|
|
30
|
-
|
|
32
|
+
for (const [t, n] of Object.entries(b))
|
|
33
|
+
s({
|
|
31
34
|
name: t,
|
|
32
35
|
export: "default",
|
|
33
36
|
filePath: `@nexxtmove/ui/${n}`
|
|
34
37
|
});
|
|
35
|
-
e.addCSS && (
|
|
38
|
+
e.addCSS && (i({
|
|
36
39
|
filename: "nexxtmove-ui.css",
|
|
37
40
|
getContents: () => `
|
|
38
41
|
@import "tailwindcss";
|
|
@@ -45,5 +48,5 @@ const i = "components/AnimatedNumber/AnimatedNumber.vue", m = "components/Button
|
|
|
45
48
|
}
|
|
46
49
|
});
|
|
47
50
|
export {
|
|
48
|
-
|
|
51
|
+
B as default
|
|
49
52
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nexxtmove/ui",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.25",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"date-fns": "^4.1.0",
|
|
24
|
+
"date-fns-tz": "^3.2.0",
|
|
24
25
|
"reka-ui": "^2.9.2"
|
|
25
26
|
},
|
|
26
27
|
"peerDependencies": {
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
2
|
import { mount } from '@vue/test-utils'
|
|
3
3
|
import { nextTick } from 'vue'
|
|
4
4
|
import Calendar from './Calendar.vue'
|
|
5
5
|
|
|
6
|
+
// Mock today's date for consistent testing
|
|
7
|
+
const MOCK_TODAY = new Date(Date.UTC(2026, 2, 20)) // March 20, 2026
|
|
8
|
+
vi.useFakeTimers()
|
|
9
|
+
vi.setSystemTime(MOCK_TODAY)
|
|
10
|
+
|
|
6
11
|
// Fixed reference date used across tests
|
|
7
12
|
const YEAR = 2026
|
|
8
13
|
const MONTH = 2 // March (0-indexed)
|
|
9
|
-
const TODAY = new Date(YEAR, MONTH, 20)
|
|
14
|
+
const TODAY = new Date(Date.UTC(YEAR, MONTH, 20))
|
|
10
15
|
|
|
11
16
|
const mountCalendar = (props = {}) => mount(Calendar, { props })
|
|
12
17
|
|
|
@@ -43,38 +48,42 @@ describe('Calendar', () => {
|
|
|
43
48
|
// ── Month navigation ───────────────────────────────────────────────────────
|
|
44
49
|
|
|
45
50
|
it('navigates to next month', async () => {
|
|
46
|
-
const wrapper = mountCalendar({ modelValue: new Date(YEAR, MONTH, 1) })
|
|
51
|
+
const wrapper = mountCalendar({ modelValue: new Date(Date.UTC(YEAR, MONTH, 1)) })
|
|
47
52
|
|
|
48
|
-
const labelBefore = wrapper
|
|
53
|
+
const labelBefore = wrapper
|
|
54
|
+
.find('[class*="flex"][class*="items-center"][class*="gap-1"]')
|
|
55
|
+
.text()
|
|
49
56
|
const nextBtn = wrapper
|
|
50
57
|
.findAll('button[aria-label]')
|
|
51
58
|
.find((b) => b.attributes('aria-label') === 'Volgende maand')
|
|
52
59
|
await nextBtn!.trigger('click')
|
|
53
60
|
await nextTick()
|
|
54
61
|
|
|
55
|
-
const labelAfter = wrapper.find('[class*="
|
|
62
|
+
const labelAfter = wrapper.find('[class*="flex"][class*="items-center"][class*="gap-1"]').text()
|
|
56
63
|
expect(labelAfter).not.toBe(labelBefore)
|
|
57
64
|
})
|
|
58
65
|
|
|
59
66
|
it('navigates to previous month', async () => {
|
|
60
|
-
const wrapper = mountCalendar({ modelValue: new Date(YEAR, MONTH, 1) })
|
|
67
|
+
const wrapper = mountCalendar({ modelValue: new Date(Date.UTC(YEAR, MONTH, 1)) })
|
|
61
68
|
|
|
62
|
-
const labelBefore = wrapper
|
|
69
|
+
const labelBefore = wrapper
|
|
70
|
+
.find('[class*="flex"][class*="items-center"][class*="gap-1"]')
|
|
71
|
+
.text()
|
|
63
72
|
const prevBtn = wrapper
|
|
64
73
|
.findAll('button[aria-label]')
|
|
65
74
|
.find((b) => b.attributes('aria-label') === 'Vorige maand')
|
|
66
75
|
await prevBtn!.trigger('click')
|
|
67
76
|
await nextTick()
|
|
68
77
|
|
|
69
|
-
const labelAfter = wrapper.find('[class*="
|
|
78
|
+
const labelAfter = wrapper.find('[class*="flex"][class*="items-center"][class*="gap-1"]').text()
|
|
70
79
|
expect(labelAfter).not.toBe(labelBefore)
|
|
71
80
|
})
|
|
72
81
|
|
|
73
82
|
// ── Marked dates ───────────────────────────────────────────────────────────
|
|
74
83
|
|
|
75
84
|
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 })
|
|
85
|
+
const markedDates = [new Date(Date.UTC(YEAR, MONTH, 10))]
|
|
86
|
+
const wrapper = mountCalendar({ modelValue: new Date(Date.UTC(YEAR, MONTH, 1)), markedDates })
|
|
78
87
|
|
|
79
88
|
expect(wrapper.find('[class*="rounded-full"][class*="bg-cornflower"]').exists()).toBe(true)
|
|
80
89
|
})
|
|
@@ -82,8 +91,8 @@ describe('Calendar', () => {
|
|
|
82
91
|
// ── Disabled dates ─────────────────────────────────────────────────────────
|
|
83
92
|
|
|
84
93
|
it('disables specified dates', () => {
|
|
85
|
-
const disabledDates = [new Date(YEAR, MONTH, 15)]
|
|
86
|
-
const wrapper = mountCalendar({ modelValue: new Date(YEAR, MONTH, 1), disabledDates })
|
|
94
|
+
const disabledDates = [new Date(Date.UTC(YEAR, MONTH, 15))]
|
|
95
|
+
const wrapper = mountCalendar({ modelValue: new Date(Date.UTC(YEAR, MONTH, 1)), disabledDates })
|
|
87
96
|
|
|
88
97
|
const disabled = wrapper
|
|
89
98
|
.findAll('button[disabled]')
|
|
@@ -92,13 +101,13 @@ describe('Calendar', () => {
|
|
|
92
101
|
})
|
|
93
102
|
|
|
94
103
|
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 })
|
|
104
|
+
const disabledDates = [new Date(Date.UTC(YEAR, MONTH, 15))]
|
|
105
|
+
const wrapper = mountCalendar({ modelValue: new Date(Date.UTC(YEAR, MONTH, 1)), disabledDates })
|
|
97
106
|
|
|
98
|
-
const
|
|
107
|
+
const disabledBtn = wrapper
|
|
99
108
|
.findAll('button[disabled]')
|
|
100
109
|
.find((b) => /^\d{1,2}$/.test(b.text().trim()))
|
|
101
|
-
await
|
|
110
|
+
await disabledBtn?.trigger('click')
|
|
102
111
|
await nextTick()
|
|
103
112
|
|
|
104
113
|
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
|
|
@@ -107,8 +116,8 @@ describe('Calendar', () => {
|
|
|
107
116
|
// ── minDate / maxDate ──────────────────────────────────────────────────────
|
|
108
117
|
|
|
109
118
|
it('disables dates before minDate', () => {
|
|
110
|
-
const minDate = new Date(YEAR, MONTH, 10)
|
|
111
|
-
const wrapper = mountCalendar({ modelValue: new Date(YEAR, MONTH, 15), minDate })
|
|
119
|
+
const minDate = new Date(Date.UTC(YEAR, MONTH, 10))
|
|
120
|
+
const wrapper = mountCalendar({ modelValue: new Date(Date.UTC(YEAR, MONTH, 15)), minDate })
|
|
112
121
|
|
|
113
122
|
const disabled = wrapper
|
|
114
123
|
.findAll('button[disabled]')
|
|
@@ -117,8 +126,8 @@ describe('Calendar', () => {
|
|
|
117
126
|
})
|
|
118
127
|
|
|
119
128
|
it('disables dates after maxDate', () => {
|
|
120
|
-
const maxDate = new Date(YEAR, MONTH, 20)
|
|
121
|
-
const wrapper = mountCalendar({ modelValue: new Date(YEAR, MONTH, 15), maxDate })
|
|
129
|
+
const maxDate = new Date(Date.UTC(YEAR, MONTH, 20))
|
|
130
|
+
const wrapper = mountCalendar({ modelValue: new Date(Date.UTC(YEAR, MONTH, 15)), maxDate })
|
|
122
131
|
|
|
123
132
|
const disabled = wrapper
|
|
124
133
|
.findAll('button[disabled]')
|
|
@@ -127,43 +136,42 @@ describe('Calendar', () => {
|
|
|
127
136
|
})
|
|
128
137
|
|
|
129
138
|
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 })
|
|
139
|
+
const minDate = new Date(Date.UTC(YEAR, MONTH, 1))
|
|
140
|
+
const wrapper = mountCalendar({ modelValue: new Date(Date.UTC(YEAR, MONTH, 15)), minDate })
|
|
132
141
|
|
|
133
|
-
const prevBtn = wrapper.
|
|
142
|
+
const prevBtn = wrapper.find('button[aria-label="Vorige maand"]')
|
|
134
143
|
expect(prevBtn.attributes('disabled')).toBeDefined()
|
|
135
144
|
})
|
|
136
145
|
|
|
137
146
|
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 })
|
|
147
|
+
const maxDate = new Date(Date.UTC(YEAR, MONTH, 28))
|
|
148
|
+
const wrapper = mountCalendar({ modelValue: new Date(Date.UTC(YEAR, MONTH, 15)), maxDate })
|
|
140
149
|
|
|
141
|
-
const
|
|
142
|
-
const nextBtn = navButtons[navButtons.length - 1]!
|
|
150
|
+
const nextBtn = wrapper.find('button[aria-label="Volgende maand"]')
|
|
143
151
|
expect(nextBtn.attributes('disabled')).toBeDefined()
|
|
144
152
|
})
|
|
145
153
|
|
|
146
154
|
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 })
|
|
155
|
+
const minDate = new Date(Date.UTC(YEAR, MONTH, 1))
|
|
156
|
+
const wrapper = mountCalendar({ modelValue: new Date(Date.UTC(YEAR, MONTH, 15)), minDate })
|
|
149
157
|
|
|
150
158
|
const prevBtn = wrapper.find('button[aria-label="Vorige maand"]')
|
|
151
159
|
await prevBtn.trigger('click')
|
|
152
160
|
await nextTick()
|
|
153
161
|
|
|
154
|
-
const label = wrapper.find('[class*="
|
|
162
|
+
const label = wrapper.find('[class*="flex"][class*="items-center"][class*="gap-1"]').text()
|
|
155
163
|
expect(label).toContain('Mrt')
|
|
156
164
|
})
|
|
157
165
|
|
|
158
166
|
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 })
|
|
167
|
+
const maxDate = new Date(Date.UTC(YEAR, MONTH, 28))
|
|
168
|
+
const wrapper = mountCalendar({ modelValue: new Date(Date.UTC(YEAR, MONTH, 15)), maxDate })
|
|
161
169
|
|
|
162
170
|
const nextBtn = wrapper.find('button[aria-label="Volgende maand"]')
|
|
163
171
|
await nextBtn.trigger('click')
|
|
164
172
|
await nextTick()
|
|
165
173
|
|
|
166
|
-
const label = wrapper.find('[class*="
|
|
174
|
+
const label = wrapper.find('[class*="flex"][class*="items-center"][class*="gap-1"]').text()
|
|
167
175
|
expect(label).toContain('Mrt')
|
|
168
176
|
})
|
|
169
177
|
|
|
@@ -172,31 +180,33 @@ describe('Calendar', () => {
|
|
|
172
180
|
it('month name is not a button without monthYearPicker prop', () => {
|
|
173
181
|
const wrapper = mountCalendar({ modelValue: TODAY })
|
|
174
182
|
|
|
175
|
-
const header = wrapper.find('[class*="
|
|
183
|
+
const header = wrapper.find('[class*="flex"][class*="items-center"][class*="gap-1"]')
|
|
176
184
|
expect(header.find('button').exists()).toBe(false)
|
|
177
185
|
})
|
|
178
186
|
|
|
179
187
|
it('month name is a button with monthYearPicker prop', () => {
|
|
180
188
|
const wrapper = mountCalendar({ modelValue: TODAY, monthYearPicker: true })
|
|
181
189
|
|
|
182
|
-
const header = wrapper.find('[class*="
|
|
190
|
+
const header = wrapper.find('[class*="flex"][class*="items-center"][class*="gap-1"]')
|
|
183
191
|
expect(header.find('button').exists()).toBe(true)
|
|
184
192
|
})
|
|
185
193
|
|
|
186
194
|
it('opens month picker when month name is clicked', async () => {
|
|
187
195
|
const wrapper = mountCalendar({ modelValue: TODAY, monthYearPicker: true })
|
|
188
196
|
|
|
189
|
-
const header = wrapper.find('[class*="
|
|
190
|
-
|
|
197
|
+
const header = wrapper.find('[class*="flex"][class*="items-center"][class*="gap-1"]')
|
|
198
|
+
const monthBtn = header.findAll('button').find((b) => b.text().includes('Mrt'))
|
|
199
|
+
await monthBtn!.trigger('click')
|
|
191
200
|
await nextTick()
|
|
192
201
|
|
|
202
|
+
// Month view uses grid-cols-3
|
|
193
203
|
expect(wrapper.find('[class*="grid-cols-3"]').exists()).toBe(true)
|
|
194
204
|
})
|
|
195
205
|
|
|
196
206
|
it('opens year picker when year is clicked', async () => {
|
|
197
207
|
const wrapper = mountCalendar({ modelValue: TODAY, monthYearPicker: true })
|
|
198
208
|
|
|
199
|
-
const header = wrapper.find('[class*="
|
|
209
|
+
const header = wrapper.find('[class*="flex"][class*="items-center"][class*="gap-1"]')
|
|
200
210
|
const buttons = header.findAll('button')
|
|
201
211
|
await buttons[1]!.trigger('click')
|
|
202
212
|
await nextTick()
|
|
@@ -207,7 +217,7 @@ describe('Calendar', () => {
|
|
|
207
217
|
it('returns to days view after selecting a month', async () => {
|
|
208
218
|
const wrapper = mountCalendar({ modelValue: TODAY, monthYearPicker: true })
|
|
209
219
|
|
|
210
|
-
const header = wrapper.find('[class*="
|
|
220
|
+
const header = wrapper.find('[class*="flex"][class*="items-center"][class*="gap-1"]')
|
|
211
221
|
await header.find('button').trigger('click')
|
|
212
222
|
await nextTick()
|
|
213
223
|
|
|
@@ -222,7 +232,7 @@ describe('Calendar', () => {
|
|
|
222
232
|
it('returns to months view after selecting a year', async () => {
|
|
223
233
|
const wrapper = mountCalendar({ modelValue: TODAY, monthYearPicker: true })
|
|
224
234
|
|
|
225
|
-
const header = wrapper.find('[class*="
|
|
235
|
+
const header = wrapper.find('[class*="flex"][class*="items-center"][class*="gap-1"]')
|
|
226
236
|
await header.findAll('button')[1]!.trigger('click')
|
|
227
237
|
await nextTick()
|
|
228
238
|
|
|
@@ -237,12 +247,12 @@ describe('Calendar', () => {
|
|
|
237
247
|
it('selects correct date from month picker', async () => {
|
|
238
248
|
const wrapper = mountCalendar({ modelValue: TODAY, monthYearPicker: true })
|
|
239
249
|
|
|
240
|
-
const header = wrapper.find('[class*="
|
|
250
|
+
const header = wrapper.find('[class*="flex"][class*="items-center"][class*="gap-1"]')
|
|
241
251
|
await header.find('button').trigger('click')
|
|
242
252
|
await nextTick()
|
|
243
253
|
|
|
244
254
|
const monthButtons = wrapper.findAll('[class*="grid-cols-3"] button')
|
|
245
|
-
await monthButtons[0]
|
|
255
|
+
await monthButtons[0]?.trigger('click')
|
|
246
256
|
await nextTick()
|
|
247
257
|
|
|
248
258
|
const labelAfter = wrapper.find('[class*="cornflower-blue-600"]').text()
|
|
@@ -252,9 +262,9 @@ describe('Calendar', () => {
|
|
|
252
262
|
it('selects correct year from year drum', async () => {
|
|
253
263
|
const wrapper = mountCalendar({ modelValue: TODAY, monthYearPicker: true })
|
|
254
264
|
|
|
255
|
-
const header = wrapper.find('[class*="
|
|
265
|
+
const header = wrapper.find('[class*="flex"][class*="items-center"][class*="gap-1"]')
|
|
256
266
|
const headerButtons = header.findAll('button')
|
|
257
|
-
await headerButtons[1]
|
|
267
|
+
await headerButtons[1]?.trigger('click')
|
|
258
268
|
await nextTick()
|
|
259
269
|
|
|
260
270
|
const yearButtons = wrapper.findAll('[class*="snap-center"]')
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
startOfDay,
|
|
16
16
|
endOfDay,
|
|
17
17
|
} from 'date-fns'
|
|
18
|
+
import { fromZonedTime, toZonedTime } from 'date-fns-tz'
|
|
18
19
|
import { nl } from 'date-fns/locale'
|
|
19
20
|
import CalendarHeader from './_CalendarHeader.vue'
|
|
20
21
|
import CalendarDayView from './_CalendarDayView.vue'
|
|
@@ -42,11 +43,14 @@ const {
|
|
|
42
43
|
minDate,
|
|
43
44
|
maxDate,
|
|
44
45
|
monthYearPicker = false,
|
|
46
|
+
timezone = 'UTC',
|
|
45
47
|
} = defineProps<NexxtCalendarProps>()
|
|
46
48
|
|
|
47
49
|
// ── State ─────────────────────────────────────────────────────────────────────
|
|
48
50
|
|
|
49
|
-
const viewDate = ref<Date>(
|
|
51
|
+
const viewDate = ref<Date>(
|
|
52
|
+
model.value ? toZonedTime(new Date(model.value), timezone) : toZonedTime(new Date(), timezone),
|
|
53
|
+
)
|
|
50
54
|
const pickerView = ref<PickerView>('days')
|
|
51
55
|
const dayViewRef = ref<InstanceType<typeof CalendarDayView> | null>(null)
|
|
52
56
|
const dayTransition = ref<DayTransition>('fade')
|
|
@@ -54,7 +58,7 @@ const dayTransition = ref<DayTransition>('fade')
|
|
|
54
58
|
// ── Watchers ──────────────────────────────────────────────────────────────────
|
|
55
59
|
|
|
56
60
|
watch(model, (val) => {
|
|
57
|
-
if (val) viewDate.value = new Date(val)
|
|
61
|
+
if (val) viewDate.value = toZonedTime(new Date(val), timezone)
|
|
58
62
|
})
|
|
59
63
|
|
|
60
64
|
// ── Logic ─────────────────────────────────────────────────────────────────────
|
|
@@ -66,29 +70,38 @@ const calendarDays = computed(() => {
|
|
|
66
70
|
})
|
|
67
71
|
|
|
68
72
|
const isDisabled = (day: Date): boolean => {
|
|
69
|
-
|
|
70
|
-
if (
|
|
71
|
-
if (
|
|
73
|
+
const zonedDay = toZonedTime(day, timezone)
|
|
74
|
+
if (disabledDates.some((d) => isSameDay(toZonedTime(d, timezone), zonedDay))) return true
|
|
75
|
+
if (minDate && isBefore(zonedDay, startOfDay(toZonedTime(minDate, timezone)))) return true
|
|
76
|
+
if (maxDate && isAfter(zonedDay, endOfDay(toZonedTime(maxDate, timezone)))) return true
|
|
72
77
|
return false
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
// ── Views ─────────────────────────────────────────────────────────────────────
|
|
76
81
|
|
|
77
82
|
const handleSelectDay = (day: Date) => {
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
const zonedDay = toZonedTime(day, timezone)
|
|
84
|
+
if (isDisabled(zonedDay)) return
|
|
85
|
+
if (!isSameMonth(zonedDay, viewDate.value)) viewDate.value = startOfMonth(zonedDay)
|
|
86
|
+
model.value = fromZonedTime(zonedDay, timezone)
|
|
87
|
+
emit('select', model.value)
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
const handleSelectMonth = (monthIndex: number) => {
|
|
85
91
|
dayTransition.value = 'fade'
|
|
86
|
-
|
|
92
|
+
// Create a new date in the target timezone at the middle of the month
|
|
93
|
+
const newDate = toZonedTime(
|
|
94
|
+
new Date(Date.UTC(viewDate.value.getUTCFullYear(), monthIndex, 1)),
|
|
95
|
+
timezone,
|
|
96
|
+
)
|
|
97
|
+
viewDate.value = newDate
|
|
87
98
|
pickerView.value = 'days'
|
|
88
99
|
}
|
|
89
100
|
|
|
90
101
|
const handleSelectYear = (year: number) => {
|
|
91
|
-
|
|
102
|
+
// Use target timezone to create a date at the start of the year/current month
|
|
103
|
+
const newDate = toZonedTime(new Date(Date.UTC(year, viewDate.value.getUTCMonth(), 1)), timezone)
|
|
104
|
+
viewDate.value = newDate
|
|
92
105
|
pickerView.value = 'months'
|
|
93
106
|
}
|
|
94
107
|
|
|
@@ -97,13 +110,15 @@ const handleSelectYear = (year: number) => {
|
|
|
97
110
|
const canGoPrev = computed(() => {
|
|
98
111
|
if (pickerView.value !== 'days') return false
|
|
99
112
|
if (!minDate) return true
|
|
100
|
-
|
|
113
|
+
const prevMonth = subMonths(viewDate.value, 1)
|
|
114
|
+
return !isBefore(endOfMonth(prevMonth), startOfDay(toZonedTime(minDate, timezone)))
|
|
101
115
|
})
|
|
102
116
|
|
|
103
117
|
const canGoNext = computed(() => {
|
|
104
118
|
if (pickerView.value !== 'days') return false
|
|
105
119
|
if (!maxDate) return true
|
|
106
|
-
|
|
120
|
+
const nextMonth = addMonths(viewDate.value, 1)
|
|
121
|
+
return !isAfter(startOfMonth(nextMonth), endOfDay(toZonedTime(maxDate, timezone)))
|
|
107
122
|
})
|
|
108
123
|
|
|
109
124
|
const handlePrev = () => {
|
|
@@ -192,6 +207,7 @@ const currentDayTransition = computed(() =>
|
|
|
192
207
|
:marked-dates="markedDates"
|
|
193
208
|
:locale="locale"
|
|
194
209
|
:is-disabled="isDisabled"
|
|
210
|
+
:timezone="timezone"
|
|
195
211
|
@select="handleSelectDay"
|
|
196
212
|
/>
|
|
197
213
|
|
|
@@ -204,6 +220,7 @@ const currentDayTransition = computed(() =>
|
|
|
204
220
|
:min-date="minDate"
|
|
205
221
|
:max-date="maxDate"
|
|
206
222
|
:locale="locale"
|
|
223
|
+
:timezone="timezone"
|
|
207
224
|
@select="handleSelectMonth"
|
|
208
225
|
/>
|
|
209
226
|
|
|
@@ -213,6 +230,7 @@ const currentDayTransition = computed(() =>
|
|
|
213
230
|
:view-year="viewDate.getFullYear()"
|
|
214
231
|
:min-date="minDate"
|
|
215
232
|
:max-date="maxDate"
|
|
233
|
+
:timezone="timezone"
|
|
216
234
|
@select="handleSelectYear"
|
|
217
235
|
/>
|
|
218
236
|
</Transition>
|
|
@@ -9,33 +9,32 @@ const start = startOfWeek(startOfMonth(VIEW_DATE), { weekStartsOn: 1 })
|
|
|
9
9
|
const end = endOfWeek(endOfMonth(VIEW_DATE), { weekStartsOn: 1 })
|
|
10
10
|
const DAYS = eachDayOfInterval({ start, end })
|
|
11
11
|
|
|
12
|
+
const defaults = {
|
|
13
|
+
viewDate: VIEW_DATE,
|
|
14
|
+
calendarDays: DAYS,
|
|
15
|
+
modelValue: null,
|
|
16
|
+
markedDates: [],
|
|
17
|
+
locale: nl,
|
|
18
|
+
isDisabled: () => false,
|
|
19
|
+
timezone: 'UTC',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const mountDayView = (props: Record<string, unknown> = {}) =>
|
|
23
|
+
mount(CalendarDayView, {
|
|
24
|
+
props: { ...defaults, ...props },
|
|
25
|
+
})
|
|
26
|
+
|
|
12
27
|
describe('CalendarDayView', () => {
|
|
13
28
|
it('renders the correct number of days', () => {
|
|
14
|
-
const wrapper =
|
|
15
|
-
props: {
|
|
16
|
-
viewDate: VIEW_DATE,
|
|
17
|
-
calendarDays: DAYS,
|
|
18
|
-
modelValue: null,
|
|
19
|
-
markedDates: [],
|
|
20
|
-
locale: nl,
|
|
21
|
-
isDisabled: () => false,
|
|
22
|
-
},
|
|
23
|
-
})
|
|
29
|
+
const wrapper = mountDayView()
|
|
24
30
|
const dayButtons = wrapper.findAll('button')
|
|
25
31
|
expect(dayButtons.length).toBe(DAYS.length)
|
|
26
32
|
})
|
|
27
33
|
|
|
28
34
|
it('highlights the selected day', () => {
|
|
29
35
|
const selectedDate = new Date(2026, 2, 15)
|
|
30
|
-
const wrapper =
|
|
31
|
-
|
|
32
|
-
viewDate: VIEW_DATE,
|
|
33
|
-
calendarDays: DAYS,
|
|
34
|
-
modelValue: selectedDate,
|
|
35
|
-
markedDates: [],
|
|
36
|
-
locale: nl,
|
|
37
|
-
isDisabled: () => false,
|
|
38
|
-
},
|
|
36
|
+
const wrapper = mountDayView({
|
|
37
|
+
modelValue: selectedDate,
|
|
39
38
|
})
|
|
40
39
|
const selectedCell = wrapper.find('[role="gridcell"][aria-selected="true"]')
|
|
41
40
|
expect(selectedCell.exists()).toBe(true)
|
|
@@ -44,15 +43,8 @@ describe('CalendarDayView', () => {
|
|
|
44
43
|
|
|
45
44
|
it('marks dates correctly', () => {
|
|
46
45
|
const markedDates = [new Date(2026, 2, 10)]
|
|
47
|
-
const wrapper =
|
|
48
|
-
|
|
49
|
-
viewDate: VIEW_DATE,
|
|
50
|
-
calendarDays: DAYS,
|
|
51
|
-
modelValue: null,
|
|
52
|
-
markedDates,
|
|
53
|
-
locale: nl,
|
|
54
|
-
isDisabled: () => false,
|
|
55
|
-
},
|
|
46
|
+
const wrapper = mountDayView({
|
|
47
|
+
markedDates,
|
|
56
48
|
})
|
|
57
49
|
// In our component, isMarked(day) is used to show a small dot.
|
|
58
50
|
// The dot is a span with bg-cornflower-blue-500 class.
|
|
@@ -60,15 +52,8 @@ describe('CalendarDayView', () => {
|
|
|
60
52
|
})
|
|
61
53
|
|
|
62
54
|
it('disables dates based on isDisabled prop', () => {
|
|
63
|
-
const wrapper =
|
|
64
|
-
|
|
65
|
-
viewDate: VIEW_DATE,
|
|
66
|
-
calendarDays: DAYS,
|
|
67
|
-
modelValue: null,
|
|
68
|
-
markedDates: [],
|
|
69
|
-
locale: nl,
|
|
70
|
-
isDisabled: (day: Date) => day.getDate() === 15,
|
|
71
|
-
},
|
|
55
|
+
const wrapper = mountDayView({
|
|
56
|
+
isDisabled: (day: Date) => day.getDate() === 15,
|
|
72
57
|
})
|
|
73
58
|
const disabledButton = wrapper.find('button[disabled]')
|
|
74
59
|
expect(disabledButton.exists()).toBe(true)
|
|
@@ -76,70 +61,44 @@ describe('CalendarDayView', () => {
|
|
|
76
61
|
})
|
|
77
62
|
|
|
78
63
|
it('emits select event when a day is clicked', async () => {
|
|
79
|
-
const wrapper =
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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)
|
|
64
|
+
const wrapper = mountDayView()
|
|
65
|
+
const button = wrapper.findAll('button')[10]
|
|
66
|
+
if (button) {
|
|
67
|
+
await button.trigger('click')
|
|
68
|
+
expect(wrapper.emitted('select')).toBeTruthy()
|
|
69
|
+
expect(wrapper.emitted('select')?.[0]?.[0]).toBeInstanceOf(Date)
|
|
70
|
+
}
|
|
92
71
|
})
|
|
93
72
|
|
|
94
73
|
it('handles keyboard navigation (ArrowRight)', async () => {
|
|
95
|
-
const wrapper =
|
|
96
|
-
props: {
|
|
97
|
-
viewDate: VIEW_DATE,
|
|
98
|
-
calendarDays: DAYS,
|
|
99
|
-
modelValue: null,
|
|
100
|
-
markedDates: [],
|
|
101
|
-
locale: nl,
|
|
102
|
-
isDisabled: () => false,
|
|
103
|
-
},
|
|
74
|
+
const wrapper = mountDayView({
|
|
104
75
|
attachTo: document.body,
|
|
105
76
|
})
|
|
106
|
-
const
|
|
107
|
-
|
|
77
|
+
const button = wrapper.findAll('button')[0]
|
|
78
|
+
if (button) {
|
|
79
|
+
await button.trigger('keydown', { key: 'ArrowRight' })
|
|
80
|
+
}
|
|
108
81
|
// In vitest/jsdom, focus might not move as easily, so we check if the method logic runs
|
|
109
82
|
// For unit tests, we can also check if emit('select') is called on Enter/Space
|
|
110
83
|
})
|
|
111
84
|
|
|
112
85
|
it('emits select when Enter or Space is pressed', async () => {
|
|
113
|
-
const wrapper =
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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()
|
|
86
|
+
const wrapper = mountDayView()
|
|
87
|
+
const button = wrapper.findAll('button')[0]
|
|
88
|
+
if (button) {
|
|
89
|
+
await button.trigger('keydown', { key: 'Enter' })
|
|
90
|
+
expect(wrapper.emitted('select')).toBeTruthy()
|
|
91
|
+
await button.trigger('keydown', { key: ' ' })
|
|
92
|
+
expect(wrapper.emitted('select')?.[1]).toBeTruthy()
|
|
93
|
+
}
|
|
128
94
|
})
|
|
129
95
|
|
|
130
96
|
it('does not emit select for other keys', async () => {
|
|
131
|
-
const wrapper =
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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()
|
|
97
|
+
const wrapper = mountDayView()
|
|
98
|
+
const button = wrapper.findAll('button')[0]
|
|
99
|
+
if (button) {
|
|
100
|
+
await button.trigger('keydown', { key: 'Tab' })
|
|
101
|
+
expect(wrapper.emitted('select')).toBeFalsy()
|
|
102
|
+
}
|
|
144
103
|
})
|
|
145
104
|
})
|