@nexxtmove/ui 0.1.22 → 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.
Files changed (66) hide show
  1. package/dist/index.d.ts +75 -8
  2. package/dist/index.js +2408 -252
  3. package/dist/nuxt.d.ts +1 -0
  4. package/dist/nuxt.js +49 -0
  5. package/package.json +23 -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 +269 -0
  20. package/src/components/Calendar/Calendar.vue +221 -0
  21. package/src/components/Calendar/_CalendarDayView.test.ts +145 -0
  22. package/src/components/Calendar/_CalendarDayView.vue +156 -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 +68 -0
  26. package/src/components/Calendar/_CalendarMonthView.vue +70 -0
  27. package/src/components/Calendar/_CalendarYearView.vue +77 -0
  28. package/src/components/Calendar/calendar.types.ts +10 -0
  29. package/src/components/Chip/Chip.stories.ts +42 -0
  30. package/src/components/Chip/Chip.test.ts +51 -0
  31. package/src/components/Chip/Chip.vue +37 -0
  32. package/src/components/DatePicker/DatePicker.stories.ts +149 -0
  33. package/src/components/DatePicker/DatePicker.test.ts +191 -0
  34. package/src/components/DatePicker/DatePicker.vue +142 -0
  35. package/src/components/Header/Header.stories.ts +48 -0
  36. package/src/components/Header/Header.test.ts +169 -0
  37. package/src/components/Header/Header.vue +42 -0
  38. package/src/components/Icon/Icon.stories.ts +50 -0
  39. package/src/components/Icon/Icon.test.ts +73 -0
  40. package/src/components/Icon/Icon.vue +20 -0
  41. package/src/components/InfoBlock/InfoBlock.stories.ts +90 -0
  42. package/src/components/InfoBlock/InfoBlock.test.ts +101 -0
  43. package/src/components/InfoBlock/InfoBlock.vue +70 -0
  44. package/src/components/ProgressBar/ProgressBar.stories.ts +30 -0
  45. package/src/components/ProgressBar/ProgressBar.test.ts +314 -0
  46. package/src/components/ProgressBar/ProgressBar.vue +102 -0
  47. package/src/components/SocialIcons/SocialIcons.stories.ts +34 -0
  48. package/src/components/SocialIcons/SocialIcons.test.ts +58 -0
  49. package/src/components/SocialIcons/SocialIcons.vue +58 -0
  50. package/src/components/SocialMediaCustomTemplate/SocialMediaCustomTemplate.stories.ts +11 -0
  51. package/src/components/SocialMediaCustomTemplate/SocialMediaCustomTemplate.test.ts +131 -0
  52. package/src/components/SocialMediaCustomTemplate/SocialMediaCustomTemplate.vue +55 -0
  53. package/src/components/SocialMediaTemplate/SocialMediaTemplate.stories.ts +71 -0
  54. package/src/components/SocialMediaTemplate/SocialMediaTemplate.test.ts +466 -0
  55. package/src/components/SocialMediaTemplate/SocialMediaTemplate.vue +130 -0
  56. package/src/components/SocialMediaType/SocialMediaType.stories.ts +43 -0
  57. package/src/components/SocialMediaType/SocialMediaType.test.ts +126 -0
  58. package/src/components/SocialMediaType/SocialMediaType.vue +117 -0
  59. package/src/components/StepperHeader/StepperHeader.stories.ts +47 -0
  60. package/src/components/StepperHeader/StepperHeader.test.ts +244 -0
  61. package/src/components/StepperHeader/StepperHeader.vue +37 -0
  62. package/src/components.json +16 -0
  63. package/src/env.d.ts +23 -0
  64. package/src/index.css +2 -0
  65. package/src/index.ts +15 -0
  66. 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,10 @@
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
+ }
@@ -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
+ })