Skip to content

Commit 46afdfb

Browse files
committed
array of strings
1 parent 5f4c26d commit 46afdfb

File tree

5 files changed

+218
-4
lines changed

5 files changed

+218
-4
lines changed

playground/docus/content/authors/farnabaz.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ modules:
88
- studio
99
- content
1010
- mdc
11+
- hub
1112
---

src/app/src/components/form/FormPanelInput.vue

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ const inputType = computed(() => {
3636
}
3737
})
3838
39+
const isArrayType = computed(() => props.formItem.type === 'array')
40+
3941
// Initialize model value
4042
const model = ref(computeValue(props.formItem))
4143
@@ -46,7 +48,7 @@ watch(model, (newValue) => {
4648
}
4749
4850
form.value = applyValueById(form.value, props.formItem.id, newValue)
49-
})
51+
}, { deep: true })
5052
5153
// Watch for external form item changes
5254
watch(() => props.formItem, (newFormItem) => {
@@ -87,7 +89,13 @@ function computeValue(formItem: FormItem): unknown {
8789
label: 'text-xs font-medium tracking-tight',
8890
}"
8991
>
92+
<FormInputArray
93+
v-if="isArrayType"
94+
v-model="model"
95+
:form-item="formItem.arrayItemForm"
96+
/>
9097
<UInput
98+
v-else
9199
:id="formItem.id"
92100
v-model="model"
93101
:placeholder="placeholder"
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
<script setup lang="ts">
2+
import { titleCase } from 'scule'
3+
import type { FormItem } from '../../../types'
4+
import type { PropType } from 'vue'
5+
import { computed, nextTick, ref } from 'vue'
6+
7+
const props = defineProps({
8+
formItem: {
9+
type: Object as PropType<FormItem>,
10+
default: () => ({}),
11+
},
12+
})
13+
14+
const model = defineModel({ type: Array as PropType<unknown[]>, default: () => [] })
15+
16+
const itemsType = computed(() => props.formItem?.type)
17+
const itemsLabel = computed(() => titleCase(props.formItem?.title))
18+
19+
const activeIndex = ref<number | null>(null)
20+
const stringEditingValue = ref('')
21+
22+
// Computed items for display
23+
const items = computed(() => {
24+
return (model.value || []).map((item, index) => ({
25+
key: index,
26+
index,
27+
value: item,
28+
label: `${itemsLabel.value} ${index + 1}`,
29+
}))
30+
})
31+
32+
function addItem() {
33+
const newItem = itemsType.value === 'object' ? {} : ''
34+
35+
model.value = [...(model.value || []), newItem]
36+
37+
// Auto-focus new string item
38+
if (itemsType.value === 'string') {
39+
nextTick(() => {
40+
startStringEditing(model.value.length - 1)
41+
})
42+
}
43+
}
44+
45+
function deleteItem(index: number) {
46+
model.value = model.value.filter((_, i) => i !== index)
47+
if (activeIndex.value === index) {
48+
activeIndex.value = null
49+
}
50+
}
51+
52+
function startStringEditing(index: number, value?: unknown) {
53+
activeIndex.value = index
54+
stringEditingValue.value = String(value || '')
55+
}
56+
57+
function saveStringEditing() {
58+
if (activeIndex.value !== null) {
59+
model.value = model.value.map((item, i) =>
60+
i === activeIndex.value ? stringEditingValue.value : item,
61+
)
62+
}
63+
activeIndex.value = null
64+
stringEditingValue.value = ''
65+
}
66+
67+
function updateObjectItem(index: number, value: Record<string, unknown>) {
68+
model.value = model.value.map((item, i) => i === index ? value : item)
69+
}
70+
71+
function toggleExpand(index: number) {
72+
activeIndex.value = activeIndex.value === index ? null : index
73+
}
74+
</script>
75+
76+
<template>
77+
<div class="space-y-2">
78+
<!-- Array of Objects -->
79+
<template v-if="itemsType === 'object'">
80+
<div
81+
v-for="item in items"
82+
:key="item.index"
83+
class="group/item rounded-lg border border-default overflow-hidden"
84+
>
85+
<button
86+
type="button"
87+
class="flex items-center justify-between w-full px-3 py-2 text-left bg-elevated hover:bg-accented transition-colors"
88+
@click="toggleExpand(item.index)"
89+
>
90+
<div class="flex items-center gap-2">
91+
<div class="flex items-center justify-center size-4 rounded bg-muted transition-colors">
92+
<UIcon
93+
name="i-lucide-chevron-right"
94+
class="size-2.5 text-muted transition-transform duration-200"
95+
:class="{ 'rotate-90': activeIndex === item.index }"
96+
/>
97+
</div>
98+
<span class="text-xs font-medium text-highlighted tracking-tight">
99+
{{ item.label }}
100+
</span>
101+
</div>
102+
103+
<UButton
104+
variant="ghost"
105+
color="neutral"
106+
size="xs"
107+
icon="i-lucide-trash-2"
108+
class="opacity-0 group-hover/item:opacity-100 transition-opacity"
109+
aria-label="Delete item"
110+
@click.stop="deleteItem(item.index)"
111+
/>
112+
</button>
113+
114+
<div
115+
v-if="activeIndex === item.index"
116+
class="px-3 py-3 border-t border-default bg-default"
117+
>
118+
<FormInputObject
119+
:model-value="item.value"
120+
:children="formItem.children"
121+
@update:model-value="(v: Record<string, unknown>) => updateObjectItem(item.index, v)"
122+
/>
123+
</div>
124+
</div>
125+
</template>
126+
127+
<!-- Array of Strings -->
128+
<template v-else-if="itemsType === 'string'">
129+
<div class="flex flex-wrap items-center gap-1.5">
130+
<UBadge
131+
v-for="item in items"
132+
:key="item.label"
133+
variant="subtle"
134+
color="neutral"
135+
size="md"
136+
class="group/badge flex items-center gap-3 px-2 py-1 min-w-0"
137+
>
138+
<UInput
139+
v-if="activeIndex === item.index"
140+
v-model="stringEditingValue"
141+
size="xs"
142+
variant="none"
143+
class="w-24 -my-1"
144+
autofocus
145+
@keypress.enter="saveStringEditing"
146+
@blur="saveStringEditing"
147+
/>
148+
<span
149+
v-else
150+
class="truncate max-w-32 text-xs"
151+
>
152+
{{ item.value }}
153+
</span>
154+
155+
<div class="flex items-center shrink-0">
156+
<UButton
157+
variant="ghost"
158+
color="neutral"
159+
size="2xs"
160+
:icon="activeIndex === item.index ? 'i-lucide-check' : 'i-lucide-pencil'"
161+
:class="{ 'font-medium': activeIndex === item.index }"
162+
aria-label="Edit item"
163+
@click.stop="activeIndex === item.index ? saveStringEditing : startStringEditing(item.index, item.value)"
164+
/>
165+
<UButton
166+
variant="ghost"
167+
color="neutral"
168+
size="2xs"
169+
:icon="activeIndex === item.index ? 'i-lucide-x' : 'i-lucide-trash'"
170+
aria-label="Delete item"
171+
@click.stop="deleteItem(item.index)"
172+
/>
173+
</div>
174+
</UBadge>
175+
</div>
176+
</template>
177+
178+
<!-- Unsupported type fallback -->
179+
<div
180+
v-else
181+
class="flex items-center justify-center py-2 rounded-lg border border-dashed border-muted"
182+
>
183+
<p class="text-xs text-muted">
184+
Array type {{ itemsType || '' }} not supported
185+
</p>
186+
</div>
187+
188+
<!-- Add button -->
189+
<div
190+
v-if="itemsType"
191+
class="flex"
192+
:class="{ 'justify-end': items.length > 0 }"
193+
>
194+
<UButton
195+
variant="link"
196+
color="neutral"
197+
size="xs"
198+
icon="i-lucide-plus"
199+
@click="addItem"
200+
>
201+
Add {{ itemsLabel.toLowerCase() }}
202+
</UButton>
203+
</div>
204+
</div>
205+
</template>

src/app/src/types/form.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,5 @@ export type FormItem = {
2020
// Not in schema, created manually by user
2121
custom?: boolean
2222
// Items for array type
23-
items?: FormItem
23+
arrayItemForm?: FormItem
2424
}

src/app/src/utils/form.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export const buildFormTreeFromSchema = (treeKey: string, schema: Draft07): FormT
9595
id,
9696
title: upperFirst(itemKey),
9797
type: 'array',
98-
items: buildFormTreeItem(def.items, `#${itemKey}/items`)!,
98+
arrayItemForm: buildFormTreeItem(def.items, `#${itemKey}/items`)!,
9999
}
100100
}
101101

@@ -127,7 +127,7 @@ export const buildFormTreeFromSchema = (treeKey: string, schema: Draft07): FormT
127127
}
128128

129129
if (type === 'array' && def.items) {
130-
item.items = buildFormTreeItem(def.items, `#${itemKey}/items`)!
130+
item.arrayItemForm = buildFormTreeItem(def.items, `#${itemKey}/items`)!
131131
}
132132

133133
if (def.enum && Array.isArray(def.enum) && def.enum.length > 0) {

0 commit comments

Comments
 (0)