@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,156 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { computed, ref, onMounted } from 'vue'
|
|
3
|
+
import { isSameDay, isSameMonth, isToday, format } from 'date-fns'
|
|
4
|
+
import type { Locale } from 'date-fns'
|
|
5
|
+
|
|
6
|
+
interface CalendarDayViewProps {
|
|
7
|
+
viewDate: Date
|
|
8
|
+
calendarDays: Date[]
|
|
9
|
+
modelValue: Date | null
|
|
10
|
+
markedDates: Date[]
|
|
11
|
+
locale: Locale
|
|
12
|
+
isDisabled: (day: Date) => boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const props = defineProps<CalendarDayViewProps>()
|
|
16
|
+
const emit = defineEmits<{
|
|
17
|
+
(e: 'select', day: Date): void
|
|
18
|
+
}>()
|
|
19
|
+
|
|
20
|
+
const dayButtonsRef = ref<HTMLElement[]>([])
|
|
21
|
+
|
|
22
|
+
const isSelected = (day: Date): boolean => {
|
|
23
|
+
return props.modelValue ? isSameDay(day, props.modelValue) : false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const isMarked = (day: Date): boolean => {
|
|
27
|
+
return props.markedDates.some((d) => isSameDay(d, day))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const weeks = computed(() => {
|
|
31
|
+
const result: Date[][] = []
|
|
32
|
+
for (let i = 0; i < props.calendarDays.length; i += 7) {
|
|
33
|
+
result.push(props.calendarDays.slice(i, i + 7))
|
|
34
|
+
}
|
|
35
|
+
return result
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const weekDayLabels = computed(() => {
|
|
39
|
+
if (props.calendarDays.length === 0) return []
|
|
40
|
+
// Take first 7 days to get labels
|
|
41
|
+
return props.calendarDays.slice(0, 7).map((d) => {
|
|
42
|
+
const raw = format(d, 'EEEEEE', { locale: props.locale })
|
|
43
|
+
return raw.charAt(0).toUpperCase() + raw.slice(1)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const handleKeydown = (event: KeyboardEvent, dayIndex: number) => {
|
|
48
|
+
let nextIdx = dayIndex
|
|
49
|
+
|
|
50
|
+
switch (event.key) {
|
|
51
|
+
case 'ArrowRight':
|
|
52
|
+
nextIdx++
|
|
53
|
+
break
|
|
54
|
+
case 'ArrowLeft':
|
|
55
|
+
nextIdx--
|
|
56
|
+
break
|
|
57
|
+
case 'ArrowDown':
|
|
58
|
+
nextIdx += 7
|
|
59
|
+
break
|
|
60
|
+
case 'ArrowUp':
|
|
61
|
+
nextIdx -= 7
|
|
62
|
+
break
|
|
63
|
+
case 'Enter':
|
|
64
|
+
case ' ':
|
|
65
|
+
event.preventDefault()
|
|
66
|
+
emit('select', props.calendarDays[dayIndex])
|
|
67
|
+
return
|
|
68
|
+
default:
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (nextIdx >= 0 && nextIdx < props.calendarDays.length) {
|
|
73
|
+
event.preventDefault()
|
|
74
|
+
// We don't change viewDate here, we let the parent handle that if needed
|
|
75
|
+
// or we just move focus. For better UX in a grid, we usually just move focus.
|
|
76
|
+
dayButtonsRef.value[nextIdx]?.focus()
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Focus the currently selected day or today when mounted
|
|
81
|
+
onMounted(() => {
|
|
82
|
+
const focusIdx = props.calendarDays.findIndex(
|
|
83
|
+
(d) =>
|
|
84
|
+
(props.modelValue && isSameDay(d, props.modelValue)) || (!props.modelValue && isToday(d)),
|
|
85
|
+
)
|
|
86
|
+
if (focusIdx !== -1) {
|
|
87
|
+
// We don't auto-focus on mount to avoid stealing focus from the popover,
|
|
88
|
+
// but we make it available for the parent to trigger if needed.
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
defineExpose({
|
|
93
|
+
focus: (date: Date) => {
|
|
94
|
+
const idx = props.calendarDays.findIndex((d) => isSameDay(d, date))
|
|
95
|
+
if (idx !== -1) dayButtonsRef.value[idx]?.focus()
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
<template>
|
|
101
|
+
<div class="calendar-day-view" role="grid" aria-label="Kalender">
|
|
102
|
+
<div class="mb-1 grid grid-cols-7" role="row">
|
|
103
|
+
<div
|
|
104
|
+
v-for="label in weekDayLabels"
|
|
105
|
+
:key="label"
|
|
106
|
+
class="flex h-8 items-center justify-center font-medium text-xs text-gray-500"
|
|
107
|
+
role="columnheader"
|
|
108
|
+
:aria-label="label"
|
|
109
|
+
>
|
|
110
|
+
{{ label }}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
<div v-for="(week, weekIdx) in weeks" :key="weekIdx" class="grid grid-cols-7" role="row">
|
|
114
|
+
<div
|
|
115
|
+
v-for="(day, dayIdx) in week"
|
|
116
|
+
:key="day.toISOString()"
|
|
117
|
+
class="flex items-center justify-center py-0.5"
|
|
118
|
+
role="gridcell"
|
|
119
|
+
:aria-selected="isSelected(day)"
|
|
120
|
+
:aria-disabled="isDisabled(day)"
|
|
121
|
+
>
|
|
122
|
+
<button
|
|
123
|
+
ref="dayButtonsRef"
|
|
124
|
+
type="button"
|
|
125
|
+
:disabled="isDisabled(day)"
|
|
126
|
+
:aria-label="format(day, 'd MMMM yyyy', { locale })"
|
|
127
|
+
:tabindex="isSameMonth(day, viewDate) ? 0 : -1"
|
|
128
|
+
:class="[
|
|
129
|
+
'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',
|
|
130
|
+
!isSameMonth(day, viewDate) ? 'text-gray-300' : 'text-gray-900',
|
|
131
|
+
isSelected(day) ? 'bg-cornflower-blue-500 font-medium text-white!' : '',
|
|
132
|
+
isToday(day) && !isSelected(day)
|
|
133
|
+
? 'font-medium text-cornflower-blue-500! ring-1 ring-cornflower-blue-500'
|
|
134
|
+
: '',
|
|
135
|
+
isToday(day) && isSelected(day) ? 'ring-2 ring-cornflower-blue-500 ring-offset-2' : '',
|
|
136
|
+
!isDisabled(day) && !isSelected(day)
|
|
137
|
+
? 'cursor-pointer hover:bg-cornflower-blue-50'
|
|
138
|
+
: '',
|
|
139
|
+
isDisabled(day) ? 'cursor-not-allowed opacity-30' : '',
|
|
140
|
+
]"
|
|
141
|
+
@click="emit('select', day)"
|
|
142
|
+
@keydown="handleKeydown($event, weekIdx * 7 + dayIdx)"
|
|
143
|
+
>
|
|
144
|
+
<span aria-hidden="true">{{ format(day, 'd') }}</span>
|
|
145
|
+
<span
|
|
146
|
+
v-if="isMarked(day)"
|
|
147
|
+
:class="[
|
|
148
|
+
'absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full',
|
|
149
|
+
isSelected(day) ? 'bg-white' : 'bg-cornflower-blue-500',
|
|
150
|
+
]"
|
|
151
|
+
/>
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</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,68 @@
|
|
|
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
|
+
describe('CalendarMonthView', () => {
|
|
10
|
+
it('renders all 12 months', () => {
|
|
11
|
+
const wrapper = mount(CalendarMonthView, {
|
|
12
|
+
props: {
|
|
13
|
+
viewYear: VIEW_YEAR,
|
|
14
|
+
modelValue: null,
|
|
15
|
+
currentMonth: CURRENT_MONTH,
|
|
16
|
+
locale: nl,
|
|
17
|
+
},
|
|
18
|
+
})
|
|
19
|
+
const buttons = wrapper.findAll('button')
|
|
20
|
+
expect(buttons.length).toBe(12)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('highlights the selected month', () => {
|
|
24
|
+
const selectedDate = new Date(VIEW_YEAR, 5, 15) // June
|
|
25
|
+
const wrapper = mount(CalendarMonthView, {
|
|
26
|
+
props: {
|
|
27
|
+
viewYear: VIEW_YEAR,
|
|
28
|
+
modelValue: selectedDate,
|
|
29
|
+
currentMonth: CURRENT_MONTH,
|
|
30
|
+
locale: nl,
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
const selectedButton = wrapper.find('button[aria-selected="true"]')
|
|
34
|
+
expect(selectedButton.text()).toBe('Jun')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('disables months based on minDate and maxDate', () => {
|
|
38
|
+
const minDate = new Date(VIEW_YEAR, 2, 1) // March 1st
|
|
39
|
+
const wrapper = mount(CalendarMonthView, {
|
|
40
|
+
props: {
|
|
41
|
+
viewYear: VIEW_YEAR,
|
|
42
|
+
modelValue: null,
|
|
43
|
+
currentMonth: CURRENT_MONTH,
|
|
44
|
+
locale: nl,
|
|
45
|
+
minDate,
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
// January (0) and February (1) should be disabled
|
|
49
|
+
const disabledButtons = wrapper.findAll('button[disabled]')
|
|
50
|
+
expect(disabledButtons.length).toBe(2)
|
|
51
|
+
expect(disabledButtons[0].text()).toBe('Jan')
|
|
52
|
+
expect(disabledButtons[1].text()).toBe('Feb')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('emits select event when a month is clicked', async () => {
|
|
56
|
+
const wrapper = mount(CalendarMonthView, {
|
|
57
|
+
props: {
|
|
58
|
+
viewYear: VIEW_YEAR,
|
|
59
|
+
modelValue: null,
|
|
60
|
+
currentMonth: CURRENT_MONTH,
|
|
61
|
+
locale: nl,
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
await wrapper.findAll('button')[5].trigger('click')
|
|
65
|
+
expect(wrapper.emitted('select')).toBeTruthy()
|
|
66
|
+
expect(wrapper.emitted('select')![0][0]).toBe(5)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { format, startOfMonth, endOfMonth, startOfDay, endOfDay } from 'date-fns'
|
|
4
|
+
import type { Locale } from 'date-fns'
|
|
5
|
+
|
|
6
|
+
interface CalendarMonthViewProps {
|
|
7
|
+
viewYear: number
|
|
8
|
+
modelValue: Date | null
|
|
9
|
+
currentMonth: number
|
|
10
|
+
minDate?: Date
|
|
11
|
+
maxDate?: Date
|
|
12
|
+
locale: Locale
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const props = defineProps<CalendarMonthViewProps>()
|
|
16
|
+
const emit = defineEmits<{
|
|
17
|
+
(e: 'select', month: number): void
|
|
18
|
+
}>()
|
|
19
|
+
|
|
20
|
+
const monthItems = computed(() =>
|
|
21
|
+
Array.from({ length: 12 }, (_, i) => {
|
|
22
|
+
const d = new Date(props.viewYear, i, 1)
|
|
23
|
+
const raw = format(d, 'LLL', { locale: props.locale }).replace('.', '').trim()
|
|
24
|
+
return { index: i, label: raw.charAt(0).toUpperCase() + raw.slice(1) }
|
|
25
|
+
}),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const isMonthDisabled = (i: number): boolean => {
|
|
29
|
+
const y = props.viewYear
|
|
30
|
+
if (props.minDate && endOfMonth(new Date(y, i)) < startOfDay(props.minDate)) return true
|
|
31
|
+
if (props.maxDate && startOfMonth(new Date(y, i)) > endOfDay(props.maxDate)) return true
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const isMonthSelected = (i: number): boolean => {
|
|
36
|
+
return (
|
|
37
|
+
!!props.modelValue &&
|
|
38
|
+
props.modelValue.getFullYear() === props.viewYear &&
|
|
39
|
+
props.modelValue.getMonth() === i
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const isMonthCurrent = (i: number): boolean => {
|
|
44
|
+
return i === props.currentMonth
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<div class="grid grid-cols-3 gap-1.5" role="list">
|
|
50
|
+
<button
|
|
51
|
+
v-for="item in monthItems"
|
|
52
|
+
:key="item.index"
|
|
53
|
+
type="button"
|
|
54
|
+
:disabled="isMonthDisabled(item.index)"
|
|
55
|
+
:aria-selected="isMonthSelected(item.index)"
|
|
56
|
+
:class="[
|
|
57
|
+
'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',
|
|
58
|
+
isMonthSelected(item.index)
|
|
59
|
+
? 'bg-cornflower-blue-500 font-medium text-white'
|
|
60
|
+
: isMonthCurrent(item.index)
|
|
61
|
+
? 'font-medium text-cornflower-blue-500 ring-1 ring-cornflower-blue-500'
|
|
62
|
+
: 'text-gray-700 hover:bg-cornflower-blue-50',
|
|
63
|
+
isMonthDisabled(item.index) ? 'cursor-not-allowed opacity-30' : 'cursor-pointer',
|
|
64
|
+
]"
|
|
65
|
+
@click="emit('select', item.index)"
|
|
66
|
+
>
|
|
67
|
+
{{ item.label }}
|
|
68
|
+
</button>
|
|
69
|
+
</div>
|
|
70
|
+
</template>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { ref, computed, nextTick, onMounted } from 'vue'
|
|
3
|
+
|
|
4
|
+
interface CalendarYearViewProps {
|
|
5
|
+
viewYear: number
|
|
6
|
+
minDate?: Date
|
|
7
|
+
maxDate?: Date
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const props = defineProps<CalendarYearViewProps>()
|
|
11
|
+
const emit = defineEmits<{
|
|
12
|
+
(e: 'select', year: number): void
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
const YEAR_ITEM_H = 36 // px — matches h-9
|
|
16
|
+
const yearScrollerRef = ref<HTMLElement | null>(null)
|
|
17
|
+
|
|
18
|
+
const yearRange = computed(() => {
|
|
19
|
+
const min = props.minDate ? props.minDate.getFullYear() : props.viewYear - 100
|
|
20
|
+
const max = props.maxDate ? props.maxDate.getFullYear() : props.viewYear + 20
|
|
21
|
+
return Array.from({ length: max - min + 1 }, (_, i) => min + i)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const scrollToYear = (year: number) => {
|
|
25
|
+
if (!yearScrollerRef.value) return
|
|
26
|
+
const idx = yearRange.value.indexOf(year)
|
|
27
|
+
if (idx !== -1) {
|
|
28
|
+
yearScrollerRef.value.scrollTop = idx * YEAR_ITEM_H
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
onMounted(async () => {
|
|
33
|
+
await nextTick()
|
|
34
|
+
scrollToYear(props.viewYear)
|
|
35
|
+
})
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<template>
|
|
39
|
+
<div class="relative h-45" role="list">
|
|
40
|
+
<!-- Selection zone highlight -->
|
|
41
|
+
<div
|
|
42
|
+
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"
|
|
43
|
+
/>
|
|
44
|
+
<!-- Fade top -->
|
|
45
|
+
<div
|
|
46
|
+
class="pointer-events-none absolute inset-x-0 top-0 z-10 h-16 bg-linear-to-b from-white to-transparent"
|
|
47
|
+
/>
|
|
48
|
+
<!-- Fade bottom -->
|
|
49
|
+
<div
|
|
50
|
+
class="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-16 bg-linear-to-t from-white to-transparent"
|
|
51
|
+
/>
|
|
52
|
+
<!-- Scrollable drum -->
|
|
53
|
+
<div
|
|
54
|
+
ref="yearScrollerRef"
|
|
55
|
+
class="relative h-full snap-y snap-mandatory overflow-y-scroll [&::-webkit-scrollbar]:hidden"
|
|
56
|
+
style="scrollbar-width: none"
|
|
57
|
+
>
|
|
58
|
+
<div class="h-18" />
|
|
59
|
+
<button
|
|
60
|
+
v-for="year in yearRange"
|
|
61
|
+
:key="year"
|
|
62
|
+
type="button"
|
|
63
|
+
:aria-selected="year === viewYear"
|
|
64
|
+
class="flex h-9 w-full snap-center items-center justify-center text-sm transition-colors duration-150 outline-none"
|
|
65
|
+
:class="
|
|
66
|
+
year === viewYear
|
|
67
|
+
? 'font-semibold text-cornflower-blue-500'
|
|
68
|
+
: 'cursor-pointer text-gray-700 hover:text-cornflower-blue-500'
|
|
69
|
+
"
|
|
70
|
+
@click="emit('select', year)"
|
|
71
|
+
>
|
|
72
|
+
{{ year }}
|
|
73
|
+
</button>
|
|
74
|
+
<div class="h-18" />
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</template>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
|
2
|
+
import Chip from './Chip.vue'
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
title: 'Components/Atoms/Chip',
|
|
6
|
+
component: Chip,
|
|
7
|
+
parameters: {
|
|
8
|
+
design: {
|
|
9
|
+
type: 'figma',
|
|
10
|
+
url: 'https://www.figma.com/design/CdbFJ7qUga6mtjagcPDvYK/Design-System?node-id=526-66&t=CbHvOQfEMIr7M5mO-4',
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
} satisfies Meta<typeof Chip>
|
|
14
|
+
|
|
15
|
+
type Story = StoryObj<typeof Chip>
|
|
16
|
+
|
|
17
|
+
export const Default: Story = {
|
|
18
|
+
args: {
|
|
19
|
+
default: 'Default',
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const Warning: Story = {
|
|
24
|
+
args: {
|
|
25
|
+
variant: 'warning',
|
|
26
|
+
default: 'Warning',
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const Success: Story = {
|
|
31
|
+
args: {
|
|
32
|
+
variant: 'success',
|
|
33
|
+
default: 'Succes',
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const Danger: Story = {
|
|
38
|
+
args: {
|
|
39
|
+
variant: 'danger',
|
|
40
|
+
default: 'Danger',
|
|
41
|
+
},
|
|
42
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import Chip from './Chip.vue'
|
|
4
|
+
|
|
5
|
+
describe('Chip', () => {
|
|
6
|
+
it('renders correctly with default slot content', () => {
|
|
7
|
+
const wrapper = mount(Chip, {
|
|
8
|
+
slots: {
|
|
9
|
+
default: 'Test Chip',
|
|
10
|
+
},
|
|
11
|
+
})
|
|
12
|
+
expect(wrapper.text()).toBe('Test Chip')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('renders default text when no slot is provided', () => {
|
|
16
|
+
const wrapper = mount(Chip)
|
|
17
|
+
expect(wrapper.text()).toBe('Chip')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('applies the correct background color for default variant', () => {
|
|
21
|
+
const wrapper = mount(Chip)
|
|
22
|
+
expect(wrapper.classes()).toContain('bg-cornflower-blue-600')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('applies the correct background color for warning variant', () => {
|
|
26
|
+
const wrapper = mount(Chip, {
|
|
27
|
+
props: {
|
|
28
|
+
variant: 'warning',
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
expect(wrapper.classes()).toContain('bg-orange-500')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('applies the correct background color for success variant', () => {
|
|
35
|
+
const wrapper = mount(Chip, {
|
|
36
|
+
props: {
|
|
37
|
+
variant: 'success',
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
expect(wrapper.classes()).toContain('bg-green-500')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('applies the correct background color for danger variant', () => {
|
|
44
|
+
const wrapper = mount(Chip, {
|
|
45
|
+
props: {
|
|
46
|
+
variant: 'danger',
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
expect(wrapper.classes()).toContain('bg-brick-500')
|
|
50
|
+
})
|
|
51
|
+
})
|