@stonecrop/aform 0.16.0 → 0.16.2

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,349 @@
1
+ <template>
2
+ <div class="aform_form-element aquantity">
3
+ <template v-if="mode === 'display'">
4
+ <span class="aform_display-value">{{ displayText }}</span>
5
+ <label class="aform_field-label">{{ label }}</label>
6
+ </template>
7
+ <template v-else>
8
+ <div class="aquantity__row">
9
+ <div class="aquantity__field aquantity__field--qty">
10
+ <div class="aquantity__group">
11
+ <input
12
+ :id="uuid"
13
+ v-model.number="qty"
14
+ class="aquantity__qty"
15
+ type="number"
16
+ :disabled="mode === 'read'"
17
+ :required="required"
18
+ @keydown="onQtyKeydown"
19
+ @paste="onQtyPaste" />
20
+ <div v-on-click-outside="closeDropdown" class="aquantity__uom">
21
+ <button
22
+ :id="`${uuid}-uom`"
23
+ type="button"
24
+ class="aquantity__uom-toggle"
25
+ :disabled="mode === 'read'"
26
+ aria-haspopup="listbox"
27
+ :aria-expanded="dropdown.open"
28
+ :aria-activedescendant="
29
+ dropdown.open && dropdown.activeIndex >= 0 ? `${uuid}-uom-opt-${dropdown.activeIndex}` : undefined
30
+ "
31
+ @click="toggleDropdown"
32
+ @keydown.down.prevent="moveActive(1)"
33
+ @keydown.up.prevent="moveActive(-1)"
34
+ @keydown.enter.prevent="selectActive"
35
+ @keydown.esc="closeDropdown">
36
+ <span class="aquantity__uom-value">{{ uom || uomLabel }}</span>
37
+ <span class="aquantity__caret" aria-hidden="true"></span>
38
+ </button>
39
+ <ul v-show="dropdown.open" class="aquantity__uom-menu" role="listbox" :aria-label="uomLabel">
40
+ <li
41
+ v-for="(option, i) in uoms"
42
+ :id="`${uuid}-uom-opt-${i}`"
43
+ :key="option"
44
+ role="option"
45
+ :aria-selected="option === uom"
46
+ class="aquantity__uom-option"
47
+ :class="{ 'is-active': i === dropdown.activeIndex }"
48
+ @mouseenter="dropdown.activeIndex = i"
49
+ @click="selectUom(option)">
50
+ {{ option }}
51
+ </li>
52
+ </ul>
53
+ </div>
54
+ </div>
55
+ <label class="aform_field-label" :for="uuid">{{ label }}</label>
56
+ </div>
57
+ </div>
58
+ <div class="aquantity__row aquantity__row--stock">
59
+ <div class="aquantity__field aquantity__field--stock-uom">
60
+ <input :value="modelValue.stockUom" class="aform_input-field aquantity__stock-field" type="text" disabled />
61
+ <label class="aform_field-label">{{ stockUomLabel }}</label>
62
+ </div>
63
+ <div class="aquantity__field aquantity__field--stock-qty">
64
+ <input :value="modelValue.stockQty" class="aform_input-field aquantity__stock-field" type="number" disabled />
65
+ <label class="aform_field-label">{{ stockQtyLabel }}</label>
66
+ </div>
67
+ <div class="aquantity__field aquantity__field--conversion">
68
+ <input
69
+ :value="modelValue.conversionFactor"
70
+ class="aform_input-field aquantity__stock-field"
71
+ type="number"
72
+ disabled />
73
+ <label class="aform_field-label">{{ conversionFactorLabel }}</label>
74
+ </div>
75
+ </div>
76
+ <p v-show="validation.errorMessage" class="aform_error" v-html="validation.errorMessage"></p>
77
+ </template>
78
+ </div>
79
+ </template>
80
+
81
+ <script setup lang="ts">
82
+ import { vOnClickOutside } from '@vueuse/components'
83
+ import { computed, reactive } from 'vue'
84
+
85
+ import type { ComponentProps, QuantityOptions, QuantityValue } from '../../types'
86
+
87
+ const {
88
+ label,
89
+ required,
90
+ mode,
91
+ uuid,
92
+ validation = { errorMessage: '&nbsp;' },
93
+ options = {},
94
+ uomLabel = 'UOM',
95
+ stockUomLabel = 'Stock UOM',
96
+ stockQtyLabel = 'Stock Qty',
97
+ conversionFactorLabel = 'Conversion Factor',
98
+ } = defineProps<
99
+ ComponentProps & {
100
+ options?: QuantityOptions
101
+ uomLabel?: string
102
+ stockUomLabel?: string
103
+ stockQtyLabel?: string
104
+ conversionFactorLabel?: string
105
+ }
106
+ >()
107
+
108
+ const modelValue = defineModel<QuantityValue>({
109
+ default: { qty: 0, uom: '', stockQty: 0, stockUom: '', conversionFactor: 1 },
110
+ })
111
+
112
+ const uoms = computed(() => options.uoms ?? [])
113
+
114
+ // Round to shed binary floating-point noise (e.g. 0.1 * 3 → 0.30000000000000004) while
115
+ // preserving any legitimate decimal places.
116
+ const roundQty = (value: number): number => Number(value.toFixed(6))
117
+
118
+ const resolveConversionFactor = (uom: string): number => {
119
+ const stockUom = options.stockUom ?? modelValue.value.stockUom
120
+ if (!uom || uom === stockUom) return 1
121
+ const mapped = options.conversionFactors?.[uom]
122
+ if (mapped !== undefined) return mapped
123
+ // UOM absent from the conversion map: keep the stored factor only when the unit is
124
+ // unchanged (e.g. editing qty on a loaded value, so the factor round-trips). Switching
125
+ // to a new, unmapped unit resets to 1 rather than silently reusing the previous factor.
126
+ if (uom === modelValue.value.uom) return modelValue.value.conversionFactor ?? 1
127
+ return 1
128
+ }
129
+
130
+ const recompute = (qty: number, uom: string) => {
131
+ const conversionFactor = resolveConversionFactor(uom)
132
+ modelValue.value = {
133
+ qty,
134
+ uom,
135
+ conversionFactor,
136
+ stockUom: options.stockUom ?? modelValue.value.stockUom,
137
+ stockQty: roundQty(qty * conversionFactor),
138
+ }
139
+ }
140
+
141
+ const qty = computed({
142
+ get: () => modelValue.value?.qty ?? 0,
143
+ set: (value: number) => recompute(value, modelValue.value?.uom ?? ''),
144
+ })
145
+
146
+ const uom = computed({
147
+ get: () => modelValue.value?.uom ?? '',
148
+ set: (value: string) => recompute(modelValue.value?.qty ?? 0, value),
149
+ })
150
+
151
+ const qtyNavigationKeys = [
152
+ 'Backspace',
153
+ 'Delete',
154
+ 'Tab',
155
+ 'Escape',
156
+ 'Enter',
157
+ 'ArrowLeft',
158
+ 'ArrowRight',
159
+ 'ArrowUp',
160
+ 'ArrowDown',
161
+ 'Home',
162
+ 'End',
163
+ ]
164
+
165
+ const onQtyKeydown = (event: KeyboardEvent) => {
166
+ if (event.ctrlKey || event.metaKey || event.altKey) return
167
+ if (qtyNavigationKeys.includes(event.key)) return
168
+ if (/^[0-9]$/.test(event.key)) return
169
+ const input = event.target as HTMLInputElement
170
+ if (event.key === '.' && !input.value.includes('.')) return
171
+ event.preventDefault()
172
+ }
173
+
174
+ const onQtyPaste = (event: ClipboardEvent) => {
175
+ const pasted = event.clipboardData?.getData('text') ?? ''
176
+ if (!/^\d*\.?\d*$/.test(pasted)) event.preventDefault()
177
+ }
178
+
179
+ const dropdown = reactive({ open: false, activeIndex: -1 })
180
+
181
+ const openDropdown = () => {
182
+ dropdown.activeIndex = Math.max(uoms.value.indexOf(uom.value), 0)
183
+ dropdown.open = true
184
+ }
185
+
186
+ const closeDropdown = () => {
187
+ dropdown.open = false
188
+ }
189
+
190
+ const toggleDropdown = () => {
191
+ if (dropdown.open) closeDropdown()
192
+ else openDropdown()
193
+ }
194
+
195
+ const selectUom = (value: string) => {
196
+ uom.value = value
197
+ closeDropdown()
198
+ }
199
+
200
+ const moveActive = (delta: number) => {
201
+ if (!dropdown.open) {
202
+ openDropdown()
203
+ return
204
+ }
205
+ const length = uoms.value.length
206
+ if (!length) return
207
+ dropdown.activeIndex = (dropdown.activeIndex + delta + length) % length
208
+ }
209
+
210
+ const selectActive = () => {
211
+ if (!dropdown.open) {
212
+ openDropdown()
213
+ return
214
+ }
215
+ const option = uoms.value[dropdown.activeIndex]
216
+ if (option !== undefined) selectUom(option)
217
+ }
218
+
219
+ const showStock = computed(() => {
220
+ const v = modelValue.value
221
+ return !!v?.stockUom && (v.uom !== v.stockUom || v.qty !== v.stockQty)
222
+ })
223
+
224
+ const displayText = computed(() => {
225
+ const v = modelValue.value
226
+ if (!v || !v.uom) return '—'
227
+ const base = `${v.qty} ${v.uom}`
228
+ return showStock.value ? `${base} (${v.stockQty} ${v.stockUom})` : base
229
+ })
230
+ </script>
231
+
232
+ <style scoped>
233
+ .aquantity__row {
234
+ display: flex;
235
+ gap: 1ch;
236
+ }
237
+
238
+ .aquantity__row--stock {
239
+ margin-top: 1.5rem;
240
+ }
241
+
242
+ .aquantity__field {
243
+ position: relative;
244
+ flex: 1;
245
+ min-width: 0;
246
+ }
247
+
248
+ .aquantity__group {
249
+ display: flex;
250
+ align-items: stretch;
251
+ width: 100%;
252
+ border: 1px solid var(--sc-input-border-color);
253
+ border-radius: 0.25rem;
254
+ }
255
+
256
+ .aquantity__group:focus-within {
257
+ border-color: var(--sc-input-active-border-color);
258
+ }
259
+
260
+ .aquantity__qty {
261
+ flex: 1;
262
+ min-width: 0;
263
+ border: none;
264
+ outline: none;
265
+ padding: 0.5ch 1ch;
266
+ background: transparent;
267
+ border-radius: 0.25rem 0 0 0.25rem;
268
+ appearance: textfield;
269
+ -moz-appearance: textfield;
270
+ }
271
+
272
+ .aquantity__qty::-webkit-outer-spin-button,
273
+ .aquantity__qty::-webkit-inner-spin-button {
274
+ appearance: none;
275
+ -webkit-appearance: none;
276
+ margin: 0;
277
+ }
278
+
279
+ .aquantity__uom {
280
+ position: relative;
281
+ flex: 0 0 auto;
282
+ border-left: 1px solid var(--sc-input-border-color);
283
+ }
284
+
285
+ .aquantity__uom-toggle {
286
+ display: flex;
287
+ align-items: center;
288
+ gap: 0.75ch;
289
+ height: 100%;
290
+ padding: 0.5ch 1ch;
291
+ background: var(--sc-gray-5);
292
+ border: none;
293
+ border-radius: 0 0.25rem 0.25rem 0;
294
+ white-space: nowrap;
295
+ cursor: pointer;
296
+ }
297
+
298
+ .aquantity__uom-toggle:disabled {
299
+ cursor: not-allowed;
300
+ color: var(--sc-gray-50, #888);
301
+ }
302
+
303
+ .aquantity__caret {
304
+ display: inline-block;
305
+ width: 0;
306
+ height: 0;
307
+ border-left: 0.3em solid transparent;
308
+ border-right: 0.3em solid transparent;
309
+ border-top: 0.3em solid currentColor;
310
+ }
311
+
312
+ .aquantity__uom-menu {
313
+ position: absolute;
314
+ top: 100%;
315
+ right: 0;
316
+ z-index: 100;
317
+ min-width: 100%;
318
+ margin: 0.15rem 0 0 0;
319
+ padding: 0.25rem 0;
320
+ list-style: none;
321
+ background: var(--sc-input-field-background, #fff);
322
+ border: 1px solid var(--sc-input-active-border-color);
323
+ border-radius: 0.25rem;
324
+ }
325
+
326
+ .aquantity__uom-option {
327
+ padding: 0.4ch 1ch;
328
+ white-space: nowrap;
329
+ cursor: pointer;
330
+ }
331
+
332
+ .aquantity__uom-option.is-active,
333
+ .aquantity__uom-option:hover {
334
+ background-color: var(--sc-row-color-zebra-light);
335
+ }
336
+
337
+ .aquantity__stock-field {
338
+ width: 100%;
339
+ font-size: 1rem;
340
+ padding: 0.5ch 1ch;
341
+ border: 1px solid var(--sc-input-border-color);
342
+ border-radius: 0.25rem;
343
+ outline: none;
344
+ }
345
+
346
+ .aquantity__stock-field:disabled {
347
+ color: var(--sc-gray-50, #888);
348
+ }
349
+ </style>
@@ -0,0 +1,66 @@
1
+ <template>
2
+ <div class="aform_form-element">
3
+ <template v-if="mode === 'display'">
4
+ <span class="aform_display-value aform_textbox-display">{{ inputText ?? '' }}</span>
5
+ <label class="aform_field-label">{{ label }}</label>
6
+ </template>
7
+ <template v-else>
8
+ <textarea
9
+ :id="uuid"
10
+ v-model="inputText"
11
+ class="aform_input-field aform_textbox"
12
+ :placeholder="placeholder"
13
+ :rows="rows"
14
+ :maxlength="maxlength"
15
+ :disabled="mode === 'read'"
16
+ :required="required"></textarea>
17
+ <label class="aform_field-label" :for="uuid">{{ label }}</label>
18
+ <p v-show="errorText" class="aform_error" v-html="errorText"></p>
19
+ </template>
20
+ </div>
21
+ </template>
22
+
23
+ <script setup lang="ts">
24
+ import { computed } from 'vue'
25
+
26
+ import { ComponentProps } from '../../types'
27
+
28
+ const {
29
+ label,
30
+ required,
31
+ mode,
32
+ uuid,
33
+ errors,
34
+ placeholder = '',
35
+ rows = 4,
36
+ maxlength,
37
+ validation = { errorMessage: '' },
38
+ } = defineProps<
39
+ ComponentProps & {
40
+ /** Placeholder shown when the field is empty */
41
+ placeholder?: string
42
+ /** Visible number of text lines (maps to the textarea `rows` attribute) */
43
+ rows?: number
44
+ /** Maximum number of characters the field will accept */
45
+ maxlength?: number
46
+ }
47
+ >()
48
+
49
+ // Dynamic trigger errors take precedence over a static schema errorMessage; empty means the slot hides.
50
+ const errorText = computed(() => (errors?.length ? errors.join('; ') : (validation.errorMessage ?? '')))
51
+
52
+ const inputText = defineModel<string | null>()
53
+ </script>
54
+
55
+ <style scoped>
56
+ .aform_textbox {
57
+ resize: vertical;
58
+ line-height: 1.5;
59
+ min-height: 4rem;
60
+ font-family: inherit;
61
+ }
62
+
63
+ .aform_textbox-display {
64
+ white-space: pre-wrap;
65
+ }
66
+ </style>
package/src/index.ts CHANGED
@@ -16,8 +16,9 @@ import AFileAttach from './components/form/AFileAttach.vue'
16
16
  import AForm from './components/AForm.vue'
17
17
  import AFormLink from './components/form/AFormLink.vue'
18
18
  import ANumericInput from './components/form/ANumericInput.vue'
19
+ import AQuantityInput from './components/form/AQuantityInput.vue'
19
20
  import ATextInput from './components/form/ATextInput.vue'
20
- import ATextarea from './components/form/ATextarea.vue'
21
+ import ATextboxInput from './components/form/ATextboxInput.vue'
21
22
  import Login from './components/utilities/Login.vue'
22
23
  import AFormLoading from './components/AFormLoading.vue'
23
24
 
@@ -43,8 +44,9 @@ function install(app: App /* options */) {
43
44
  app.component('AForm', AForm)
44
45
  app.component('AFormLink', AFormLink)
45
46
  app.component('ANumericInput', ANumericInput)
47
+ app.component('AQuantityInput', AQuantityInput)
46
48
  app.component('ATextInput', ATextInput)
47
- app.component('ATextarea', ATextarea)
49
+ app.component('ATextboxInput', ATextboxInput)
48
50
  app.component('ADuration', ADuration)
49
51
  app.component('AFormLoading', AFormLoading)
50
52
  }
@@ -63,8 +65,9 @@ export {
63
65
  AForm,
64
66
  AFormLink,
65
67
  ANumericInput,
68
+ AQuantityInput,
66
69
  ATextInput,
67
- ATextarea,
70
+ ATextboxInput,
68
71
  Login,
69
72
  AFormLoading,
70
73
  install,
@@ -236,3 +236,35 @@ export interface AFormLinkNavigator {
236
236
  /** Navigate to the linked document. Implementation is app-defined. */
237
237
  navigate(doctype: string, id: string | number): void
238
238
  }
239
+
240
+ /**
241
+ * The value shape for AQuantityInput — a quantity paired with its unit of measure, plus the
242
+ * derived stock-equivalent quantity/UOM. `conversionFactor` is carried on the value so it
243
+ * round-trips with the record even though it is never shown in the UI.
244
+ * @public
245
+ */
246
+ export interface QuantityValue {
247
+ /** The entered quantity, in `uom` units */
248
+ qty: number
249
+ /** Unit of measure the user entered `qty` in */
250
+ uom: string
251
+ /** `qty` converted into `stockUom` units — `qty * conversionFactor` */
252
+ stockQty: number
253
+ /** The item's base/stock unit of measure — fixed, not user-editable */
254
+ stockUom: string
255
+ /** Multiplier from `uom` to `stockUom` — hidden from the UI, drives `stockQty` */
256
+ conversionFactor: number
257
+ }
258
+
259
+ /**
260
+ * Type-specific configuration for AQuantityInput, passed via the field's `options` property.
261
+ * @public
262
+ */
263
+ export interface QuantityOptions {
264
+ /** Dropdown choices for the `uom` field */
265
+ uoms?: string[]
266
+ /** The item's base/stock unit of measure — fixed, not user-editable */
267
+ stockUom?: string
268
+ /** Conversion factor lookup for each non-stock UOM, relative to `stockUom` (which is implicitly `1`) */
269
+ conversionFactors?: Record<string, number>
270
+ }
@@ -1,31 +0,0 @@
1
- <template>
2
- <div class="aform_form-element">
3
- <template v-if="mode === 'display'">
4
- <span class="aform_display-value">{{ inputText ?? '' }}</span>
5
- <label class="aform_field-label">{{ label }}</label>
6
- </template>
7
- <template v-else>
8
- <textarea
9
- :id="uuid"
10
- v-model="inputText"
11
- class="aform_input-field aform_textarea"
12
- :disabled="mode === 'read'"
13
- :required="required"></textarea>
14
- <label class="aform_field-label" :for="uuid">{{ label }} </label>
15
- <p v-show="errorText" class="aform_error" v-html="errorText"></p>
16
- </template>
17
- </div>
18
- </template>
19
-
20
- <script setup lang="ts">
21
- import { computed } from 'vue'
22
-
23
- import { ComponentProps } from '../../types'
24
-
25
- const { label, required, mode, uuid, errors, validation = { errorMessage: '' } } = defineProps<ComponentProps>()
26
-
27
- // Dynamic trigger errors take precedence over a static schema errorMessage; empty means the slot hides.
28
- const errorText = computed(() => (errors?.length ? errors.join('; ') : (validation.errorMessage ?? '')))
29
-
30
- const inputText = defineModel<number | string>()
31
- </script>