feat(10-03): tabs, toggles, relation fields, form lifecycle and settings pages

- FormTabs groups fields by tab (untabbed fields in the default tab) as a
  segmented tablist; a tab holding invalid fields after a 422 shows a
  count badge and the form switches to the first invalid field
- switch, checkbox and relation join the renderer registry: toggle cards
  (role=switch, 20px checkbox) keep a numeric value numeric; relation
  fields are read-only labels from meta.labels, a searchable single
  select over fields/{field}/options with emptyOption first, or ordered
  removable chips with an appending search (300 ms debounce, 20 per
  page, more on scroll or the more action)
- FormView gets the back button, record title and update subtitle, a
  sticky footer (Usuń with deleteConfirm then DELETE, Anuluj, Zapisz i
  zamknij to the mapped redirectClose, Zapisz), and asks before leaving a
  dirty form on any route change plus a beforeunload guard
- Settings: the rail pins Ustawienia to the bottom when /settings is
  non-empty; /settings lists pages by category; /settings/:code renders
  the settings schema through FormGrid and the registry, PUTs the values
  and maps a 422 like the record form
- New backend::lang keys (form.more_options, tab_default, discard,
  settings.back); form and settings smoke tests; boardwalk/dist rebuilt
This commit is contained in:
Jakub Zych
2026-09-27 17:00:18 +02:00
parent 8b5f85603f
commit 8f32416f4f
30 changed files with 1898 additions and 58 deletions

View File

@@ -0,0 +1,403 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ChevronDown, X } from '@lucide/vue'
import { api } from '../../../api/client'
import type { RelationOption } from '../../../api/types'
import { t } from '../../../app/i18n'
import type { FieldControlProps } from '../registry'
// Relation field (D-17, D-18, D-26), three modes from the schema:
// readOnly the label from meta.labels only, no control
// single a searchable select over fields/{field}/options; emptyOption
// first and muted; sends the id or null
// multiple removable chips in order plus an inline search that appends
// the chosen id
// Options are searched with a 300 ms debounce, 20 per page, with more loaded
// on scroll or through the "more" action. Labels come from meta.labels and
// from every options page seen.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: number | number[] | null] }>()
const PER_PAGE = 20
const SEARCH_DEBOUNCE = 300
const mode = computed(() => (props.field.readOnly ? 'readonly' : props.field.multiple ? 'multiple' : 'single'))
const listId = computed(() => `${props.controlId}-listbox`)
const known = ref<Record<number, string>>({})
watch(
() => props.labels,
(labels) => {
const next = { ...known.value }
for (const option of labels ?? []) {
next[option.value] = option.label
}
known.value = next
},
{ immediate: true },
)
function asId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value
}
if (typeof value === 'string' && /^\d+$/.test(value)) {
return Number(value)
}
return null
}
const singleId = computed(() => asId(props.modelValue))
const selectedIds = computed(() =>
Array.isArray(props.modelValue) ? props.modelValue.map(asId).filter((id): id is number => id !== null) : [],
)
function labelOf(id: number): string {
return known.value[id] ?? String(id)
}
function initials(label: string): string {
return label
.split(/[\s@._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('')
}
const readOnlyText = computed(() => {
const label = props.labels?.[0]?.label
if (label) {
return label
}
return props.field.emptyOption ?? '—'
})
// Options listbox.
const open = ref(false)
const query = ref('')
const options = ref<RelationOption[]>([])
const page = ref(0)
const lastPage = ref(0)
const loading = ref(false)
const active = ref(-1)
let generation = 0
let searchTimer: ReturnType<typeof setTimeout> | null = null
const hasMore = computed(() => page.value < lastPage.value)
async function fetchPage(nextPage: number, append: boolean): Promise<void> {
const source = props.source
if (!source) {
options.value = []
return
}
const current = ++generation
loading.value = true
try {
const search = query.value.trim()
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/fields/{field}/options', {
params: {
path: { ...source, field: props.field.name },
query: { search: search === '' ? undefined : search, page: nextPage, per_page: PER_PAGE },
},
})
if (current !== generation) {
return
}
const rows = data?.data ?? []
const labels = { ...known.value }
for (const option of rows) {
labels[option.value] = option.label
}
known.value = labels
options.value = append ? [...options.value, ...rows] : rows
page.value = data?.meta.page ?? nextPage
lastPage.value = data?.meta.last_page ?? nextPage
} catch {
if (current === generation && !append) {
options.value = []
}
} finally {
if (current === generation) {
loading.value = false
}
}
}
interface Entry {
key: string
label: string
value: number | null
muted: boolean
}
const entries = computed<Entry[]>(() => {
const out: Entry[] = []
if (mode.value === 'single' && props.field.emptyOption !== undefined) {
out.push({ key: 'empty', label: props.field.emptyOption, value: null, muted: true })
}
const taken = new Set(selectedIds.value)
for (const option of options.value) {
if (mode.value === 'multiple' && taken.has(option.value)) {
continue
}
out.push({ key: `option-${option.value}`, label: option.label, value: option.value, muted: false })
}
return out
})
function optionId(index: number): string {
return `${listId.value}-${index}`
}
function openList(): void {
if (open.value) {
return
}
open.value = true
active.value = -1
void fetchPage(1, false)
}
function closeList(): void {
open.value = false
query.value = ''
active.value = -1
if (searchTimer !== null) {
clearTimeout(searchTimer)
searchTimer = null
}
}
function onQuery(event: Event): void {
query.value = (event.target as HTMLInputElement).value
open.value = true
active.value = -1
if (searchTimer !== null) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(() => {
searchTimer = null
void fetchPage(1, false)
}, SEARCH_DEBOUNCE)
}
function loadMore(): void {
if (hasMore.value && !loading.value) {
void fetchPage(page.value + 1, true)
}
}
function onScroll(event: Event): void {
const list = event.target as HTMLElement
if (list.scrollTop + list.clientHeight >= list.scrollHeight - 24) {
loadMore()
}
}
function choose(entry: Entry): void {
if (mode.value === 'multiple') {
if (entry.value !== null && !selectedIds.value.includes(entry.value)) {
emit('update:modelValue', [...selectedIds.value, entry.value])
}
} else {
emit('update:modelValue', entry.value)
}
closeList()
}
function remove(id: number): void {
emit(
'update:modelValue',
selectedIds.value.filter((item) => item !== id),
)
}
function onKeydown(event: KeyboardEvent): void {
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
if (!open.value) {
openList()
} else {
active.value = Math.min(active.value + 1, entries.value.length - 1)
}
break
case 'ArrowUp':
event.preventDefault()
active.value = Math.max(active.value - 1, 0)
break
case 'Enter':
if (open.value) {
event.preventDefault()
const entry = entries.value[active.value]
if (entry) {
choose(entry)
}
}
break
case 'Escape':
if (open.value) {
event.preventDefault()
closeList()
}
break
case 'Backspace':
if (mode.value === 'multiple' && query.value === '' && selectedIds.value.length > 0) {
remove(selectedIds.value[selectedIds.value.length - 1]!)
}
break
}
}
const singleText = computed(() => {
if (singleId.value !== null) {
return labelOf(singleId.value)
}
return props.field.emptyOption ?? ''
})
onBeforeUnmount(() => {
if (searchTimer !== null) {
clearTimeout(searchTimer)
}
})
</script>
<template>
<div
v-if="mode === 'readonly'"
:id="controlId"
data-relation-readonly
:aria-describedby="describedBy || undefined"
:class="labels?.[0]?.label ? 'text-text' : 'text-muted'"
class="flex min-h-input items-center rounded-control border border-border bg-subtle px-3.5"
>
{{ readOnlyText }}
</div>
<div v-else class="relative" :data-relation="mode">
<div
v-if="mode === 'multiple'"
:class="invalid ? 'border-danger' : 'border-border-strong'"
class="flex min-h-input flex-wrap items-center gap-1.5 rounded-control border bg-surface p-1.5"
>
<span
v-for="id in selectedIds"
:key="id"
data-chip
class="inline-flex h-[30px] items-center gap-1.5 rounded-pill bg-subtle pr-1 pl-1"
>
<span
class="flex size-[22px] items-center justify-center rounded-full bg-primary text-[10px] font-bold text-on-primary"
aria-hidden="true"
>{{ initials(labelOf(id)) }}</span
>
<span class="font-semibold">{{ labelOf(id) }}</span>
<button
type="button"
:aria-label="t('backend::lang.form.remove_item', { name: labelOf(id) })"
class="flex size-6 items-center justify-center rounded-full text-muted hover:bg-hover hover:text-text"
@click="remove(id)"
>
<X :size="14" aria-hidden="true" />
</button>
</span>
<input
:id="controlId"
type="text"
role="combobox"
autocomplete="off"
:aria-expanded="open ? 'true' : 'false'"
:aria-controls="listId"
aria-autocomplete="list"
:aria-activedescendant="open && active >= 0 ? optionId(active) : undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:value="query"
:placeholder="t('backend::lang.form.add_item')"
class="h-[30px] min-w-[120px] flex-1 border-0 bg-transparent px-2 focus-visible:shadow-none"
@click="openList"
@input="onQuery"
@keydown="onKeydown"
@blur="closeList"
/>
</div>
<div v-else class="relative">
<input
:id="controlId"
type="text"
role="combobox"
autocomplete="off"
:aria-expanded="open ? 'true' : 'false'"
:aria-controls="listId"
aria-autocomplete="list"
:aria-activedescendant="open && active >= 0 ? optionId(active) : undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:value="open ? query : singleText"
:placeholder="open ? singleText || t('backend::lang.form.select_placeholder') : t('backend::lang.form.select_placeholder')"
:class="[
invalid ? 'border-danger' : 'border-border-strong',
!open && singleId === null ? 'text-muted' : 'text-text',
]"
class="h-input w-full rounded-control border bg-surface pr-10 pl-3.5"
@click="openList"
@input="onQuery"
@keydown="onKeydown"
@blur="closeList"
/>
<ChevronDown
:size="16"
class="pointer-events-none absolute top-1/2 right-3.5 -translate-y-1/2 text-muted"
aria-hidden="true"
/>
</div>
<ul
v-show="open"
:id="listId"
role="listbox"
class="absolute inset-x-0 top-full z-20 mt-1 max-h-64 overflow-y-auto rounded-inner border border-border bg-surface p-1 shadow-menu"
@scroll="onScroll"
>
<li
v-for="(entry, index) in entries"
:id="optionId(index)"
:key="entry.key"
role="option"
:data-value="entry.value ?? 'empty'"
:aria-selected="mode === 'single' && entry.value === singleId ? 'true' : 'false'"
:class="[entry.muted ? 'text-muted' : 'text-text', index === active ? 'bg-hover' : '']"
class="flex h-10 cursor-pointer items-center rounded-control px-3 hover:bg-hover"
@mousedown.prevent="choose(entry)"
>
{{ entry.label }}
</li>
<li v-if="loading" role="presentation" class="px-3 py-2 text-[13px] text-muted">
{{ t('backend::lang.form.loading_options') }}
</li>
<li
v-else-if="entries.filter((entry) => entry.value !== null).length === 0"
role="presentation"
class="px-3 py-2 text-[13px] text-muted"
>
{{ t('backend::lang.form.no_options') }}
</li>
<li v-if="hasMore && !loading" role="presentation" class="p-1">
<button
type="button"
data-action="more-options"
class="h-8 w-full rounded-control text-[13px] font-semibold text-muted hover:bg-hover hover:text-text"
@mousedown.prevent="loadMore"
>
{{ t('backend::lang.form.more_options') }}
</button>
</li>
</ul>
</div>
</template>