feat: relation field and picker

This commit is contained in:
Florian Sylvain committed 2026-10-23 00:13:42 +02:00
1 parent b45c99be3b
commit 37a77c9c22
92 files changed
+6826 -80

No files matched your search

+461
View File
@@ -0,0 +1,461 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onMounted, ref, useId, watch } from 'vue'
import DirectoryField from '@/components/DirectoryField.vue'
import { useRelationSearch } from '@/composables/useRelationSearch'
import { relationLabel } from '@/lib/entryCells'
import {
RELATION_MAX,
isMissingRelation,
relationIdsOf,
type RelationSummary,
} from '@/lib/relationField'
import { relationRowOf } from '@/lib/relationRows'
import { useContentTypesStore } from '@/stores/contentTypes'
interface ChipItem {
label: string
value: number
missing: boolean
summary: RelationSummary
}
interface RowItem {
label: string
description: string
value: number
summary: RelationSummary
}
interface HighlightPayload {
ref?: HTMLElement
}
const SKELETON_ROWS = 3
const LISTBOX = '[role="listbox"]'
const OWNER_ATTRIBUTE = 'data-relation-owner'
const VOWEL = /^[aeiou]/
const props = withDefaults(
defineProps<{
label: string
name?: string
required?: boolean
many?: boolean
target: string
typeName: string
value: unknown
summaries?: RelationSummary[]
disabled?: boolean
error?: string
}>(),
{
name: undefined,
required: false,
many: false,
summaries: () => [],
disabled: false,
error: '',
},
)
const emit = defineEmits<{
change: [value: number | number[] | null, summaries: RelationSummary[]]
}>()
const id = useId()
const labelId = `${id}-label`
const helpId = `${id}-help`
const errorId = `${id}-error`
const store = useContentTypesStore()
const search = useRelationSearch(() => props.target)
const open = ref(false)
const searchTerm = ref('')
const announcement = ref('')
const activeId = ref<string | undefined>(undefined)
const listboxId = ref<string | undefined>(undefined)
const ids = computed(() => relationIdsOf(props.value))
const chips = computed<ChipItem[]>(() =>
ids.value.map((chipId) => {
const summary = props.summaries.find((candidate) => candidate.id === chipId) ?? {
id: chipId,
missing: true as const,
}
return {
label: relationLabel(summary),
value: chipId,
missing: isMissingRelation(summary),
summary,
}
}),
)
const targetType = computed(() => store.items.find((type) => type.key === props.target))
const targetName = computed(() => targetType.value?.name ?? props.target)
const atCap = computed(() => props.many && ids.value.length >= RELATION_MAX)
const rows = computed<RowItem[]>(() =>
search.entries.value
.map((entry) => relationRowOf(entry, props.target, targetType.value?.fields ?? []))
.filter((row) => !ids.value.includes(row.id))
.map((row) => ({
label: row.title,
description: row.detail,
value: row.id,
summary: row.summary,
})),
)
const menuItems = computed(() => (atCap.value || search.showSkeleton.value ? [] : rows.value))
const hasMissing = computed(() => chips.value.some((chip) => chip.missing))
const ownerWord = computed(() => props.typeName.replace(/s$/i, '').toLowerCase())
const help = computed(() => {
if (hasMissing.value) {
return props.required
? t('{0} is required. Choose another to save.', [props.label])
: t('You can still save. The entry keeps the link until you change it.')
}
if (!props.many) {
return ''
}
const plural = props.label.toLowerCase()
return VOWEL.test(ownerWord.value)
? t('An {0} can have many {1}.', [ownerWord.value, plural])
: t('A {0} can have many {1}.', [ownerWord.value, plural])
})
const describedBy = computed(() => (props.error ? `${errorId} ${helpId}` : helpId))
const emptyKind = computed(() => {
if (atCap.value) {
return 'cap'
}
if (search.showSkeleton.value) {
return 'loading'
}
const status = search.status.value
if (status === 'limited' || status === 'failed') {
return status
}
if (status !== 'ready') {
return 'none'
}
if (search.total.value > 0) {
return 'chosen'
}
return search.query.value === '' ? 'empty' : 'noMatch'
})
const baseUi = computed(() =>
props.error
? 'border-error shadow-[inset_0_0_0_1px_var(--ui-error)] has-focus-visible:border-error'
: 'border-accented has-focus-visible:border-primary has-focus-visible:shadow-[inset_0_0_0_1px_var(--ui-primary)]',
)
const menuUi = computed(() => ({
base: `min-h-8 gap-1.5 rounded-none border bg-default px-2 py-1 text-sm ring-0 outline-0 has-focus-visible:ring-0 has-focus-visible:outline-0 max-md:min-h-11 max-md:text-lg ${baseUi.value} ${props.many ? '' : 'pe-8'}`,
tagsItem:
'h-6 gap-1.5 rounded-none bg-(--rc-sand) py-0 ps-2 pe-1 text-sm font-medium text-(--rc-on-sand) ring-0 has-data-[missing=true]:border has-data-[missing=true]:border-dashed has-data-[missing=true]:border-accented has-data-[missing=true]:bg-transparent has-data-[missing=true]:font-normal has-data-[missing=true]:text-toned max-md:h-8',
tagsItemText: 'truncate',
tagsItemDelete:
'relative rounded-none text-inherit hover:bg-transparent hover:text-inherit max-md:before:absolute max-md:before:-inset-1.5',
tagsInput: 'min-w-20 flex-1 placeholder:text-muted',
trailing: 'pe-2.5',
trailingIcon: 'size-3.5 text-muted',
content: 'rounded-none border border-default p-1 shadow-(--shadow-popover) ring-0',
empty: 'p-2 text-left text-sm text-muted',
group: 'p-0',
item: 'min-h-10 flex-col justify-center gap-0 rounded-none px-2 py-1 before:inset-0 before:rounded-none data-highlighted:not-data-disabled:before:bg-elevated max-md:min-h-12',
itemLabel: 'font-medium text-highlighted',
itemDescription: 'text-xs/5 text-muted',
}))
function removeName(element: HTMLElement, binding: { value: string }) {
const button = element.closest('button')
button?.removeAttribute('aria-labelledby')
button?.setAttribute('aria-label', binding.value)
}
const vRemoveName = { mounted: removeName, updated: removeName }
function removeLabel(item: ChipItem) {
return t('Remove {0} from {1}', [item.label, props.label])
}
function announce(message: string) {
announcement.value = ''
nextTick(() => {
announcement.value = message
})
}
function summaryOf(chipId: number, picked: RowItem[]) {
return (
chips.value.find((chip) => chip.value === chipId)?.summary ??
picked.find((row) => row.value === chipId)?.summary
)
}
function changeSingle(picked: RowItem[]) {
const row = picked[picked.length - 1]
if (!row) {
emit('change', null, [])
announce(t('{0} cleared.', [props.label]))
return
}
emit('change', row.value, [row.summary])
announce(t('{0} set to {1}.', [props.label, row.label]))
open.value = false
}
function changeMany(next: Array<ChipItem | RowItem>, picked: RowItem[]) {
const nextIds = next.map((item) => item.value)
emit(
'change',
nextIds,
nextIds.flatMap((nextId) => summaryOf(nextId, picked) ?? []),
)
const removed = chips.value.find((chip) => !nextIds.includes(chip.value))
if (removed) {
announce(t('{0} removed from {1}.', [removed.label, props.label]))
} else if (picked[0]) {
announce(t('{0} added to {1}.', [picked[0].label, props.label]))
}
}
function onUpdate(next: Array<ChipItem | RowItem>) {
if (props.disabled) {
return
}
const picked = next.filter(
(item): item is RowItem => !ids.value.includes(item.value) && 'description' in item,
)
if (props.many) {
changeMany(next, picked)
} else {
changeSingle(picked)
}
}
function onBackspace(event: KeyboardEvent) {
const input = event.target as HTMLInputElement
const lastChip = chips.value.length - 1
if (
event.key !== 'Backspace' ||
input.getAttribute('role') !== 'combobox' ||
input.value !== '' ||
lastChip < 0 ||
props.disabled
) {
return
}
event.preventDefault()
event.stopPropagation()
onUpdate(chips.value.slice(0, lastChip))
}
function onHighlight(payload: HighlightPayload | undefined) {
activeId.value = payload?.ref?.id || undefined
}
function clearSearch() {
searchTerm.value = ''
}
async function trackListbox() {
await nextTick()
const owned = `[${OWNER_ATTRIBUTE}="${id}"]`
listboxId.value =
document.querySelector<HTMLElement>(`${owned}${LISTBOX}, ${owned} ${LISTBOX}`)?.id ||
undefined
}
function announceResults() {
const total = search.total.value
announce(total > 0 ? t('{0} results', [total]) : t('No results'))
}
watch(searchTerm, (term) => {
if (open.value) {
search.search(term)
}
})
watch(open, (isOpen) => {
if (isOpen) {
search.search(searchTerm.value)
trackListbox()
} else {
activeId.value = undefined
listboxId.value = undefined
}
})
watch(
() => search.status.value,
(status) => {
if (open.value && status === 'ready') {
announceResults()
}
},
)
onMounted(() => store.load())
</script>
<template>
<div class="min-w-0">
<DirectoryField
:label="label"
:required="required"
:name="name"
:label-id="labelId"
:error="error"
:error-id="errorId"
>
<div
role="group"
:aria-labelledby="labelId"
data-testid="relation-control"
@keydown.capture="onBackspace"
>
<UInputMenu
:model-value="chips"
v-model:open="open"
v-model:search-term="searchTerm"
multiple
ignore-filter
open-on-focus
open-on-click
by="value"
:items="menuItems"
:placeholder="t('Search to add')"
:disabled="disabled"
:color="error ? 'error' : undefined"
:trailing="!many"
trailing-icon="i-lucide-search"
:aria-controls="listboxId"
:aria-required="required ? 'true' : undefined"
:aria-activedescendant="activeId"
:aria-describedby="describedBy"
class="w-full"
:content="{ sideOffset: 6, [OWNER_ATTRIBUTE]: id }"
:ui="menuUi"
@update:model-value="onUpdate"
@highlight="onHighlight"
>
<template #tags-item-text="{ item }">
<span :data-missing="item.missing ? 'true' : undefined">{{
item.label
}}</span>
</template>
<template #tags-item-delete="{ item }">
<UIcon
v-remove-name="removeLabel(item)"
name="i-lucide-x"
class="size-3.5"
/>
</template>
<template #item="{ item }">
<span class="font-medium text-highlighted">{{ item.label }}</span>
<span v-if="item.description" class="text-xs/5 text-muted">{{
item.description
}}</span>
</template>
<template #empty>
<div data-testid="relation-empty" class="flex flex-col items-start gap-2">
<template v-if="emptyKind === 'loading'">
<span class="sr-only" role="status" aria-live="polite">{{
t('Searching…')
}}</span>
<USkeleton
v-for="row in SKELETON_ROWS"
:key="row"
data-testid="relation-skeleton"
class="h-10 w-full rounded-none bg-accented max-md:h-12"
/>
</template>
<template v-else-if="emptyKind === 'failed'">
<span class="font-medium text-error">{{
t("Couldn't load {0}", [targetName])
}}</span>
<span>{{ t('Check your connection and try again.') }}</span>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-8 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 max-md:h-11"
@click="search.reload()"
>
{{ t('Try again') }}
</UButton>
</template>
<template v-else-if="emptyKind === 'limited'">
<span class="font-medium text-warning">{{
t('Too many requests')
}}</span>
<span>{{
search.limit.active.value
? t('Try again in {0}', [search.limit.formatted.value])
: t('Wait a moment before trying again.')
}}</span>
<UButton
v-if="!search.limit.active.value"
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-8 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 max-md:h-11"
@click="search.reload()"
>
{{ t('Try again') }}
</UButton>
</template>
<span v-else-if="emptyKind === 'cap'">{{
t('You can add up to {0}.', [RELATION_MAX])
}}</span>
<span v-else-if="emptyKind === 'empty'">{{
t('No {0} yet. Add one in Content, then come back.', [targetName])
}}</span>
<template v-else-if="emptyKind === 'noMatch'">
<span>{{
t('No {0} match "{1}"', [targetName, search.query.value])
}}</span>
<UButton
color="neutral"
variant="ghost"
class="h-8 px-2 text-xs text-highlighted max-md:h-11"
@click="clearSearch"
>
{{ t('Clear search') }}
</UButton>
</template>
<span v-else-if="emptyKind === 'chosen'">{{
t('No other {0} to add.', [targetName])
}}</span>
</div>
</template>
<template #content-bottom>
<UButton
v-if="search.hasMore.value && !atCap"
color="neutral"
variant="ghost"
:loading="search.loadingMore.value"
class="h-8 w-full justify-center text-xs text-highlighted max-md:h-11"
@click="search.more()"
>
{{ t('Load more') }}
</UButton>
</template>
</UInputMenu>
</div>
<p
v-if="help"
:id="helpId"
data-testid="relation-help"
class="mt-1.5 text-xs/4.5 text-pretty text-muted"
:class="error ? 'sr-only' : ''"
>
{{ help }}
</p>
<span class="sr-only" role="status" aria-live="polite" data-testid="relation-live">{{
announcement
}}</span>
</DirectoryField>
</div>
</template>