@stonecrop/aform 0.22.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,50 +1,62 @@
1
1
  <template>
2
- <div v-if="mode === 'display'" class="input-wrapper">
3
- <span class="aform_display-value">{{ search ?? '' }}</span>
4
- <label>{{ label }}</label>
5
- <p v-show="errorText" class="aform_error" v-html="errorText"></p>
6
- </div>
7
- <div v-else v-on-click-outside="onClickOutside" class="autocomplete" :class="{ isOpen: dropdown.open }">
8
- <div class="input-wrapper">
9
- <input
10
- v-model="search"
11
- type="text"
12
- :disabled="mode === 'read'"
13
- @input="filter"
14
- @focus="openDropdown"
15
- @keydown.down="selectNextResult"
16
- @keydown.up="selectPrevResult"
17
- @keydown.enter="setCurrentResult"
18
- @keydown.esc="onClickOutside"
19
- @keydown.tab="onClickOutside" />
20
-
21
- <ul v-show="dropdown.open" id="autocomplete-results" class="autocomplete-results">
22
- <li v-if="dropdown.loading" class="loading autocomplete-result">Loading results...</li>
23
- <li
24
- v-for="(result, i) in dropdown.results"
25
- v-else
26
- :key="result"
27
- class="autocomplete-result"
28
- :class="{ 'is-active': i === dropdown.activeItemIndex }"
29
- @click.stop="setResult(result)">
30
- {{ result }}
31
- </li>
32
- </ul>
33
- <label>{{ label }}</label>
34
- </div>
35
- <p v-show="errorText" class="aform_error" v-html="errorText"></p>
2
+ <div class="aform_form-element">
3
+ <template v-if="mode === 'display'">
4
+ <span v-if="badgeDescriptor" class="aform_display-value" :style="displayAccentStyle">{{
5
+ badgeDescriptor.label
6
+ }}</span>
7
+ <span v-else class="aform_display-value">{{ search ?? '' }}</span>
8
+ <label class="aform_field-label">{{ label }}</label>
9
+ </template>
10
+ <template v-else>
11
+ <div v-on-click-outside="onClickOutside" class="autocomplete" :class="{ isOpen: dropdown.open }">
12
+ <input
13
+ v-model="search"
14
+ type="text"
15
+ class="aform_input-field"
16
+ :disabled="mode === 'read'"
17
+ :style="inputAccentStyle"
18
+ @input="filter"
19
+ @focus="openDropdown"
20
+ @keydown.down="selectNextResult"
21
+ @keydown.up="selectPrevResult"
22
+ @keydown.enter="setCurrentResult"
23
+ @keydown.esc="onClickOutside"
24
+ @keydown.tab="onClickOutside" />
25
+
26
+ <ul v-show="dropdown.open" id="autocomplete-results" class="autocomplete-results">
27
+ <li v-if="dropdown.loading" class="loading autocomplete-result">Loading results...</li>
28
+ <li
29
+ v-for="(result, i) in dropdown.results"
30
+ v-else
31
+ :key="result"
32
+ class="autocomplete-result"
33
+ :class="{ 'is-active': i === dropdown.activeItemIndex }"
34
+ @click.stop="setResult(result)">
35
+ {{ result }}
36
+ </li>
37
+ </ul>
38
+ <label class="aform_field-label">{{ label }}</label>
39
+ </div>
40
+ <p v-show="errorText" class="aform_error" v-html="errorText"></p>
41
+ </template>
36
42
  </div>
37
43
  </template>
38
44
 
39
45
  <script setup lang="ts">
46
+ import type { FieldOptions } from '@stonecrop/schema'
47
+ import { selectChoices } from '@stonecrop/schema'
40
48
  import { vOnClickOutside } from '@vueuse/components'
41
- import { computed, reactive, ref } from 'vue'
49
+ import { computed, reactive, ref, watch } from 'vue'
42
50
 
43
51
  import type { ComponentProps } from '../../types'
52
+ import type { BadgeFormatFn } from '../../utils/badge'
53
+ import { badgeInputAccentStyle, resolveFieldBadge } from '../../utils/badge'
54
+ import { deserializeFunction } from '../../utils/deserialize'
44
55
 
45
56
  const {
46
57
  label,
47
58
  options = [],
59
+ format,
48
60
  isAsync = false,
49
61
  filterFunction = undefined,
50
62
  mode,
@@ -52,26 +64,46 @@ const {
52
64
  validation = { errorMessage: '' },
53
65
  } = defineProps<
54
66
  ComponentProps & {
55
- options?: string[]
67
+ options?: FieldOptions
68
+ format?: string
56
69
  isAsync?: boolean
57
70
  filterFunction?: (search: string) => string[] | Promise<string[]>
58
71
  }
59
72
  >()
60
73
 
61
- // Dynamic trigger errors take precedence over a static schema errorMessage; empty means the slot hides.
74
+ const choiceList = computed(() => selectChoices(options))
75
+
76
+ // Compile the serialized formatter once per `format` string, not once per keystroke:
77
+ // badgeDescriptor re-evaluates on every input event and deserializeFunction runs the
78
+ // Function constructor.
79
+ const formatFn = computed(() => (format ? deserializeFunction<BadgeFormatFn>(format) : undefined))
80
+
81
+ const badgeDescriptor = computed(() => resolveFieldBadge(search.value, options, formatFn.value))
82
+
83
+ const inputAccentStyle = computed(() => badgeInputAccentStyle(badgeDescriptor.value))
84
+
85
+ const displayAccentStyle = computed(() => badgeInputAccentStyle(badgeDescriptor.value))
86
+
62
87
  const errorText = computed(() => (errors?.length ? errors.join('; ') : (validation.errorMessage ?? '')))
63
88
  const search = defineModel<string>()
64
89
 
65
- // tracks the last explicitly-committed value so outside-click reverts instead of clears
66
90
  const committedValue = ref(search.value ?? '')
67
91
 
68
92
  const dropdown = reactive({
69
93
  activeItemIndex: null as number | null,
70
94
  open: false,
71
95
  loading: false,
72
- results: options,
96
+ results: [] as string[],
73
97
  })
74
98
 
99
+ watch(
100
+ choiceList,
101
+ choices => {
102
+ dropdown.results = choices
103
+ },
104
+ { immediate: true }
105
+ )
106
+
75
107
  const onClickOutside = () => closeDropdown()
76
108
 
77
109
  const filter = async () => {
@@ -99,26 +131,25 @@ const setResult = (result: string) => {
99
131
  }
100
132
 
101
133
  const openDropdown = () => {
102
- const idx = options?.indexOf(search.value ?? '') ?? -1
134
+ const idx = choiceList.value.indexOf(search.value ?? '')
103
135
  dropdown.activeItemIndex = isAsync ? null : idx >= 0 ? idx : null
104
136
  dropdown.open = true
105
- // TODO: this should probably call the async function if it's async
106
- dropdown.results = isAsync ? [] : options
137
+ dropdown.results = isAsync ? [] : choiceList.value
107
138
  }
108
139
 
109
140
  const closeDropdown = (result?: string) => {
110
141
  dropdown.activeItemIndex = null
111
142
  dropdown.open = false
112
- if (!options?.includes(result || search.value || '')) {
143
+ if (!choiceList.value.includes(result || search.value || '')) {
113
144
  search.value = committedValue.value
114
145
  }
115
146
  }
116
147
 
117
148
  const filterResults = () => {
118
149
  if (!search.value) {
119
- dropdown.results = options
150
+ dropdown.results = choiceList.value
120
151
  } else {
121
- dropdown.results = options?.filter(item => item.toLowerCase().includes((search.value ?? '').toLowerCase()))
152
+ dropdown.results = choiceList.value.filter(item => item.toLowerCase().includes((search.value ?? '').toLowerCase()))
122
153
  }
123
154
  }
124
155
 
@@ -157,65 +188,26 @@ const setCurrentResult = () => {
157
188
  </script>
158
189
 
159
190
  <style scoped>
160
- /* variables taken from here: https://github.com/frappe/frappe/blob/version-13/frappe/public/scss/common/awesomeplete.scss */
161
191
  .autocomplete {
162
192
  position: relative;
163
193
  }
164
194
 
165
- .input-wrapper {
166
- border: 1px solid transparent;
167
- padding: 0rem;
168
- margin: 0rem;
169
- margin-right: 1ch;
170
- }
171
-
172
- input {
173
- width: calc(100% - 1ch);
174
- outline: 1px solid transparent;
175
- border: 1px solid var(--sc-input-border-color);
176
- padding: 1ch 0.5ch 0.5ch 1ch;
177
- margin: calc(1.15rem / 2) 0 0 0;
178
- min-height: 1.15rem;
179
- border-radius: 0.25rem;
180
- font-family: var(--sc-font-family);
181
- }
182
-
183
- input:focus {
184
- border: 1px solid var(--sc-input-active-border-color);
185
- border-radius: 0.25rem 0.25rem 0 0;
186
- border-bottom: none;
187
- }
188
-
189
- label {
190
- display: block;
191
- min-height: 1.15rem;
192
- padding: 0rem;
193
- margin: 0rem;
194
- border: 1px solid transparent;
195
- margin-bottom: 0.25rem;
196
- z-index: 0;
197
- font-size: 80%;
198
- position: absolute;
199
- background: white;
200
- margin: calc(-1.5rem - calc(2.15rem / 2)) 0 0 1ch;
201
- padding: 0 0.25ch 0 0.25ch;
202
- }
203
-
204
195
  .autocomplete-results {
205
196
  position: absolute;
206
- width: calc(100% - 1ch + 1.5px);
197
+ left: 0;
198
+ right: 0;
207
199
  z-index: 100;
208
200
  padding: 0;
209
201
  margin: 0;
210
202
  color: var(--sc-input-active-border-color);
211
203
  border: 1px solid var(--sc-input-active-border-color);
212
- border-radius: 0 0 0.25rem 0.25rem;
204
+ border-radius: 0;
213
205
  border-top: none;
214
- background-color: #fff;
206
+ background-color: var(--sc-input-field-background, #fff);
207
+ list-style: none;
215
208
  }
216
209
 
217
210
  .autocomplete-result {
218
- list-style: none;
219
211
  text-align: left;
220
212
  padding: 4px 6px;
221
213
  cursor: pointer;
@@ -227,14 +219,4 @@ label {
227
219
  background-color: var(--sc-row-color-zebra-light);
228
220
  color: var(--sc-input-active-border-color);
229
221
  }
230
-
231
- /* Keep the field error in-flow below the control. The shared .aform_error is absolutely
232
- positioned against a .aform_form-element anchor, which this component does not use. */
233
- p.aform_error {
234
- position: static;
235
- display: block;
236
- color: var(--sc-brand-danger, red);
237
- font-size: 0.7rem;
238
- margin: 0.25rem 0 0;
239
- }
240
222
  </style>
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export type * from '@stonecrop/atable/types'
2
2
  export { deserializeFunction } from './utils/deserialize'
3
+ export { badgeInputAccentStyle, resolveFieldBadge } from './utils/badge'
4
+ export type { BadgeFormatContext, BadgeFormatFn } from './utils/badge'
3
5
  import { install as installATable } from '@stonecrop/atable'
4
6
  import type { App } from 'vue'
5
7
 
@@ -7,6 +9,7 @@ import ACheckbox from './components/form/ACheckbox.vue'
7
9
  import ACurrencyInput from './components/form/ACurrencyInput.vue'
8
10
  import ADate from './components/form/ADate.vue'
9
11
  import ADropdown from './components/form/ADropdown.vue'
12
+ import ABadge from './components/form/ABadge.vue'
10
13
  import ADatePicker from './components/form/ADatePicker.vue'
11
14
  import ADateTime from './components/form/ADateTime.vue'
12
15
  import ADateTimeInput from './components/form/ADateTimeInput.vue'
@@ -38,6 +41,7 @@ function install(app: App /* options */) {
38
41
  app.component('ACurrencyInput', ACurrencyInput)
39
42
  app.component('ADate', ADate)
40
43
  app.component('ADropdown', ADropdown)
44
+ app.component('ABadge', ABadge)
41
45
  app.component('ADatePicker', ADatePicker)
42
46
  app.component('ADateTime', ADateTime)
43
47
  app.component('ADateTimeInput', ADateTimeInput)
@@ -60,6 +64,7 @@ export {
60
64
  ACurrencyInput,
61
65
  ADate,
62
66
  ADropdown,
67
+ ABadge,
63
68
  ADatePicker,
64
69
  ADateRange,
65
70
  ADateSelection,
@@ -0,0 +1,94 @@
1
+ import type { BadgeDescriptor, FieldOptions } from '@stonecrop/schema'
2
+ import { isBadgeDescriptor, lookupBadge } from '@stonecrop/schema'
3
+
4
+ import { deserializeFunction } from './deserialize'
5
+
6
+ /**
7
+ * Row context passed to badge `format` functions in form fields.
8
+ * @public
9
+ */
10
+ export type BadgeFormatContext = {
11
+ record?: Record<string, unknown>
12
+ row?: Record<string, unknown>
13
+ }
14
+
15
+ /**
16
+ * Badge-aware field `format` function signature.
17
+ * @public
18
+ */
19
+ export type BadgeFormatFn = (value: unknown, context: BadgeFormatContext) => string | BadgeDescriptor
20
+
21
+ /**
22
+ * Resolve a field value to a badge descriptor using format (if present) then options map.
23
+ * @public
24
+ */
25
+ export function resolveFieldBadge(
26
+ value: unknown,
27
+ options: FieldOptions | undefined,
28
+ format: string | BadgeFormatFn | undefined,
29
+ context: BadgeFormatContext = {}
30
+ ): BadgeDescriptor | undefined {
31
+ if (format) {
32
+ let formatted: unknown
33
+ if (typeof format === 'function') {
34
+ formatted = format(value, context)
35
+ } else {
36
+ const formatFn = deserializeFunction<BadgeFormatFn>(format)
37
+ formatted = formatFn(value, context)
38
+ }
39
+ if (isBadgeDescriptor(formatted)) {
40
+ const label = formatted.label.trim()
41
+ return label ? formatted : undefined
42
+ }
43
+ if (typeof formatted === 'string' && formatted !== '') {
44
+ return lookupBadge(options, formatted) ?? { label: formatted, variant: 'neutral' }
45
+ }
46
+ }
47
+
48
+ const key = badgeLookupKey(value)
49
+ if (key === undefined) return undefined
50
+ return lookupBadge(options, key)
51
+ }
52
+
53
+ /**
54
+ * Stored choice value as an options-map key. Only primitives can match a declared choice, so
55
+ * anything else yields undefined rather than the '[object Object]' that String() would produce.
56
+ */
57
+ function badgeLookupKey(value: unknown): string | undefined {
58
+ switch (typeof value) {
59
+ case 'string':
60
+ return value === '' ? undefined : value
61
+ case 'number':
62
+ case 'boolean':
63
+ case 'bigint':
64
+ return String(value)
65
+ default:
66
+ return undefined
67
+ }
68
+ }
69
+
70
+ const BADGE_VARIANTS = new Set<string>(['neutral', 'success', 'warning', 'danger', 'brand'])
71
+
72
+ /**
73
+ * CSS custom properties for input-accent styling on a native input.
74
+ * @public
75
+ */
76
+ export function badgeInputAccentStyle(descriptor: BadgeDescriptor | undefined): Record<string, string> | undefined {
77
+ if (!descriptor?.label?.trim()) return undefined
78
+ const variant = descriptor.variant ?? 'neutral'
79
+ if (descriptor.color) {
80
+ return {
81
+ borderLeftWidth: '4px',
82
+ borderLeftStyle: 'solid',
83
+ borderLeftColor: descriptor.color,
84
+ paddingLeft: 'calc(1ch - 4px)',
85
+ }
86
+ }
87
+ if (!BADGE_VARIANTS.has(variant)) return undefined
88
+ return {
89
+ borderLeftWidth: '4px',
90
+ borderLeftStyle: 'solid',
91
+ borderLeftColor: `var(--sc-badge-${variant}-accent)`,
92
+ paddingLeft: 'calc(1ch - 4px)',
93
+ }
94
+ }