feat: relation field and picker
This commit is contained in:
1 parent
b45c99be3b
commit
37a77c9c22
92 files changed
+6826
-80
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user