@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.
- package/dist/aform.d.ts +32 -0
- package/dist/aform.js +1112 -1016
- package/dist/aform.js.map +1 -1
- package/dist/assets/index.css +1 -1
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -1
- package/dist/src/utils/badge.d.ts +25 -0
- package/dist/src/utils/badge.d.ts.map +1 -0
- package/dist/src/utils/badge.js +71 -0
- package/package.json +5 -5
- package/src/components/form/ABadge.vue +135 -0
- package/src/components/form/ADropdown.vue +81 -99
- package/src/index.ts +5 -0
- package/src/utils/badge.ts +94 -0
|
@@ -1,50 +1,62 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
3
|
-
<
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class="autocomplete-result"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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?:
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
134
|
+
const idx = choiceList.value.indexOf(search.value ?? '')
|
|
103
135
|
dropdown.activeItemIndex = isAsync ? null : idx >= 0 ? idx : null
|
|
104
136
|
dropdown.open = true
|
|
105
|
-
|
|
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 (!
|
|
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 =
|
|
150
|
+
dropdown.results = choiceList.value
|
|
120
151
|
} else {
|
|
121
|
-
dropdown.results =
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|