@bagelink/vue 1.15.166 → 1.15.170
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/components/Badge.vue.d.ts +5 -3
- package/dist/components/Badge.vue.d.ts.map +1 -1
- package/dist/components/BglVideo.vue.d.ts +2 -0
- package/dist/components/BglVideo.vue.d.ts.map +1 -1
- package/dist/components/Btn.vue.d.ts.map +1 -1
- package/dist/components/RouterWrapper.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/ArrayInput.vue.d.ts +2 -0
- package/dist/components/form/inputs/ArrayInput.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/RadioGroup.vue.d.ts +1 -0
- package/dist/components/form/inputs/RadioGroup.vue.d.ts.map +1 -1
- package/dist/composables/index.d.ts +1 -0
- package/dist/composables/index.d.ts.map +1 -1
- package/dist/composables/usePrefersReducedMotion.d.ts +3 -0
- package/dist/composables/usePrefersReducedMotion.d.ts.map +1 -0
- package/dist/dialog/useDialog.d.ts.map +1 -1
- package/dist/form-flow/FormFlow.vue.d.ts.map +1 -1
- package/dist/index.cjs +177 -150
- package/dist/index.mjs +36089 -42101
- package/dist/style.css +1 -2
- package/package.json +1 -1
- package/src/components/Badge.vue +55 -19
- package/src/components/BglVideo.vue +4 -1
- package/src/components/Btn.vue +4 -3
- package/src/components/RouterWrapper.vue +21 -1
- package/src/components/form/inputs/ArrayInput.vue +6 -2
- package/src/components/form/inputs/RadioGroup.vue +18 -2
- package/src/composables/index.ts +1 -0
- package/src/composables/usePrefersReducedMotion.ts +24 -0
- package/src/dialog/useDialog.ts +8 -1
- package/src/form-flow/FormFlow.vue +52 -7
- package/src/styles/appearance.css +8 -0
- package/src/styles/text.css +232 -0
package/package.json
CHANGED
package/src/components/Badge.vue
CHANGED
|
@@ -53,14 +53,16 @@ const props = defineProps<{
|
|
|
53
53
|
round?: boolean
|
|
54
54
|
btn?: BtnProp
|
|
55
55
|
btnEnd?: BtnProp
|
|
56
|
-
size?: 'sm' | 'md' | 'lg'
|
|
57
|
-
/** Boolean size shorthands: <Badge sm value="New" /> */
|
|
56
|
+
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
|
57
|
+
/** Boolean size shorthands: <Badge sm value="New" /> — xs · sm · md · lg · xl */
|
|
58
|
+
xs?: boolean
|
|
58
59
|
sm?: boolean
|
|
59
60
|
lg?: boolean
|
|
61
|
+
xl?: boolean
|
|
60
62
|
/** Mobile-only size override (≤ 910px). Lets a `lg` desktop badge shrink on
|
|
61
63
|
phones so it doesn't overflow / touch neighbours. e.g.
|
|
62
64
|
<Badge lg mobile-size="sm" />. Mirrors `Icon`'s `mobileSize` prop. */
|
|
63
|
-
mobileSize?: 'sm' | 'md' | 'lg'
|
|
65
|
+
mobileSize?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
|
64
66
|
uppercase?: boolean
|
|
65
67
|
}>()
|
|
66
68
|
|
|
@@ -91,8 +93,10 @@ const computedPairClass = computed(() => {
|
|
|
91
93
|
|
|
92
94
|
const computedSize = computed(() => {
|
|
93
95
|
if (props.size) { return props.size }
|
|
96
|
+
if (props.xs) { return 'xs' }
|
|
94
97
|
if (props.sm) { return 'sm' }
|
|
95
98
|
if (props.lg) { return 'lg' }
|
|
99
|
+
if (props.xl) { return 'xl' }
|
|
96
100
|
return 'md'
|
|
97
101
|
})
|
|
98
102
|
|
|
@@ -122,11 +126,15 @@ const computedClasses = computed(() => {
|
|
|
122
126
|
'soft': computedVariant.value === 'soft',
|
|
123
127
|
'frost': computedVariant.value === 'frost',
|
|
124
128
|
'gradient': computedVariant.value === 'gradient',
|
|
125
|
-
'
|
|
129
|
+
'pillXSmall': computedSize.value === 'xs',
|
|
126
130
|
'pillSmall': computedSize.value === 'sm',
|
|
127
|
-
'
|
|
128
|
-
'
|
|
131
|
+
'pillLarge': computedSize.value === 'lg',
|
|
132
|
+
'pillXLarge': computedSize.value === 'xl',
|
|
133
|
+
'm_pillXSmall': computedMobileSize.value === 'xs',
|
|
129
134
|
'm_pillSmall': computedMobileSize.value === 'sm',
|
|
135
|
+
'm_pillMd': computedMobileSize.value === 'md',
|
|
136
|
+
'm_pillLarge': computedMobileSize.value === 'lg',
|
|
137
|
+
'm_pillXLarge': computedMobileSize.value === 'xl',
|
|
130
138
|
'bgl_pill-flush': props.flush,
|
|
131
139
|
}
|
|
132
140
|
|
|
@@ -146,7 +154,7 @@ const computedClasses = computed(() => {
|
|
|
146
154
|
>
|
|
147
155
|
<div class="flex h-100 justify-content-center relative ">
|
|
148
156
|
<div v-if="loading" class="loading absolute inset-0 mx-auto" />
|
|
149
|
-
<div class="px-025 flex gap-025 justify-content-center align-items-center" :class="{ 'opacity-0': loading }">
|
|
157
|
+
<div class="px-025 flex gap-025 justify-content-center align-items-center" :class="{ 'opacity-0': loading }" style="font-size: var(--bgl-pill-font-size)">
|
|
150
158
|
<Btn v-if="btn" class="bgl_pill-btn -ms-025" icon-size="0.8" round v-bind="btn" />
|
|
151
159
|
<span v-if="dot" class="bgl_pill-dot" />
|
|
152
160
|
<Icon v-if="icon" class="line-height-0" :icon="icon" style="font-size: var(--bgl-pill-font-size)" />
|
|
@@ -165,30 +173,58 @@ const computedClasses = computed(() => {
|
|
|
165
173
|
|
|
166
174
|
<style scoped>
|
|
167
175
|
|
|
168
|
-
.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
.pillSmall{
|
|
176
|
+
/* Size scale (xs → xl). Each size drives font-size, height, and corner radius
|
|
177
|
+
together so the pill scales naturally; padding derives from font-size on
|
|
178
|
+
.bgl_pill, so it follows along. `md` is the base (theme.css defaults). */
|
|
179
|
+
.pillXSmall {
|
|
173
180
|
--bgl-pill-font-size: 9px;
|
|
174
|
-
--bgl-pill-height:15px;
|
|
181
|
+
--bgl-pill-height: 15px;
|
|
182
|
+
--bgl-pill-border-radius: 5px;
|
|
183
|
+
}
|
|
184
|
+
.pillSmall {
|
|
185
|
+
--bgl-pill-font-size: 10px;
|
|
186
|
+
--bgl-pill-height: 17px;
|
|
187
|
+
--bgl-pill-border-radius: 6px;
|
|
188
|
+
}
|
|
189
|
+
.pillLarge {
|
|
190
|
+
--bgl-pill-font-size: 16px;
|
|
191
|
+
--bgl-pill-height: 28px;
|
|
192
|
+
--bgl-pill-border-radius: 10px;
|
|
193
|
+
}
|
|
194
|
+
.pillXLarge {
|
|
195
|
+
--bgl-pill-font-size: 18px;
|
|
196
|
+
--bgl-pill-height: 34px;
|
|
197
|
+
--bgl-pill-border-radius: 12px;
|
|
175
198
|
}
|
|
176
199
|
|
|
177
200
|
/* Mobile-only size overrides (≤ 910px). These win over the desktop size class
|
|
178
201
|
because the size is driven entirely by --bgl-pill-font-size / --bgl-pill-height,
|
|
179
202
|
and a later @media rule with equal specificity overrides the base class. */
|
|
180
203
|
@media screen and (max-width: 910px) {
|
|
181
|
-
.
|
|
182
|
-
--bgl-pill-font-size:
|
|
183
|
-
--bgl-pill-height:
|
|
204
|
+
.m_pillXSmall {
|
|
205
|
+
--bgl-pill-font-size: 9px;
|
|
206
|
+
--bgl-pill-height: 15px;
|
|
207
|
+
--bgl-pill-border-radius: 5px;
|
|
208
|
+
}
|
|
209
|
+
.m_pillSmall {
|
|
210
|
+
--bgl-pill-font-size: 10px;
|
|
211
|
+
--bgl-pill-height: 17px;
|
|
212
|
+
--bgl-pill-border-radius: 6px;
|
|
184
213
|
}
|
|
185
214
|
.m_pillMd {
|
|
186
215
|
--bgl-pill-font-size: 12px;
|
|
187
216
|
--bgl-pill-height: 20px;
|
|
217
|
+
--bgl-pill-border-radius: 8px;
|
|
188
218
|
}
|
|
189
|
-
.
|
|
190
|
-
--bgl-pill-font-size:
|
|
191
|
-
--bgl-pill-height:
|
|
219
|
+
.m_pillLarge {
|
|
220
|
+
--bgl-pill-font-size: 16px;
|
|
221
|
+
--bgl-pill-height: 28px;
|
|
222
|
+
--bgl-pill-border-radius: 10px;
|
|
223
|
+
}
|
|
224
|
+
.m_pillXLarge {
|
|
225
|
+
--bgl-pill-font-size: 18px;
|
|
226
|
+
--bgl-pill-height: 34px;
|
|
227
|
+
--bgl-pill-border-radius: 12px;
|
|
192
228
|
}
|
|
193
229
|
}
|
|
194
230
|
.pillText{
|
|
@@ -12,6 +12,8 @@ interface Props {
|
|
|
12
12
|
loop?: boolean
|
|
13
13
|
status?: string
|
|
14
14
|
playsinline?: boolean
|
|
15
|
+
/** Accessible name for embed iframes / native video */
|
|
16
|
+
title?: string
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
const props = defineProps<Props>()
|
|
@@ -108,13 +110,14 @@ const videoUrl = computed(() => {
|
|
|
108
110
|
<template>
|
|
109
111
|
<div ref="videoContainer" class="bgl_vid" :class="{ vid_empty: !src, vid_short: isYoutubeShort }">
|
|
110
112
|
<iframe
|
|
111
|
-
v-if="embedType" :src="videoUrl" :style="{ aspectRatio }" frameborder="0" title="Video"
|
|
113
|
+
v-if="embedType" :src="videoUrl" :style="{ aspectRatio }" frameborder="0" :title="title || 'Video'"
|
|
112
114
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
113
115
|
referrerpolicy="strict-origin-when-cross-origin" allowfullscreen
|
|
114
116
|
/>
|
|
115
117
|
<video
|
|
116
118
|
v-else-if="src" ref="video" :autoplay="autoplay === true" :muted="mute" :loop="loop"
|
|
117
119
|
:style="{ aspectRatio }" :controls="controls" :playsinline="playsinline"
|
|
120
|
+
:aria-label="title || undefined"
|
|
118
121
|
>
|
|
119
122
|
<source :src="src" :type="`video/${videoFormat}`">
|
|
120
123
|
</video>
|
package/src/components/Btn.vue
CHANGED
|
@@ -81,6 +81,8 @@ const emit = defineEmits<{
|
|
|
81
81
|
}>()
|
|
82
82
|
|
|
83
83
|
const { $t } = useI18n()
|
|
84
|
+
// Must run in setup — inject() cannot be called from click handlers.
|
|
85
|
+
const dialog = useDialog()
|
|
84
86
|
|
|
85
87
|
const { isGradient, gradientStyle } = useGradientVariant({
|
|
86
88
|
gradient: () => props.gradient,
|
|
@@ -152,11 +154,10 @@ onUnmounted(() => {
|
|
|
152
154
|
|
|
153
155
|
async function handleClick(event: MouseEvent) {
|
|
154
156
|
if (props.confirm !== undefined && props.confirm !== false) {
|
|
155
|
-
const { confirm } = useDialog()
|
|
156
157
|
const message = typeof props.confirm === 'string' ? props.confirm : $t('modalConfirm.message')
|
|
157
|
-
const confirmed = await confirm({
|
|
158
|
+
const confirmed = await dialog.confirm({
|
|
158
159
|
title: $t('modalConfirm.title'),
|
|
159
|
-
message
|
|
160
|
+
message,
|
|
160
161
|
})
|
|
161
162
|
|
|
162
163
|
if (!confirmed) { return }
|
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
|
2
3
|
import { nextTick, ref, watch } from 'vue'
|
|
3
4
|
import { useRoute } from 'vue-router'
|
|
4
5
|
import Loading from './Loading.vue'
|
|
5
6
|
|
|
7
|
+
// Key used for the routed component + its transition. The key IS the identity of
|
|
8
|
+
// the mounted view: it changes only when we actually switch to a different view,
|
|
9
|
+
// so we remount + animate on real view changes but NOT on same-view param/query
|
|
10
|
+
// changes (e.g. /ai/:chatId → /ai/new, or /user/1 → /user/2 — params drive
|
|
11
|
+
// reactive updates inside the component, not a remount).
|
|
12
|
+
//
|
|
13
|
+
// The identity of a view is the deepest matched route record's static `path`
|
|
14
|
+
// (which component is mounted), independent of its dynamic params. A parent can
|
|
15
|
+
// still opt to stay mounted while a nested <RouterView> swaps its children by
|
|
16
|
+
// setting `meta.persistOnChildNav: true`: the key then resolves to that record's
|
|
17
|
+
// path so the parent shell isn't remounted on child navigation.
|
|
18
|
+
function viewKey(route: RouteLocationNormalizedLoaded): string {
|
|
19
|
+
const persistRecord = [...route.matched].reverse().find(r => r.meta?.persistOnChildNav)
|
|
20
|
+
if (persistRecord) return persistRecord.path
|
|
21
|
+
// Default: the deepest matched record — i.e. the routed component's identity.
|
|
22
|
+
const leaf = route.matched[route.matched.length - 1]
|
|
23
|
+
return leaf?.path ?? route.path
|
|
24
|
+
}
|
|
25
|
+
|
|
6
26
|
// Reset the scroll position on every route change. The app's scroll container is
|
|
7
27
|
// the inner `.pageContent` element in AppContent (the window itself doesn't
|
|
8
28
|
// scroll), so a plain vue-router `scrollBehavior` wouldn't help. We scroll the
|
|
@@ -32,7 +52,7 @@ watch(
|
|
|
32
52
|
</div>
|
|
33
53
|
</slot>
|
|
34
54
|
<transition v-else :duration="300" name="slide-fade" mode="out-in" appear>
|
|
35
|
-
<component :is="Component" :key="route
|
|
55
|
+
<component :is="Component" :key="viewKey(route)" />
|
|
36
56
|
</transition>
|
|
37
57
|
</RouterView>
|
|
38
58
|
</template>
|
|
@@ -21,6 +21,8 @@ const props = defineProps<{
|
|
|
21
21
|
itemLabel?: string
|
|
22
22
|
collapsible?: boolean
|
|
23
23
|
simple?: boolean
|
|
24
|
+
/** Factory for new rows. Prefer this over pushing `undefined` (which becomes `null` after JSON sync). */
|
|
25
|
+
createItem?: () => T
|
|
24
26
|
} & BagelInputShellProps>()
|
|
25
27
|
|
|
26
28
|
const { shellClass, shellStyle } = useBagelInputShell(props)
|
|
@@ -96,8 +98,10 @@ watch(
|
|
|
96
98
|
)
|
|
97
99
|
|
|
98
100
|
function addItem() {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
+
const next = props.createItem ? props.createItem() : ({} as T)
|
|
102
|
+
items.value.push(next)
|
|
103
|
+
// Open the new row so nested FormFlow mounts and the user can edit immediately.
|
|
104
|
+
minimizedItems.value.push(false)
|
|
101
105
|
updateModel()
|
|
102
106
|
}
|
|
103
107
|
|
|
@@ -45,6 +45,7 @@ const props = withDefaults(
|
|
|
45
45
|
textColor?: string
|
|
46
46
|
textAlign?: 'left' | 'center' | 'right'
|
|
47
47
|
wrapperClass?: string
|
|
48
|
+
inline?: boolean
|
|
48
49
|
} & BagelInputShellProps>(),
|
|
49
50
|
{
|
|
50
51
|
align: 'center'
|
|
@@ -143,9 +144,9 @@ function handleChange() {
|
|
|
143
144
|
<p v-if="label" :id="`${name}-label`" class="group-label">
|
|
144
145
|
{{ resolveI18n(label) }} <span v-if="required">*</span>
|
|
145
146
|
</p>
|
|
146
|
-
<div class="radio-group-wrap" :class="wrapperClass">
|
|
147
|
+
<div class="radio-group-wrap" :class="[wrapperClass, { 'radio-group-wrap--inline': inline }]">
|
|
147
148
|
<label v-for="(opt, index) in visibleOptions" :key="opt.id || `${name}-${index}`" class="border rounded flex active-list-item hover mb-05" :for="opt.id || `${name}-${index}`"
|
|
148
|
-
:class="{ 'p-05 gap-025': thin, 'py-1 gap-075': !thin, 'ps-05': !hideRadio, 'bg-gray-light': !bgColor && !flat, 'align-items-start': align === 'start' || align === 'top', 'align-items-center': align === 'center', 'align-items-end': align === 'end' || align === 'bottom', invertedActive, 'is-option-disabled': disabled || opt.disabled }"
|
|
149
|
+
:class="{ 'p-05 gap-025': thin, 'py-1 gap-075': !thin, 'ps-05': !hideRadio, 'px-1': hideRadio && !thin, 'bg-gray-light': !bgColor && !flat, 'align-items-start': align === 'start' || align === 'top', 'align-items-center': align === 'center', 'align-items-end': align === 'end' || align === 'bottom', invertedActive, 'is-option-disabled': disabled || opt.disabled }"
|
|
149
150
|
:style="{ backgroundColor: bgColor, borderColor }">
|
|
150
151
|
<input :id="opt.id || `${name}-${index}`" v-model="selectedOption" :disabled="disabled || opt.disabled" :tabindex="optionTabindex(opt, index)" class="radio-input-list" type="radio" :name :value="opt.value" :required="required" :class="{
|
|
151
152
|
'mt-025': align === 'start' || align === 'top',
|
|
@@ -200,6 +201,21 @@ accent-color: var(--bgl-input-label-active-color, var(--bgl-primary));
|
|
|
200
201
|
display: none;
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
.radio-group-wrap--inline {
|
|
205
|
+
display: flex;
|
|
206
|
+
flex-wrap: wrap;
|
|
207
|
+
gap: 0.5rem;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.radio-group-wrap--inline > label {
|
|
211
|
+
flex: 1 1 0;
|
|
212
|
+
margin-bottom: 0 !important;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.radio-group-wrap--inline > label > .w-100 {
|
|
216
|
+
width: auto;
|
|
217
|
+
}
|
|
218
|
+
|
|
203
219
|
.is-option-disabled {
|
|
204
220
|
opacity: 0.5;
|
|
205
221
|
cursor: not-allowed;
|
package/src/composables/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export { useGradientVariant } from './useGradientVariant'
|
|
|
13
13
|
export { useImageSrc } from './useImageSrc'
|
|
14
14
|
export { useLocalStore } from './useLocalStore'
|
|
15
15
|
export { usePolling } from './usePolling'
|
|
16
|
+
export { usePrefersReducedMotion } from './usePrefersReducedMotion'
|
|
16
17
|
export { useQuery } from './useQuery'
|
|
17
18
|
export { useResizableLayoutProvider } from './useResizableLayout'
|
|
18
19
|
export type { PanelConfig, PanelState, ResizableContext } from './useResizableLayout'
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { onMounted, onUnmounted, ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/** Whether the user requested reduced motion — use to disable autoplay, etc. */
|
|
4
|
+
export function usePrefersReducedMotion() {
|
|
5
|
+
const prefersReducedMotion = ref(false)
|
|
6
|
+
|
|
7
|
+
let mq: MediaQueryList | undefined
|
|
8
|
+
function onChange(e: MediaQueryListEvent) {
|
|
9
|
+
prefersReducedMotion.value = e.matches
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
onMounted(() => {
|
|
13
|
+
if (typeof window === 'undefined' || !window.matchMedia) return
|
|
14
|
+
mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
15
|
+
prefersReducedMotion.value = mq.matches
|
|
16
|
+
mq.addEventListener('change', onChange)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
onUnmounted(() => {
|
|
20
|
+
mq?.removeEventListener('change', onChange)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
return prefersReducedMotion
|
|
24
|
+
}
|
package/src/dialog/useDialog.ts
CHANGED
|
@@ -19,7 +19,14 @@ export const DialogSymbol: InjectionKey<DialogApi> = Symbol('dialog')
|
|
|
19
19
|
let globalDialogApi: DialogApi | null = null
|
|
20
20
|
|
|
21
21
|
export function useDialog(): DialogApi {
|
|
22
|
-
|
|
22
|
+
// Prefer inject when called inside setup(); fall back to the plugin singleton
|
|
23
|
+
// when inject is unavailable (e.g. legacy call sites outside setup).
|
|
24
|
+
let dialogApi: DialogApi | null = null
|
|
25
|
+
try {
|
|
26
|
+
dialogApi = inject(DialogSymbol, null)
|
|
27
|
+
} catch {
|
|
28
|
+
dialogApi = null
|
|
29
|
+
}
|
|
23
30
|
if (!dialogApi) {
|
|
24
31
|
if (globalDialogApi) {
|
|
25
32
|
return globalDialogApi
|
|
@@ -90,17 +90,52 @@ function setNestedValue(obj: any, path: string, value: any): void {
|
|
|
90
90
|
target[keys[keys.length - 1]] = value
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/** Deep-clone defaults so array/object defaults are never shared across form instances. */
|
|
94
|
+
function cloneDefault<V>(value: V): V {
|
|
95
|
+
if (value === undefined || value === null || typeof value !== 'object') return value
|
|
96
|
+
try {
|
|
97
|
+
return structuredClone(value)
|
|
98
|
+
} catch {
|
|
99
|
+
return JSON.parse(JSON.stringify(value)) as V
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
93
103
|
function applyDefaults(modelValue: T | undefined): T {
|
|
94
104
|
const data = { ...(modelValue ?? {}) } as T
|
|
95
105
|
for (const [key, field] of Object.entries(props.schema._fields)) {
|
|
96
106
|
const defaultValue = (field as FieldBuilder)._config.default
|
|
97
107
|
if (defaultValue !== undefined && getNestedValue(data, key) === undefined) {
|
|
98
|
-
setNestedValue(data, key, defaultValue)
|
|
108
|
+
setNestedValue(data, key, cloneDefault(defaultValue))
|
|
99
109
|
}
|
|
100
110
|
}
|
|
101
111
|
return data
|
|
102
112
|
}
|
|
103
113
|
|
|
114
|
+
/** Build a new array row from nested schema field defaults, or a clone of the array's sample default item. */
|
|
115
|
+
function createArrayItem(field: FieldBuilder): Record<string, unknown> {
|
|
116
|
+
const nested = field._config.schema as SchemaDefinition | undefined
|
|
117
|
+
if (nested && typeof (nested as any).getDefaults === 'function') {
|
|
118
|
+
const fromFields = cloneDefault((nested as any).getDefaults() as Record<string, unknown>)
|
|
119
|
+
if (fromFields && Object.keys(fromFields).length > 0) return fromFields
|
|
120
|
+
}
|
|
121
|
+
// Prefer a template from the array field's default sample (e.g. TeamGroupBlox).
|
|
122
|
+
const sample = field._config.default
|
|
123
|
+
if (Array.isArray(sample) && sample.length > 0 && sample[0] != null && typeof sample[0] === 'object') {
|
|
124
|
+
return cloneDefault(toRawPlain(sample[0] as Record<string, unknown>))
|
|
125
|
+
}
|
|
126
|
+
// Nested schema fields without defaults → empty plain object (not undefined/null).
|
|
127
|
+
return {}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Strip Vue proxies before clone (structuredClone rejects Proxy). */
|
|
131
|
+
function toRawPlain<V extends Record<string, unknown>>(value: V): V {
|
|
132
|
+
try {
|
|
133
|
+
return JSON.parse(JSON.stringify(value)) as V
|
|
134
|
+
} catch {
|
|
135
|
+
return { ...value }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
104
139
|
const formData = ref(applyDefaults(props.modelValue)) as any
|
|
105
140
|
const validationErrors = ref<Record<string, string>>({})
|
|
106
141
|
const hasSubmitted = ref(false)
|
|
@@ -116,7 +151,9 @@ watch(() => props.modelValue, (newVal) => {
|
|
|
116
151
|
|
|
117
152
|
watch(formData, (newVal) => {
|
|
118
153
|
isInternalUpdate = true
|
|
119
|
-
|
|
154
|
+
// Emit a plain clone so parent state never shares reactive refs with
|
|
155
|
+
// formData (shared refs make deep modelValue watches fight array pushes).
|
|
156
|
+
emit('update:modelValue', cloneDefault(newVal))
|
|
120
157
|
nextTick(() => { isInternalUpdate = false })
|
|
121
158
|
}, { deep: true })
|
|
122
159
|
|
|
@@ -288,10 +325,18 @@ function getFieldProps(field: FieldBuilder, key: string) {
|
|
|
288
325
|
}
|
|
289
326
|
|
|
290
327
|
if (field._type === 'array') {
|
|
328
|
+
// `default` / `schema` are form-schema metadata — never pass them through to ArrayInput
|
|
329
|
+
// (and the earlier `...field._config` spread may have already set them).
|
|
330
|
+
delete baseProps.default
|
|
331
|
+
delete baseProps.schema
|
|
332
|
+
delete baseProps.itemType
|
|
291
333
|
const configWithoutSchema = Object.fromEntries(
|
|
292
|
-
Object.entries(field._config).filter(([k]) => k !== 'schema' && k !== 'itemType')
|
|
334
|
+
Object.entries(field._config).filter(([k]) => k !== 'schema' && k !== 'itemType' && k !== 'default')
|
|
293
335
|
)
|
|
294
336
|
Object.assign(baseProps, configWithoutSchema)
|
|
337
|
+
// Re-apply binding after config spread so handlers never get overwritten.
|
|
338
|
+
baseProps.modelValue = getFieldValue(key)
|
|
339
|
+
baseProps['onUpdate:modelValue'] = (val: any) => { updateField(key, val, field) }
|
|
295
340
|
baseProps.allowAdd = baseProps.allowAdd ?? true
|
|
296
341
|
baseProps.allowDelete = baseProps.allowDelete ?? true
|
|
297
342
|
}
|
|
@@ -412,7 +457,7 @@ defineExpose({
|
|
|
412
457
|
>
|
|
413
458
|
<!-- Array field with nested schema -->
|
|
414
459
|
<template v-if="field._type === 'array' && field._config.schema?._isSchema">
|
|
415
|
-
<ArrayInput v-bind="getFieldProps(field, key)">
|
|
460
|
+
<ArrayInput v-bind="getFieldProps(field, key)" :create-item="() => createArrayItem(field)">
|
|
416
461
|
<template #default="{ item }">
|
|
417
462
|
<!-- @vue-ignore -->
|
|
418
463
|
<FormFlow
|
|
@@ -427,7 +472,7 @@ defineExpose({
|
|
|
427
472
|
|
|
428
473
|
<!-- Array field with primitive string item type e.g. $.array('Label', 'text') -->
|
|
429
474
|
<template v-else-if="field._type === 'array' && typeof field._config.schema === 'string'">
|
|
430
|
-
<ArrayInput v-bind="getFieldProps(field, key)">
|
|
475
|
+
<ArrayInput v-bind="getFieldProps(field, key)" :create-item="() => '' as any">
|
|
431
476
|
<template #default="{ item }">
|
|
432
477
|
<component
|
|
433
478
|
:is="getFieldComponent(field._config.schema as string)"
|
|
@@ -440,7 +485,7 @@ defineExpose({
|
|
|
440
485
|
|
|
441
486
|
<!-- Array field with custom component -->
|
|
442
487
|
<template v-else-if="field._type === 'array' && field._config.schema">
|
|
443
|
-
<ArrayInput v-bind="getFieldProps(field, key)">
|
|
488
|
+
<ArrayInput v-bind="getFieldProps(field, key)" :create-item="() => createArrayItem(field)">
|
|
444
489
|
<template #default="{ item }">
|
|
445
490
|
<component :is="resolveComponent(field._config.schema)" v-model="item.value" />
|
|
446
491
|
</template>
|
|
@@ -449,7 +494,7 @@ defineExpose({
|
|
|
449
494
|
|
|
450
495
|
<!-- Array field with primitive item type (e.g. from JSON Schema type: array with items: { type: string }) -->
|
|
451
496
|
<template v-else-if="field._type === 'array' && field._config.itemType">
|
|
452
|
-
<ArrayInput v-bind="getFieldProps(field, key)">
|
|
497
|
+
<ArrayInput v-bind="getFieldProps(field, key)" :create-item="() => '' as any">
|
|
453
498
|
<template #default="{ item }">
|
|
454
499
|
<component
|
|
455
500
|
:is="getFieldComponent(field._config.itemType)"
|
|
@@ -340,7 +340,15 @@
|
|
|
340
340
|
mask-image: linear-gradient(to right, transparent 0, black 24px, black calc(100% - 24px), transparent 100%);
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
.isolation-isolate {
|
|
344
|
+
isolation: isolate !important;
|
|
345
|
+
}
|
|
346
|
+
|
|
343
347
|
@media screen and (max-width: 910px) {
|
|
348
|
+
.m_isolation-isolate {
|
|
349
|
+
isolation: isolate !important;
|
|
350
|
+
}
|
|
351
|
+
|
|
344
352
|
.m_filters-mask {
|
|
345
353
|
-webkit-mask-image: linear-gradient(to right, transparent 0, black 24px, black calc(100% - 24px), transparent 100%);
|
|
346
354
|
mask-image: linear-gradient(to right, transparent 0, black 24px, black calc(100% - 24px), transparent 100%);
|