feat: schema builder's create flow

This commit is contained in:
Florian Sylvain committed 2026-10-20 00:29:01 +02:00
1 parent f514146fed
commit 083cc9e23e
71 files changed
+7223 -61

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue'
import FieldRow from '@/components/FieldRow.vue'
import { useKeyboardReorder } from '@/composables/useKeyboardReorder'
import { BUILDER_LIMITS, type BuilderField } from '@/lib/schemaBuilderModel'
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
const props = defineProps<{
fields: BuilderField[]
selectedUid: number | null
errors: Map<number, FieldErrors>
listError?: string
disabled: boolean
mobile: boolean
}>()
const emit = defineEmits<{
select: [uid: number]
add: []
remove: [uid: number]
move: [uid: number, delta: number]
reorder: [order: number[]]
}>()
const list = ref<HTMLElement | null>(null)
const focusedUid = ref<number | null>(null)
const reorder = useKeyboardReorder<BuilderField>({
items: () => props.fields,
name: (field) => field.label.trim() || t('Untitled field'),
commit: (next) =>
emit(
'reorder',
next.map((field) => field.uid),
),
root: () => list.value,
})
const rows = computed(() => reorder.shown.value)
const tabbableUid = computed(
() => focusedUid.value ?? props.selectedUid ?? props.fields[0]?.uid ?? null,
)
const full = computed(() => props.fields.length >= BUILDER_LIMITS.fieldsMax)
const countText = computed(() => t('{0} of {1}', [props.fields.length, BUILDER_LIMITS.fieldsMax]))
async function onMove(uid: number, delta: number) {
const action = delta < 0 ? 'up' : 'down'
emit('move', uid, delta)
await nextTick()
const row = list.value?.querySelector(`li[data-uid="${uid}"]`)
const same = row?.querySelector<HTMLButtonElement>(`[data-action="${action}"]`)
const other = row?.querySelector<HTMLButtonElement>(
`[data-action="${action === 'up' ? 'down' : 'up'}"]`,
)
;(same && !same.disabled ? same : other)?.focus()
}
function onRowKeydown(event: KeyboardEvent) {
if (reorder.active.value || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) {
return
}
const target = event.target as HTMLElement
if (!target.matches('[data-field-row]')) {
return
}
const buttons = Array.from(list.value?.querySelectorAll<HTMLElement>('[data-field-row]') ?? [])
const next = buttons[buttons.indexOf(target) + (event.key === 'ArrowUp' ? -1 : 1)]
if (next) {
event.preventDefault()
next.focus()
}
}
function onFocusin(event: FocusEvent) {
const uid = (event.target as HTMLElement).closest('li[data-uid]')?.getAttribute('data-uid')
focusedUid.value = uid ? Number(uid) : null
}
</script>
<template>
<div class="flex min-w-0 flex-col gap-2">
<div class="flex h-7 items-center justify-between">
<h2 class="font-semibold text-highlighted">{{ t('Fields') }}</h2>
<span class="font-mono text-xs text-muted" data-testid="field-count">{{
countText
}}</span>
</div>
<div
role="status"
aria-live="polite"
data-testid="reorder-live"
:class="
reorder.active.value
? 'bg-inverted px-2.5 py-2 text-xs/4.5 text-inverted'
: 'sr-only'
"
>
{{ reorder.announcement.value }}
</div>
<ul
v-if="rows.length"
ref="list"
class="m-0 flex list-none flex-col gap-2 p-0"
@keydown="onRowKeydown"
@focusin="onFocusin"
>
<FieldRow
v-for="(field, index) in rows"
:key="field.uid"
:field="field"
:index="index"
:total="rows.length"
:selected="!mobile && field.uid === selectedUid"
:invalid="errors.has(field.uid)"
:grabbed="reorder.grabbed.value === index"
:disabled="disabled"
:tabbable="field.uid === tabbableUid"
:show-actions="!mobile"
@select="emit('select', field.uid)"
@up="onMove(field.uid, -1)"
@down="onMove(field.uid, 1)"
@remove="emit('remove', field.uid)"
@grip-keydown="reorder.onKeydown($event, index)"
/>
</ul>
<div
v-else
ref="list"
data-testid="field-empty"
class="flex flex-col items-center gap-2 border border-dashed px-4 py-8 text-center"
:class="listError ? 'border-error' : 'border-accented'"
>
<UIcon name="i-lucide-rows-3" class="size-5 text-muted" />
<span class="font-semibold">{{ t('No fields yet') }}</span>
<span :class="listError ? 'text-error' : 'text-muted'">{{
listError || t('Add the first field.')
}}</span>
</div>
<UButton
icon="i-lucide-plus"
color="neutral"
variant="outline"
:disabled="disabled || full"
class="h-11 w-full justify-center border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
@click="emit('add')"
>
{{ t('Add field') }}
</UButton>
<slot />
</div>
</template>