Files
RenewCMS/web/src/components/FieldList.vue
T

196 lines
5.6 KiB
Vue

<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 type { RowMarks } from '@/composables/useSchemaBuilder'
import { BUILDER_LIMITS, type BuilderField } from '@/lib/schemaBuilderModel'
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
const NEW_MARKS: RowMarks = { isNew: true, changed: false, cause: false }
const props = withDefaults(
defineProps<{
fields: BuilderField[]
selectedUid: number | null
errors: Map<number, FieldErrors>
listError?: string
disabled: boolean
mobile: boolean
marks?: Map<number, RowMarks>
removed?: BuilderField[]
canUndo?: boolean
}>(),
{
listError: undefined,
marks: undefined,
removed: () => [],
canUndo: true,
},
)
const emit = defineEmits<{
select: [uid: number]
add: []
remove: [uid: number]
move: [uid: number, delta: number]
reorder: [order: number[]]
undo: [key: string]
}>()
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"
:marks="marks?.get(field.uid) ?? NEW_MARKS"
@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)"
@grip-pointerdown="reorder.onPointerdown($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>
<div
v-for="field in removed"
:key="field.key"
data-testid="undo-row"
class="flex min-h-11 items-center gap-2 border border-dashed border-accented px-3 text-toned"
:class="disabled ? 'opacity-60' : ''"
>
<UIcon name="i-lucide-minus" class="size-3.5 shrink-0" />
<span class="flex-1">{{
t('{0} will be removed', [field.label.trim() || t('Untitled field')])
}}</span>
<button
type="button"
data-testid="undo-action"
:disabled="disabled || !canUndo"
class="min-h-11 font-medium text-highlighted underline underline-offset-3 focus-visible:outline-2 focus-visible:outline-primary disabled:text-dimmed md:min-h-0"
@click="emit('undo', field.key)"
>
{{ t('Undo') }}
</button>
</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>