@stonecrop/aform 0.13.3 → 0.13.4

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.
@@ -11,6 +11,30 @@
11
11
  <th colspan="5" :tabindex="-1">{{ monthAndYear }}</th>
12
12
  <td id="next-month-btn" :tabindex="-1" @click="nextMonth">&gt;</td>
13
13
  </tr>
14
+ <tr v-if="selectRange">
15
+ <td colspan="7">
16
+ <div class="date-input">
17
+ <input
18
+ ref="start-date-input"
19
+ :value="getStartDate"
20
+ class="date-input-start aform_input-field"
21
+ type="text"
22
+ placeholder="start date"
23
+ @blur="enterInputDate()"
24
+ @keydown="enterDate" />
25
+ <div>-</div>
26
+ <input
27
+ ref="end-date-input"
28
+ :value="getEndDate"
29
+ class="date-input-end aform_input-field"
30
+ type="text"
31
+ placeholder="end date"
32
+ @blur="enterInputDate()"
33
+ @keydown="enterDate" />
34
+ </div>
35
+ <!-- {{ formattedDateRange }} -->
36
+ </td>
37
+ </tr>
14
38
  <tr class="days-header">
15
39
  <td>M</td>
16
40
  <td>T</td>
@@ -26,15 +50,20 @@
26
50
  v-for="colNo in numberOfColumns"
27
51
  ref="celldate"
28
52
  :key="getCurrentCell(rowNo, colNo)"
53
+ class="date-cell"
29
54
  :contenteditable="false"
30
55
  :spellcheck="false"
31
56
  :tabindex="0"
32
57
  :class="{
33
58
  todaysDate: isTodaysDate(getCurrentDate(rowNo, colNo)),
34
59
  selectedDate: isSelectedDate(getCurrentDate(rowNo, colNo)),
60
+ withinRange: selectRange ? isInDateRange(getCurrentDate(rowNo, colNo)) : false,
61
+ startDate: selectRange ? isStartDate(getCurrentDate(rowNo, colNo)) : false,
62
+ endDate: selectRange ? isEndDate(getCurrentDate(rowNo, colNo)) : false,
35
63
  }"
36
64
  @click.prevent.stop="selectDate(getCurrentCell(rowNo, colNo))"
37
- @keydown.enter="selectDate(getCurrentCell(rowNo, colNo))">
65
+ @keydown.enter="selectDate(getCurrentCell(rowNo, colNo))"
66
+ @mouseover="hoverDate(getCurrentCell(rowNo, colNo))">
38
67
  {{ new Date(getCurrentDate(rowNo, colNo)).getDate() }}
39
68
  </td>
40
69
  </tr>
@@ -44,7 +73,8 @@
44
73
  </template>
45
74
 
46
75
  <script setup lang="ts">
47
- import { defaultKeypressHandlers, useKeyboardNav } from '@stonecrop/utilities'
76
+ /* removed keyboard nav temportarily since it interfered with user experience navigating input fields */
77
+ // import { defaultKeypressHandlers, useKeyboardNav } from '@stonecrop/utilities'
48
78
  import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'
49
79
 
50
80
  import type { ComponentProps } from '../../types'
@@ -52,32 +82,105 @@ import type { ComponentProps } from '../../types'
52
82
  const numberOfRows = 6
53
83
  const numberOfColumns = 7
54
84
 
55
- const { mode, label } = defineProps<ComponentProps>()
85
+ const { mode, label, selectRange = false } = defineProps<ComponentProps>()
56
86
 
57
87
  const date = defineModel<number | Date>({ default: new Date() })
58
88
  const selectedDate = ref(new Date(date.value))
59
89
  const currentMonth = ref<number>(selectedDate.value.getMonth())
60
90
  const currentYear = ref<number>(selectedDate.value.getFullYear())
61
91
  const currentDates = ref<number[]>([])
62
- const datepickerRef = useTemplateRef<HTMLDivElement>('datepicker')
63
92
 
64
- onMounted(async () => {
65
- populateMonth()
93
+ /* needed for keyboard navigation. uncomment if implementing */
94
+ // const datepickerRef = useTemplateRef<HTMLDivElement>('datepicker')
66
95
 
67
- // required to allow the elements to be focused in the next step
68
- await nextTick()
96
+ const hoveredDate = ref(new Date(date.value))
97
+ const start_date = ref<Date | null>(null)
98
+ const end_date = ref<Date | null>(null)
99
+ const startDateInput = useTemplateRef<HTMLInputElement>('start-date-input')
100
+ const endDateInput = useTemplateRef<HTMLInputElement>('end-date-input')
69
101
 
70
- const $selectedDate = document.getElementsByClassName('selectedDate')
71
- if ($selectedDate.length > 0) {
72
- ;($selectedDate[0] as HTMLElement).focus()
73
- } else {
74
- const $todaysDate = document.getElementsByClassName('todaysDate')
75
- if ($todaysDate.length > 0) {
76
- ;($todaysDate[0] as HTMLElement).focus()
77
- }
78
- }
102
+ /*******************
103
+ Emits
104
+ *******************/
105
+
106
+ const emit = defineEmits<{
107
+ 'get-date': [{ start: Date | null; end: Date | null; selected: Date }]
108
+ }>()
109
+
110
+ /*******************
111
+ Computed
112
+ *******************/
113
+
114
+ const monthAndYear = computed(() => {
115
+ return new Date(currentYear.value, currentMonth.value, 1).toLocaleDateString(undefined, {
116
+ year: 'numeric',
117
+ month: 'long',
118
+ })
119
+ })
120
+
121
+ const getStartDate = computed(() => {
122
+ return start_date.value != null ? parseDateToString(start_date.value) : ''
79
123
  })
80
124
 
125
+ const getEndDate = computed(() => {
126
+ return end_date.value != null ? parseDateToString(end_date.value) : ''
127
+ })
128
+
129
+ /*******************
130
+ Functions
131
+ *******************/
132
+
133
+ const parseDateToString = (date: Date | null) => {
134
+ if (!validateDate(date)) return ''
135
+ return date.getMonth() + 1 + '/' + date.getDate() + '/' + date.getFullYear()
136
+ }
137
+
138
+ const isTodaysDate = (day: string | number | Date): boolean => {
139
+ const todaysDate = new Date()
140
+ if (currentMonth.value !== todaysDate.getMonth()) return false
141
+ return todaysDate.toDateString() === new Date(day).toDateString()
142
+ }
143
+
144
+ const isSelectedDate = (day: string | number | Date) => {
145
+ return new Date(day).toDateString() === new Date(selectedDate.value).toDateString()
146
+ }
147
+
148
+ const isStartDate = (day: string | number | Date) => {
149
+ const start = start_date.value
150
+ if (!validateDate(start)) return false
151
+ return new Date(day).toDateString() === start.toDateString()
152
+ }
153
+
154
+ const isEndDate = (day: string | number | Date) => {
155
+ const end = end_date.value
156
+ if (!validateDate(end)) return false
157
+ return new Date(day).toDateString() === end.toDateString()
158
+ }
159
+
160
+ const getCurrentCell = (rowNo: number, colNo: number) => {
161
+ return (rowNo - 1) * numberOfColumns + colNo
162
+ }
163
+
164
+ const isInDateRange = (day: string | number | Date) => {
165
+ const start = start_date.value
166
+ if (!validateDate(start)) return false
167
+ const this_date = new Date(day)
168
+
169
+ //the end is either the selected end date or wherever the user is hovering
170
+ const end = end_date.value
171
+ const temp_end_date = validateDate(end) ? end : new Date(hoveredDate.value)
172
+
173
+ return this_date.getTime() > start.getTime() && this_date.getTime() < temp_end_date.getTime()
174
+ }
175
+
176
+ const getCurrentDate = (rowNo: number, colNo: number) => {
177
+ return currentDates.value[getCurrentCell(rowNo, colNo)]
178
+ }
179
+
180
+ const hoverDate = (currentIndex: number) => {
181
+ hoveredDate.value = new Date(currentDates.value[currentIndex])
182
+ }
183
+
81
184
  const populateMonth = () => {
82
185
  currentDates.value = []
83
186
  const firstOfMonth = new Date(currentYear.value, currentMonth.value, 1)
@@ -89,8 +192,6 @@ const populateMonth = () => {
89
192
  currentDates.value.push(calendarStartDay + dayIndex * 86400000)
90
193
  }
91
194
  }
92
-
93
- watch([currentMonth, currentYear], populateMonth)
94
195
  const previousYear = () => (currentYear.value -= 1)
95
196
  const nextYear = () => (currentYear.value += 1)
96
197
 
@@ -112,57 +213,141 @@ const nextMonth = () => {
112
213
  }
113
214
  }
114
215
 
115
- const isTodaysDate = (day: string | number | Date) => {
116
- const todaysDate = new Date()
117
- if (currentMonth.value !== todaysDate.getMonth()) {
118
- return
119
- }
120
- return todaysDate.toDateString() === new Date(day).toDateString()
216
+ const enterDate = (event: KeyboardEvent) => {
217
+ if (event.key === 'Enter') enterInputDate()
121
218
  }
122
219
 
123
- const isSelectedDate = (day: string | number | Date) => {
124
- return new Date(day).toDateString() === new Date(selectedDate.value).toDateString()
220
+ // useKeyboardNav([
221
+ // {
222
+ // parent: datepickerRef,
223
+ // selectors: 'td',
224
+ // handlers: {
225
+ // ...defaultKeypressHandlers,
226
+ // ...{
227
+ // 'keydown.pageup': previousMonth,
228
+ // 'keydown.shift.pageup': previousYear,
229
+ // 'keydown.pagedown': nextMonth,
230
+ // 'keydown.shift.pagedown': nextYear,
231
+ // // TODO: this is a hack to override the stonecrop enter handler;
232
+ // // store context inside the component so that handlers can be setup consistently
233
+ // // eslint-disable-next-line @typescript-eslint/no-empty-function
234
+ // 'keydown.enter': () => {}, // select this date
235
+ // },
236
+ // },
237
+ // },
238
+ // ])
239
+
240
+ const selectDate = (currentIndex: number) => {
241
+ date.value = selectedDate.value = new Date(currentDates.value[currentIndex])
242
+
243
+ if (selectRange) {
244
+ const start = start_date.value
245
+ if (start == null || end_date.value != null) {
246
+ start_date.value = date.value
247
+ end_date.value = null
248
+ } else if (validateDate(start) && selectedDate.value.getTime() < start.getTime()) {
249
+ end_date.value = null
250
+ start_date.value = date.value
251
+ } else {
252
+ end_date.value = date.value
253
+ }
254
+ if (startDateInput.value) startDateInput.value.value = parseDateToString(start_date.value) ?? ''
255
+ if (endDateInput.value) endDateInput.value.value = parseDateToString(end_date.value) ?? ''
256
+ }
257
+ emitData()
125
258
  }
126
259
 
127
- const getCurrentCell = (rowNo: number, colNo: number) => {
128
- return (rowNo - 1) * numberOfColumns + colNo
260
+ const testDateOrder = () => {
261
+ const start = start_date.value
262
+ const end = end_date.value
263
+ if (validateDate(end) && validateDate(start) && end.getTime() < start.getTime())
264
+ [start_date.value, end_date.value] = [end, start]
129
265
  }
130
266
 
131
- const getCurrentDate = (rowNo: number, colNo: number) => {
132
- return currentDates.value[getCurrentCell(rowNo, colNo)]
267
+ const validateDate = (date: unknown): date is Date => {
268
+ return date instanceof Date && !isNaN(date.getTime())
133
269
  }
134
270
 
135
- const selectDate = (currentIndex: number) => {
136
- date.value = selectedDate.value = new Date(currentDates.value[currentIndex])
271
+ const enterInputDate = () => {
272
+ if (startDateInput.value?.value == '') {
273
+ start_date.value = null
274
+ } else if (startDateInput.value) {
275
+ const start = new Date(startDateInput.value.value)
276
+ start_date.value = validateDate(start) ? start : null
277
+ }
278
+
279
+ if (endDateInput.value?.value == '') {
280
+ end_date.value = null
281
+ } else if (endDateInput.value) {
282
+ const end = new Date(endDateInput.value.value)
283
+ end_date.value = validateDate(end) ? end : null
284
+ }
285
+
286
+ if (validateDate(start_date.value)) {
287
+ if (validateDate(end_date.value)) testDateOrder()
288
+ selectedDate.value = start_date.value
289
+ }
290
+
291
+ emitData()
137
292
  }
138
293
 
139
- const monthAndYear = computed(() => {
140
- return new Date(currentYear.value, currentMonth.value, 1).toLocaleDateString(undefined, {
141
- year: 'numeric',
142
- month: 'long',
294
+ const emitData = () => {
295
+ emit('get-date', {
296
+ start: selectRange ? start_date.value : null,
297
+ end: selectRange ? end_date.value : null,
298
+ selected: selectedDate.value,
143
299
  })
300
+ }
301
+
302
+ /*******************
303
+ Hooks
304
+ *******************/
305
+
306
+ onMounted(async () => {
307
+ populateMonth()
308
+ // required to allow the elements to be focused in the next step
309
+ await nextTick()
310
+ const $selectedDate = document.getElementsByClassName('selectedDate')
311
+ if ($selectedDate.length > 0) {
312
+ ;($selectedDate[0] as HTMLElement).focus()
313
+ } else {
314
+ const $todaysDate = document.getElementsByClassName('todaysDate')
315
+ if ($todaysDate.length > 0) {
316
+ ;($todaysDate[0] as HTMLElement).focus()
317
+ }
318
+ }
144
319
  })
145
320
 
146
321
  // setup keyboard navigation
147
- useKeyboardNav([
148
- {
149
- parent: datepickerRef,
150
- selectors: 'td',
151
- handlers: {
152
- ...defaultKeypressHandlers,
153
- ...{
154
- 'keydown.pageup': previousMonth,
155
- 'keydown.shift.pageup': previousYear,
156
- 'keydown.pagedown': nextMonth,
157
- 'keydown.shift.pagedown': nextYear,
158
- // TODO: this is a hack to override the stonecrop enter handler;
159
- // store context inside the component so that handlers can be setup consistently
160
-
161
- 'keydown.enter': () => {}, // select this date
162
- },
163
- },
164
- },
165
- ])
322
+ // useKeyboardNav([
323
+ // {
324
+ // parent: datepickerRef,
325
+ // selectors: 'td',
326
+ // handlers: {
327
+ // ...defaultKeypressHandlers,
328
+ // ...{
329
+ // 'keydown.pageup': previousMonth,
330
+ // 'keydown.shift.pageup': previousYear,
331
+ // 'keydown.pagedown': nextMonth,
332
+ // 'keydown.shift.pagedown': nextYear,
333
+ // // TODO: this is a hack to override the stonecrop enter handler;
334
+ // // store context inside the component so that handlers can be setup consistently
335
+
336
+ // 'keydown.enter': () => {}, // select this date
337
+ // },
338
+ // },
339
+ // },
340
+ // ])
341
+
342
+ /*******************
343
+ Watchers
344
+ *******************/
345
+
346
+ watch([currentMonth, currentYear], populateMonth)
347
+
348
+ /*******************
349
+ Expose
350
+ *******************/
166
351
 
167
352
  defineExpose({ currentMonth, currentYear, selectedDate })
168
353
  </script>
@@ -175,6 +360,7 @@ defineExpose({ currentMonth, currentYear, selectedDate })
175
360
  color: var(--sc-cell-text-color);
176
361
  outline: none;
177
362
  border-collapse: collapse;
363
+ margin-bottom: 10px;
178
364
  /* width: calc(100% - 4px); */
179
365
  }
180
366
 
@@ -190,26 +376,45 @@ defineExpose({ currentMonth, currentYear, selectedDate })
190
376
  outline: 2px solid transparent;
191
377
  min-width: 3ch;
192
378
  max-width: 3ch;
379
+ cursor: pointer;
380
+ }
381
+ .adatepicker td.date-cell:hover {
382
+ background: var(--sc-gray-10);
193
383
  }
194
384
 
195
385
  .adatepicker td:focus,
196
386
  .adatepicker td:focus-within {
197
- outline: 1px dashed black;
387
+ /* outline: 1px dashed black; */
198
388
  box-shadow: none;
199
389
  overflow: hidden;
200
390
  min-height: 1.15em;
201
391
  max-height: 1.15em;
202
392
  overflow: hidden;
203
393
  }
204
- .adatepicker .selectedDate {
205
- outline: 1px solid black;
394
+ .adatepicker .selectedDate,
395
+ .adatepicker .startDate,
396
+ .adatepicker .endDate {
397
+ /* outline: 1px solid black; */
206
398
  background: var(--sc-gray-20);
207
399
  font-weight: bolder;
208
400
  }
401
+ .adatepicker .startDate {
402
+ /* border-radius: 5px 0px 0px 5px; */
403
+ border-left: 1px solid var(--sc-gray-50);
404
+ background: var(--sc-gray-20) !important;
405
+ }
406
+ .adatepicker .endDate {
407
+ border-right: 1px solid var(--sc-gray-50);
408
+ /* border-radius: 0px 5px 5px 0px; */
409
+ background: var(--sc-gray-20) !important;
410
+ }
411
+ .adatepicker .withinRange {
412
+ background: var(--sc-gray-5);
413
+ }
209
414
 
210
415
  .adatepicker .todaysDate {
211
416
  font-weight: bolder;
212
- text-decoration: underline;
417
+ /* text-decoration: underline; */
213
418
  color: black;
214
419
  }
215
420
  .days-header > td {
@@ -218,4 +423,15 @@ defineExpose({ currentMonth, currentYear, selectedDate })
218
423
  .prev-date {
219
424
  color: var(--sc-gray-20);
220
425
  }
426
+
427
+ .adatepicker .date-input {
428
+ display: flex;
429
+ width: 100%;
430
+ gap: 5px;
431
+ align-items: center;
432
+ }
433
+ .adatepicker .date-input > input {
434
+ width: 50%;
435
+ padding: 2px;
436
+ }
221
437
  </style>
@@ -0,0 +1,190 @@
1
+ <template>
2
+ <div class="adaterange">
3
+ <template v-if="mode === 'display'">
4
+ <span class="aform_display-value">{{ displayValue }}</span>
5
+ <label>{{ label }}</label>
6
+ </template>
7
+
8
+ <template v-else>
9
+ <input
10
+ :id="uuid"
11
+ class="adate-input aform_input-field"
12
+ type="text"
13
+ :value="rangeDisplay"
14
+ placeholder="Select date range"
15
+ :disabled="mode === 'read'"
16
+ readonly
17
+ @click="openPicker" />
18
+ <label :for="uuid">{{ label }}</label>
19
+
20
+ <p v-show="validation.errorMessage" v-html="validation.errorMessage"></p>
21
+
22
+ <ADateSelection
23
+ v-if="showPicker"
24
+ ref="pickerRef"
25
+ class="picker"
26
+ :select-range="true"
27
+ :show-time="false"
28
+ @get-date="handlePickerDate" />
29
+ </template>
30
+ </div>
31
+ </template>
32
+
33
+ <script setup lang="ts">
34
+ import { ref, computed, watch } from 'vue'
35
+ import { onClickOutside } from '@vueuse/core'
36
+ import ADateSelection from './ADateSelection.vue'
37
+ import type { ComponentProps } from '../../types'
38
+
39
+ const { label = 'Date Range', mode, uuid, validation = { errorMessage: '&nbsp;' } } = defineProps<ComponentProps>()
40
+
41
+ export interface DateRangeValue {
42
+ start_date: string | null
43
+ end_date: string | null
44
+ }
45
+
46
+ const modelValue = defineModel<DateRangeValue>({
47
+ default: () => ({ start_date: null, end_date: null }),
48
+ })
49
+
50
+ const startDate = ref<Date | null>(modelValue.value.start_date ? new Date(modelValue.value.start_date) : null)
51
+ const endDate = ref<Date | null>(modelValue.value.end_date ? new Date(modelValue.value.end_date) : null)
52
+
53
+ const showPicker = ref(false)
54
+ const pickerRef = ref(null)
55
+ onClickOutside(pickerRef, () => (showPicker.value = false))
56
+
57
+ const openPicker = () => {
58
+ if (mode !== 'read') showPicker.value = true
59
+ }
60
+
61
+ const formatDate = (d: Date | null): string => {
62
+ if (!d) return ''
63
+ return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`
64
+ }
65
+
66
+ const rangeDisplay = computed(() => {
67
+ const s = formatDate(startDate.value)
68
+ const e = formatDate(endDate.value)
69
+ if (s && e) return `${s} — ${e}`
70
+ if (s) return `${s} — ...`
71
+ return ''
72
+ })
73
+
74
+ const displayValue = computed(() => {
75
+ const s = modelValue.value.start_date
76
+ const e = modelValue.value.end_date
77
+ if (!s && !e) return ''
78
+ const fmt = (d: string) => new Date(d).toLocaleDateString()
79
+ if (s && e) return `${fmt(s)} — ${fmt(e)}`
80
+ if (s) return `From ${fmt(s)}`
81
+ return `Until ${fmt(e!)}`
82
+ })
83
+
84
+ const ensureOrder = () => {
85
+ const s = startDate.value
86
+ const e = endDate.value
87
+ if (s && e && e.getTime() < s.getTime()) {
88
+ ;[startDate.value, endDate.value] = [e, s]
89
+ }
90
+ }
91
+
92
+ const toISODate = (d: Date | null): string | null => (d ? d.toISOString().split('T')[0] : null)
93
+
94
+ const emitModel = () => {
95
+ modelValue.value = {
96
+ start_date: toISODate(startDate.value),
97
+ end_date: toISODate(endDate.value),
98
+ }
99
+ }
100
+
101
+ const handlePickerDate = (data: { selected: Date; start?: Date | null; end?: Date | null }) => {
102
+ if (data.start) startDate.value = data.start
103
+ if (data.end) {
104
+ endDate.value = data.end
105
+ ensureOrder()
106
+ showPicker.value = false
107
+ }
108
+ emitModel()
109
+ }
110
+
111
+ watch(
112
+ () => modelValue.value,
113
+ newVal => {
114
+ startDate.value = newVal.start_date ? new Date(newVal.start_date) : null
115
+ endDate.value = newVal.end_date ? new Date(newVal.end_date) : null
116
+ },
117
+ { deep: true }
118
+ )
119
+ </script>
120
+
121
+ <style scoped>
122
+ .adaterange {
123
+ min-width: 40ch;
124
+ width: 100%;
125
+ box-sizing: border-box;
126
+ border: 1px solid transparent;
127
+ padding: 0;
128
+ margin: 0;
129
+ margin-right: 1ch;
130
+ position: relative;
131
+ overflow: visible;
132
+ }
133
+
134
+ .adate-input {
135
+ width: calc(100% - 1ch);
136
+ box-sizing: border-box;
137
+ outline: 1px solid transparent;
138
+ border: 1px solid var(--sc-input-border-color);
139
+ padding: 1ch 0.5ch 0.5ch 1ch;
140
+ margin: calc(1.15rem / 2) 0 0 0;
141
+ min-height: 1.15rem;
142
+ border-radius: 0.25rem;
143
+ cursor: pointer;
144
+ background: white;
145
+ font-size: 1rem;
146
+ }
147
+
148
+ .adate-input:focus {
149
+ border: 1px solid var(--sc-input-active-border-color);
150
+ }
151
+
152
+ .adate-input:focus + label {
153
+ color: var(--sc-input-active-label-color);
154
+ }
155
+
156
+ p,
157
+ label {
158
+ color: var(--sc-input-label-color);
159
+ display: block;
160
+ min-height: 1.15rem;
161
+ padding: 0;
162
+ margin: 0;
163
+ border: 1px solid transparent;
164
+ margin-bottom: 0.25rem;
165
+ box-sizing: border-box;
166
+ }
167
+
168
+ p {
169
+ width: 100%;
170
+ color: red;
171
+ font-size: 85%;
172
+ }
173
+
174
+ label {
175
+ z-index: 0;
176
+ font-size: 80%;
177
+ position: absolute;
178
+ background: white;
179
+ margin: calc(-1.5rem - calc(2.15rem / 2)) 0 0 1ch;
180
+ padding: 0 0.25ch 0 0.25ch;
181
+ box-sizing: border-box;
182
+ }
183
+
184
+ .picker {
185
+ position: absolute;
186
+ top: 50px;
187
+ left: 0;
188
+ z-index: 1000;
189
+ }
190
+ </style>