@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.
@@ -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
- class="aform_input-field"
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
- const modelValue = defineModel<AFormLinkValue>({ default: { id: '', displayText: '' } })
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
- displayText = results.find(r => String(r.id) === String(id))?.displayText
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
- searchText.value = displayText
135
- modelValue.value = { ...modelValue.value, displayText }
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
- searchText.value = option.displayText ?? String(option.id)
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.includes(event.key)) return
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,
@@ -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
+ }