@stonecrop/aform 0.16.2 → 0.16.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.
- package/dist/aform.d.ts +42 -0
- package/dist/aform.js +1216 -3802
- package/dist/aform.js.map +1 -1
- package/dist/assets/index.css +1 -1
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -1
- package/dist/src/types/index.d.ts +37 -0
- package/dist/src/types/index.d.ts.map +1 -1
- package/dist/tsdoc-metadata.json +1 -1
- package/package.json +7 -7
- package/src/components/form/ACurrencyInput.vue +357 -0
- package/src/components/form/ADatePicker.vue +1 -1
- package/src/components/form/AFormLink.vue +79 -12
- package/src/components/form/AQuantityInput.vue +4 -4
- package/src/index.ts +3 -0
- package/src/types/index.ts +39 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="aform_form-element">
|
|
2
|
+
<div :class="['aform_form-element', { 'aform_form-element--embedded': embedded }]">
|
|
3
3
|
<span v-if="mode === 'display'" class="aform_display-value">{{ displayedText }}</span>
|
|
4
4
|
<template v-else>
|
|
5
5
|
<div v-on-click-outside="onClickOutside" class="aform_form-link-wrapper">
|
|
@@ -7,7 +7,16 @@
|
|
|
7
7
|
<input
|
|
8
8
|
v-model="searchText"
|
|
9
9
|
type="text"
|
|
10
|
-
|
|
10
|
+
role="combobox"
|
|
11
|
+
autocomplete="off"
|
|
12
|
+
aria-autocomplete="list"
|
|
13
|
+
:class="['aform_input-field', { 'aform_input-field--embedded': embedded }]"
|
|
14
|
+
:placeholder="placeholder"
|
|
15
|
+
:aria-label="ariaLabel"
|
|
16
|
+
:required="required"
|
|
17
|
+
:aria-expanded="dropdownOpen"
|
|
18
|
+
:aria-controls="dropdownOpen ? listboxId : undefined"
|
|
19
|
+
:aria-activedescendant="activeIndex === null ? undefined : `${listboxId}-opt-${activeIndex}`"
|
|
11
20
|
:disabled="disabled || mode === 'read'"
|
|
12
21
|
@input="onInput"
|
|
13
22
|
@focus="onFocus"
|
|
@@ -17,7 +26,7 @@
|
|
|
17
26
|
@keydown.esc="closeDropdown"
|
|
18
27
|
@keydown.tab="closeDropdown" />
|
|
19
28
|
<button
|
|
20
|
-
v-if="hasValidId && !disabled"
|
|
29
|
+
v-if="hasValidId && !disabled && !embedded"
|
|
21
30
|
type="button"
|
|
22
31
|
class="aform_form-btn"
|
|
23
32
|
@click="handleNavigate"
|
|
@@ -25,27 +34,30 @@
|
|
|
25
34
|
<span>{{ icon === 'chevron-right' ? '›' : '→' }}</span>
|
|
26
35
|
</button>
|
|
27
36
|
</div>
|
|
28
|
-
<ul v-if="dropdownOpen" class="autocomplete-results">
|
|
37
|
+
<ul v-if="dropdownOpen" :id="listboxId" class="autocomplete-results" role="listbox" :aria-label="ariaLabel">
|
|
29
38
|
<li v-if="loading" class="autocomplete-result loading">Loading…</li>
|
|
30
39
|
<li
|
|
31
40
|
v-for="(option, i) in dropdownResults"
|
|
32
41
|
v-else
|
|
42
|
+
:id="`${listboxId}-opt-${i}`"
|
|
33
43
|
:key="String(option.id)"
|
|
44
|
+
role="option"
|
|
45
|
+
:aria-selected="i === activeIndex"
|
|
34
46
|
class="autocomplete-result"
|
|
35
47
|
:class="{ 'is-active': i === activeIndex }"
|
|
36
48
|
@mousedown.prevent="selectOption(option)">
|
|
37
|
-
{{ option.displayText ?? String(option.id) }}
|
|
49
|
+
<slot name="option" :option="option">{{ option.displayText ?? String(option.id) }}</slot>
|
|
38
50
|
</li>
|
|
39
51
|
</ul>
|
|
40
52
|
</div>
|
|
41
|
-
<label v-if="label" class="aform_field-label">{{ label }}</label>
|
|
53
|
+
<label v-if="label && !embedded" class="aform_field-label">{{ label }}</label>
|
|
42
54
|
</template>
|
|
43
55
|
</div>
|
|
44
56
|
</template>
|
|
45
57
|
|
|
46
58
|
<script setup lang="ts">
|
|
47
59
|
import { vOnClickOutside } from '@vueuse/components'
|
|
48
|
-
import { computed, inject, ref, watch } from 'vue'
|
|
60
|
+
import { computed, inject, ref, useId, watch } from 'vue'
|
|
49
61
|
|
|
50
62
|
import type { AFormLinkNavigator, AFormLinkValue, ComponentProps } from '../../types'
|
|
51
63
|
import { deserializeFunction } from '../../utils/deserialize'
|
|
@@ -53,12 +65,17 @@ import { deserializeFunction } from '../../utils/deserialize'
|
|
|
53
65
|
const {
|
|
54
66
|
label,
|
|
55
67
|
mode,
|
|
68
|
+
uuid,
|
|
69
|
+
required,
|
|
56
70
|
doctype = undefined,
|
|
57
71
|
formatter = undefined,
|
|
58
72
|
icon = 'arrow-right',
|
|
59
73
|
disabled = false,
|
|
60
74
|
filterFunction = undefined,
|
|
61
75
|
isAsync = false,
|
|
76
|
+
embedded = false,
|
|
77
|
+
placeholder = undefined,
|
|
78
|
+
ariaLabel = undefined,
|
|
62
79
|
} = defineProps<
|
|
63
80
|
ComponentProps & {
|
|
64
81
|
doctype?: string
|
|
@@ -67,10 +84,24 @@ const {
|
|
|
67
84
|
disabled?: boolean
|
|
68
85
|
filterFunction?: string | ((search: string) => AFormLinkValue[] | Promise<AFormLinkValue[]>)
|
|
69
86
|
isAsync?: boolean
|
|
87
|
+
// Bare rendering for compositing into another component's own bordered container
|
|
88
|
+
// (e.g. ACurrencyInput's merged amount+currency group): suppresses this component's
|
|
89
|
+
// own outline/border and floating label so the parent supplies both exactly once.
|
|
90
|
+
embedded?: boolean
|
|
91
|
+
placeholder?: string
|
|
92
|
+
// Accessible name for the search input. Needed in `embedded` mode, where the visible
|
|
93
|
+
// <label> is suppressed and a placeholder is not a dependable accessible name — it is
|
|
94
|
+
// not exposed consistently across screen readers and vanishes once a value is picked.
|
|
95
|
+
ariaLabel?: string
|
|
70
96
|
}
|
|
71
97
|
>()
|
|
72
98
|
|
|
73
|
-
|
|
99
|
+
// Ties the input to its listbox and to the active option (aria-controls / aria-activedescendant).
|
|
100
|
+
// `uuid` is per-field when AForm supplies it; the fallback keeps the ids unique for a standalone
|
|
101
|
+
// mount so two pickers on one page can't cross-wire their ARIA relationships.
|
|
102
|
+
const listboxId = `${uuid ?? `aform-link-${useId()}`}-listbox`
|
|
103
|
+
|
|
104
|
+
const modelValue = defineModel<AFormLinkValue>({ default: () => ({ id: '', displayText: '' }) })
|
|
74
105
|
|
|
75
106
|
const hasValidId = computed(() => {
|
|
76
107
|
const id = modelValue.value?.id
|
|
@@ -115,12 +146,14 @@ watch(
|
|
|
115
146
|
async id => {
|
|
116
147
|
if (!id || modelValue.value.displayText) return
|
|
117
148
|
try {
|
|
149
|
+
let match: AFormLinkValue | undefined
|
|
118
150
|
let displayText: string | undefined
|
|
119
151
|
if (filterFunction) {
|
|
120
152
|
const fn: FilterFn =
|
|
121
153
|
typeof filterFunction === 'string' ? deserializeFunction<FilterFn>(filterFunction) : filterFunction
|
|
122
154
|
const results = await fn(String(id))
|
|
123
|
-
|
|
155
|
+
match = results.find(r => String(r.id) === String(id))
|
|
156
|
+
displayText = match?.displayText
|
|
124
157
|
if (displayText === undefined) {
|
|
125
158
|
console.warn(
|
|
126
159
|
`[AFormLink] filterFunction returned no matching result for id "${id}". ` +
|
|
@@ -131,8 +164,17 @@ watch(
|
|
|
131
164
|
displayText = (await resolver(doctype, id.toString())) ?? undefined
|
|
132
165
|
}
|
|
133
166
|
if (displayText) {
|
|
134
|
-
|
|
135
|
-
|
|
167
|
+
// Merge the whole matched record, not just its display text — the extra properties
|
|
168
|
+
// an AFormLinkValue carries are part of the value (ACurrencyInput's `symbol`, which
|
|
169
|
+
// its formatter renders, is one). `id` is pinned to the value we already hold so a
|
|
170
|
+
// loosely-typed match (1 vs '1') can't change the FK's type underneath the record.
|
|
171
|
+
const resolved: AFormLinkValue = { ...modelValue.value, ...match, id: modelValue.value.id, displayText }
|
|
172
|
+
// Format for the same reason selectOption does: the input shows `formatter`'s output
|
|
173
|
+
// everywhere else (initial render, blur), so assigning the raw displayText here would
|
|
174
|
+
// make a value that arrived as a bare id render differently from the identical value
|
|
175
|
+
// picked by hand — e.g. "Euro" instead of "€".
|
|
176
|
+
searchText.value = formatter ? formatter(resolved) : displayText
|
|
177
|
+
modelValue.value = resolved
|
|
136
178
|
}
|
|
137
179
|
} catch {
|
|
138
180
|
// silent — fall back to showing the raw id
|
|
@@ -184,7 +226,11 @@ const onInput = () => openDropdown(searchText.value)
|
|
|
184
226
|
|
|
185
227
|
const selectOption = (option: AFormLinkValue) => {
|
|
186
228
|
modelValue.value = option
|
|
187
|
-
|
|
229
|
+
// Format `option` directly rather than reading back displayedText/modelValue: under a real
|
|
230
|
+
// two-way v-model (e.g. ACurrencyInput binding to a computed with a side-effecting setter),
|
|
231
|
+
// modelValue.value still reflects the *prop* until the parent's update round-trips back,
|
|
232
|
+
// so reading it synchronously here would show stale (pre-selection) text for a tick.
|
|
233
|
+
searchText.value = formatter ? formatter(option) : (option.displayText ?? String(option.id))
|
|
188
234
|
dropdownOpen.value = false
|
|
189
235
|
activeIndex.value = null
|
|
190
236
|
}
|
|
@@ -227,6 +273,27 @@ const selectCurrent = () => {
|
|
|
227
273
|
min-width: 0;
|
|
228
274
|
}
|
|
229
275
|
|
|
276
|
+
.aform_form-element--embedded {
|
|
277
|
+
min-width: 0;
|
|
278
|
+
flex-grow: 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* Embedded mode: the host component's own container supplies the border, so this input
|
|
282
|
+
goes borderless and inherits the host's padding scale instead of the standalone default. */
|
|
283
|
+
.aform_input-field--embedded {
|
|
284
|
+
outline: none;
|
|
285
|
+
background: transparent;
|
|
286
|
+
padding: 0.5ch 1ch;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/* Only in embedded mode is the trigger deliberately narrower than its options (e.g. a currency
|
|
290
|
+
symbol box): let the dropdown grow past it rather than truncate. Standalone pickers keep the
|
|
291
|
+
dropdown flush with the input, so a long display text can't overhang a narrow form column. */
|
|
292
|
+
.aform_form-element--embedded .autocomplete-results {
|
|
293
|
+
min-width: 100%;
|
|
294
|
+
width: max-content;
|
|
295
|
+
}
|
|
296
|
+
|
|
230
297
|
/* Give the button the same outline as the input, then slide it 2px left so the outlines
|
|
231
298
|
overlap exactly at the join: input right outline sits at (input_right - 1px),
|
|
232
299
|
button left outline also sits at (button_left + 1px) = (input_right - 2px + 1px) = same pixel. */
|
|
@@ -106,7 +106,7 @@ const {
|
|
|
106
106
|
>()
|
|
107
107
|
|
|
108
108
|
const modelValue = defineModel<QuantityValue>({
|
|
109
|
-
default: { qty: 0, uom: '', stockQty: 0, stockUom: '', conversionFactor: 1 },
|
|
109
|
+
default: () => ({ qty: 0, uom: '', stockQty: 0, stockUom: '', conversionFactor: 1 }),
|
|
110
110
|
})
|
|
111
111
|
|
|
112
112
|
const uoms = computed(() => options.uoms ?? [])
|
|
@@ -148,7 +148,7 @@ const uom = computed({
|
|
|
148
148
|
set: (value: string) => recompute(modelValue.value?.qty ?? 0, value),
|
|
149
149
|
})
|
|
150
150
|
|
|
151
|
-
const qtyNavigationKeys = [
|
|
151
|
+
const qtyNavigationKeys = new Set([
|
|
152
152
|
'Backspace',
|
|
153
153
|
'Delete',
|
|
154
154
|
'Tab',
|
|
@@ -160,11 +160,11 @@ const qtyNavigationKeys = [
|
|
|
160
160
|
'ArrowDown',
|
|
161
161
|
'Home',
|
|
162
162
|
'End',
|
|
163
|
-
]
|
|
163
|
+
])
|
|
164
164
|
|
|
165
165
|
const onQtyKeydown = (event: KeyboardEvent) => {
|
|
166
166
|
if (event.ctrlKey || event.metaKey || event.altKey) return
|
|
167
|
-
if (qtyNavigationKeys.
|
|
167
|
+
if (qtyNavigationKeys.has(event.key)) return
|
|
168
168
|
if (/^[0-9]$/.test(event.key)) return
|
|
169
169
|
const input = event.target as HTMLInputElement
|
|
170
170
|
if (event.key === '.' && !input.value.includes('.')) return
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { install as installATable } from '@stonecrop/atable'
|
|
|
4
4
|
import type { App } from 'vue'
|
|
5
5
|
|
|
6
6
|
import ACheckbox from './components/form/ACheckbox.vue'
|
|
7
|
+
import ACurrencyInput from './components/form/ACurrencyInput.vue'
|
|
7
8
|
import ADate from './components/form/ADate.vue'
|
|
8
9
|
import ADropdown from './components/form/ADropdown.vue'
|
|
9
10
|
import ADatePicker from './components/form/ADatePicker.vue'
|
|
@@ -33,6 +34,7 @@ function install(app: App /* options */) {
|
|
|
33
34
|
app.use(installATable) // Install ATable components for use within AForm
|
|
34
35
|
|
|
35
36
|
app.component('ACheckbox', ACheckbox)
|
|
37
|
+
app.component('ACurrencyInput', ACurrencyInput)
|
|
36
38
|
app.component('ADate', ADate)
|
|
37
39
|
app.component('ADropdown', ADropdown)
|
|
38
40
|
app.component('ADatePicker', ADatePicker)
|
|
@@ -53,6 +55,7 @@ function install(app: App /* options */) {
|
|
|
53
55
|
|
|
54
56
|
export {
|
|
55
57
|
ACheckbox,
|
|
58
|
+
ACurrencyInput,
|
|
56
59
|
ADate,
|
|
57
60
|
ADropdown,
|
|
58
61
|
ADatePicker,
|
package/src/types/index.ts
CHANGED
|
@@ -268,3 +268,42 @@ export interface QuantityOptions {
|
|
|
268
268
|
/** Conversion factor lookup for each non-stock UOM, relative to `stockUom` (which is implicitly `1`) */
|
|
269
269
|
conversionFactors?: Record<string, number>
|
|
270
270
|
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The value shape for ACurrencyInput — an amount paired with its currency (an
|
|
274
|
+
* {@link AFormLinkValue} FK reference), plus the derived base-currency-equivalent amount.
|
|
275
|
+
* `exchangeRate` is carried on the value so it round-trips with the record even though it is
|
|
276
|
+
* never directly edited by the user.
|
|
277
|
+
* @public
|
|
278
|
+
*/
|
|
279
|
+
export interface CurrencyValue {
|
|
280
|
+
/** The entered amount, in `currency` units */
|
|
281
|
+
amount: number
|
|
282
|
+
/** FK reference to the Currency doctype the user entered `amount` in */
|
|
283
|
+
currency: AFormLinkValue
|
|
284
|
+
/** `amount` converted into `baseCurrency` units — `amount * exchangeRate` */
|
|
285
|
+
baseAmount: number
|
|
286
|
+
/** The record's base currency — fixed, not user-editable */
|
|
287
|
+
baseCurrency: AFormLinkValue
|
|
288
|
+
/** Multiplier from `currency` to `baseCurrency` — hidden from the UI, drives `baseAmount` */
|
|
289
|
+
exchangeRate: number
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Type-specific configuration for ACurrencyInput, passed via the field's `options` property.
|
|
294
|
+
* @public
|
|
295
|
+
*/
|
|
296
|
+
export interface CurrencyOptions {
|
|
297
|
+
/** Currency doctype name, used for FK resolution via `aformLinkResolver`. The currency picker is embedded, so it renders no navigate button. */
|
|
298
|
+
doctype?: string
|
|
299
|
+
/** The record's base currency — fixed, not user-editable. A bare id resolves to displayText via `aformLinkResolver`. */
|
|
300
|
+
baseCurrency?: AFormLinkValue | string
|
|
301
|
+
/** Exchange rate lookup for each non-base currency id, relative to `baseCurrency` (which is implicitly `1`) */
|
|
302
|
+
exchangeRates?: Record<string, number>
|
|
303
|
+
/** Decimal places to round the derived `baseAmount` to — the base currency's scale (JPY carries 0, most carry 2, KWD 3). Applies only to `baseAmount`; the entered `amount` is left as typed. Omit to round only enough to shed binary floating-point noise, which never discards a digit the rate actually produced. A non-integer or out-of-range value falls back to that default. */
|
|
304
|
+
precision?: number
|
|
305
|
+
/** Search function backing the `currency` autocomplete dropdown — see AFormLink's `filterFunction` */
|
|
306
|
+
filterFunction?: string | ((search: string) => AFormLinkValue[] | Promise<AFormLinkValue[]>)
|
|
307
|
+
/** Whether `filterFunction` results should show a loading state — see AFormLink's `isAsync` */
|
|
308
|
+
isAsync?: boolean
|
|
309
|
+
}
|