@stonecrop/aform 0.21.0 → 0.23.0

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.
@@ -1,10 +1,10 @@
1
- <!-- This serves as a wrapper component for ADatePicker and ADateTime in one component -->
1
+ <!-- This serves as a wrapper component for ADatePicker and ADateTimeInput in one component -->
2
2
 
3
3
  <template>
4
4
  <div class="adate-selection">
5
5
  <ADatePicker v-if="showDate" :select-range="selectRange" @get-date="handleDate" />
6
6
 
7
- <ADateTime
7
+ <ADateTimeInput
8
8
  v-if="showTime"
9
9
  :allow-military-time="allowMilitaryTime"
10
10
  :default-hours="defaultHours"
@@ -16,7 +16,7 @@
16
16
 
17
17
  <template v-if="selectRange && showTime && showEndTime">
18
18
  <div class="adate-selection__end-label">End time</div>
19
- <ADateTime
19
+ <ADateTimeInput
20
20
  :allow-military-time="allowMilitaryTime"
21
21
  :default-hours="defaultHours"
22
22
  :default-minutes="defaultMinutes"
@@ -33,7 +33,7 @@
33
33
  <script setup lang="ts">
34
34
  import { provide, ref } from 'vue'
35
35
  import ADatePicker from './ADatePicker.vue'
36
- import ADateTime from './ADateTime.vue'
36
+ import ADateTimeInput from './ADateTimeInput.vue'
37
37
 
38
38
  const {
39
39
  showDate = true,
@@ -59,10 +59,14 @@ const {
59
59
  useSeconds?: boolean
60
60
  }>()
61
61
 
62
+ // `source` is forwarded from ADateTimeInput, which declares it on every `get-time`. It is
63
+ // optional here only because a host or a test may emit these events by hand, and doing so always
64
+ // means a user action — `'init'` is the one case that has to be marked, because it is the widget
65
+ // announcing its start value as it mounts rather than anything the user did.
62
66
  const emit = defineEmits<{
63
67
  'get-date': [{ selected: Date; start?: Date | null; end?: Date | null }]
64
- 'get-time': [{ hours: number; minutes: number; seconds: number; meridiem: string }]
65
- 'get-range': [{ start: Date; end: Date }]
68
+ 'get-time': [{ hours: number; minutes: number; seconds: number; meridiem: string; source?: 'init' | 'user' }]
69
+ 'get-range': [{ start: Date; end: Date; source?: 'init' | 'user' }]
66
70
  }>()
67
71
 
68
72
  provide('select-range', selectRange)
@@ -96,11 +100,12 @@ const timePayloadToMs = (payload: {
96
100
  return (h * 3600 + payload.minutes * 60 + (payload.seconds ?? 0)) * 1000
97
101
  }
98
102
 
99
- const tryEmitRange = () => {
103
+ const tryEmitRange = (source: 'init' | 'user') => {
100
104
  if (!selectRange || !showTime || !showEndTime) return
101
105
  emit('get-range', {
102
106
  start: mergeDateTime(pickerStart.value, startTimeMs.value),
103
107
  end: mergeDateTime(pickerEnd.value, endTimeMs.value),
108
+ source,
104
109
  })
105
110
  }
106
111
 
@@ -109,7 +114,8 @@ const handleDate = (data: { start: Date | null; end: Date | null; selected: Date
109
114
  if (selectRange) {
110
115
  pickerStart.value = data.start ?? data.selected
111
116
  pickerEnd.value = data.end ?? data.selected
112
- tryEmitRange()
117
+ // Picking a day on the calendar is unambiguously a user action.
118
+ tryEmitRange('user')
113
119
  }
114
120
  }
115
121
 
@@ -119,10 +125,11 @@ const handleStartTime = (data: {
119
125
  seconds: number
120
126
  meridiem: string
121
127
  militaryTime?: number
128
+ source?: 'init' | 'user'
122
129
  }) => {
123
130
  startTimeMs.value = timePayloadToMs(data)
124
131
  if (showEndTime) {
125
- tryEmitRange()
132
+ tryEmitRange(data.source ?? 'user')
126
133
  } else {
127
134
  emit('get-time', data)
128
135
  }
@@ -134,9 +141,10 @@ const handleEndTime = (data: {
134
141
  seconds: number
135
142
  meridiem: string
136
143
  militaryTime?: number
144
+ source?: 'init' | 'user'
137
145
  }) => {
138
146
  endTimeMs.value = timePayloadToMs(data)
139
- tryEmitRange()
147
+ tryEmitRange(data.source ?? 'user')
140
148
  }
141
149
  </script>
142
150
 
@@ -1,285 +1,149 @@
1
1
  <template>
2
- <div class="adate_time">
3
- <div class="adate_time_fields">
4
- <input
5
- v-model="timeData.hours"
6
- type="text"
7
- inputmode="numeric"
8
- @paste="pasteInput($event, true)"
9
- @focus="focusInput"
10
- @blur="confirmTime"
11
- @keydown.enter.prevent="confirmTime"
12
- @keydown.up.prevent="tick('hours')"
13
- @keydown.down.prevent="tick('hours', -1)" />
14
- <span class="colon">:</span>
15
- <input
16
- v-model="timeData.minutes"
17
- type="text"
18
- inputmode="numeric"
19
- @paste="pasteInput"
20
- @focus="focusInput"
21
- @blur="confirmTime"
22
- @keydown.enter.prevent="confirmTime"
23
- @keydown.up.prevent="tick('minutes')"
24
- @keydown.down.prevent="tick('minutes', -1)" />
25
- <span v-if="useSeconds" class="colon">:</span>
2
+ <div class="aform_form-element">
3
+ <template v-if="mode === 'display'">
4
+ <span class="aform_display-value">{{ displayValue }}</span>
5
+ <label class="aform_field-label">{{ label }}</label>
6
+ </template>
7
+
8
+ <template v-else>
26
9
  <input
27
- v-if="useSeconds"
28
- v-model="timeData.seconds"
10
+ :id="uuid"
11
+ class="aform_input-field"
29
12
  type="text"
30
- inputmode="numeric"
31
- @paste="pasteInput"
32
- @focus="focusInput"
33
- @blur="confirmTime"
34
- @keydown.enter.prevent="confirmTime"
35
- @keydown.up.prevent="tick('seconds')"
36
- @keydown.down.prevent="tick('seconds', -1)" />
37
- <select
38
- v-if="!allowMilitaryTime"
39
- ref="meridiem-selector"
40
- v-model="meridiem"
41
- class="aform-select meridiem-selector"
42
- @change="confirmTime">
43
- <option value="AM">AM</option>
44
- <option value="PM">PM</option>
45
- </select>
46
- </div>
13
+ :value="datetimeDisplay"
14
+ placeholder="Select date and time"
15
+ :disabled="mode === 'read'"
16
+ readonly
17
+ @click="openPicker" />
18
+ <label class="aform_field-label" :for="uuid">{{ label }}</label>
19
+
20
+ <p v-show="errorText" class="aform_error" v-html="errorText"></p>
21
+
22
+ <ADateSelection
23
+ v-if="showPicker"
24
+ ref="pickerRef"
25
+ class="adatetime-picker"
26
+ :select-range="false"
27
+ :show-date="true"
28
+ :show-time="true"
29
+ :default-hours="pickerDefaults.hours"
30
+ :default-minutes="pickerDefaults.minutes"
31
+ :default-seconds="pickerDefaults.seconds"
32
+ :default-meridiem="pickerDefaults.meridiem"
33
+ :allow-military-time="allowMilitaryTime"
34
+ :use-seconds="useSeconds"
35
+ @get-date="handleDate"
36
+ @get-time="handleTime" />
37
+ </template>
47
38
  </div>
48
39
  </template>
49
40
 
50
41
  <script setup lang="ts">
51
- import { ref, reactive, useTemplateRef, watch, onMounted } from 'vue'
42
+ import { ref, computed, watch } from 'vue'
43
+ import { onClickOutside } from '@vueuse/core'
44
+ import ADateSelection from './ADateSelection.vue'
45
+ import type { ComponentProps } from '../../types'
52
46
 
53
47
  const {
48
+ label = 'Date & Time',
49
+ mode,
50
+ uuid,
51
+ errors,
52
+ validation = { errorMessage: '' },
54
53
  allowMilitaryTime = false,
55
- defaultHours = 12,
56
- defaultMinutes = 0,
57
- defaultSeconds = 0,
58
- defaultMeridiem = 'AM',
59
54
  useSeconds = true,
60
- } = defineProps<{
61
- allowMilitaryTime?: boolean
62
- defaultHours?: number
63
- defaultMinutes?: number
64
- defaultSeconds?: number
65
- defaultMeridiem?: string
66
- useSeconds?: boolean
67
- }>()
68
-
69
- const emit = defineEmits<{
70
- 'get-time': [{ hours: number; minutes: number; seconds: number; meridiem: string; militaryTime: number }]
71
- }>()
72
-
73
- const meridiemSelector = useTemplateRef<HTMLSelectElement>('meridiem-selector')
74
-
75
- const timeData = reactive({
76
- hours: String(defaultHours).padStart(2, '0'),
77
- minutes: String(defaultMinutes).padStart(2, '0'),
78
- seconds: String(defaultSeconds).padStart(2, '0'),
79
- })
80
-
81
- const meridiem = ref(defaultMeridiem == 'AM' ? 'AM' : 'PM')
82
-
83
- onMounted(() => {
84
- emitTime()
85
- })
86
-
87
- const confirmTime = () => {
88
- const maxHours = allowMilitaryTime ? 23 : 12
89
- const minHours = allowMilitaryTime ? 0 : 1
90
- let hours = Number(timeData.hours)
91
- let minutes = Number(timeData.minutes)
92
- let seconds = Number(timeData.seconds)
55
+ } = defineProps<
56
+ ComponentProps & {
57
+ allowMilitaryTime?: boolean
58
+ useSeconds?: boolean
59
+ }
60
+ >()
93
61
 
94
- if (isNaN(hours) || timeData.hours === '' || hours > maxHours) hours = maxHours
95
- if (!allowMilitaryTime && hours < minHours) hours = minHours
96
- if (isNaN(minutes) || timeData.minutes === '' || minutes > 59) minutes = 59
97
- if (isNaN(seconds) || timeData.seconds === '' || seconds > 59) seconds = 59
62
+ const errorText = computed(() => (errors?.length ? errors.join('; ') : (validation.errorMessage ?? '')))
98
63
 
99
- timeData.hours = String(hours).padStart(2, '0')
100
- timeData.minutes = String(minutes).padStart(2, '0')
101
- timeData.seconds = String(seconds).padStart(2, '0')
64
+ const modelValue = defineModel<string | Date>()
102
65
 
103
- emitTime()
104
- }
66
+ const currentDateTime = ref<Date>(modelValue.value ? new Date(modelValue.value) : new Date())
105
67
 
106
- const emitTime = () => {
107
- const hours = Number(timeData.hours)
108
- const minutes = Number(timeData.minutes)
109
- const seconds = Number(timeData.seconds)
110
- emit('get-time', {
111
- hours,
112
- minutes,
113
- seconds,
114
- meridiem: meridiem.value,
115
- militaryTime: allowMilitaryTime ? hours : meridiem.value === 'PM' ? (hours === 12 ? 12 : hours + 12) : hours % 12,
116
- })
117
- }
68
+ const showPicker = ref(false)
69
+ const pickerRef = ref(null)
70
+ onClickOutside(pickerRef, () => (showPicker.value = false))
118
71
 
119
- const focusInput = (event: FocusEvent) => {
120
- const target = event.target
121
- if (target instanceof HTMLInputElement) {
122
- target.select()
123
- }
72
+ const openPicker = () => {
73
+ if (mode !== 'read') showPicker.value = true
124
74
  }
125
75
 
126
- const tick = (target: 'hours' | 'minutes' | 'seconds', amount = 1) => {
127
- const maxHours = allowMilitaryTime ? 23 : 12
128
- const minHours = allowMilitaryTime ? 0 : 1
129
-
130
- if (target == 'hours') {
131
- const oldHours = Number(timeData.hours)
132
- timeData.hours = String(oldHours + amount)
133
- if ((oldHours == 11 && Number(timeData.hours) == 12) || (oldHours == 12 && Number(timeData.hours) == 11)) {
134
- changeMeridiem()
135
- }
136
- } else if (target == 'minutes') {
137
- timeData.minutes = String(Number(timeData.minutes) + amount)
138
- } else if (target == 'seconds') {
139
- timeData.seconds = String(Number(timeData.seconds) + amount)
140
- }
141
-
142
- const prevHours = Number(timeData.hours)
143
-
144
- if (Number(timeData.seconds) < 0) timeData.minutes = String(Number(timeData.minutes) - 1)
145
- else if (Number(timeData.seconds) > 59) timeData.minutes = String(Number(timeData.minutes) + 1)
146
-
147
- if (Number(timeData.minutes) < 0) timeData.hours = String(prevHours - 1)
148
- else if (Number(timeData.minutes) > 59) timeData.hours = String(prevHours + 1)
76
+ const displayValue = computed(() => {
77
+ if (!modelValue.value) return ''
78
+ return currentDateTime.value.toLocaleString()
79
+ })
149
80
 
150
- const newRawHours = Number(timeData.hours)
151
- if (!allowMilitaryTime && newRawHours !== prevHours) {
152
- if ((prevHours === 11 && newRawHours === 12) || (prevHours === 12 && newRawHours === 11)) {
153
- changeMeridiem()
154
- }
81
+ const datetimeDisplay = computed(() => displayValue.value)
82
+
83
+ const pickerDefaults = computed(() => {
84
+ const d = currentDateTime.value
85
+ const hours24 = d.getHours()
86
+ const meridiem = hours24 >= 12 ? 'PM' : 'AM'
87
+ const hours12 = hours24 % 12 || 12
88
+ return {
89
+ hours: allowMilitaryTime ? hours24 : hours12,
90
+ minutes: d.getMinutes(),
91
+ seconds: d.getSeconds(),
92
+ meridiem,
155
93
  }
94
+ })
156
95
 
157
- timeData.hours = String(formatTime(Number(timeData.hours), minHours, maxHours)).padStart(2, '0')
158
- timeData.minutes = String(formatTime(Number(timeData.minutes), 0, 59)).padStart(2, '0')
159
- timeData.seconds = String(formatTime(Number(timeData.seconds), 0, 59)).padStart(2, '0')
96
+ const emitModel = () => {
97
+ modelValue.value = currentDateTime.value.toISOString()
98
+ }
99
+
100
+ const handleDate = (data: { selected: Date }) => {
101
+ const next = new Date(currentDateTime.value)
102
+ next.setFullYear(data.selected.getFullYear(), data.selected.getMonth(), data.selected.getDate())
103
+ currentDateTime.value = next
104
+ emitModel()
105
+ }
106
+
107
+ const handleTime = (data: {
108
+ hours: number
109
+ minutes: number
110
+ seconds: number
111
+ meridiem: string
112
+ militaryTime?: number
113
+ source?: 'init' | 'user'
114
+ }) => {
115
+ // The widget announces its own starting value as it mounts, and those defaults come from
116
+ // `pickerDefaults` — i.e. straight back out of this component. Writing that echo to the model
117
+ // meant one click on an empty field silently filled it with the current date and time.
118
+ if (data.source === 'init') return
119
+
120
+ const next = new Date(currentDateTime.value)
121
+ const hours = data.militaryTime ?? data.hours
122
+ next.setHours(hours, data.minutes, useSeconds ? data.seconds : 0, 0)
123
+ currentDateTime.value = next
124
+ emitModel()
125
+ // Deliberately does NOT close the picker. `get-time` is the widget's current value, not a
126
+ // commit — it fires on every blur, arrow key and meridiem change — so closing here shut the
127
+ // picker as soon as the user tabbed out of the hours field. Dismissal is the click-outside
128
+ // handler above; closing on `get-date` instead would strand the time half of a datetime.
160
129
  }
161
130
 
162
131
  watch(
163
- () => timeData.hours,
164
- (newVal, oldVal) => {
165
- timeData.hours = Number(newVal) > 99 ? oldVal : newVal
166
- }
167
- )
168
- watch(
169
- () => timeData.minutes,
170
- (newVal, oldVal) => {
171
- timeData.minutes = Number(newVal) > 99 ? oldVal : newVal
172
- }
173
- )
174
- watch(
175
- () => timeData.seconds,
176
- (newVal, oldVal) => {
177
- timeData.seconds = Number(newVal) > 99 ? oldVal : newVal
178
- }
179
- )
180
-
181
- const formatTime = (target: number, min: number, max: number): number => {
182
- if (target > max) return min
183
- else if (target < min) return max
184
- return target
185
- }
186
-
187
- const changeMeridiem = () => {
188
- meridiem.value = meridiem.value == 'PM' ? 'AM' : 'PM'
189
- emitTime()
190
- }
191
-
192
- const pasteInput = (event: ClipboardEvent, pasteAllFields = false) => {
193
- event.stopPropagation()
194
- event.preventDefault()
195
-
196
- const clipboardData = event.clipboardData
197
- if (!clipboardData) return
198
- let pastedData: string = clipboardData.getData('Text')
199
-
200
- pastedData = pastedData.replace(/[^0-9]/g, '')
201
-
202
- if (pasteAllFields) {
203
- if (pastedData.length % 2 != 0) pastedData = '0' + pastedData
204
- if (pastedData.length < 3) pastedData += '00'
205
- if (pastedData.length < 5) pastedData += '00'
206
-
207
- const timeUnits = pastedData.match(/(..?)/g)
208
- if (!timeUnits || timeUnits.length < 3) return
209
-
210
- timeData.seconds = timeUnits[2]
211
- timeData.minutes = timeUnits[1]
212
- timeData.hours = timeUnits[0]
213
- confirmTime()
214
- if (!allowMilitaryTime) meridiemSelector.value?.focus()
215
- } else {
216
- if (pastedData.length > 2) pastedData = pastedData.slice(0, 2)
217
- const target = event.target
218
- if (target instanceof HTMLInputElement) {
219
- target.value = pastedData
220
- target.dispatchEvent(new Event('input'))
132
+ () => modelValue.value,
133
+ newValue => {
134
+ if (newValue) {
135
+ currentDateTime.value = new Date(newValue)
221
136
  }
222
137
  }
223
- }
138
+ )
224
139
  </script>
225
140
 
226
141
  <style scoped>
227
- .adate_time {
228
- width: auto;
229
- padding: 10px;
230
- box-sizing: border-box;
231
- font-size: 1rem;
232
- background: var(--sc-gray-10);
233
- }
234
- .adate_time_fields {
235
- display: flex;
236
- align-items: stretch;
237
- gap: 5px;
238
- justify-content: flex-start;
239
- }
240
- .adate_time_fields > input {
241
- min-width: 30px;
242
- padding: 2px;
243
- text-align: center;
244
- display: inline-block;
245
- flex-basis: 0;
246
- }
247
- .meridiem-selector {
248
- cursor: pointer;
249
- display: inline-block;
250
- flex-basis: 0;
251
- padding: 5px;
252
- user-select: none;
253
- }
254
- .meridiem-selector:focus {
255
- outline: 2px solid black;
256
- outline-offset: -2px;
257
- }
258
- .adate_time_segment {
259
- display: flex;
260
- flex-direction: column;
261
- width: 40px;
262
- }
263
- .colon {
264
- display: flex;
265
- align-items: normal;
266
- }
267
- .aform_form-btn {
268
- cursor: pointer;
269
- }
270
- .aform-select {
271
- border-radius: 0px;
272
- border: 1px solid rgb(118, 118, 118);
273
- font-size: 1rem;
274
- padding: 0rem;
275
- margin: 0;
276
- border-radius: 0;
277
- box-sizing: border-box;
278
- min-height: auto;
279
- position: relative;
280
- color: var(--sc-cell-text-color);
281
- }
282
- .meridiem-selector {
283
- margin-left: 6px;
142
+ .adatetime-picker {
143
+ position: absolute;
144
+ top: 100%;
145
+ left: 0;
146
+ z-index: 1000;
147
+ margin-top: 0.25rem;
284
148
  }
285
149
  </style>