@stonecrop/aform 0.25.0 → 0.27.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 +28 -0
- package/dist/aform.js +1152 -991
- 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 +6 -1
- package/dist/src/utils/columns.d.ts +24 -0
- package/dist/src/utils/columns.d.ts.map +1 -0
- package/dist/src/utils/columns.js +33 -0
- package/package.json +5 -5
- package/src/components/AForm.vue +13 -4
- package/src/components/base/ExpandButton.vue +34 -0
- package/src/components/form/ASegmentedControl.vue +342 -0
- package/src/index.ts +7 -0
- package/src/utils/columns.ts +38 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="aform_form-element aform_segmented-control"
|
|
4
|
+
:class="{
|
|
5
|
+
'aform_segmented-control--xs': size === 'xs',
|
|
6
|
+
'aform_segmented-control--sm': size === 'sm',
|
|
7
|
+
'aform_segmented-control--equal': equal,
|
|
8
|
+
}">
|
|
9
|
+
<template v-if="mode === 'display'">
|
|
10
|
+
<label class="aform_field-label" :class="{ 'aform_segmented-sr-label': hideLabel }">{{ label }}</label>
|
|
11
|
+
<ABadge
|
|
12
|
+
v-if="displayBadge"
|
|
13
|
+
class="aform_display-value aform_segmented-display-badge"
|
|
14
|
+
:value="displayBadgeValue"
|
|
15
|
+
:options="options"
|
|
16
|
+
presentation="input-accent" />
|
|
17
|
+
<span v-else class="aform_display-value">{{ displayText }}</span>
|
|
18
|
+
</template>
|
|
19
|
+
<template v-else>
|
|
20
|
+
<label :id="labelId" class="aform_field-label" :class="{ 'aform_segmented-sr-label': hideLabel }">
|
|
21
|
+
{{ label }}
|
|
22
|
+
</label>
|
|
23
|
+
<div
|
|
24
|
+
:role="multiple ? 'group' : 'radiogroup'"
|
|
25
|
+
class="aform_segmented-track"
|
|
26
|
+
:aria-labelledby="groupAriaLabel ? undefined : labelId"
|
|
27
|
+
:aria-label="groupAriaLabel"
|
|
28
|
+
:aria-busy="busy || undefined">
|
|
29
|
+
<label
|
|
30
|
+
v-for="choice in choices"
|
|
31
|
+
:key="choice"
|
|
32
|
+
class="aform_segmented-segment"
|
|
33
|
+
:class="segmentClasses(choice)"
|
|
34
|
+
:style="segmentStyle(choice)"
|
|
35
|
+
:data-segment-value="choice">
|
|
36
|
+
<input
|
|
37
|
+
v-if="multiple"
|
|
38
|
+
v-model="selectedMany"
|
|
39
|
+
type="checkbox"
|
|
40
|
+
class="aform_segmented-input"
|
|
41
|
+
:name="groupName"
|
|
42
|
+
:value="choice"
|
|
43
|
+
:disabled="mode === 'read'" />
|
|
44
|
+
<input
|
|
45
|
+
v-else
|
|
46
|
+
v-model="selected"
|
|
47
|
+
type="radio"
|
|
48
|
+
class="aform_segmented-input"
|
|
49
|
+
:name="groupName"
|
|
50
|
+
:value="choice"
|
|
51
|
+
:disabled="mode === 'read'" />
|
|
52
|
+
<span class="aform_segmented-label">{{ segmentLabel(choice) }}</span>
|
|
53
|
+
</label>
|
|
54
|
+
</div>
|
|
55
|
+
<p v-show="errorText" class="aform_error" v-html="errorText"></p>
|
|
56
|
+
</template>
|
|
57
|
+
</div>
|
|
58
|
+
</template>
|
|
59
|
+
|
|
60
|
+
<script setup lang="ts">
|
|
61
|
+
import type { BadgeVariant, FieldOptions } from '@stonecrop/schema'
|
|
62
|
+
import { hasBadgeOptions, lookupBadge, selectChoices } from '@stonecrop/schema'
|
|
63
|
+
import { computed, useId, type CSSProperties } from 'vue'
|
|
64
|
+
|
|
65
|
+
import type { ComponentProps } from '../../types'
|
|
66
|
+
import ABadge from './ABadge.vue'
|
|
67
|
+
|
|
68
|
+
const {
|
|
69
|
+
label,
|
|
70
|
+
options = undefined,
|
|
71
|
+
mode,
|
|
72
|
+
uuid,
|
|
73
|
+
errors,
|
|
74
|
+
validation = { errorMessage: '' },
|
|
75
|
+
size = 'sm',
|
|
76
|
+
equal = false,
|
|
77
|
+
hideLabel = false,
|
|
78
|
+
ariaLabel = undefined,
|
|
79
|
+
busy = false,
|
|
80
|
+
multiple = false,
|
|
81
|
+
} = defineProps<
|
|
82
|
+
ComponentProps & {
|
|
83
|
+
options?: FieldOptions
|
|
84
|
+
size?: 'xs' | 'sm'
|
|
85
|
+
equal?: boolean
|
|
86
|
+
hideLabel?: boolean
|
|
87
|
+
ariaLabel?: string
|
|
88
|
+
busy?: boolean
|
|
89
|
+
multiple?: boolean
|
|
90
|
+
}
|
|
91
|
+
>()
|
|
92
|
+
|
|
93
|
+
const selected = defineModel<string | string[]>({ default: '' })
|
|
94
|
+
|
|
95
|
+
const selectedMany = computed({
|
|
96
|
+
get: () => (Array.isArray(selected.value) ? selected.value : []),
|
|
97
|
+
set: (value: string[]) => {
|
|
98
|
+
selected.value = value
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const errorText = computed(() => (errors?.length ? errors.join('; ') : (validation.errorMessage ?? '')))
|
|
103
|
+
|
|
104
|
+
const fallbackId = useId()
|
|
105
|
+
const groupName = computed(() => uuid ?? `aform-segmented-${fallbackId}`)
|
|
106
|
+
const labelId = computed(() => `${groupName.value}-label`)
|
|
107
|
+
|
|
108
|
+
// aria-labelledby outranks aria-label in the accessible-name algorithm, so the two are mutually
|
|
109
|
+
// exclusive here: emitting both would silently leave `ariaLabel` inert. It only applies while the
|
|
110
|
+
// label element is visually hidden — overriding a *visible* label would break WCAG 2.5.3.
|
|
111
|
+
const groupAriaLabel = computed(() => (hideLabel ? ariaLabel : undefined))
|
|
112
|
+
|
|
113
|
+
const choices = computed(() => selectChoices(options))
|
|
114
|
+
const badgeOptions = computed(() => hasBadgeOptions(options))
|
|
115
|
+
|
|
116
|
+
function segmentLabel(choice: string): string {
|
|
117
|
+
return lookupBadge(options, choice)?.label ?? choice
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function segmentVariant(choice: string): BadgeVariant {
|
|
121
|
+
return lookupBadge(options, choice)?.variant ?? 'neutral'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isChoiceSelected(choice: string): boolean {
|
|
125
|
+
if (multiple) return selectedMany.value.includes(choice)
|
|
126
|
+
return selected.value === choice
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function segmentClasses(choice: string): Record<string, boolean> {
|
|
130
|
+
if (!badgeOptions.value) return {}
|
|
131
|
+
return {
|
|
132
|
+
'aform_segmented-segment--badge': true,
|
|
133
|
+
[`aform_segmented-segment--${segmentVariant(choice)}`]: isChoiceSelected(choice),
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function segmentStyle(choice: string): CSSProperties {
|
|
138
|
+
if (!badgeOptions.value || !isChoiceSelected(choice)) return {}
|
|
139
|
+
const color = lookupBadge(options, choice)?.color
|
|
140
|
+
if (!color) return {}
|
|
141
|
+
return {
|
|
142
|
+
'--sc-badge-bg': color,
|
|
143
|
+
'--sc-badge-text': color,
|
|
144
|
+
} as CSSProperties
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const displayText = computed(() => {
|
|
148
|
+
if (multiple) {
|
|
149
|
+
return selectedMany.value.map(segmentLabel).filter(Boolean).join(', ')
|
|
150
|
+
}
|
|
151
|
+
const value = selected.value
|
|
152
|
+
if (typeof value !== 'string' || value === '') return ''
|
|
153
|
+
return segmentLabel(value)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const displayBadge = computed(() => {
|
|
157
|
+
if (!badgeOptions.value) return false
|
|
158
|
+
if (multiple) return selectedMany.value.length === 1
|
|
159
|
+
return typeof selected.value === 'string' && selected.value !== ''
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const displayBadgeValue = computed(() => {
|
|
163
|
+
if (multiple) return selectedMany.value[0] ?? ''
|
|
164
|
+
return typeof selected.value === 'string' ? selected.value : ''
|
|
165
|
+
})
|
|
166
|
+
</script>
|
|
167
|
+
|
|
168
|
+
<style scoped>
|
|
169
|
+
.aform_segmented-control--xs.aform_form-element {
|
|
170
|
+
min-width: 0;
|
|
171
|
+
padding-top: 0;
|
|
172
|
+
max-width: none;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.aform_segmented-control--xs:not(:has(.aform_segmented-sr-label)) {
|
|
176
|
+
padding-top: 0.5rem;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.aform_segmented-sr-label {
|
|
180
|
+
position: absolute;
|
|
181
|
+
width: 1px;
|
|
182
|
+
height: 1px;
|
|
183
|
+
padding: 0;
|
|
184
|
+
margin: -1px;
|
|
185
|
+
overflow: hidden;
|
|
186
|
+
clip: rect(0, 0, 0, 0);
|
|
187
|
+
white-space: nowrap;
|
|
188
|
+
border: 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.aform_segmented-track {
|
|
192
|
+
display: inline-flex;
|
|
193
|
+
flex-wrap: wrap;
|
|
194
|
+
align-items: stretch;
|
|
195
|
+
gap: 0.125rem;
|
|
196
|
+
padding: 0.125rem;
|
|
197
|
+
width: auto;
|
|
198
|
+
min-width: 0;
|
|
199
|
+
min-height: 2rem;
|
|
200
|
+
outline: 1px solid var(--sc-input-border-color);
|
|
201
|
+
outline-offset: -1px;
|
|
202
|
+
border-radius: 0;
|
|
203
|
+
box-sizing: border-box;
|
|
204
|
+
background: var(--sc-input-field-disabled-background);
|
|
205
|
+
font-family: var(--sc-font-family);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.aform_segmented-control--xs .aform_segmented-track {
|
|
209
|
+
min-height: 1.5rem;
|
|
210
|
+
font-size: 0.875rem;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.aform_segmented-control--equal .aform_segmented-track {
|
|
214
|
+
display: flex;
|
|
215
|
+
width: 100%;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.aform_segmented-segment {
|
|
219
|
+
position: relative;
|
|
220
|
+
display: inline-flex;
|
|
221
|
+
align-items: center;
|
|
222
|
+
justify-content: center;
|
|
223
|
+
flex: none;
|
|
224
|
+
margin: 0;
|
|
225
|
+
padding: 0.125rem 0.5rem;
|
|
226
|
+
min-height: 1.5rem;
|
|
227
|
+
border-radius: 0;
|
|
228
|
+
color: var(--sc-input-label-color);
|
|
229
|
+
font-family: inherit;
|
|
230
|
+
font-size: inherit;
|
|
231
|
+
font-weight: 400;
|
|
232
|
+
line-height: 1.25;
|
|
233
|
+
cursor: pointer;
|
|
234
|
+
user-select: none;
|
|
235
|
+
box-sizing: border-box;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.aform_segmented-control--sm .aform_segmented-segment {
|
|
239
|
+
min-height: 1.75rem;
|
|
240
|
+
padding: 0.125rem 0.75rem;
|
|
241
|
+
font-size: var(--sc-font-size);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.aform_segmented-control--equal .aform_segmented-segment {
|
|
245
|
+
flex: 1 1 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.aform_segmented-input {
|
|
249
|
+
position: absolute;
|
|
250
|
+
inline-size: 1px;
|
|
251
|
+
block-size: 1px;
|
|
252
|
+
padding: 0;
|
|
253
|
+
margin: -1px;
|
|
254
|
+
overflow: hidden;
|
|
255
|
+
clip: rect(0, 0, 0, 0);
|
|
256
|
+
white-space: nowrap;
|
|
257
|
+
border: 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.aform_segmented-label {
|
|
261
|
+
pointer-events: none;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.aform_segmented-segment:not(.aform_segmented-segment--badge):has(.aform_segmented-input:checked) {
|
|
265
|
+
background: var(--sc-input-field-background);
|
|
266
|
+
color: var(--sc-cell-text-color);
|
|
267
|
+
font-weight: 500;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.aform_segmented-segment--badge:has(.aform_segmented-input:checked) {
|
|
271
|
+
font-weight: 500;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.aform_segmented-segment--badge:has(.aform_segmented-input:checked).aform_segmented-segment--neutral {
|
|
275
|
+
background: var(--sc-badge-neutral-bg);
|
|
276
|
+
color: var(--sc-badge-neutral-text);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.aform_segmented-segment--badge:has(.aform_segmented-input:checked).aform_segmented-segment--success {
|
|
280
|
+
background: var(--sc-badge-success-bg);
|
|
281
|
+
color: var(--sc-badge-success-text);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.aform_segmented-segment--badge:has(.aform_segmented-input:checked).aform_segmented-segment--warning {
|
|
285
|
+
background: var(--sc-badge-warning-bg);
|
|
286
|
+
color: var(--sc-badge-warning-text);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.aform_segmented-segment--badge:has(.aform_segmented-input:checked).aform_segmented-segment--danger {
|
|
290
|
+
background: var(--sc-badge-danger-bg);
|
|
291
|
+
color: var(--sc-badge-danger-text);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.aform_segmented-segment--badge:has(.aform_segmented-input:checked).aform_segmented-segment--brand {
|
|
295
|
+
background: var(--sc-badge-brand-bg);
|
|
296
|
+
color: var(--sc-badge-brand-text);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.aform_segmented-segment--badge:has(.aform_segmented-input:checked)[style*='--sc-badge-bg'] {
|
|
300
|
+
background: var(--sc-badge-bg);
|
|
301
|
+
color: var(--sc-badge-text);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.aform_segmented-segment:has(.aform_segmented-input:focus-visible) {
|
|
305
|
+
outline: 1px solid var(--sc-input-active-border-color);
|
|
306
|
+
outline-offset: -1px;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.aform_segmented-segment:hover:has(.aform_segmented-input:not(:disabled):not(:checked)) {
|
|
310
|
+
background: color-mix(in srgb, var(--sc-input-field-background) 50%, transparent);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.aform_segmented-segment:has(.aform_segmented-input:disabled) {
|
|
314
|
+
cursor: not-allowed;
|
|
315
|
+
opacity: 0.5;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.aform_segmented-track:has(.aform_segmented-input:focus-visible) {
|
|
319
|
+
outline-color: var(--sc-input-active-border-color);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.aform_segmented-display-badge {
|
|
323
|
+
display: block;
|
|
324
|
+
padding: 0.5rem;
|
|
325
|
+
min-height: 2rem;
|
|
326
|
+
box-sizing: border-box;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
330
|
+
.aform_segmented-segment {
|
|
331
|
+
transition:
|
|
332
|
+
background-color 0.1s ease-out,
|
|
333
|
+
color 0.1s ease-out;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
@media (prefers-reduced-motion: reduce) {
|
|
338
|
+
.aform_segmented-segment {
|
|
339
|
+
transition: none;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
</style>
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type * from '@stonecrop/atable/types'
|
|
2
2
|
export { deserializeFunction } from './utils/deserialize'
|
|
3
3
|
export { badgeInputAccentStyle, resolveFieldBadge } from './utils/badge'
|
|
4
|
+
export { resolvedFieldsToColumns } from './utils/columns'
|
|
4
5
|
export type { BadgeFormatContext, BadgeFormatFn } from './utils/badge'
|
|
5
6
|
import { install as installATable } from '@stonecrop/atable'
|
|
6
7
|
import type { App } from 'vue'
|
|
@@ -9,6 +10,7 @@ import ACheckbox from './components/form/ACheckbox.vue'
|
|
|
9
10
|
import ACurrencyInput from './components/form/ACurrencyInput.vue'
|
|
10
11
|
import ADate from './components/form/ADate.vue'
|
|
11
12
|
import ADropdown from './components/form/ADropdown.vue'
|
|
13
|
+
import ASegmentedControl from './components/form/ASegmentedControl.vue'
|
|
12
14
|
import ABadge from './components/form/ABadge.vue'
|
|
13
15
|
import ADatePicker from './components/form/ADatePicker.vue'
|
|
14
16
|
import ADateTime from './components/form/ADateTime.vue'
|
|
@@ -26,6 +28,7 @@ import ATextInput from './components/form/ATextInput.vue'
|
|
|
26
28
|
import ATextboxInput from './components/form/ATextboxInput.vue'
|
|
27
29
|
import Login from './components/utilities/Login.vue'
|
|
28
30
|
import AFormLoading from './components/AFormLoading.vue'
|
|
31
|
+
import ExpandButton from './components/base/ExpandButton.vue'
|
|
29
32
|
|
|
30
33
|
export type * from './types'
|
|
31
34
|
|
|
@@ -41,6 +44,7 @@ function install(app: App /* options */) {
|
|
|
41
44
|
app.component('ACurrencyInput', ACurrencyInput)
|
|
42
45
|
app.component('ADate', ADate)
|
|
43
46
|
app.component('ADropdown', ADropdown)
|
|
47
|
+
app.component('ASegmentedControl', ASegmentedControl)
|
|
44
48
|
app.component('ABadge', ABadge)
|
|
45
49
|
app.component('ADatePicker', ADatePicker)
|
|
46
50
|
app.component('ADateTime', ADateTime)
|
|
@@ -57,6 +61,7 @@ function install(app: App /* options */) {
|
|
|
57
61
|
app.component('ATextboxInput', ATextboxInput)
|
|
58
62
|
app.component('ADuration', ADuration)
|
|
59
63
|
app.component('AFormLoading', AFormLoading)
|
|
64
|
+
app.component('ExpandButton', ExpandButton)
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
export {
|
|
@@ -64,6 +69,7 @@ export {
|
|
|
64
69
|
ACurrencyInput,
|
|
65
70
|
ADate,
|
|
66
71
|
ADropdown,
|
|
72
|
+
ASegmentedControl,
|
|
67
73
|
ABadge,
|
|
68
74
|
ADatePicker,
|
|
69
75
|
ADateRange,
|
|
@@ -81,5 +87,6 @@ export {
|
|
|
81
87
|
ATextboxInput,
|
|
82
88
|
Login,
|
|
83
89
|
AFormLoading,
|
|
90
|
+
ExpandButton,
|
|
84
91
|
install,
|
|
85
92
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ColumnSchema } from '@stonecrop/schema'
|
|
2
|
+
|
|
3
|
+
import type { ResolvedField } from '../types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The resolved fields that can be columns in a list or table view, in declaration order.
|
|
7
|
+
*
|
|
8
|
+
* A cell renders one value, so only `kind: 'field'` qualifies. The other three kinds are
|
|
9
|
+
* containers: a `fieldset` groups fields for layout and has no value of its own, while a `link`
|
|
10
|
+
* and a `table` hold a nested record and an array of them. Neither has a `cellComponent`, so a
|
|
11
|
+
* container reaching `ACell` falls through to plain-text rendering and stringifies its children —
|
|
12
|
+
* no error and no log, just a wrong column.
|
|
13
|
+
*
|
|
14
|
+
* A fieldset's *children* are real columns, so it is flattened rather than dropped; losing them is
|
|
15
|
+
* the same silent defect in the other direction.
|
|
16
|
+
*
|
|
17
|
+
* One definition, called by both consumers: `Registry.buildTableConfig` (a child table's columns,
|
|
18
|
+
* from its target's resolved schema) and Desktop's records list. Re-deriving it at either call
|
|
19
|
+
* site produced exactly one of the two failures above at each.
|
|
20
|
+
*
|
|
21
|
+
* @param fields - resolved fields, as produced by `resolveSchema`
|
|
22
|
+
* @returns column definitions, with the `kind` discriminator stripped
|
|
23
|
+
* @public
|
|
24
|
+
*/
|
|
25
|
+
export function resolvedFieldsToColumns(fields: readonly ResolvedField[]): ColumnSchema[] {
|
|
26
|
+
const columns: ColumnSchema[] = []
|
|
27
|
+
|
|
28
|
+
for (const field of fields) {
|
|
29
|
+
if (field.kind === 'fieldset') {
|
|
30
|
+
columns.push(...resolvedFieldsToColumns(field.schema))
|
|
31
|
+
} else if (field.kind === 'field') {
|
|
32
|
+
const { kind: _kind, ...column } = field
|
|
33
|
+
columns.push(column)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return columns
|
|
38
|
+
}
|