@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.
Files changed (67) hide show
  1. package/dist/index.d.ts +76 -8
  2. package/dist/index.js +2758 -252
  3. package/dist/nuxt.d.ts +1 -0
  4. package/dist/nuxt.js +49 -0
  5. package/package.json +24 -13
  6. package/src/assets/images/Template_aanvragen.jpg +0 -0
  7. package/src/assets/svg/type_post.svg +1 -0
  8. package/src/assets/svg/type_reel.svg +54 -0
  9. package/src/assets/svg/type_story.svg +46 -0
  10. package/src/assets/svg/type_tiktok.svg +1 -0
  11. package/src/assets/video/Template_aanvragen.mp4 +0 -0
  12. package/src/components/AnimatedNumber/AnimatedNumber.stories.ts +15 -0
  13. package/src/components/AnimatedNumber/AnimatedNumber.test.ts +56 -0
  14. package/src/components/AnimatedNumber/AnimatedNumber.vue +61 -0
  15. package/src/components/Button/Button.stories.ts +212 -0
  16. package/src/components/Button/Button.test.ts +318 -0
  17. package/src/components/Button/Button.vue +67 -0
  18. package/src/components/Calendar/Calendar.stories.ts +91 -0
  19. package/src/components/Calendar/Calendar.test.ts +279 -0
  20. package/src/components/Calendar/Calendar.vue +239 -0
  21. package/src/components/Calendar/_CalendarDayView.test.ts +104 -0
  22. package/src/components/Calendar/_CalendarDayView.vue +169 -0
  23. package/src/components/Calendar/_CalendarHeader.test.ts +86 -0
  24. package/src/components/Calendar/_CalendarHeader.vue +123 -0
  25. package/src/components/Calendar/_CalendarMonthView.test.ts +52 -0
  26. package/src/components/Calendar/_CalendarMonthView.vue +79 -0
  27. package/src/components/Calendar/_CalendarYearView.test.ts +56 -0
  28. package/src/components/Calendar/_CalendarYearView.vue +83 -0
  29. package/src/components/Calendar/calendar.types.ts +11 -0
  30. package/src/components/Chip/Chip.stories.ts +42 -0
  31. package/src/components/Chip/Chip.test.ts +51 -0
  32. package/src/components/Chip/Chip.vue +37 -0
  33. package/src/components/DatePicker/DatePicker.stories.ts +149 -0
  34. package/src/components/DatePicker/DatePicker.test.ts +191 -0
  35. package/src/components/DatePicker/DatePicker.vue +145 -0
  36. package/src/components/Header/Header.stories.ts +48 -0
  37. package/src/components/Header/Header.test.ts +169 -0
  38. package/src/components/Header/Header.vue +42 -0
  39. package/src/components/Icon/Icon.stories.ts +50 -0
  40. package/src/components/Icon/Icon.test.ts +73 -0
  41. package/src/components/Icon/Icon.vue +20 -0
  42. package/src/components/InfoBlock/InfoBlock.stories.ts +90 -0
  43. package/src/components/InfoBlock/InfoBlock.test.ts +101 -0
  44. package/src/components/InfoBlock/InfoBlock.vue +70 -0
  45. package/src/components/ProgressBar/ProgressBar.stories.ts +30 -0
  46. package/src/components/ProgressBar/ProgressBar.test.ts +314 -0
  47. package/src/components/ProgressBar/ProgressBar.vue +102 -0
  48. package/src/components/SocialIcons/SocialIcons.stories.ts +34 -0
  49. package/src/components/SocialIcons/SocialIcons.test.ts +58 -0
  50. package/src/components/SocialIcons/SocialIcons.vue +58 -0
  51. package/src/components/SocialMediaCustomTemplate/SocialMediaCustomTemplate.stories.ts +11 -0
  52. package/src/components/SocialMediaCustomTemplate/SocialMediaCustomTemplate.test.ts +131 -0
  53. package/src/components/SocialMediaCustomTemplate/SocialMediaCustomTemplate.vue +55 -0
  54. package/src/components/SocialMediaTemplate/SocialMediaTemplate.stories.ts +71 -0
  55. package/src/components/SocialMediaTemplate/SocialMediaTemplate.test.ts +466 -0
  56. package/src/components/SocialMediaTemplate/SocialMediaTemplate.vue +130 -0
  57. package/src/components/SocialMediaType/SocialMediaType.stories.ts +43 -0
  58. package/src/components/SocialMediaType/SocialMediaType.test.ts +126 -0
  59. package/src/components/SocialMediaType/SocialMediaType.vue +117 -0
  60. package/src/components/StepperHeader/StepperHeader.stories.ts +47 -0
  61. package/src/components/StepperHeader/StepperHeader.test.ts +244 -0
  62. package/src/components/StepperHeader/StepperHeader.vue +37 -0
  63. package/src/components.json +16 -0
  64. package/src/env.d.ts +23 -0
  65. package/src/index.css +2 -0
  66. package/src/index.ts +15 -0
  67. 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>
@@ -0,0 +1,11 @@
1
+ import type { Locale } from 'date-fns'
2
+
3
+ export interface NexxtCalendarProps {
4
+ locale?: Locale
5
+ markedDates?: Date[]
6
+ disabledDates?: Date[]
7
+ minDate?: Date
8
+ maxDate?: Date
9
+ monthYearPicker?: boolean
10
+ timezone?: string
11
+ }