feat: schema builder's create flow
This commit is contained in:
1 parent
f514146fed
commit
083cc9e23e
71 files changed
+7223
-61
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user