@nexxtmove/ui 0.1.22 → 0.1.24
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 +76 -8
- package/dist/index.js +2758 -252
- package/dist/nuxt.d.ts +1 -0
- package/dist/nuxt.js +49 -0
- package/package.json +24 -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 +279 -0
- package/src/components/Calendar/Calendar.vue +239 -0
- package/src/components/Calendar/_CalendarDayView.test.ts +104 -0
- package/src/components/Calendar/_CalendarDayView.vue +169 -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 +52 -0
- package/src/components/Calendar/_CalendarMonthView.vue +79 -0
- package/src/components/Calendar/_CalendarYearView.test.ts +56 -0
- package/src/components/Calendar/_CalendarYearView.vue +83 -0
- package/src/components/Calendar/calendar.types.ts +11 -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 +145 -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,169 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { computed, ref, onMounted } from 'vue'
|
|
3
|
+
import { isSameDay, isSameMonth, format } from 'date-fns'
|
|
4
|
+
import { toZonedTime } from 'date-fns-tz'
|
|
5
|
+
import type { Locale } from 'date-fns'
|
|
6
|
+
|
|
7
|
+
interface CalendarDayViewProps {
|
|
8
|
+
viewDate: Date
|
|
9
|
+
calendarDays: Date[]
|
|
10
|
+
modelValue: Date | null
|
|
11
|
+
markedDates: Date[]
|
|
12
|
+
locale: Locale
|
|
13
|
+
isDisabled: (day: Date) => boolean
|
|
14
|
+
timezone: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const props = defineProps<CalendarDayViewProps>()
|
|
18
|
+
const emit = defineEmits<{
|
|
19
|
+
(e: 'select', day: Date): void
|
|
20
|
+
}>()
|
|
21
|
+
|
|
22
|
+
const dayButtonsRef = ref<HTMLElement[]>([])
|
|
23
|
+
|
|
24
|
+
const isTodayByTimezone = (day: Date): boolean => {
|
|
25
|
+
const zonedDay = toZonedTime(day, props.timezone)
|
|
26
|
+
const nowZoned = toZonedTime(new Date(), props.timezone)
|
|
27
|
+
return isSameDay(zonedDay, nowZoned)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isSelected = (day: Date): boolean => {
|
|
31
|
+
return props.modelValue ? isSameDay(day, props.modelValue) : false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const isMarked = (day: Date): boolean => {
|
|
35
|
+
return props.markedDates.some((d) => isSameDay(d, day))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const weeks = computed(() => {
|
|
39
|
+
const result: Date[][] = []
|
|
40
|
+
for (let i = 0; i < props.calendarDays.length; i += 7) {
|
|
41
|
+
result.push(props.calendarDays.slice(i, i + 7))
|
|
42
|
+
}
|
|
43
|
+
return result
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const weekDayLabels = computed(() => {
|
|
47
|
+
if (props.calendarDays.length === 0) return []
|
|
48
|
+
// Take first 7 days to get labels
|
|
49
|
+
return props.calendarDays.slice(0, 7).map((d) => {
|
|
50
|
+
const raw = format(d, 'EEEEEE', { locale: props.locale })
|
|
51
|
+
return raw.charAt(0).toUpperCase() + raw.slice(1)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const handleKeydown = (event: KeyboardEvent, dayIndex: number) => {
|
|
56
|
+
let nextIdx = dayIndex
|
|
57
|
+
|
|
58
|
+
switch (event.key) {
|
|
59
|
+
case 'ArrowRight':
|
|
60
|
+
nextIdx++
|
|
61
|
+
break
|
|
62
|
+
case 'ArrowLeft':
|
|
63
|
+
nextIdx--
|
|
64
|
+
break
|
|
65
|
+
case 'ArrowDown':
|
|
66
|
+
nextIdx += 7
|
|
67
|
+
break
|
|
68
|
+
case 'ArrowUp':
|
|
69
|
+
nextIdx -= 7
|
|
70
|
+
break
|
|
71
|
+
case 'Enter':
|
|
72
|
+
case ' ': {
|
|
73
|
+
event.preventDefault()
|
|
74
|
+
const selectedDay = props.calendarDays[dayIndex]
|
|
75
|
+
if (selectedDay) emit('select', selectedDay)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
default:
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (nextIdx >= 0 && nextIdx < props.calendarDays.length) {
|
|
83
|
+
event.preventDefault()
|
|
84
|
+
// We don't change viewDate here, we let the parent handle that if needed
|
|
85
|
+
// or we just move focus. For better UX in a grid, we usually just move focus.
|
|
86
|
+
dayButtonsRef.value[nextIdx]?.focus()
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Focus the currently selected day or today when mounted
|
|
91
|
+
onMounted(() => {
|
|
92
|
+
const focusIdx = props.calendarDays.findIndex(
|
|
93
|
+
(d) =>
|
|
94
|
+
(props.modelValue && isSameDay(d, props.modelValue)) ||
|
|
95
|
+
(!props.modelValue && isTodayByTimezone(d)),
|
|
96
|
+
)
|
|
97
|
+
if (focusIdx !== -1) {
|
|
98
|
+
// We don't auto-focus on mount to avoid stealing focus from the popover,
|
|
99
|
+
// but we make it available for the parent to trigger if needed.
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
defineExpose({
|
|
104
|
+
focus: (date: Date) => {
|
|
105
|
+
const idx = props.calendarDays.findIndex((d) => isSameDay(d, date))
|
|
106
|
+
if (idx !== -1) dayButtonsRef.value[idx]?.focus()
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
</script>
|
|
110
|
+
|
|
111
|
+
<template>
|
|
112
|
+
<div class="calendar-day-view" role="grid" aria-label="Kalender">
|
|
113
|
+
<div class="mb-1 grid grid-cols-7" role="row">
|
|
114
|
+
<div
|
|
115
|
+
v-for="label in weekDayLabels"
|
|
116
|
+
:key="label"
|
|
117
|
+
class="flex h-8 items-center justify-center font-medium text-xs text-gray-500"
|
|
118
|
+
role="columnheader"
|
|
119
|
+
:aria-label="label"
|
|
120
|
+
>
|
|
121
|
+
{{ label }}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
<div v-for="(week, weekIdx) in weeks" :key="weekIdx" class="grid grid-cols-7" role="row">
|
|
125
|
+
<div
|
|
126
|
+
v-for="(day, dayIdx) in week"
|
|
127
|
+
:key="day.toISOString()"
|
|
128
|
+
class="flex items-center justify-center py-0.5"
|
|
129
|
+
role="gridcell"
|
|
130
|
+
:aria-selected="isSelected(day)"
|
|
131
|
+
:aria-disabled="isDisabled(day)"
|
|
132
|
+
>
|
|
133
|
+
<button
|
|
134
|
+
ref="dayButtonsRef"
|
|
135
|
+
type="button"
|
|
136
|
+
:disabled="isDisabled(day)"
|
|
137
|
+
:aria-label="format(day, 'd MMMM yyyy', { locale })"
|
|
138
|
+
:tabindex="isSameMonth(day, viewDate) ? 0 : -1"
|
|
139
|
+
:class="[
|
|
140
|
+
'relative flex h-8 w-8 items-center justify-center rounded-xl text-xs transition-colors duration-150 outline-none focus:ring-2 focus:ring-cornflower-blue-500 focus:ring-offset-1',
|
|
141
|
+
!isSameMonth(day, viewDate) ? 'text-gray-300' : 'text-gray-900',
|
|
142
|
+
isSelected(day) ? 'bg-cornflower-blue-500 font-medium text-white!' : '',
|
|
143
|
+
isTodayByTimezone(day) && !isSelected(day)
|
|
144
|
+
? 'font-medium text-cornflower-blue-500! ring-1 ring-cornflower-blue-500'
|
|
145
|
+
: '',
|
|
146
|
+
isTodayByTimezone(day) && isSelected(day)
|
|
147
|
+
? 'ring-2 ring-cornflower-blue-500 ring-offset-2'
|
|
148
|
+
: '',
|
|
149
|
+
!isDisabled(day) && !isSelected(day)
|
|
150
|
+
? 'cursor-pointer hover:bg-cornflower-blue-50'
|
|
151
|
+
: '',
|
|
152
|
+
isDisabled(day) ? 'cursor-not-allowed opacity-30' : '',
|
|
153
|
+
]"
|
|
154
|
+
@click="emit('select', day)"
|
|
155
|
+
@keydown="handleKeydown($event, weekIdx * 7 + dayIdx)"
|
|
156
|
+
>
|
|
157
|
+
<span aria-hidden="true">{{ format(day, 'd') }}</span>
|
|
158
|
+
<span
|
|
159
|
+
v-if="isMarked(day)"
|
|
160
|
+
:class="[
|
|
161
|
+
'absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full',
|
|
162
|
+
isSelected(day) ? 'bg-white' : 'bg-cornflower-blue-500',
|
|
163
|
+
]"
|
|
164
|
+
/>
|
|
165
|
+
</button>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</template>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import CalendarHeader from './_CalendarHeader.vue'
|
|
4
|
+
import { nl } from 'date-fns/locale'
|
|
5
|
+
|
|
6
|
+
const VIEW_DATE = new Date(2026, 2, 20) // March 20, 2026
|
|
7
|
+
|
|
8
|
+
describe('CalendarHeader', () => {
|
|
9
|
+
it('renders the correct month and year label', () => {
|
|
10
|
+
const wrapper = mount(CalendarHeader, {
|
|
11
|
+
props: {
|
|
12
|
+
viewDate: VIEW_DATE,
|
|
13
|
+
pickerView: 'days',
|
|
14
|
+
monthYearPicker: false,
|
|
15
|
+
locale: nl,
|
|
16
|
+
canGoPrev: true,
|
|
17
|
+
canGoNext: true,
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
expect(wrapper.text()).toContain('Mrt.')
|
|
21
|
+
expect(wrapper.text()).toContain('2026')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders month and year as buttons if monthYearPicker is true', () => {
|
|
25
|
+
const wrapper = mount(CalendarHeader, {
|
|
26
|
+
props: {
|
|
27
|
+
viewDate: VIEW_DATE,
|
|
28
|
+
pickerView: 'days',
|
|
29
|
+
monthYearPicker: true,
|
|
30
|
+
locale: nl,
|
|
31
|
+
canGoPrev: true,
|
|
32
|
+
canGoNext: true,
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
const buttons = wrapper.findAll('.flex.items-center.gap-1 button')
|
|
36
|
+
expect(buttons.length).toBe(2)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('emits openMonthPicker when month label button is clicked', async () => {
|
|
40
|
+
const wrapper = mount(CalendarHeader, {
|
|
41
|
+
props: {
|
|
42
|
+
viewDate: VIEW_DATE,
|
|
43
|
+
pickerView: 'days',
|
|
44
|
+
monthYearPicker: true,
|
|
45
|
+
locale: nl,
|
|
46
|
+
canGoPrev: true,
|
|
47
|
+
canGoNext: true,
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
await wrapper.findAll('.flex.items-center.gap-1 button')[0].trigger('click')
|
|
51
|
+
expect(wrapper.emitted('openMonthPicker')).toBeTruthy()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('emits prev and next events', async () => {
|
|
55
|
+
const wrapper = mount(CalendarHeader, {
|
|
56
|
+
props: {
|
|
57
|
+
viewDate: VIEW_DATE,
|
|
58
|
+
pickerView: 'days',
|
|
59
|
+
monthYearPicker: false,
|
|
60
|
+
locale: nl,
|
|
61
|
+
canGoPrev: true,
|
|
62
|
+
canGoNext: true,
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
const navButtons = wrapper.findAll('button[aria-label]')
|
|
66
|
+
await navButtons[0].trigger('click')
|
|
67
|
+
expect(wrapper.emitted('prev')).toBeTruthy()
|
|
68
|
+
await navButtons[1].trigger('click')
|
|
69
|
+
expect(wrapper.emitted('next')).toBeTruthy()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('shows back arrow instead of chevron when in years view', () => {
|
|
73
|
+
const wrapper = mount(CalendarHeader, {
|
|
74
|
+
props: {
|
|
75
|
+
viewDate: VIEW_DATE,
|
|
76
|
+
pickerView: 'years',
|
|
77
|
+
monthYearPicker: true,
|
|
78
|
+
locale: nl,
|
|
79
|
+
canGoPrev: false,
|
|
80
|
+
canGoNext: false,
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
const backButton = wrapper.find('button[aria-label="Terug naar maanden"]')
|
|
84
|
+
expect(backButton.exists()).toBe(true)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { format } from 'date-fns'
|
|
4
|
+
import type { Locale } from 'date-fns'
|
|
5
|
+
import NexxtIcon from '../Icon/Icon.vue'
|
|
6
|
+
|
|
7
|
+
interface CalendarHeaderProps {
|
|
8
|
+
viewDate: Date
|
|
9
|
+
pickerView: 'days' | 'months' | 'years'
|
|
10
|
+
monthYearPicker: boolean
|
|
11
|
+
locale: Locale
|
|
12
|
+
canGoPrev: boolean
|
|
13
|
+
canGoNext: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const props = defineProps<CalendarHeaderProps>()
|
|
17
|
+
const emit = defineEmits<{
|
|
18
|
+
(e: 'prev'): void
|
|
19
|
+
(e: 'next'): void
|
|
20
|
+
(e: 'openMonthPicker'): void
|
|
21
|
+
(e: 'openYearPicker'): void
|
|
22
|
+
(e: 'closePicker'): void
|
|
23
|
+
}>()
|
|
24
|
+
|
|
25
|
+
const monthName = computed(() => {
|
|
26
|
+
const raw = format(props.viewDate, 'MMM', { locale: props.locale }).replace('.', '').trim()
|
|
27
|
+
return raw.charAt(0).toUpperCase() + raw.slice(1) + '.'
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const yearValue = computed(() => format(props.viewDate, 'yyyy'))
|
|
31
|
+
|
|
32
|
+
const monthTransition = {
|
|
33
|
+
enterActiveClass: 'transition-all duration-200 ease-out',
|
|
34
|
+
enterFromClass: 'translate-y-full',
|
|
35
|
+
leaveActiveClass: 'transition-all duration-200 ease-out absolute inset-x-0 top-0',
|
|
36
|
+
leaveToClass: '-translate-y-full',
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<div class="mb-4 flex items-center justify-between">
|
|
42
|
+
<!-- Left: prev (only in days view) or back (in year view) -->
|
|
43
|
+
<button
|
|
44
|
+
v-if="pickerView === 'days'"
|
|
45
|
+
type="button"
|
|
46
|
+
:disabled="!canGoPrev"
|
|
47
|
+
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded-full bg-gray-200 text-gray-950 transition-colors hover:bg-gray-300 disabled:cursor-not-allowed disabled:bg-transparent disabled:opacity-30"
|
|
48
|
+
@click="emit('prev')"
|
|
49
|
+
aria-label="Vorige maand"
|
|
50
|
+
>
|
|
51
|
+
<NexxtIcon name="chevron-left" class="text-xs" />
|
|
52
|
+
</button>
|
|
53
|
+
<button
|
|
54
|
+
v-else-if="pickerView === 'years'"
|
|
55
|
+
type="button"
|
|
56
|
+
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded-full bg-gray-200 text-gray-950 transition-colors hover:bg-gray-300"
|
|
57
|
+
@click="emit('closePicker')"
|
|
58
|
+
aria-label="Terug naar maanden"
|
|
59
|
+
>
|
|
60
|
+
<NexxtIcon name="arrow-left" class="text-xs" />
|
|
61
|
+
</button>
|
|
62
|
+
<div v-else class="h-7 w-7" />
|
|
63
|
+
|
|
64
|
+
<!-- Center: month + year labels -->
|
|
65
|
+
<div
|
|
66
|
+
class="flex items-center gap-1 font-normal text-sm text-cornflower-blue-600"
|
|
67
|
+
role="status"
|
|
68
|
+
aria-live="polite"
|
|
69
|
+
>
|
|
70
|
+
<!-- Month name — only in days view -->
|
|
71
|
+
<div v-if="pickerView === 'days'" class="relative h-5 overflow-hidden">
|
|
72
|
+
<Transition v-bind="monthTransition">
|
|
73
|
+
<button
|
|
74
|
+
v-if="monthYearPicker"
|
|
75
|
+
:key="`button-${monthName}`"
|
|
76
|
+
type="button"
|
|
77
|
+
class="block cursor-pointer rounded px-0.5 leading-5 whitespace-nowrap outline-none hover:text-cornflower-blue-700"
|
|
78
|
+
@click="emit('openMonthPicker')"
|
|
79
|
+
>
|
|
80
|
+
{{ monthName }}
|
|
81
|
+
</button>
|
|
82
|
+
<span v-else :key="monthName" class="block leading-5 whitespace-nowrap">
|
|
83
|
+
{{ monthName }}
|
|
84
|
+
</span>
|
|
85
|
+
</Transition>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<!-- Year — days + months view -->
|
|
89
|
+
<div v-if="pickerView !== 'years'" class="relative h-5 overflow-hidden">
|
|
90
|
+
<Transition v-bind="monthTransition">
|
|
91
|
+
<button
|
|
92
|
+
v-if="monthYearPicker"
|
|
93
|
+
:key="`button-${yearValue}`"
|
|
94
|
+
type="button"
|
|
95
|
+
class="block cursor-pointer rounded px-0.5 leading-5 whitespace-nowrap outline-none hover:text-cornflower-blue-700"
|
|
96
|
+
@click="emit('openYearPicker')"
|
|
97
|
+
>
|
|
98
|
+
{{ yearValue }}
|
|
99
|
+
</button>
|
|
100
|
+
<span v-else :key="yearValue" class="block leading-5 whitespace-nowrap">
|
|
101
|
+
{{ yearValue }}
|
|
102
|
+
</span>
|
|
103
|
+
</Transition>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<!-- Years view: static title -->
|
|
107
|
+
<span v-if="pickerView === 'years'">Kies jaar</span>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<!-- Right: next (only in days view) -->
|
|
111
|
+
<button
|
|
112
|
+
v-if="pickerView === 'days'"
|
|
113
|
+
type="button"
|
|
114
|
+
:disabled="!canGoNext"
|
|
115
|
+
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded-full bg-gray-200 text-gray-950 transition-colors hover:bg-gray-300 disabled:cursor-not-allowed disabled:bg-transparent disabled:opacity-30"
|
|
116
|
+
@click="emit('next')"
|
|
117
|
+
aria-label="Volgende maand"
|
|
118
|
+
>
|
|
119
|
+
<NexxtIcon name="chevron-right" class="text-xs" />
|
|
120
|
+
</button>
|
|
121
|
+
<div v-else class="h-7 w-7" />
|
|
122
|
+
</div>
|
|
123
|
+
</template>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import CalendarMonthView from './_CalendarMonthView.vue'
|
|
4
|
+
import { nl } from 'date-fns/locale'
|
|
5
|
+
|
|
6
|
+
const VIEW_YEAR = 2026
|
|
7
|
+
const CURRENT_MONTH = 2 // March
|
|
8
|
+
|
|
9
|
+
const mountMonthView = (props: Record<string, unknown>) =>
|
|
10
|
+
mount(CalendarMonthView, {
|
|
11
|
+
props: {
|
|
12
|
+
viewYear: VIEW_YEAR,
|
|
13
|
+
modelValue: null,
|
|
14
|
+
currentMonth: CURRENT_MONTH,
|
|
15
|
+
locale: nl,
|
|
16
|
+
timezone: 'UTC',
|
|
17
|
+
...(props as Record<string, unknown>),
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('CalendarMonthView', () => {
|
|
22
|
+
it('renders all 12 months', () => {
|
|
23
|
+
const wrapper = mountMonthView({})
|
|
24
|
+
const buttons = wrapper.findAll('button')
|
|
25
|
+
expect(buttons.length).toBe(12)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('highlights the selected month', () => {
|
|
29
|
+
const selectedDate = new Date(Date.UTC(VIEW_YEAR, 5, 15)) // June
|
|
30
|
+
const wrapper = mountMonthView({ modelValue: selectedDate })
|
|
31
|
+
const selectedButton = wrapper.find('button[aria-selected="true"]')
|
|
32
|
+
expect(selectedButton.text()).toBe('Jun')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('disables months based on minDate and maxDate', () => {
|
|
36
|
+
const minDate = new Date(Date.UTC(VIEW_YEAR, 2, 1)) // March 1st
|
|
37
|
+
const wrapper = mountMonthView({ minDate })
|
|
38
|
+
// January (0) and February (1) should be disabled
|
|
39
|
+
const disabledButtons = wrapper.findAll('button[disabled]')
|
|
40
|
+
expect(disabledButtons.length).toBe(2)
|
|
41
|
+
expect(disabledButtons[0]?.text()).toBe('Jan')
|
|
42
|
+
expect(disabledButtons[1]?.text()).toBe('Feb')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('emits select event when a month is clicked', async () => {
|
|
46
|
+
const wrapper = mountMonthView({})
|
|
47
|
+
await wrapper.findAll('button')[5]?.trigger('click')
|
|
48
|
+
const emitted = wrapper.emitted('select')
|
|
49
|
+
expect(emitted).toBeTruthy()
|
|
50
|
+
expect(emitted?.[0]?.[0]).toBe(5)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { format, startOfMonth, endOfMonth, startOfDay, endOfDay } from 'date-fns'
|
|
4
|
+
import { toZonedTime } from 'date-fns-tz'
|
|
5
|
+
import type { Locale } from 'date-fns'
|
|
6
|
+
|
|
7
|
+
interface CalendarMonthViewProps {
|
|
8
|
+
viewYear: number
|
|
9
|
+
modelValue: Date | null
|
|
10
|
+
currentMonth: number
|
|
11
|
+
minDate?: Date
|
|
12
|
+
maxDate?: Date
|
|
13
|
+
locale: Locale
|
|
14
|
+
timezone: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const props = defineProps<CalendarMonthViewProps>()
|
|
18
|
+
const emit = defineEmits<{
|
|
19
|
+
(e: 'select', month: number): void
|
|
20
|
+
}>()
|
|
21
|
+
|
|
22
|
+
const monthItems = computed(() =>
|
|
23
|
+
Array.from({ length: 12 }, (_, i) => {
|
|
24
|
+
const d = toZonedTime(new Date(Date.UTC(props.viewYear, i, 1)), props.timezone)
|
|
25
|
+
const raw = format(d, 'LLL', { locale: props.locale }).replace('.', '').trim()
|
|
26
|
+
return { index: i, label: raw.charAt(0).toUpperCase() + raw.slice(1) }
|
|
27
|
+
}),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const isMonthDisabled = (i: number): boolean => {
|
|
31
|
+
const y = props.viewYear
|
|
32
|
+
const monthDate = toZonedTime(new Date(Date.UTC(y, i, 1)), props.timezone)
|
|
33
|
+
if (
|
|
34
|
+
props.minDate &&
|
|
35
|
+
endOfMonth(monthDate) < startOfDay(toZonedTime(props.minDate, props.timezone))
|
|
36
|
+
)
|
|
37
|
+
return true
|
|
38
|
+
if (
|
|
39
|
+
props.maxDate &&
|
|
40
|
+
startOfMonth(monthDate) > endOfDay(toZonedTime(props.maxDate, props.timezone))
|
|
41
|
+
)
|
|
42
|
+
return true
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const isMonthSelected = (i: number): boolean => {
|
|
47
|
+
if (!props.modelValue) return false
|
|
48
|
+
const zonedModel = toZonedTime(props.modelValue, props.timezone)
|
|
49
|
+
return zonedModel.getFullYear() === props.viewYear && zonedModel.getMonth() === i
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const isMonthCurrent = (i: number): boolean => {
|
|
53
|
+
return i === props.currentMonth
|
|
54
|
+
}
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<template>
|
|
58
|
+
<div class="grid grid-cols-3 gap-1.5" role="list">
|
|
59
|
+
<button
|
|
60
|
+
v-for="item in monthItems"
|
|
61
|
+
:key="item.index"
|
|
62
|
+
type="button"
|
|
63
|
+
:disabled="isMonthDisabled(item.index)"
|
|
64
|
+
:aria-selected="isMonthSelected(item.index)"
|
|
65
|
+
:class="[
|
|
66
|
+
'flex h-10 items-center justify-center rounded-lg text-sm transition-colors duration-150 outline-none focus:ring-2 focus:ring-cornflower-blue-500 focus:ring-offset-1',
|
|
67
|
+
isMonthSelected(item.index)
|
|
68
|
+
? 'bg-cornflower-blue-500 font-medium text-white'
|
|
69
|
+
: isMonthCurrent(item.index)
|
|
70
|
+
? 'font-medium text-cornflower-blue-500 ring-1 ring-cornflower-blue-500'
|
|
71
|
+
: 'text-gray-700 hover:bg-cornflower-blue-50',
|
|
72
|
+
isMonthDisabled(item.index) ? 'cursor-not-allowed opacity-30' : 'cursor-pointer',
|
|
73
|
+
]"
|
|
74
|
+
@click="emit('select', item.index)"
|
|
75
|
+
>
|
|
76
|
+
{{ item.label }}
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
</template>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import CalendarYearView from './_CalendarYearView.vue'
|
|
4
|
+
|
|
5
|
+
describe('CalendarYearView', () => {
|
|
6
|
+
it('renders a range of years', () => {
|
|
7
|
+
const wrapper = mount(CalendarYearView, {
|
|
8
|
+
props: {
|
|
9
|
+
viewYear: 2024,
|
|
10
|
+
timezone: 'UTC',
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
const items = wrapper.findAll('button')
|
|
14
|
+
expect(items.length).toBeGreaterThan(100) // Default range is viewYear - 100 to + 20
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('respects minDate and maxDate', () => {
|
|
18
|
+
const wrapper = mount(CalendarYearView, {
|
|
19
|
+
props: {
|
|
20
|
+
viewYear: 2024,
|
|
21
|
+
minDate: new Date(Date.UTC(2020, 0, 1)),
|
|
22
|
+
maxDate: new Date(Date.UTC(2025, 0, 1)),
|
|
23
|
+
timezone: 'UTC',
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
const items = wrapper.findAll('button')
|
|
27
|
+
// 2020 to 2025 inclusive is 6 years
|
|
28
|
+
expect(items.length).toBe(6)
|
|
29
|
+
expect(items[0]?.text()).toBe('2020')
|
|
30
|
+
expect(items[5]?.text()).toBe('2025')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('emits select when a year is clicked', async () => {
|
|
34
|
+
const wrapper = mount(CalendarYearView, {
|
|
35
|
+
props: {
|
|
36
|
+
viewYear: 2024,
|
|
37
|
+
timezone: 'UTC',
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
const year2024 = wrapper.findAll('button').find((i) => i.text() === '2024')
|
|
41
|
+
await year2024?.trigger('click')
|
|
42
|
+
expect(wrapper.emitted('select')).toBeTruthy()
|
|
43
|
+
expect(wrapper.emitted('select')?.[0]?.[0]).toBe(2024)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('highlights the current year', async () => {
|
|
47
|
+
const wrapper = mount(CalendarYearView, {
|
|
48
|
+
props: {
|
|
49
|
+
viewYear: 2024,
|
|
50
|
+
timezone: 'UTC',
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
const activeYear = wrapper.find('button.text-cornflower-blue-500')
|
|
54
|
+
expect(activeYear.text()).toBe('2024')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { ref, computed, nextTick, onMounted } from 'vue'
|
|
3
|
+
import { toZonedTime } from 'date-fns-tz'
|
|
4
|
+
|
|
5
|
+
interface CalendarYearViewProps {
|
|
6
|
+
viewYear: number
|
|
7
|
+
minDate?: Date
|
|
8
|
+
maxDate?: Date
|
|
9
|
+
timezone: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const props = defineProps<CalendarYearViewProps>()
|
|
13
|
+
const emit = defineEmits<{
|
|
14
|
+
(e: 'select', year: number): void
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
const YEAR_ITEM_H = 36 // px — matches h-9
|
|
18
|
+
const yearScrollerRef = ref<HTMLElement | null>(null)
|
|
19
|
+
|
|
20
|
+
const yearRange = computed(() => {
|
|
21
|
+
const min = props.minDate
|
|
22
|
+
? toZonedTime(props.minDate, props.timezone).getFullYear()
|
|
23
|
+
: props.viewYear - 100
|
|
24
|
+
const max = props.maxDate
|
|
25
|
+
? toZonedTime(props.maxDate, props.timezone).getFullYear()
|
|
26
|
+
: props.viewYear + 20
|
|
27
|
+
return Array.from({ length: max - min + 1 }, (_, i) => min + i)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const scrollToYear = (year: number) => {
|
|
31
|
+
if (!yearScrollerRef.value) return
|
|
32
|
+
const idx = yearRange.value.indexOf(year)
|
|
33
|
+
if (idx !== -1) {
|
|
34
|
+
yearScrollerRef.value.scrollTop = idx * YEAR_ITEM_H
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
onMounted(async () => {
|
|
39
|
+
await nextTick()
|
|
40
|
+
scrollToYear(props.viewYear)
|
|
41
|
+
})
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<div class="relative h-45" role="list">
|
|
46
|
+
<!-- Selection zone highlight -->
|
|
47
|
+
<div
|
|
48
|
+
class="pointer-events-none absolute inset-x-0 top-1/2 h-9 -translate-y-1/2 rounded-lg bg-cornflower-blue-50 ring-1 ring-cornflower-blue-200"
|
|
49
|
+
/>
|
|
50
|
+
<!-- Fade top -->
|
|
51
|
+
<div
|
|
52
|
+
class="pointer-events-none absolute inset-x-0 top-0 z-10 h-16 bg-linear-to-b from-white to-transparent"
|
|
53
|
+
/>
|
|
54
|
+
<!-- Fade bottom -->
|
|
55
|
+
<div
|
|
56
|
+
class="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-16 bg-linear-to-t from-white to-transparent"
|
|
57
|
+
/>
|
|
58
|
+
<!-- Scrollable drum -->
|
|
59
|
+
<div
|
|
60
|
+
ref="yearScrollerRef"
|
|
61
|
+
class="relative h-full snap-y snap-mandatory overflow-y-scroll [&::-webkit-scrollbar]:hidden"
|
|
62
|
+
style="scrollbar-width: none"
|
|
63
|
+
>
|
|
64
|
+
<div class="h-18" />
|
|
65
|
+
<button
|
|
66
|
+
v-for="year in yearRange"
|
|
67
|
+
:key="year"
|
|
68
|
+
type="button"
|
|
69
|
+
:aria-selected="year === viewYear"
|
|
70
|
+
class="flex h-9 w-full snap-center items-center justify-center text-sm transition-colors duration-150 outline-none"
|
|
71
|
+
:class="
|
|
72
|
+
year === viewYear
|
|
73
|
+
? 'font-semibold text-cornflower-blue-500'
|
|
74
|
+
: 'cursor-pointer text-gray-700 hover:text-cornflower-blue-500'
|
|
75
|
+
"
|
|
76
|
+
@click="emit('select', year)"
|
|
77
|
+
>
|
|
78
|
+
{{ year }}
|
|
79
|
+
</button>
|
|
80
|
+
<div class="h-18" />
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</template>
|