Skip to content

Commit bdfbf36

Browse files
committed
adds a month-year selector to the date of birth question
previously the calendar required you to scroll by month starting from today back in time which irritated participants. this lets you jump by year and month.
1 parent 49003fa commit bdfbf36

2 files changed

Lines changed: 213 additions & 46 deletions

File tree

src/builtins/demographicSurvey/DemographicSurveyView.vue

Lines changed: 20 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
<script setup>
2-
import { reactive, computed, ref, watch } from 'vue'
3-
import { DateFormatter, getLocalTimeZone, CalendarDate } from '@internationalized/date'
2+
import { reactive, computed, ref } from 'vue'
43
import { CalendarIcon } from 'lucide-vue-next'
54
65
// Import and initialize Smile API
76
import useViewAPI from '@/core/composables/useViewAPI'
87
import { Button } from '@/uikit/components/ui/button'
9-
import { Calendar } from '@/uikit/components/ui/calendar'
8+
import MonthYearDayPicker from '@/uikit/components/forms/MonthYearDayPicker.vue'
109
import { Popover, PopoverContent, PopoverTrigger } from '@/uikit/components/ui/popover'
1110
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/uikit/components/ui/select'
1211
import { cn } from '@/uikit/lib/utils'
@@ -22,18 +21,6 @@ const api = useViewAPI()
2221
*/
2322
api.steps.append([{ id: 'survey_page1' }, { id: 'survey_page2' }, { id: 'survey_page3' }])
2423
25-
/**
26-
* Date formatter for displaying dates in long format
27-
*/
28-
const df = new DateFormatter('en-US', {
29-
dateStyle: 'long',
30-
})
31-
32-
/**
33-
* Reactive reference for the selected date value
34-
*/
35-
const dateValue = ref()
36-
3724
/**
3825
* Reactive reference for controlling the date picker popover state
3926
*/
@@ -63,35 +50,14 @@ if (!api.persist.isDefined('forminfo')) {
6350
}
6451
6552
/**
66-
* Watch for date changes and update the form data
67-
* Converts CalendarDate to ISO string format and closes popover
53+
* Computed property to format the date for display
6854
*/
69-
watch(dateValue, (newValue) => {
70-
if (newValue) {
71-
const date = newValue.toDate(getLocalTimeZone())
72-
api.persist.forminfo.dob = date.toISOString().split('T')[0] // Format as YYYY-MM-DD
73-
isPopoverOpen.value = false // Close popover when date is selected
74-
}
55+
const formattedDate = computed(() => {
56+
if (!api.persist.forminfo.dob) return 'Pick a date'
57+
const date = new Date(api.persist.forminfo.dob + 'T00:00:00')
58+
return new Intl.DateTimeFormat('en-US', { dateStyle: 'long' }).format(date)
7559
})
7660
77-
/**
78-
* Watch for form data changes and update the date picker
79-
* Synchronizes the date picker with the stored form data
80-
*/
81-
watch(
82-
() => api.persist.forminfo.dob,
83-
(newValue) => {
84-
if (newValue && newValue !== '') {
85-
// Parse the date string and create a CalendarDate object
86-
const [year, month, day] = newValue.split('-').map(Number)
87-
dateValue.value = new CalendarDate(year, month, day)
88-
} else {
89-
dateValue.value = null
90-
}
91-
},
92-
{ immediate: true }
93-
)
94-
9561
/**
9662
* Computed property to check if page one is complete
9763
* Validates that all required fields on the first page are filled
@@ -133,8 +99,7 @@ const page_three_complete = computed(
13399
* Pre-populates the form with sample data
134100
*/
135101
function autofill() {
136-
// Set the date value first so the watcher can handle it
137-
dateValue.value = new CalendarDate(1978, 9, 12)
102+
api.persist.forminfo.dob = '1978-09-12'
138103
api.persist.forminfo.gender = 'Male'
139104
api.persist.forminfo.race = 'Caucasian/White'
140105
api.persist.forminfo.hispanic = 'No'
@@ -220,16 +185,25 @@ function finish() {
220185
:class="
221186
cn(
222187
'w-full justify-start text-left font-normal text-base border border-input bg-background hover:bg-background',
223-
!dateValue && 'text-muted-foreground'
188+
!api.persist.forminfo.dob && 'text-muted-foreground'
224189
)
225190
"
226191
>
227192
<CalendarIcon class="mr-2 h-4 w-4" />
228-
{{ dateValue ? df.format(dateValue.toDate(getLocalTimeZone())) : 'Pick a date' }}
193+
{{ formattedDate }}
229194
</Button>
230195
</PopoverTrigger>
231196
<PopoverContent class="w-auto p-0">
232-
<Calendar v-model="dateValue" initial-focus />
197+
<MonthYearDayPicker v-model="api.persist.forminfo.dob" initial-focus />
198+
<div class="p-3 border-t border-border">
199+
<Button
200+
variant="default"
201+
class="w-full"
202+
@click="isPopoverOpen = false"
203+
>
204+
Done
205+
</Button>
206+
</div>
233207
</PopoverContent>
234208
</Popover>
235209
<p class="text-xs text-muted-foreground mt-1">Enter your birthday (required)</p>
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
<script setup>
2+
import { CalendarRoot, useForwardPropsEmits, useDateFormatter } from 'reka-ui'
3+
import { createDecade, createYear, toDate } from 'reka-ui/date'
4+
import { getLocalTimeZone, today, CalendarDate } from '@internationalized/date'
5+
import { computed, ref, watch } from 'vue'
6+
import { cn } from '@/uikit/lib/utils'
7+
import {
8+
CalendarCell,
9+
CalendarCellTrigger,
10+
CalendarGrid,
11+
CalendarGridBody,
12+
CalendarGridHead,
13+
CalendarGridRow,
14+
CalendarHeadCell,
15+
CalendarHeader,
16+
CalendarHeading,
17+
} from '@/uikit/components/ui/calendar'
18+
import {
19+
Select,
20+
SelectContent,
21+
SelectItem,
22+
SelectTrigger,
23+
SelectValue,
24+
} from '@/uikit/components/ui/select'
25+
26+
const props = defineProps({
27+
defaultValue: { type: null, required: false },
28+
defaultPlaceholder: { type: null, required: false },
29+
placeholder: { type: null, required: false },
30+
pagedNavigation: { type: Boolean, required: false },
31+
preventDeselect: { type: Boolean, required: false },
32+
weekStartsOn: { type: Number, required: false },
33+
weekdayFormat: { type: String, required: false, default: 'short' },
34+
calendarLabel: { type: String, required: false },
35+
fixedWeeks: { type: Boolean, required: false },
36+
maxValue: { type: null, required: false },
37+
minValue: { type: null, required: false },
38+
locale: { type: String, required: false },
39+
numberOfMonths: { type: Number, required: false },
40+
disabled: { type: Boolean, required: false },
41+
readonly: { type: Boolean, required: false },
42+
initialFocus: { type: Boolean, required: false },
43+
isDateDisabled: { type: Function, required: false },
44+
isDateUnavailable: { type: Function, required: false },
45+
dir: { type: String, required: false },
46+
nextPage: { type: Function, required: false },
47+
prevPage: { type: Function, required: false },
48+
modelValue: { type: String, required: false }, // ISO date string (YYYY-MM-DD)
49+
multiple: { type: Boolean, required: false },
50+
disableDaysOutsideCurrentView: { type: Boolean, required: false },
51+
asChild: { type: Boolean, required: false },
52+
as: { type: null, required: false },
53+
class: { type: null, required: false },
54+
})
55+
const emits = defineEmits(['update:modelValue', 'update:placeholder'])
56+
57+
// Convert ISO string to CalendarDate
58+
function isoToCalendarDate(isoString) {
59+
if (!isoString || typeof isoString !== 'string') return null
60+
const [year, month, day] = isoString.split('-').map(Number)
61+
return new CalendarDate(year, month, day)
62+
}
63+
64+
// Convert CalendarDate to ISO string
65+
function calendarDateToIso(calendarDate) {
66+
if (!calendarDate) return ''
67+
const date = calendarDate.toDate(getLocalTimeZone())
68+
return date.toISOString().split('T')[0]
69+
}
70+
71+
// Internal state for placeholder (controls which month/year is shown)
72+
const placeholderRef = ref(
73+
(props.modelValue && typeof props.modelValue === 'string')
74+
? isoToCalendarDate(props.modelValue)
75+
: today(getLocalTimeZone())
76+
)
77+
78+
// Watch for external changes to modelValue and update placeholder
79+
watch(() => props.modelValue, (newValue) => {
80+
if (!newValue || typeof newValue !== 'string') {
81+
return
82+
}
83+
const newDate = isoToCalendarDate(newValue)
84+
if (newDate && (!placeholderRef.value || calendarDateToIso(newDate) !== calendarDateToIso(placeholderRef.value))) {
85+
placeholderRef.value = newDate
86+
}
87+
})
88+
89+
// Forward only the props we need to CalendarRoot (exclude modelValue, placeholder, class)
90+
const calendarProps = computed(() => {
91+
const { class: _, placeholder, modelValue, ...rest } = props
92+
return rest
93+
})
94+
95+
const formatter = useDateFormatter('en')
96+
97+
// Create year range from 100 years ago to current year based on today's date
98+
const currentDate = today(getLocalTimeZone())
99+
const yearRange = createDecade({ dateObj: currentDate, startIndex: -100, endIndex: 0 })
100+
</script>
101+
102+
<template>
103+
<CalendarRoot
104+
v-slot="{ grid, weekDays, date }"
105+
v-model:placeholder="placeholderRef"
106+
:model-value="isoToCalendarDate(props.modelValue)"
107+
@update:model-value="(val) => {
108+
if (val) {
109+
emits('update:modelValue', calendarDateToIso(val))
110+
}
111+
}"
112+
@update:placeholder="(val) => emits('update:placeholder', val)"
113+
:class="cn('p-3', props.class)"
114+
v-bind="calendarProps"
115+
>
116+
<CalendarHeader>
117+
<CalendarHeading class="flex w-full items-center justify-between gap-2">
118+
<Select
119+
:model-value="placeholderRef.month.toString()"
120+
@update:model-value="(v) => {
121+
if (!v || !placeholderRef) return;
122+
if (Number(v) === placeholderRef.month) return;
123+
const newPlaceholder = placeholderRef.set({ month: Number(v) })
124+
placeholderRef = newPlaceholder
125+
// If there's a selected date, update it too
126+
if (props.modelValue) {
127+
emits('update:modelValue', calendarDateToIso(newPlaceholder))
128+
}
129+
}"
130+
>
131+
<SelectTrigger aria-label="Select month" class="w-[60%]">
132+
<SelectValue placeholder="Select month" />
133+
</SelectTrigger>
134+
<SelectContent class="max-h-[200px]">
135+
<SelectItem
136+
v-for="month in createYear({ dateObj: date })"
137+
:key="month.toString()"
138+
:value="month.month.toString()"
139+
>
140+
{{ formatter.custom(toDate(month), { month: 'long' }) }}
141+
</SelectItem>
142+
</SelectContent>
143+
</Select>
144+
145+
<Select
146+
:model-value="placeholderRef.year.toString()"
147+
@update:model-value="(v) => {
148+
if (!v || !placeholderRef) return;
149+
if (Number(v) === placeholderRef.year) return;
150+
const newPlaceholder = placeholderRef.set({ year: Number(v) })
151+
placeholderRef = newPlaceholder
152+
// If there's a selected date, update it too
153+
if (props.modelValue) {
154+
emits('update:modelValue', calendarDateToIso(newPlaceholder))
155+
}
156+
}"
157+
>
158+
<SelectTrigger aria-label="Select year" class="w-[40%]">
159+
<SelectValue placeholder="Select year" />
160+
</SelectTrigger>
161+
<SelectContent class="max-h-[200px]">
162+
<SelectItem
163+
v-for="yearValue in yearRange"
164+
:key="yearValue.toString()"
165+
:value="yearValue.year.toString()"
166+
>
167+
{{ yearValue.year }}
168+
</SelectItem>
169+
</SelectContent>
170+
</Select>
171+
</CalendarHeading>
172+
</CalendarHeader>
173+
174+
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
175+
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
176+
<CalendarGridHead>
177+
<CalendarGridRow>
178+
<CalendarHeadCell v-for="day in weekDays" :key="day">
179+
{{ day }}
180+
</CalendarHeadCell>
181+
</CalendarGridRow>
182+
</CalendarGridHead>
183+
<CalendarGridBody>
184+
<CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
185+
<CalendarCell v-for="weekDate in weekDates" :key="weekDate.toString()" :date="weekDate">
186+
<CalendarCellTrigger :day="weekDate" :month="month.value" />
187+
</CalendarCell>
188+
</CalendarGridRow>
189+
</CalendarGridBody>
190+
</CalendarGrid>
191+
</div>
192+
</CalendarRoot>
193+
</template>

0 commit comments

Comments
 (0)