462 lines
13 KiB
Vue
462 lines
13 KiB
Vue
<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>
|