@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.
@@ -0,0 +1,166 @@
1
+ <!-- This serves as a wrapper component for ADatePicker and ADateTime in one component -->
2
+
3
+ <template>
4
+ <div class="adate-selection">
5
+ <ADatePicker v-if="showDate" :select-range="selectRange" @get-date="handleDate" />
6
+
7
+ <ADateTime
8
+ v-if="showTime"
9
+ :allow-military-time="allowMilitaryTime"
10
+ :default-hours="defaultHours"
11
+ :default-minutes="defaultMinutes"
12
+ :default-seconds="defaultSeconds"
13
+ :default-meridiem="defaultMeridiem"
14
+ :use-seconds="useSeconds"
15
+ @get-time="handleStartTime" />
16
+
17
+ <template v-if="selectRange && showTime && showEndTime">
18
+ <div class="adate-selection__end-label">End time</div>
19
+ <ADateTime
20
+ :allow-military-time="allowMilitaryTime"
21
+ :default-hours="defaultHours"
22
+ :default-minutes="defaultMinutes"
23
+ :default-seconds="defaultSeconds"
24
+ :default-meridiem="defaultMeridiem"
25
+ :use-seconds="useSeconds"
26
+ @get-time="handleEndTime" />
27
+ </template>
28
+
29
+ <p v-if="!showTime && !showDate" class="empty">empty</p>
30
+ </div>
31
+ </template>
32
+
33
+ <script setup lang="ts">
34
+ import { provide, ref } from 'vue'
35
+ import ADatePicker from './ADatePicker.vue'
36
+ import ADateTime from './ADateTime.vue'
37
+
38
+ const {
39
+ showDate = true,
40
+ showTime = true,
41
+ selectRange = true,
42
+ showEndTime = false,
43
+ allowMilitaryTime = false,
44
+ defaultHours = 12,
45
+ defaultMinutes = 0,
46
+ defaultSeconds = 0,
47
+ defaultMeridiem = 'AM',
48
+ useSeconds = true,
49
+ } = defineProps<{
50
+ showDate?: boolean
51
+ showTime?: boolean
52
+ selectRange?: boolean
53
+ showEndTime?: boolean
54
+ allowMilitaryTime?: boolean
55
+ defaultHours?: number
56
+ defaultMinutes?: number
57
+ defaultSeconds?: number
58
+ defaultMeridiem?: string
59
+ useSeconds?: boolean
60
+ }>()
61
+
62
+ const emit = defineEmits<{
63
+ '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 }]
66
+ }>()
67
+
68
+ provide('select-range', selectRange)
69
+
70
+ const today = new Date()
71
+ const pickerStart = ref<Date>(today)
72
+ const pickerEnd = ref<Date>(today)
73
+ const startTimeMs = ref<number>(0)
74
+ const endTimeMs = ref<number>(0)
75
+
76
+ const mergeDateTime = (date: Date, timeMs: number): Date => {
77
+ const d = new Date(date)
78
+ d.setHours(0, 0, 0, 0)
79
+ return new Date(d.getTime() + timeMs)
80
+ }
81
+
82
+ const timePayloadToMs = (payload: {
83
+ hours: number
84
+ minutes: number
85
+ seconds: number
86
+ meridiem: string
87
+ militaryTime?: number
88
+ }): number => {
89
+ let h: number
90
+ if (payload.militaryTime !== undefined) {
91
+ h = payload.militaryTime
92
+ } else {
93
+ const hrs = payload.hours % 12
94
+ h = payload.meridiem === 'PM' ? hrs + 12 : hrs
95
+ }
96
+ return (h * 3600 + payload.minutes * 60 + (payload.seconds ?? 0)) * 1000
97
+ }
98
+
99
+ const tryEmitRange = () => {
100
+ if (!selectRange || !showTime || !showEndTime) return
101
+ emit('get-range', {
102
+ start: mergeDateTime(pickerStart.value, startTimeMs.value),
103
+ end: mergeDateTime(pickerEnd.value, endTimeMs.value),
104
+ })
105
+ }
106
+
107
+ const handleDate = (data: { start: Date | null; end: Date | null; selected: Date }) => {
108
+ emit('get-date', data)
109
+ if (selectRange) {
110
+ pickerStart.value = data.start ?? data.selected
111
+ pickerEnd.value = data.end ?? data.selected
112
+ tryEmitRange()
113
+ }
114
+ }
115
+
116
+ const handleStartTime = (data: {
117
+ hours: number
118
+ minutes: number
119
+ seconds: number
120
+ meridiem: string
121
+ militaryTime?: number
122
+ }) => {
123
+ startTimeMs.value = timePayloadToMs(data)
124
+ if (showEndTime) {
125
+ tryEmitRange()
126
+ } else {
127
+ emit('get-time', data)
128
+ }
129
+ }
130
+
131
+ const handleEndTime = (data: {
132
+ hours: number
133
+ minutes: number
134
+ seconds: number
135
+ meridiem: string
136
+ militaryTime?: number
137
+ }) => {
138
+ endTimeMs.value = timePayloadToMs(data)
139
+ tryEmitRange()
140
+ }
141
+ </script>
142
+
143
+ <style scoped>
144
+ .adate-selection {
145
+ display: inline-block;
146
+ border: 1px solid var(--sc-gray-80);
147
+ padding: 10px;
148
+ background: var(--sc-form-background);
149
+ }
150
+
151
+ .adate-selection__end-label {
152
+ font-size: 0.8em;
153
+ font-weight: bold;
154
+ color: var(--sc-input-active-label-color, #555);
155
+ margin-top: 8px;
156
+ margin-bottom: 2px;
157
+ padding-left: 2px;
158
+ }
159
+
160
+ .empty {
161
+ color: #ccc;
162
+ padding: 10px;
163
+ text-align: center;
164
+ border: 1px solid #ccc;
165
+ }
166
+ </style>
@@ -0,0 +1,285 @@
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>
26
+ <input
27
+ v-if="useSeconds"
28
+ v-model="timeData.seconds"
29
+ 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>
47
+ </div>
48
+ </template>
49
+
50
+ <script setup lang="ts">
51
+ import { ref, reactive, useTemplateRef, watch, onMounted } from 'vue'
52
+
53
+ const {
54
+ allowMilitaryTime = false,
55
+ defaultHours = 12,
56
+ defaultMinutes = 0,
57
+ defaultSeconds = 0,
58
+ defaultMeridiem = 'AM',
59
+ 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)
93
+
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
98
+
99
+ timeData.hours = String(hours).padStart(2, '0')
100
+ timeData.minutes = String(minutes).padStart(2, '0')
101
+ timeData.seconds = String(seconds).padStart(2, '0')
102
+
103
+ emitTime()
104
+ }
105
+
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
+ }
118
+
119
+ const focusInput = (event: FocusEvent) => {
120
+ const target = event.target
121
+ if (target instanceof HTMLInputElement) {
122
+ target.select()
123
+ }
124
+ }
125
+
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)
149
+
150
+ const newRawHours = Number(timeData.hours)
151
+ if (!allowMilitaryTime && newRawHours !== prevHours) {
152
+ if ((prevHours === 11 && newRawHours === 12) || (prevHours === 12 && newRawHours === 11)) {
153
+ changeMeridiem()
154
+ }
155
+ }
156
+
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')
160
+ }
161
+
162
+ 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'))
221
+ }
222
+ }
223
+ }
224
+ </script>
225
+
226
+ <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;
284
+ }
285
+ </style>
@@ -0,0 +1,119 @@
1
+ <template>
2
+ <div class="aduration">
3
+ <template v-if="mode === 'display' || mode === 'read'">
4
+ <span class="aform_display-value">{{ displayValue }}</span>
5
+ <label>{{ label }}</label>
6
+ </template>
7
+
8
+ <template v-else>
9
+ <ADateSelection
10
+ ref="selectionRef"
11
+ :select-range="true"
12
+ :show-time="true"
13
+ :show-end-time="true"
14
+ :allow-military-time="allowMilitaryTime"
15
+ :use-seconds="useSeconds"
16
+ @get-range="handleRange" />
17
+ <div class="aduration__footer">
18
+ <label>{{ label }}</label>
19
+ <div v-if="startDatetime && endDatetime" class="aduration__summary">
20
+ <span class="aduration__label">Duration:</span>
21
+ <span class="aduration__value">{{ humanDuration }}</span>
22
+ <span class="aduration__ms">({{ modelValue ?? 0 }} ms)</span>
23
+ </div>
24
+ </div>
25
+ </template>
26
+ </div>
27
+ </template>
28
+
29
+ <script setup lang="ts">
30
+ import { ref, computed, watch } from 'vue'
31
+ import ADateSelection from './ADateSelection.vue'
32
+
33
+ const {
34
+ label = 'Duration',
35
+ mode = 'edit',
36
+ allowMilitaryTime = false,
37
+ useSeconds = false,
38
+ } = defineProps<{
39
+ label?: string
40
+ mode?: string
41
+ allowMilitaryTime?: boolean
42
+ useSeconds?: boolean
43
+ }>()
44
+
45
+ const modelValue = defineModel<number>()
46
+
47
+ const startDatetime = ref<Date | null>(null)
48
+ const endDatetime = ref<Date | null>(null)
49
+
50
+ const duration = computed<number>(() => {
51
+ if (!startDatetime.value || !endDatetime.value) return 0
52
+ const ms = endDatetime.value.getTime() - startDatetime.value.getTime()
53
+ return ms > 0 ? ms : 0
54
+ })
55
+
56
+ watch(duration, newMs => {
57
+ modelValue.value = newMs
58
+ })
59
+
60
+ const handleRange = (data: { start: Date; end: Date }) => {
61
+ startDatetime.value = data.start
62
+ endDatetime.value = data.end
63
+ modelValue.value = duration.value
64
+ }
65
+
66
+ const humanDuration = computed(() => {
67
+ const ms = duration.value
68
+ if (ms === 0) return '0s'
69
+ const s = Math.floor(ms / 1000) % 60
70
+ const m = Math.floor(ms / 60000) % 60
71
+ const h = Math.floor(ms / 3600000) % 24
72
+ const d = Math.floor(ms / 86400000)
73
+ return [d && `${d}d`, h && `${h}h`, m && `${m}m`, s && `${s}s`].filter(Boolean).join(' ') || '0s'
74
+ })
75
+
76
+ const displayValue = computed(() => {
77
+ const ms = modelValue.value
78
+ if (!ms) return '—'
79
+ const s = Math.floor(ms / 1000) % 60
80
+ const m = Math.floor(ms / 60000) % 60
81
+ const h = Math.floor(ms / 3600000) % 24
82
+ const d = Math.floor(ms / 86400000)
83
+ return [d && `${d}d`, h && `${h}h`, m && `${m}m`, s && `${s}s`].filter(Boolean).join(' ') || '0s'
84
+ })
85
+ </script>
86
+
87
+ <style scoped>
88
+ .aduration {
89
+ position: relative;
90
+ min-width: 40ch;
91
+ width: 100%;
92
+ }
93
+
94
+ .aduration__summary {
95
+ display: flex;
96
+ gap: 6px;
97
+ align-items: baseline;
98
+ margin-top: 6px;
99
+ font-size: 0.9em;
100
+ }
101
+
102
+ .aduration__label {
103
+ font-weight: bold;
104
+ color: var(--sc-input-active-label-color, #555);
105
+ }
106
+
107
+ .aduration__value {
108
+ color: var(--sc-cell-text-color, #000);
109
+ }
110
+
111
+ .aduration__ms {
112
+ color: var(--sc-gray-50, #888);
113
+ font-size: 0.85em;
114
+ }
115
+
116
+ .aduration__footer {
117
+ margin-top: 8px;
118
+ }
119
+ </style>
package/src/index.ts CHANGED
@@ -8,6 +8,10 @@ import AComboBox from './components/form/AComboBox.vue'
8
8
  import ADate from './components/form/ADate.vue'
9
9
  import ADropdown from './components/form/ADropdown.vue'
10
10
  import ADatePicker from './components/form/ADatePicker.vue'
11
+ import ADateTime from './components/form/ADateTime.vue'
12
+ import ADateSelection from './components/form/ADateSelection.vue'
13
+ import ADuration from './components/form/ADuration.vue'
14
+ import ADateRange from './components/form/ADateRange.vue'
11
15
  import AFieldset from './components/form/AFieldset.vue'
12
16
  import AFileAttach from './components/form/AFileAttach.vue'
13
17
  import AForm from './components/AForm.vue'
@@ -30,12 +34,16 @@ function install(app: App /* options */) {
30
34
  app.component('ADate', ADate)
31
35
  app.component('ADropdown', ADropdown)
32
36
  app.component('ADatePicker', ADatePicker)
37
+ app.component('ADateTime', ADateTime)
38
+ app.component('ADateRange', ADateRange)
39
+ app.component('ADateSelection', ADateSelection)
33
40
  app.component('AFieldset', AFieldset)
34
41
  app.component('AFileAttach', AFileAttach)
35
42
  app.component('AForm', AForm)
36
43
  app.component('AFormLink', AFormLink)
37
44
  app.component('ANumericInput', ANumericInput)
38
45
  app.component('ATextInput', ATextInput)
46
+ app.component('ADuration', ADuration)
39
47
  }
40
48
 
41
49
  export {
@@ -44,6 +52,10 @@ export {
44
52
  ADate,
45
53
  ADropdown,
46
54
  ADatePicker,
55
+ ADateRange,
56
+ ADateSelection,
57
+ ADuration,
58
+ ADateTime,
47
59
  AFieldset,
48
60
  AFileAttach,
49
61
  AForm,
@@ -24,6 +24,9 @@ export type ComponentProps = {
24
24
  */
25
25
  label?: string
26
26
 
27
+ // TODO: add docstring
28
+ selectRange?: boolean
29
+
27
30
  /**
28
31
  * The mask to apply to inputs inside the component. Accepts either a plain
29
32
  * mask string (e.g. `"(###) ###-####"`) or a stringified arrow function that