feat(SPA): media field type
This commit is contained in:
1 parent
a2d596f4ca
commit
3e7fc9a4a5
57 files changed
+6109
-75
No files matched your search
@@ -132,6 +132,7 @@ const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey))
|
||||
:type-key="typeKey"
|
||||
:entries="list.items.value"
|
||||
:fields="fields"
|
||||
:columns="columns.columns.value"
|
||||
:names="list.names.value"
|
||||
:caption="caption"
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,7 @@ const props = withDefaults(
|
||||
notice?: DirectoryNotice | null
|
||||
closable?: boolean
|
||||
confirmDisabled?: boolean
|
||||
confirmBlocked?: boolean
|
||||
wide?: boolean
|
||||
closeGutter?: boolean
|
||||
}>(),
|
||||
@@ -27,6 +28,7 @@ const props = withDefaults(
|
||||
notice: null,
|
||||
closable: false,
|
||||
confirmDisabled: false,
|
||||
confirmBlocked: false,
|
||||
wide: false,
|
||||
closeGutter: false,
|
||||
},
|
||||
@@ -78,6 +80,12 @@ function onOpenChange(value: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
if (!props.confirmBlocked) {
|
||||
emit('confirm')
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
emit('cancel')
|
||||
onOpenChange(false)
|
||||
@@ -146,10 +154,14 @@ function onCancel() {
|
||||
:color="tone === 'danger' ? 'error' : 'primary'"
|
||||
:loading="pending"
|
||||
:disabled="pending || confirmDisabled"
|
||||
:aria-disabled="confirmBlocked ? 'true' : undefined"
|
||||
block
|
||||
class="h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3"
|
||||
:class="wide ? 'disabled:opacity-40' : ''"
|
||||
@click="emit('confirm')"
|
||||
:class="[
|
||||
wide ? 'disabled:opacity-40' : '',
|
||||
confirmBlocked ? 'cursor-not-allowed opacity-40' : '',
|
||||
]"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</UButton>
|
||||
|
||||
@@ -9,8 +9,19 @@ withDefaults(
|
||||
error?: string
|
||||
help?: string
|
||||
name?: string
|
||||
labelId?: string
|
||||
errorId?: string
|
||||
}>(),
|
||||
{ label: '', optional: false, required: false, error: '', help: '', name: undefined },
|
||||
{
|
||||
label: '',
|
||||
optional: false,
|
||||
required: false,
|
||||
error: '',
|
||||
help: '',
|
||||
name: undefined,
|
||||
labelId: undefined,
|
||||
errorId: undefined,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -30,7 +41,7 @@ withDefaults(
|
||||
}"
|
||||
>
|
||||
<template v-if="label" #label>
|
||||
{{ label }}<span v-if="required" aria-hidden="true"> *</span>
|
||||
<span :id="labelId">{{ label }}<span v-if="required" aria-hidden="true"> *</span></span>
|
||||
<span v-if="optional" class="font-normal text-muted"> {{ t('(optional)') }}</span>
|
||||
</template>
|
||||
<template v-if="$slots.hint" #hint>
|
||||
@@ -38,7 +49,7 @@ withDefaults(
|
||||
</template>
|
||||
<slot />
|
||||
<template v-if="error" #error>
|
||||
<span class="flex items-start gap-1.5" role="alert">
|
||||
<span :id="errorId" class="flex items-start gap-1.5" role="alert">
|
||||
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{{ error }}</span>
|
||||
</span>
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
import { RouterLink } from 'vue-router'
|
||||
import FieldCell from '@/components/FieldCell.vue'
|
||||
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
|
||||
import { EMPTY_TEXT, cardFields, describeCell, isEmptyCell, plainCellText } from '@/lib/entryCells'
|
||||
import {
|
||||
EMPTY_TEXT,
|
||||
cardFields,
|
||||
describeCell,
|
||||
isEmptyCell,
|
||||
plainCellText,
|
||||
thumbnailColumn,
|
||||
} from '@/lib/entryCells'
|
||||
import { t } from '@/i18n'
|
||||
import { mediaOf } from '@/lib/mediaField'
|
||||
import { entryPath } from '@/lib/routePaths'
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -10,14 +19,28 @@ const props = defineProps<{
|
||||
typeKey: string
|
||||
entries: ContentEntry[]
|
||||
fields: BuiltinField[]
|
||||
columns: BuiltinField[]
|
||||
names: Record<number, string>
|
||||
caption: string
|
||||
}>()
|
||||
|
||||
const shown = computed(() => cardFields(props.fields))
|
||||
const lead = computed(() => thumbnailColumn(props.columns))
|
||||
|
||||
function textOf(field: BuiltinField | undefined, entry: ContentEntry) {
|
||||
return field ? plainCellText(describeCell(field, entry.data[field.key])) : ''
|
||||
return field
|
||||
? plainCellText(
|
||||
describeCell(field, entry.data[field.key], mediaOf(entry, field.key) ?? undefined),
|
||||
)
|
||||
: ''
|
||||
}
|
||||
|
||||
function titleOf(entry: ContentEntry) {
|
||||
const { first } = shown.value
|
||||
if (first && describeCell(first, entry.data[first.key]).kind === 'thumbnail') {
|
||||
return t('Entry {0}', [entry.id])
|
||||
}
|
||||
return textOf(first, entry)
|
||||
}
|
||||
|
||||
const secondIsChoice = computed(
|
||||
@@ -46,9 +69,16 @@ function nameOf(entry: ContentEntry) {
|
||||
data-testid="entry-card"
|
||||
class="flex min-h-17 items-center gap-2 py-3 pr-1 pl-4 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
|
||||
>
|
||||
<FieldCell
|
||||
v-if="lead"
|
||||
:field="lead"
|
||||
:value="entry.data[lead.key]"
|
||||
:image="mediaOf(entry, lead.key)"
|
||||
compact
|
||||
/>
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="truncate text-md/5.25 font-medium text-highlighted">{{
|
||||
textOf(shown.first, entry)
|
||||
titleOf(entry)
|
||||
}}</span>
|
||||
<span class="flex items-center gap-2 text-xs text-muted">
|
||||
<template v-if="shown.second && !secondIsChoice">
|
||||
|
||||
@@ -110,6 +110,7 @@ function backToList() {
|
||||
<EntryForm
|
||||
:fields="page.fields.value"
|
||||
:values="page.form.values.value"
|
||||
:images="page.form.images.value"
|
||||
:issues="page.form.issues.value"
|
||||
:first-issue="page.form.firstIssue.value"
|
||||
:focus-token="page.focusToken.value"
|
||||
|
||||
@@ -3,9 +3,11 @@ import { computed } from 'vue'
|
||||
import BuilderBadge from '@/components/BuilderBadge.vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import EntryControl from '@/components/EntryControl.vue'
|
||||
import MediaFieldControl from '@/components/MediaFieldControl.vue'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import type { FieldIssue } from '@/lib/entryForm'
|
||||
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
||||
import { isMediaId, type MediaSummary } from '@/lib/mediaField'
|
||||
|
||||
const props = defineProps<{
|
||||
field: BuiltinField
|
||||
@@ -13,18 +15,33 @@ const props = defineProps<{
|
||||
issue?: FieldIssue
|
||||
isDefault: boolean
|
||||
disabled: boolean
|
||||
image?: MediaSummary | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ update: [key: string, value: unknown] }>()
|
||||
const emit = defineEmits<{ update: [key: string, value: unknown, image?: MediaSummary | null] }>()
|
||||
|
||||
const editor = computed(() => fieldTypeSpec(props.field.type).editor)
|
||||
const counter = computed(() => editor.value.counter(props.field, props.value))
|
||||
const help = computed(() => editor.value.help(props.field))
|
||||
const isMedia = computed(() => editor.value.control === 'media')
|
||||
const mediaId = computed(() => (isMediaId(props.value) ? props.value : null))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :data-entry-field="field.key" class="min-w-0">
|
||||
<MediaFieldControl
|
||||
v-if="isMedia"
|
||||
:label="field.label"
|
||||
:name="field.key"
|
||||
:required="field.required"
|
||||
:model-value="mediaId"
|
||||
:image="image"
|
||||
:disabled="disabled"
|
||||
:error="issue?.message"
|
||||
@change="(id, picked) => emit('update', field.key, id, picked)"
|
||||
/>
|
||||
<DirectoryField
|
||||
v-else
|
||||
:label="field.label"
|
||||
:name="field.key"
|
||||
:required="field.required"
|
||||
|
||||
@@ -5,14 +5,16 @@ import { computed, nextTick, watch } from 'vue'
|
||||
import EntryField from '@/components/EntryField.vue'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import type { EntryIssues, EntryValues } from '@/lib/entryForm'
|
||||
import type { MediaMap, MediaSummary } from '@/lib/mediaField'
|
||||
import { groupFields } from '@/lib/entryLayout'
|
||||
|
||||
const FOCUSABLE =
|
||||
'input:not([type="hidden"]), textarea, [role="spinbutton"], [role="switch"], [role="combobox"]'
|
||||
'[data-field-focus], input:not([type="hidden"]), textarea, [role="spinbutton"], [role="switch"], [role="combobox"]'
|
||||
|
||||
const props = defineProps<{
|
||||
fields: BuiltinField[]
|
||||
values: EntryValues
|
||||
images: MediaMap
|
||||
issues: EntryIssues
|
||||
firstIssue: string | null
|
||||
focusToken: number
|
||||
@@ -25,7 +27,10 @@ const props = defineProps<{
|
||||
updatedLine: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ update: [key: string, value: unknown]; submit: [] }>()
|
||||
const emit = defineEmits<{
|
||||
update: [key: string, value: unknown, image?: MediaSummary | null]
|
||||
submit: []
|
||||
}>()
|
||||
|
||||
const rows = computed(() => groupFields(props.fields))
|
||||
|
||||
@@ -67,10 +72,11 @@ watch(() => props.focusToken, focusFirstIssue)
|
||||
:key="field.key"
|
||||
:field="field"
|
||||
:value="values[field.key]"
|
||||
:image="images[field.key] ?? null"
|
||||
:issue="issues[field.key]"
|
||||
:is-default="isDefault(field)"
|
||||
:disabled="locked"
|
||||
@update="(key, value) => emit('update', key, value)"
|
||||
@update="(key, value, image) => emit('update', key, value, image)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import RetryAlert from '@/components/RetryAlert.vue'
|
||||
import type { SaveFailure } from '@/composables/useEntryDraft'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import type { EntryIssues } from '@/lib/entryForm'
|
||||
import { schemaChangedLine, uniqueAlert } from '@/lib/entryMessages'
|
||||
import { mediaChangedLine, schemaChangedLine, uniqueAlert } from '@/lib/entryMessages'
|
||||
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
failure: SaveFailure
|
||||
@@ -22,6 +23,12 @@ defineEmits<{ retry: []; reload: [] }>()
|
||||
const uniqueField = computed(() =>
|
||||
props.fields.find((field) => props.issues[field.key]?.rule === 'unique'),
|
||||
)
|
||||
const changedLine = computed(() => {
|
||||
const changed = props.fields.find((field) => field.key === props.schemaField)
|
||||
return changed && fieldTypeSpec(changed.type).editor.control === 'media'
|
||||
? mediaChangedLine(changed.label)
|
||||
: schemaChangedLine(props.schemaField)
|
||||
})
|
||||
const alertUi = {
|
||||
root: 'items-start gap-2.5 px-3.5 py-3',
|
||||
title: 'font-semibold',
|
||||
@@ -60,7 +67,7 @@ const alertUi = {
|
||||
orientation="vertical"
|
||||
icon="i-lucide-triangle-alert"
|
||||
:title="t('The fields of {0} changed', [typeName])"
|
||||
:description="schemaChangedLine(schemaField)"
|
||||
:description="changedLine"
|
||||
:ui="{ ...alertUi, actions: 'mt-1.5' }"
|
||||
>
|
||||
<template #actions>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
|
||||
import { EMPTY_TEXT, formatEntryDate } from '@/lib/entryCells'
|
||||
import { ENTRY_SORT_FIELDS, sortParts } from '@/lib/entryListQuery'
|
||||
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
||||
import { mediaOf } from '@/lib/mediaField'
|
||||
import { entryPath } from '@/lib/routePaths'
|
||||
|
||||
const ID_WIDTH = 'w-16.5'
|
||||
@@ -150,10 +151,16 @@ watchPostEffect(() => {
|
||||
<FieldCell
|
||||
:field="field"
|
||||
:value="row.original.data[field.key]"
|
||||
:image="mediaOf(row.original, field.key)"
|
||||
class="min-w-0"
|
||||
/>
|
||||
</RouterLink>
|
||||
<FieldCell v-else :field="field" :value="row.original.data[field.key]" />
|
||||
<FieldCell
|
||||
v-else
|
||||
:field="field"
|
||||
:value="row.original.data[field.key]"
|
||||
:image="mediaOf(row.original, field.key)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #created_at-cell="{ row }">
|
||||
|
||||
@@ -4,14 +4,63 @@ import { t } from '@/i18n'
|
||||
import { computed } from 'vue'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import { EMPTY_TEXT, describeCell, isEmptyCell } from '@/lib/entryCells'
|
||||
import { isMissingImage, type MediaSummary } from '@/lib/mediaField'
|
||||
|
||||
const props = defineProps<{ field: BuiltinField; value: unknown }>()
|
||||
const props = defineProps<{
|
||||
field: BuiltinField
|
||||
value: unknown
|
||||
image?: MediaSummary | null
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const cell = computed(() => describeCell(props.field, props.value))
|
||||
const cell = computed(() => describeCell(props.field, props.value, props.image ?? undefined))
|
||||
const shownImage = computed(() => {
|
||||
const image = cell.value.image
|
||||
return image && !isMissingImage(image) ? image : null
|
||||
})
|
||||
const squareClass = computed(() => (props.compact ? 'size-8' : 'size-10'))
|
||||
const TIP_UI = {
|
||||
content:
|
||||
'h-auto items-center bg-inverted px-2 py-1.5 text-xs/4.5 text-inverted shadow-(--shadow-popover) ring-0',
|
||||
text: 'whitespace-nowrap',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-if="isEmptyCell(cell)" class="text-muted">{{ EMPTY_TEXT }}</span>
|
||||
<span
|
||||
v-if="isEmptyCell(cell)"
|
||||
class="text-muted"
|
||||
:class="field.type === 'media' && compact ? 'inline-block w-8 text-center' : ''"
|
||||
>{{ EMPTY_TEXT }}</span
|
||||
>
|
||||
<span
|
||||
v-else-if="cell.kind === 'thumbnail' && shownImage"
|
||||
class="block shrink-0 overflow-hidden bg-elevated"
|
||||
:class="squareClass"
|
||||
>
|
||||
<img
|
||||
:src="shownImage.thumbnail_url"
|
||||
:alt="shownImage.filename"
|
||||
loading="lazy"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</span>
|
||||
<UTooltip
|
||||
v-else-if="cell.kind === 'thumbnail'"
|
||||
:text="t('Image no longer in the library')"
|
||||
:disabled="compact"
|
||||
:ui="TIP_UI"
|
||||
>
|
||||
<span
|
||||
role="img"
|
||||
:aria-label="t('Image no longer in the library')"
|
||||
:tabindex="compact ? undefined : 0"
|
||||
class="flex shrink-0 items-center justify-center border border-dashed border-accented text-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
:class="squareClass"
|
||||
>
|
||||
<UIcon name="i-lucide-unlink" :class="compact ? 'size-3' : 'size-3.5'" />
|
||||
</span>
|
||||
</UTooltip>
|
||||
<span v-else-if="cell.kind === 'boolean'" class="flex items-center gap-1.5">
|
||||
<UIcon
|
||||
:name="cell.yes ? 'i-lucide-check' : 'i-lucide-minus'"
|
||||
|
||||
@@ -7,6 +7,7 @@ import ChoiceList from '@/components/ChoiceList.vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import FieldTypePicker from '@/components/FieldTypePicker.vue'
|
||||
import SlugField from '@/components/SlugField.vue'
|
||||
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
||||
import {
|
||||
BUILDER_LIMITS,
|
||||
STARTER_KEY,
|
||||
@@ -50,8 +51,14 @@ const emit = defineEmits<{
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
const type = computed(() => props.field.type)
|
||||
const spec = computed(() => fieldTypeSpec(type.value))
|
||||
const hasLimits = computed(() => supportsLimits(type.value))
|
||||
const canUnique = computed(() => supportsUnique(type.value))
|
||||
const showUnique = computed(() => canUnique.value || spec.value.showsUnsupportedOptions)
|
||||
const showRuleGrid = computed(
|
||||
() => hasLimits.value || spec.value.supportsDefault || spec.value.showsUnsupportedOptions,
|
||||
)
|
||||
const hint = computed(() => spec.value.builderHint?.() ?? '')
|
||||
const keyMode = computed(() => keyDisplayMode(props.field.keyMode, props.field.key))
|
||||
const gridClass = computed(() => (props.compact ? 'grid-cols-1' : 'md:grid-cols-2'))
|
||||
|
||||
@@ -196,6 +203,7 @@ watch(
|
||||
@update:model-value="emit('required', Boolean($event))"
|
||||
/>
|
||||
<AppSwitch
|
||||
v-if="showUnique"
|
||||
:model-value="canUnique && field.unique"
|
||||
:label="t('Unique values')"
|
||||
:description="uniqueHelp"
|
||||
@@ -214,7 +222,7 @@ watch(
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid items-start gap-3.5" :class="gridClass">
|
||||
<div v-if="showRuleGrid" class="grid items-start gap-3.5" :class="gridClass">
|
||||
<template v-if="hasLimits">
|
||||
<DirectoryField :label="limitLabels[0]" name="min" :error="errors.min">
|
||||
<UInput
|
||||
@@ -255,7 +263,7 @@ watch(
|
||||
</DirectoryField>
|
||||
</template>
|
||||
<DirectoryField
|
||||
v-if="type !== 'boolean'"
|
||||
v-if="type !== 'boolean' && spec.supportsDefault"
|
||||
:label="t('Default value')"
|
||||
name="default"
|
||||
:error="errors.default"
|
||||
@@ -292,14 +300,26 @@ watch(
|
||||
@update:model-value="emit('default', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<div v-if="!hasLimits" class="flex min-w-0 flex-col gap-1.5">
|
||||
<div
|
||||
v-if="!hasLimits && spec.showsUnsupportedOptions"
|
||||
class="flex min-w-0 flex-col gap-1.5"
|
||||
>
|
||||
<span class="font-medium text-highlighted">{{ t('Limits') }}</span>
|
||||
<span class="text-xs/4.5 text-muted">{{ noLimitsText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hint"
|
||||
data-testid="builder-hint"
|
||||
class="flex gap-2 bg-(--rc-info-soft) px-3 py-2.5 text-info"
|
||||
>
|
||||
<span class="flex pt-0.5"><UIcon name="i-lucide-info" class="size-4" /></span>
|
||||
<span class="text-pretty text-toned">{{ hint }}</span>
|
||||
</div>
|
||||
|
||||
<ChoiceList
|
||||
v-if="type === 'select'"
|
||||
v-if="spec.supportsChoices"
|
||||
:options="field.options"
|
||||
:row-errors="errors.choices"
|
||||
:list-error="errors.options"
|
||||
|
||||
@@ -49,21 +49,24 @@ import {
|
||||
|
||||
type Corner = 'tl' | 'tr' | 'bl' | 'br'
|
||||
import { useImageUpload } from '@/composables/useImageUpload'
|
||||
import type { ImageChoice } from '@/lib/imageChoice'
|
||||
import type { FieldPick, ImageChoice } from '@/lib/imageChoice'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
initialFile?: File | null
|
||||
inArticleIds?: number[]
|
||||
choose?: ImageChoice
|
||||
field?: FieldPick
|
||||
}>(),
|
||||
{ initialFile: null, inArticleIds: () => [], choose: undefined },
|
||||
{ initialFile: null, inArticleIds: () => [], choose: undefined, field: undefined },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploaded: [item: MediaItem]
|
||||
inserted: [item: MediaItem, alt: string, src: string]
|
||||
chosen: [item: MediaItem | null]
|
||||
close: []
|
||||
closed: []
|
||||
}>()
|
||||
|
||||
type Phase = 'idle' | 'dragging-over' | 'duplicate' | 'crop' | 'uploading' | 'rejected' | 'failed'
|
||||
@@ -78,6 +81,9 @@ const DROP_ZONE_UI = {
|
||||
description: 'mt-0 max-w-75 text-xs/5 text-toned',
|
||||
}
|
||||
|
||||
const HEADER_CLASS = 'min-h-12 items-start px-6 pt-5 pb-0 sm:px-6'
|
||||
const FIELD_HEADER_CLASS = `${HEADER_CLASS} max-md:min-h-16`
|
||||
const FIELD_CLOSE = { class: 'size-7 justify-center text-muted max-md:size-11' }
|
||||
const FOOTER_BUTTON_CLASS = 'h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3'
|
||||
|
||||
const TAB_ITEMS = [
|
||||
@@ -108,6 +114,7 @@ const succeeded = ref(false)
|
||||
const tab = ref<Tab>('library')
|
||||
const existing = ref<MediaItem | null>(null)
|
||||
const selected = ref<MediaItem | null>(null)
|
||||
const noneChosen = ref(false)
|
||||
const alt = ref('')
|
||||
const variant = ref('')
|
||||
let tabTouched = false
|
||||
@@ -192,6 +199,9 @@ const modalTitle = computed(() => {
|
||||
if (phase.value === 'crop') {
|
||||
return t('Crop and resize')
|
||||
}
|
||||
if (props.field) {
|
||||
return t('Choose an image')
|
||||
}
|
||||
return props.choose?.title ?? t('Add an image')
|
||||
})
|
||||
|
||||
@@ -432,6 +442,7 @@ function useExisting() {
|
||||
}
|
||||
|
||||
function selectItem(item: MediaItem) {
|
||||
noneChosen.value = false
|
||||
if (selected.value?.id === item.id) {
|
||||
return
|
||||
}
|
||||
@@ -441,8 +452,28 @@ function selectItem(item: MediaItem) {
|
||||
requestVariants()
|
||||
}
|
||||
|
||||
function selectNone() {
|
||||
selected.value = null
|
||||
noneChosen.value = true
|
||||
}
|
||||
|
||||
const canConfirm = computed(() => selected.value !== null || noneChosen.value)
|
||||
|
||||
function confirmField() {
|
||||
if (!canConfirm.value) {
|
||||
return
|
||||
}
|
||||
emit('chosen', noneChosen.value ? null : selected.value)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function returnFocus(event: Event) {
|
||||
event.preventDefault()
|
||||
emit('closed')
|
||||
}
|
||||
|
||||
function requestVariants() {
|
||||
if (variantsRequested || !canManageVariants.value) {
|
||||
if (props.field || variantsRequested || !canManageVariants.value) {
|
||||
return
|
||||
}
|
||||
variantsRequested = true
|
||||
@@ -451,6 +482,7 @@ function requestVariants() {
|
||||
|
||||
async function searchLibrary(query: string) {
|
||||
selected.value = null
|
||||
noneChosen.value = false
|
||||
alt.value = ''
|
||||
variant.value = ''
|
||||
await library.search(query)
|
||||
@@ -739,6 +771,7 @@ watch(open, (isOpen) => {
|
||||
succeeded.value = false
|
||||
existing.value = null
|
||||
selected.value = null
|
||||
noneChosen.value = false
|
||||
alt.value = ''
|
||||
variant.value = ''
|
||||
variantsRequested = false
|
||||
@@ -771,9 +804,11 @@ onUnmounted(() => {
|
||||
v-model:open="open"
|
||||
:title="modalTitle"
|
||||
:description="modalDescription"
|
||||
:content="field ? { onCloseAutoFocus: returnFocus } : undefined"
|
||||
:close="field ? FIELD_CLOSE : undefined"
|
||||
:ui="{
|
||||
content: modalContentClass,
|
||||
header: 'min-h-12 items-start px-6 pt-5 pb-0 sm:px-6',
|
||||
header: field ? FIELD_HEADER_CLASS : HEADER_CLASS,
|
||||
close: 'top-5 end-6',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
body: 'p-0 sm:p-0',
|
||||
@@ -813,11 +848,14 @@ onUnmounted(() => {
|
||||
:first-new-id="library.firstNewId.value"
|
||||
:in-article-ids="inArticleIds"
|
||||
:selected="selected"
|
||||
:plain="choose !== undefined"
|
||||
:none-chosen="noneChosen"
|
||||
:field="field"
|
||||
:plain="choose !== undefined && !field"
|
||||
:tile-ratio="choose && !choose.square ? 'landscape' : 'square'"
|
||||
:hint="choose?.hint"
|
||||
@search="searchLibrary"
|
||||
@select="selectItem"
|
||||
@select-none="selectNone"
|
||||
@load-more="library.loadMore"
|
||||
@retry="library.load()"
|
||||
@upload="selectTab('upload')"
|
||||
@@ -1128,7 +1166,19 @@ onUnmounted(() => {
|
||||
{{ cancelLabel }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="onLibraryTab"
|
||||
v-if="onLibraryTab && field"
|
||||
block
|
||||
:class="[
|
||||
FOOTER_BUTTON_CLASS,
|
||||
canConfirm ? '' : 'cursor-not-allowed opacity-40',
|
||||
]"
|
||||
:aria-disabled="canConfirm ? undefined : 'true'"
|
||||
@click="confirmField"
|
||||
>
|
||||
{{ noneChosen ? t('Remove image') : t('Use this image') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else-if="onLibraryTab"
|
||||
block
|
||||
:class="FOOTER_BUTTON_CLASS"
|
||||
:disabled="!selected"
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, useId } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import ImageUploader from '@/components/ImageUploader.vue'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import {
|
||||
imageButtonName,
|
||||
imageMeta,
|
||||
imageOfItem,
|
||||
isMissingImage,
|
||||
type MediaImage,
|
||||
type MediaSummary,
|
||||
} from '@/lib/mediaField'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const BUTTON_CLASS = 'h-11 gap-1.5 px-3 text-sm text-highlighted disabled:opacity-40 md:h-8'
|
||||
const OUTLINE_CLASS = `${BUTTON_CLASS} border border-accented bg-default ring-0`
|
||||
const ROW_BUTTON_CLASS = `${BUTTON_CLASS} max-md:flex-1`
|
||||
const ROW_OUTLINE_CLASS = `${OUTLINE_CLASS} max-md:flex-1`
|
||||
const FOCUS_RING =
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
required?: boolean
|
||||
image?: MediaSummary | null
|
||||
disabled?: boolean
|
||||
error?: string
|
||||
name?: string
|
||||
}>(),
|
||||
{ required: false, image: null, disabled: false, error: '', name: undefined },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [id: number | null, image: MediaSummary | null]
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number | null>({ default: null })
|
||||
|
||||
const id = useId()
|
||||
const labelId = `${id}-label`
|
||||
const helpId = `${id}-help`
|
||||
const errorId = `${id}-error`
|
||||
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const pickerOpen = ref(false)
|
||||
const hovered = ref(false)
|
||||
const announcement = ref('')
|
||||
const mainButton = ref<{ $el: HTMLElement } | null>(null)
|
||||
let opener: HTMLElement | null = null
|
||||
let focusMain = false
|
||||
|
||||
const state = computed<'empty' | 'filled' | 'missing'>(() => {
|
||||
if (modelValue.value === null) {
|
||||
return 'empty'
|
||||
}
|
||||
return props.image && !isMissingImage(props.image) ? 'filled' : 'missing'
|
||||
})
|
||||
const filled = computed(() => (state.value === 'filled' ? (props.image as MediaImage) : null))
|
||||
const meta = computed(() => (filled.value ? imageMeta(filled.value) : ''))
|
||||
const help = computed(() => t('One image from your library.'))
|
||||
const missingHelp = computed(() =>
|
||||
props.required
|
||||
? t('{0} is required. Choose another image to save.', [props.label])
|
||||
: t('You can still save. The entry keeps the link until you change it.'),
|
||||
)
|
||||
const showHelp = computed(() => state.value !== 'missing')
|
||||
const describedBy = computed(() => (props.error ? `${errorId} ${helpId}` : helpId))
|
||||
const boxClass = computed(() => {
|
||||
if (props.error) {
|
||||
return 'border-error shadow-[inset_0_0_0_1px_var(--ui-error)]'
|
||||
}
|
||||
return state.value === 'empty' ? 'border-dashed border-accented' : 'border-accented'
|
||||
})
|
||||
const mainLabel = computed(() => {
|
||||
if (state.value === 'empty') {
|
||||
return t('Pick an image')
|
||||
}
|
||||
return state.value === 'filled' ? t('Change image') : t('Choose another image')
|
||||
})
|
||||
const mainAriaLabel = computed(() =>
|
||||
state.value === 'empty' ? t('Pick an image for {0}', [props.label]) : undefined,
|
||||
)
|
||||
const mainIcon = computed(() =>
|
||||
state.value === 'empty' ? 'i-lucide-image-plus' : 'i-lucide-image',
|
||||
)
|
||||
const thumbName = computed(() => (filled.value ? imageButtonName(filled.value) : undefined))
|
||||
|
||||
function announce(message: string) {
|
||||
announcement.value = ''
|
||||
nextTick(() => {
|
||||
announcement.value = message
|
||||
})
|
||||
}
|
||||
|
||||
function focusMainButton() {
|
||||
mainButton.value?.$el.focus()
|
||||
}
|
||||
|
||||
async function settleFocus() {
|
||||
await nextTick()
|
||||
focusMainButton()
|
||||
}
|
||||
|
||||
function openPicker(event?: Event) {
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
opener = (event?.currentTarget as HTMLElement | null) ?? mainButton.value?.$el ?? null
|
||||
focusMain = false
|
||||
hovered.value = false
|
||||
pickerOpen.value = true
|
||||
}
|
||||
|
||||
function change(id: number | null, image: MediaSummary | null) {
|
||||
modelValue.value = id
|
||||
emit('change', id, image)
|
||||
}
|
||||
|
||||
function pick(item: MediaItem) {
|
||||
focusMain = true
|
||||
if (item.id === modelValue.value) {
|
||||
return
|
||||
}
|
||||
change(item.id, imageOfItem(item))
|
||||
announce(t('Image selected: {0}', [item.filename]))
|
||||
}
|
||||
|
||||
function onChosen(item: MediaItem | null) {
|
||||
if (item) {
|
||||
pick(item)
|
||||
return
|
||||
}
|
||||
focusMain = true
|
||||
if (modelValue.value !== null) {
|
||||
change(null, null)
|
||||
announce(t('Image removed from {0}.', [props.label]))
|
||||
}
|
||||
}
|
||||
|
||||
async function onPickerClosed() {
|
||||
await nextTick()
|
||||
const target = focusMain ? mainButton.value?.$el : opener
|
||||
;(target?.isConnected ? target : mainButton.value?.$el)?.focus()
|
||||
}
|
||||
|
||||
function drop(message: string) {
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
change(null, null)
|
||||
announce(message)
|
||||
settleFocus()
|
||||
}
|
||||
|
||||
function remove() {
|
||||
drop(t('Image removed from {0}.', [props.label]))
|
||||
}
|
||||
|
||||
function clear() {
|
||||
drop(t('{0} cleared.', [props.label]))
|
||||
}
|
||||
</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="media-control"
|
||||
class="border bg-default p-3"
|
||||
:class="boxClass"
|
||||
>
|
||||
<div
|
||||
v-if="state === 'empty'"
|
||||
class="flex flex-col gap-3 md:flex-row md:items-center"
|
||||
data-testid="media-empty"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span
|
||||
class="flex size-10 shrink-0 items-center justify-center bg-elevated text-toned"
|
||||
>
|
||||
<UIcon name="i-lucide-image" class="size-5" />
|
||||
</span>
|
||||
<span class="text-muted">{{ t('No image selected.') }}</span>
|
||||
</div>
|
||||
<UButton
|
||||
ref="mainButton"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:icon="mainIcon"
|
||||
:disabled="disabled"
|
||||
:aria-label="mainAriaLabel"
|
||||
:aria-required="required ? 'true' : undefined"
|
||||
:aria-invalid="error ? 'true' : undefined"
|
||||
:aria-describedby="describedBy"
|
||||
data-field-focus
|
||||
:class="OUTLINE_CLASS"
|
||||
@click="openPicker"
|
||||
>
|
||||
{{ mainLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col gap-3 md:flex-row md:items-start md:gap-4"
|
||||
:data-testid="state === 'filled' ? 'media-filled' : 'media-missing'"
|
||||
>
|
||||
<button
|
||||
v-if="filled"
|
||||
type="button"
|
||||
data-testid="media-thumbnail"
|
||||
:aria-label="thumbName"
|
||||
:disabled="disabled"
|
||||
class="relative w-full shrink-0 overflow-hidden border bg-elevated max-md:aspect-video md:h-30 md:w-40"
|
||||
:class="[
|
||||
hovered && isDesktop ? 'border-(--ui-text-muted)' : 'border-accented',
|
||||
disabled ? 'opacity-60' : '',
|
||||
FOCUS_RING,
|
||||
]"
|
||||
@click="openPicker"
|
||||
@pointerenter="hovered = true"
|
||||
@pointerleave="hovered = false"
|
||||
>
|
||||
<img
|
||||
:src="filled.thumbnail_url"
|
||||
alt=""
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
<span
|
||||
v-if="hovered && isDesktop && !disabled"
|
||||
data-testid="media-hover-bar"
|
||||
class="absolute inset-x-0 bottom-0 flex h-7 items-center justify-center gap-1.5 bg-inverted text-xs font-medium text-inverted"
|
||||
>
|
||||
<UIcon name="i-lucide-image" class="size-3.5" />
|
||||
{{ t('Change image') }}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-else
|
||||
role="img"
|
||||
:aria-label="t('Image no longer in the library')"
|
||||
data-testid="media-placeholder"
|
||||
class="flex w-full shrink-0 items-center justify-center border border-dashed border-accented text-muted max-md:aspect-video md:h-30 md:w-40"
|
||||
:class="disabled ? 'opacity-60' : ''"
|
||||
>
|
||||
<UIcon name="i-lucide-image-off" class="size-6" />
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-3 md:min-h-30 md:justify-between">
|
||||
<div v-if="filled" class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="truncate font-medium text-highlighted">{{
|
||||
filled.filename
|
||||
}}</span>
|
||||
<span class="text-xs/4.5 text-muted" data-testid="media-meta">{{
|
||||
meta
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<span class="font-medium text-highlighted">{{
|
||||
t('This image is no longer in the library')
|
||||
}}</span>
|
||||
<span
|
||||
:id="helpId"
|
||||
class="text-xs/4.5 text-pretty text-muted"
|
||||
data-testid="media-missing-help"
|
||||
>{{ missingHelp }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UButton
|
||||
ref="mainButton"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:icon="mainIcon"
|
||||
:disabled="disabled"
|
||||
:aria-required="required ? 'true' : undefined"
|
||||
:aria-invalid="error ? 'true' : undefined"
|
||||
:aria-describedby="describedBy"
|
||||
data-field-focus
|
||||
:class="ROW_OUTLINE_CLASS"
|
||||
@click="openPicker"
|
||||
>
|
||||
{{ mainLabel }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="filled"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
:disabled="disabled"
|
||||
:aria-label="t('Remove image from {0}', [label])"
|
||||
:class="ROW_BUTTON_CLASS"
|
||||
@click="remove"
|
||||
>
|
||||
{{ t('Remove') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:disabled="disabled"
|
||||
:aria-label="t('Clear {0}', [label])"
|
||||
:class="ROW_BUTTON_CLASS"
|
||||
@click="clear"
|
||||
>
|
||||
{{ t('Clear') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="showHelp"
|
||||
:id="helpId"
|
||||
data-testid="media-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="media-live">{{
|
||||
announcement
|
||||
}}</span>
|
||||
</DirectoryField>
|
||||
|
||||
<ImageUploader
|
||||
v-model:open="pickerOpen"
|
||||
:field="{ optional: !required, currentId: modelValue }"
|
||||
@chosen="onChosen"
|
||||
@uploaded="pick"
|
||||
@closed="onPickerClosed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,6 +8,7 @@ import VariantPickerField from '@/components/variants/VariantPickerField.vue'
|
||||
import type { LibraryStatus } from '@/composables/useLibraryList'
|
||||
import type { ImageVariant } from '@/composables/useImageVariants'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import type { FieldPick } from '@/lib/imageChoice'
|
||||
import { accessibleName, formatDimensions, formatSize } from '@/lib/mediaFormat'
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 120
|
||||
@@ -41,11 +42,14 @@ const props = defineProps<{
|
||||
plain?: boolean
|
||||
tileRatio?: 'square' | 'landscape'
|
||||
hint?: string
|
||||
field?: FieldPick
|
||||
noneChosen?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
search: [query: string]
|
||||
select: [item: MediaItem]
|
||||
'select-none': []
|
||||
'load-more': []
|
||||
retry: []
|
||||
upload: []
|
||||
@@ -71,12 +75,19 @@ const searching = computed(() => props.query !== '')
|
||||
const summary = computed(() =>
|
||||
searching.value ? t('Newest first') : t('Newest first · {0} images', [props.total]),
|
||||
)
|
||||
const chosen = computed(() => props.selected !== null || props.noneChosen)
|
||||
const scrollHeight = computed(() => {
|
||||
if (props.plain) {
|
||||
return 'max-h-71 md:max-h-89'
|
||||
}
|
||||
return props.selected ? 'max-h-32 md:max-h-49.5' : 'max-h-77'
|
||||
return chosen.value ? 'max-h-32 md:max-h-49.5' : 'max-h-77'
|
||||
})
|
||||
const showNoImage = computed(() => props.field?.optional === true && showGrid.value)
|
||||
const emptyDescription = computed(() =>
|
||||
props.field
|
||||
? t('Images you upload appear here, ready to use in any article or entry.')
|
||||
: t('Images you upload appear here, ready to use in any article.'),
|
||||
)
|
||||
const showEmptyLibrary = computed(
|
||||
() => props.status === 'ready' && props.items.length === 0 && !searching.value,
|
||||
)
|
||||
@@ -85,7 +96,7 @@ const showNoResults = computed(
|
||||
)
|
||||
const showGrid = computed(() => props.status === 'ready' && props.items.length > 0)
|
||||
const showLoadMore = computed(
|
||||
() => showGrid.value && props.hasMore && (props.plain || !props.selected),
|
||||
() => showGrid.value && props.hasMore && (props.plain || !chosen.value),
|
||||
)
|
||||
const noResultsTitle = computed(() => t('No images match \u0022{0}\u0022', [props.query]))
|
||||
|
||||
@@ -115,7 +126,19 @@ function isSelected(item: MediaItem) {
|
||||
}
|
||||
|
||||
function isInArticle(item: MediaItem) {
|
||||
return props.inArticleIds.includes(item.id)
|
||||
return !props.field && props.inArticleIds.includes(item.id)
|
||||
}
|
||||
|
||||
function isCurrent(item: MediaItem) {
|
||||
return props.field?.currentId === item.id
|
||||
}
|
||||
|
||||
function tileName(item: MediaItem) {
|
||||
if (!props.field) {
|
||||
return accessibleName(item)
|
||||
}
|
||||
const name = `${item.filename}, ${formatSize(item.size)}`
|
||||
return isCurrent(item) ? `${name}, ${t('Current')}` : name
|
||||
}
|
||||
|
||||
function onTileKeydown(event: KeyboardEvent, item: MediaItem) {
|
||||
@@ -206,7 +229,7 @@ onBeforeUnmount(clearDebounce)
|
||||
variant="naked"
|
||||
icon="i-lucide-images"
|
||||
:title="t('Your library is empty')"
|
||||
:description="t('Images you upload appear here, ready to use in any article.')"
|
||||
:description="emptyDescription"
|
||||
:ui="EMPTY_STATE_UI"
|
||||
>
|
||||
<template #actions>
|
||||
@@ -253,6 +276,26 @@ onBeforeUnmount(clearDebounce)
|
||||
</div>
|
||||
|
||||
<template v-else-if="showGrid">
|
||||
<button
|
||||
v-if="showNoImage"
|
||||
type="button"
|
||||
data-testid="picker-no-image"
|
||||
:aria-pressed="noneChosen"
|
||||
class="flex min-h-10 items-center gap-2.5 border px-3 text-start focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary max-md:min-h-12"
|
||||
:class="
|
||||
noneChosen
|
||||
? 'border-primary bg-(--rc-sand) text-(--rc-on-sand)'
|
||||
: 'border-accented bg-default text-highlighted'
|
||||
"
|
||||
@click="emit('select-none')"
|
||||
>
|
||||
<UIcon name="i-lucide-image-off" class="size-4 shrink-0" />
|
||||
<span class="font-medium">{{ t('No image') }}</span>
|
||||
<span class="flex-1" :class="noneChosen ? 'text-(--rc-on-sand)' : 'text-muted'">{{
|
||||
t('Leave this field empty.')
|
||||
}}</span>
|
||||
<UIcon v-if="noneChosen" name="i-lucide-check" class="size-4 shrink-0" />
|
||||
</button>
|
||||
<span v-if="!plain" data-testid="picker-summary" class="text-xs/5 text-muted">{{
|
||||
summary
|
||||
}}</span>
|
||||
@@ -268,7 +311,7 @@ onBeforeUnmount(clearDebounce)
|
||||
type="button"
|
||||
data-picker-tile
|
||||
:data-image-id="item.id"
|
||||
:aria-label="accessibleName(item)"
|
||||
:aria-label="tileName(item)"
|
||||
:aria-pressed="isSelected(item)"
|
||||
class="group flex min-w-0 flex-col gap-1.5 text-start focus-visible:outline-none"
|
||||
@click="emit('select', item)"
|
||||
@@ -287,6 +330,11 @@ onBeforeUnmount(clearDebounce)
|
||||
class="w-full border border-default"
|
||||
:class="tileAspect"
|
||||
/>
|
||||
<span
|
||||
v-if="isCurrent(item)"
|
||||
class="absolute bottom-1 left-1 inline-flex h-4.5 items-center bg-(--rc-sand) px-1.25 text-[0.6875rem] font-medium text-(--rc-on-sand)"
|
||||
>{{ t('Current') }}</span
|
||||
>
|
||||
<span
|
||||
v-if="isInArticle(item)"
|
||||
class="absolute bottom-1 left-1 inline-flex h-4.5 items-center bg-secondary-200 px-1.25 text-[0.6875rem] font-medium text-primary-900 dark:bg-secondary-300"
|
||||
@@ -338,7 +386,7 @@ onBeforeUnmount(clearDebounce)
|
||||
<p v-if="hint" class="text-xs/5 text-muted">{{ hint }}</p>
|
||||
|
||||
<div
|
||||
v-if="selected && !plain"
|
||||
v-if="selected && !plain && !noneChosen"
|
||||
data-testid="picker-panel"
|
||||
class="flex flex-col gap-3 border-t border-default pt-3"
|
||||
>
|
||||
@@ -355,12 +403,14 @@ onBeforeUnmount(clearDebounce)
|
||||
</div>
|
||||
</div>
|
||||
<VariantPickerField
|
||||
v-if="!field"
|
||||
v-model:variant="variant"
|
||||
:image="selected"
|
||||
:variants="variants ?? []"
|
||||
:ready="variantsReady ?? false"
|
||||
/>
|
||||
<UFormField
|
||||
v-if="!field"
|
||||
:label="t('Alt text (optional)')"
|
||||
:help="
|
||||
t(
|
||||
|
||||
@@ -23,6 +23,9 @@ const badge = computed(() => {
|
||||
if (props.cause) {
|
||||
return t('Possible cause')
|
||||
}
|
||||
if (props.change.flag === 'cannotApply') {
|
||||
return t("Can't be applied")
|
||||
}
|
||||
if (props.change.flag === 'mayNotFit') {
|
||||
return t('Entries may not fit')
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ import { computed } from 'vue'
|
||||
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
||||
import SchemaChangeList from '@/components/SchemaChangeList.vue'
|
||||
import { entriesLine } from '@/lib/schemaEntries'
|
||||
import { summaryLine, type FieldChange } from '@/lib/schemaBuilderDiff'
|
||||
import {
|
||||
hasRefusedChange,
|
||||
isMediaOnlyClean,
|
||||
summaryLine,
|
||||
type FieldChange,
|
||||
} from '@/lib/schemaBuilderDiff'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
@@ -19,6 +24,8 @@ defineEmits<{ apply: []; close: [] }>()
|
||||
|
||||
const summary = computed(() => summaryLine(props.changes))
|
||||
const entries = computed(() => entriesLine(props.name, props.total))
|
||||
const refused = computed(() => hasRefusedChange(props.changes))
|
||||
const mediaOnlyClean = computed(() => isMediaOnlyClean(props.changes))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -32,6 +39,7 @@ const entries = computed(() => entriesLine(props.name, props.total))
|
||||
wide
|
||||
:pending="applying"
|
||||
:confirm-disabled="offline"
|
||||
:confirm-blocked="refused"
|
||||
@confirm="$emit('apply')"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
@@ -42,7 +50,10 @@ const entries = computed(() => entriesLine(props.name, props.total))
|
||||
<span class="text-muted">{{ entries }}</span>
|
||||
</div>
|
||||
<SchemaChangeList :changes="changes" test-id="review-changes" />
|
||||
<p v-if="!applying" class="text-xs/4.5 text-muted">
|
||||
<p v-if="refused" class="text-xs/4.5 text-muted">
|
||||
{{ t('Back to editing keeps your other changes.') }}
|
||||
</p>
|
||||
<p v-else-if="!mediaOnlyClean && !applying" class="text-xs/4.5 text-muted">
|
||||
{{
|
||||
t(
|
||||
"Flagged changes can be refused if existing entries don't fit. If that happens, nothing is changed.",
|
||||
|
||||
@@ -111,3 +111,59 @@ describe('FieldCell', () => {
|
||||
expect(element.className).toContain('text-muted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FieldCell for media', () => {
|
||||
const LIME = {
|
||||
id: 7,
|
||||
filename: 'lime-plaster-workshop.jpg',
|
||||
width: 1600,
|
||||
height: 900,
|
||||
size: 245760,
|
||||
thumbnail_url: '/media/lime-plaster-workshop.jpg',
|
||||
}
|
||||
|
||||
async function showMedia(value: unknown, image: unknown, compact = false) {
|
||||
await mountInApp(FieldCell, {
|
||||
field: apiField('Thumbnail', 'thumbnail', 'media'),
|
||||
value,
|
||||
image,
|
||||
compact,
|
||||
})
|
||||
return cell()
|
||||
}
|
||||
|
||||
it('draws a 40 px thumbnail', async () => {
|
||||
const element = await showMedia(7, LIME)
|
||||
|
||||
const img = element.querySelector('img')!
|
||||
expect(img.getAttribute('src')).toBe(LIME.thumbnail_url)
|
||||
expect(img.getAttribute('alt')).toBe(LIME.filename)
|
||||
expect(element.className).toContain('size-10')
|
||||
})
|
||||
|
||||
it('draws a 32 px thumbnail when compact', async () => {
|
||||
const element = await showMedia(7, LIME, true)
|
||||
|
||||
expect(element.className).toContain('size-8')
|
||||
})
|
||||
|
||||
it('shows a muted dash for no image, centred in 32 px when compact', async () => {
|
||||
const plain = await showMedia(null, null)
|
||||
expect(text(plain)).toBe('–')
|
||||
expect(plain.className).toContain('text-muted')
|
||||
unmountApp()
|
||||
|
||||
const compact = await showMedia(null, null, true)
|
||||
expect(compact.className).toContain('w-8')
|
||||
expect(compact.className).toContain('text-center')
|
||||
})
|
||||
|
||||
it('shows the dashed placeholder with its name for a missing image', async () => {
|
||||
const element = await showMedia(7, { id: 7, missing: true })
|
||||
|
||||
expect(element.getAttribute('role')).toBe('img')
|
||||
expect(element.getAttribute('aria-label')).toBe('Image no longer in the library')
|
||||
expect(element.className).toContain('border-dashed')
|
||||
expect(element.className).toContain('size-10')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,533 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import { ApiError, NetworkError } from '@/composables/useApi'
|
||||
import { drawsIcon, fieldLibrary } from '@/views/__tests__/mediaFieldKit'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH } from './viewport'
|
||||
import {
|
||||
altField,
|
||||
apiKit,
|
||||
click,
|
||||
clickTab,
|
||||
dialogText,
|
||||
findButton,
|
||||
installApi,
|
||||
listCalls,
|
||||
modalOpen,
|
||||
mountUploader,
|
||||
openUploader,
|
||||
pageBody,
|
||||
press,
|
||||
searchField,
|
||||
secondPage,
|
||||
selectedTab,
|
||||
selectTile,
|
||||
settle,
|
||||
skeletonTiles,
|
||||
tabButtons,
|
||||
textOf,
|
||||
tile,
|
||||
tiles,
|
||||
typeInto,
|
||||
type PickerRoutes,
|
||||
} from './pickerKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/fileHash', () => ({ hashFile: vi.fn(async () => 'a'.repeat(64)) }))
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
let wrapper: ReturnType<typeof mountUploader> | null = null
|
||||
const RealXMLHttpRequest = global.XMLHttpRequest
|
||||
|
||||
class FakeUpload {
|
||||
static last: FakeUpload | null = null
|
||||
listeners: Record<string, Array<() => void>> = {}
|
||||
status = 0
|
||||
responseText = ''
|
||||
upload = { addEventListener: () => undefined }
|
||||
open() {}
|
||||
constructor() {
|
||||
FakeUpload.last = this
|
||||
}
|
||||
addEventListener(type: string, listener: () => void) {
|
||||
this.listeners[type] = [...(this.listeners[type] ?? []), listener]
|
||||
}
|
||||
send() {}
|
||||
abort() {}
|
||||
answer(status: number, body: string) {
|
||||
this.status = status
|
||||
this.responseText = body
|
||||
this.listeners.load?.forEach((listener) => listener())
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
global.XMLHttpRequest = FakeUpload as unknown as typeof XMLHttpRequest
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
global.XMLHttpRequest = RealXMLHttpRequest
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function libraryRoutes(extra: PickerRoutes = {}): PickerRoutes {
|
||||
return { list: () => pageBody(fieldLibrary()), ...extra }
|
||||
}
|
||||
|
||||
async function openField(
|
||||
field: { optional: boolean; currentId: number | null } = { optional: true, currentId: 1 },
|
||||
routes: PickerRoutes = libraryRoutes(),
|
||||
width = DESKTOP_WIDTH,
|
||||
) {
|
||||
installApi(routes)
|
||||
wrapper = mountUploader({ field }, width)
|
||||
await openUploader(wrapper)
|
||||
}
|
||||
|
||||
const summary = () => textOf(document.body.querySelector('[data-testid="picker-summary"]'))
|
||||
const strip = () => document.body.querySelector('[data-testid="picker-panel"]')
|
||||
const noImage = () =>
|
||||
document.body.querySelector<HTMLButtonElement>('[data-testid="picker-no-image"]')
|
||||
const variantRequests = () =>
|
||||
apiKit.request.mock.calls.filter(([path]) => String(path).includes('/v1/image-variants'))
|
||||
|
||||
describe('The dialog of a field', () => {
|
||||
it('is titled Choose an image, with the tabs Upload and Library opening on Library', async () => {
|
||||
await openField()
|
||||
|
||||
expect(dialogText()).toContain('Choose an image')
|
||||
expect(dialogText()).not.toContain('Add an image')
|
||||
expect(tabButtons().map(textOf)).toEqual(['Upload', 'Library'])
|
||||
expect(textOf(selectedTab())).toBe('Library')
|
||||
expect(searchField().placeholder).toBe('Search by filename')
|
||||
})
|
||||
|
||||
it('shows the newest first line with the count, square tiles and Load more', async () => {
|
||||
await openField()
|
||||
|
||||
expect(summary()).toBe('Newest first · 48 images')
|
||||
expect(tiles()).toHaveLength(12)
|
||||
expect(findButton('Load more')).toBeInstanceOf(HTMLButtonElement)
|
||||
const thumb = tile('north-wall.jpg').querySelector('[data-testid="media-thumb"]')!
|
||||
expect(thumb.className).toContain('aspect-square')
|
||||
})
|
||||
|
||||
it('has 4 columns on desktop and 3 on mobile', async () => {
|
||||
await openField()
|
||||
expect(tiles()[0]!.parentElement!.className).toContain('md:grid-cols-4')
|
||||
wrapper!.unmount()
|
||||
document.body.innerHTML = ''
|
||||
|
||||
await openField(undefined, undefined, MOBILE_WIDTH)
|
||||
expect(tiles()[0]!.parentElement!.className).toContain('grid-cols-3')
|
||||
})
|
||||
|
||||
it('opens with Cancel enabled and Use this image announced as disabled', async () => {
|
||||
await openField()
|
||||
|
||||
expect(findButton('Cancel')?.disabled).toBe(false)
|
||||
expect(findButton('Use this image')?.getAttribute('aria-disabled')).toBe('true')
|
||||
await click(findButton('Use this image'))
|
||||
expect(wrapper!.emitted('chosen')).toBeUndefined()
|
||||
expect(modalOpen()).toBe(true)
|
||||
})
|
||||
|
||||
it('never asks for the variants and shows neither a variant select nor an alt text', async () => {
|
||||
await openField()
|
||||
await selectTile('north-wall.jpg')
|
||||
|
||||
expect(variantRequests()).toHaveLength(0)
|
||||
expect(altField()).toBeNull()
|
||||
expect(dialogText()).not.toContain('Alt text')
|
||||
expect(dialogText()).not.toContain('Variant')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The No image row', () => {
|
||||
it('tops the Library tab of an optional field', async () => {
|
||||
await openField({ optional: true, currentId: 1 })
|
||||
|
||||
const row = noImage()!
|
||||
expect(textOf(row)).toBe('No image Leave this field empty.')
|
||||
expect(row.getAttribute('aria-pressed')).toBe('false')
|
||||
expect(drawsIcon(row, 'image-off')).toBe(true)
|
||||
const order = [
|
||||
...document.body.querySelectorAll('input, [data-testid], [data-picker-tile]'),
|
||||
]
|
||||
expect(order.indexOf(searchField())).toBeLessThan(order.indexOf(row))
|
||||
expect(order.indexOf(row)).toBeLessThan(order.indexOf(tiles()[0]!))
|
||||
})
|
||||
|
||||
it('is absent on a required field, whose title stays Choose an image', async () => {
|
||||
await openField({ optional: false, currentId: 1 })
|
||||
|
||||
expect(noImage()).toBeNull()
|
||||
expect(dialogText()).toContain('Choose an image')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['empty', { list: () => pageBody([], null) }],
|
||||
['failed', { list: () => Promise.reject(new ApiError(500)) }],
|
||||
['loading', { list: () => new Promise(() => undefined) }],
|
||||
])('is absent while the library is %s', async (_name, routes) => {
|
||||
await openField({ optional: true, currentId: null }, routes as PickerRoutes)
|
||||
|
||||
expect(noImage()).toBeNull()
|
||||
})
|
||||
|
||||
it('takes the sand fill, the primary border and a check when chosen, releasing any tile', async () => {
|
||||
await openField()
|
||||
await selectTile('kitchen-after.webp')
|
||||
|
||||
await click(noImage())
|
||||
|
||||
const row = noImage()!
|
||||
expect(row.getAttribute('aria-pressed')).toBe('true')
|
||||
expect(row.className).toContain('bg-(--rc-sand)')
|
||||
expect(row.className).toContain('border-primary')
|
||||
expect(drawsIcon(row, 'check')).toBe(true)
|
||||
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('false')
|
||||
expect(strip()).toBeNull()
|
||||
expect(findButton('Load more')).toBeUndefined()
|
||||
expect(findButton('Remove image')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(findButton('Use this image')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is released when a tile is chosen again', async () => {
|
||||
await openField()
|
||||
await click(noImage())
|
||||
|
||||
await selectTile('kitchen-after.webp')
|
||||
|
||||
expect(noImage()!.getAttribute('aria-pressed')).toBe('false')
|
||||
expect(findButton('Use this image')).toBeInstanceOf(HTMLButtonElement)
|
||||
})
|
||||
|
||||
it('emits the removal and closes with Remove image', async () => {
|
||||
await openField()
|
||||
await click(noImage())
|
||||
|
||||
await click(findButton('Remove image'))
|
||||
|
||||
expect(wrapper!.emitted('chosen')).toEqual([[null]])
|
||||
await vi.waitFor(() => expect(modalOpen()).toBe(false))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tiles of a field', () => {
|
||||
it('mark the current image with the sand badge instead of In this article', async () => {
|
||||
await openField({ optional: true, currentId: 1 })
|
||||
|
||||
const current = tile('north-wall.jpg')
|
||||
const badge = current.querySelector<HTMLElement>('span[class*="bottom-1"]')!
|
||||
expect(textOf(badge)).toBe('Current')
|
||||
expect(badge.className).toContain('bg-(--rc-sand)')
|
||||
expect(badge.className).toContain('text-(--rc-on-sand)')
|
||||
expect(badge.className).toContain('h-4.5')
|
||||
expect(badge.className).toContain('bottom-1')
|
||||
expect(badge.className).toContain('left-1')
|
||||
expect(dialogText()).not.toContain('In this article')
|
||||
expect(tiles().filter((element) => textOf(element).includes('Current'))).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('are buttons named with the filename and size, plus Current for the current image', async () => {
|
||||
await openField({ optional: true, currentId: 1 })
|
||||
|
||||
expect(tile('kitchen-after.webp').getAttribute('aria-label')).toBe(
|
||||
'kitchen-after.webp, 1.2 MB',
|
||||
)
|
||||
expect(tiles()[0]!.getAttribute('aria-label')).toBe('north-wall.jpg, 3.9 MB, Current')
|
||||
expect(
|
||||
tiles().every((element) => !element.getAttribute('aria-label')!.includes('used')),
|
||||
).toBe(true)
|
||||
expect(tiles().every((element) => element.hasAttribute('aria-pressed'))).toBe(true)
|
||||
})
|
||||
|
||||
it('keep the article picker names and badges unchanged', async () => {
|
||||
installApi(libraryRoutes())
|
||||
wrapper = mountUploader({})
|
||||
await openUploader(wrapper)
|
||||
|
||||
expect(dialogText()).toContain('Add an image')
|
||||
expect(tiles()[0]!.getAttribute('aria-label')).toContain('used in')
|
||||
expect(dialogText()).toContain('In this article')
|
||||
expect(noImage()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Choosing an image', () => {
|
||||
it('gives the tile the primary outline and a check, and shows the strip below the grid', async () => {
|
||||
await openField()
|
||||
|
||||
await selectTile('lime-plaster-workshop.jpg')
|
||||
|
||||
const chosen = tile('lime-plaster-workshop.jpg')
|
||||
expect(chosen.getAttribute('aria-pressed')).toBe('true')
|
||||
expect(chosen.querySelector('[data-testid="picker-check"]')).not.toBeNull()
|
||||
expect(chosen.innerHTML).toContain('outline-primary')
|
||||
expect(textOf(strip())).toBe('lime-plaster-workshop.jpg 1600 × 900 · 240 KB')
|
||||
expect(strip()!.querySelector('[data-testid="media-thumb"]')!.className).toContain(
|
||||
'size-14',
|
||||
)
|
||||
expect(strip()!.querySelector('input, select, [role="combobox"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('draws the strip of a vector image as Vector', async () => {
|
||||
await openField()
|
||||
|
||||
await selectTile('logo-renew.svg')
|
||||
|
||||
expect(textOf(strip())).toBe('logo-renew.svg Vector · 8 KB')
|
||||
})
|
||||
|
||||
it('hides Load more while a tile is selected and shows it again after a search release', async () => {
|
||||
await openField()
|
||||
expect(findButton('Load more')).toBeInstanceOf(HTMLButtonElement)
|
||||
|
||||
await selectTile('north-wall.jpg')
|
||||
|
||||
expect(findButton('Load more')).toBeUndefined()
|
||||
expect(findButton('Use this image')?.getAttribute('aria-disabled')).toBeNull()
|
||||
})
|
||||
|
||||
it('emits the item and closes with Use this image', async () => {
|
||||
await openField()
|
||||
await selectTile('bathroom-tiles.png')
|
||||
|
||||
await click(findButton('Use this image'))
|
||||
|
||||
const [[item]] = wrapper!.emitted('chosen') as [[{ id: number; filename: string }]]
|
||||
expect(item.id).toBe(8)
|
||||
expect(item.filename).toBe('bathroom-tiles.png')
|
||||
await vi.waitFor(() => expect(modalOpen()).toBe(false))
|
||||
})
|
||||
|
||||
it('lets the user pick the current tile again', async () => {
|
||||
await openField({ optional: true, currentId: 1 })
|
||||
|
||||
await selectTile('north-wall.jpg')
|
||||
await click(findButton('Use this image'))
|
||||
|
||||
expect(wrapper!.emitted('chosen')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('selects with Enter and Space', async () => {
|
||||
await openField()
|
||||
|
||||
press(tile('north-wall.jpg'), 'Enter')
|
||||
await settle()
|
||||
expect(tile('north-wall.jpg').getAttribute('aria-pressed')).toBe('true')
|
||||
|
||||
press(tile('kitchen-after.webp'), ' ')
|
||||
await settle()
|
||||
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('true')
|
||||
expect(tile('north-wall.jpg').getAttribute('aria-pressed')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Closing without choosing', () => {
|
||||
it.each([
|
||||
['Cancel', async () => click(findButton('Cancel'))],
|
||||
[
|
||||
'Escape',
|
||||
async () => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
)
|
||||
await settle()
|
||||
},
|
||||
],
|
||||
])('emits nothing with %s and reports the close', async (_name, close) => {
|
||||
await openField()
|
||||
await selectTile('north-wall.jpg')
|
||||
|
||||
await close()
|
||||
|
||||
await vi.waitFor(() => expect(modalOpen()).toBe(false))
|
||||
expect(wrapper!.emitted('chosen')).toBeUndefined()
|
||||
expect(wrapper!.emitted('closed')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Library states of a field', () => {
|
||||
it('shows the field copy of the empty library and Upload an image opens the Upload tab', async () => {
|
||||
await openField({ optional: true, currentId: null }, { list: () => pageBody([], null) })
|
||||
expect(textOf(selectedTab())).toBe('Upload')
|
||||
|
||||
await clickTab('Library')
|
||||
|
||||
expect(dialogText()).toContain('Your library is empty')
|
||||
expect(dialogText()).toContain(
|
||||
'Images you upload appear here, ready to use in any article or entry.',
|
||||
)
|
||||
expect(findButton('Use this image')?.getAttribute('aria-disabled')).toBe('true')
|
||||
await click(findButton('Upload an image'))
|
||||
expect(textOf(selectedTab())).toBe('Upload')
|
||||
})
|
||||
|
||||
it('keeps the article copy of the empty library', async () => {
|
||||
installApi({ list: () => pageBody([], null) })
|
||||
wrapper = mountUploader({})
|
||||
await openUploader(wrapper)
|
||||
await clickTab('Library')
|
||||
|
||||
expect(dialogText()).toContain(
|
||||
'Images you upload appear here, ready to use in any article.',
|
||||
)
|
||||
expect(dialogText()).not.toContain('or entry')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['500', () => Promise.reject(new ApiError(500))],
|
||||
['a network error', () => Promise.reject(new NetworkError())],
|
||||
])('shows the error alert with Try again on %s and no No image row', async (_name, list) => {
|
||||
await openField({ optional: true, currentId: 1 }, { list })
|
||||
|
||||
expect(dialogText()).toContain("Couldn't load your library")
|
||||
expect(dialogText()).toContain('Check your connection and try again.')
|
||||
expect(noImage()).toBeNull()
|
||||
})
|
||||
|
||||
it('repeats only the library request with Try again', async () => {
|
||||
let fail = true
|
||||
await openField(
|
||||
{ optional: true, currentId: 1 },
|
||||
{ list: () => (fail ? Promise.reject(new ApiError(500)) : pageBody(fieldLibrary())) },
|
||||
)
|
||||
fail = false
|
||||
const before = apiKit.request.mock.calls.length
|
||||
|
||||
await click(findButton('Try again'))
|
||||
|
||||
expect(apiKit.request.mock.calls.length - before).toBe(1)
|
||||
expect(listCalls()).toHaveLength(2)
|
||||
expect(tiles()).toHaveLength(12)
|
||||
expect(dialogText()).not.toContain("Couldn't load your library")
|
||||
})
|
||||
|
||||
it('draws 8 skeleton tiles after 150 ms on desktop, 9 on mobile, with Use this image disabled', async () => {
|
||||
vi.useFakeTimers()
|
||||
installApi({ list: () => new Promise(() => undefined) })
|
||||
wrapper = mountUploader({ field: { optional: true, currentId: 1 } })
|
||||
;(wrapper.vm as unknown as { open: boolean }).open = true
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(skeletonTiles()).toHaveLength(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
expect(skeletonTiles()).toHaveLength(8)
|
||||
expect(noImage()).toBeNull()
|
||||
expect(findButton('Use this image')?.getAttribute('aria-disabled')).toBe('true')
|
||||
})
|
||||
|
||||
it('draws 9 skeleton tiles on mobile', async () => {
|
||||
vi.useFakeTimers()
|
||||
installApi({ list: () => new Promise(() => undefined) })
|
||||
wrapper = mountUploader({ field: { optional: true, currentId: 1 } }, MOBILE_WIDTH)
|
||||
;(wrapper.vm as unknown as { open: boolean }).open = true
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
expect(skeletonTiles()).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('trims and debounces the search, releases the selection and reloads the grid', async () => {
|
||||
await openField(undefined, {
|
||||
list: (query) =>
|
||||
query.get('q') ? pageBody(fieldLibrary().slice(0, 1), 1) : pageBody(fieldLibrary()),
|
||||
})
|
||||
await selectTile('kitchen-after.webp')
|
||||
|
||||
await typeInto(searchField(), ' wall ')
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
await settle()
|
||||
|
||||
expect(listCalls().map((query) => query.get('q'))).toEqual([null, 'wall'])
|
||||
expect(tiles()).toHaveLength(1)
|
||||
expect(strip()).toBeNull()
|
||||
expect(findButton('Use this image')?.getAttribute('aria-disabled')).toBe('true')
|
||||
})
|
||||
|
||||
it('reads the no results copy with Clear search', async () => {
|
||||
await openField(undefined, {
|
||||
list: (query) => (query.get('q') ? pageBody([], 0) : pageBody(fieldLibrary())),
|
||||
})
|
||||
|
||||
await typeInto(searchField(), 'wall')
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
await settle()
|
||||
|
||||
expect(dialogText()).toContain('No images match "wall"')
|
||||
expect(findButton('Clear search')).toBeInstanceOf(HTMLButtonElement)
|
||||
})
|
||||
|
||||
it('moves the focus to the first new tile after Load more', async () => {
|
||||
await openField(undefined, {
|
||||
list: (query) =>
|
||||
query.get('page') === '2' ? pageBody(secondPage()) : pageBody(fieldLibrary()),
|
||||
})
|
||||
|
||||
await click(findButton('Load more'))
|
||||
await flushPromises()
|
||||
|
||||
expect(tiles()).toHaveLength(24)
|
||||
expect(document.activeElement).toBe(tiles()[12])
|
||||
})
|
||||
})
|
||||
|
||||
describe('The Upload tab of a field', () => {
|
||||
it('shows the existing drop zone', async () => {
|
||||
await openField()
|
||||
|
||||
await clickTab('Upload')
|
||||
|
||||
expect(dialogText()).toContain('Drop an image here, or choose a file')
|
||||
expect(dialogText()).toContain('PNG, JPEG, WEBP, SVG · max 10 MB')
|
||||
expect(findButton('Use this image')).toBeUndefined()
|
||||
})
|
||||
|
||||
async function chooseSvg() {
|
||||
const input = document.body.querySelector<HTMLInputElement>(
|
||||
'[data-testid="image-file-input"]',
|
||||
)!
|
||||
const file = new File(['<svg/>'], 'diagram.svg', { type: 'image/svg+xml' })
|
||||
Object.defineProperty(input, 'files', { value: { 0: file, length: 1 }, configurable: true })
|
||||
input.dispatchEvent(new Event('change'))
|
||||
await settle()
|
||||
}
|
||||
|
||||
it('emits the uploaded item and closes a moment later', async () => {
|
||||
await openField(undefined, libraryRoutes())
|
||||
await clickTab('Upload')
|
||||
await chooseSvg()
|
||||
const item = { ...fieldLibrary()[0]!, id: 13, filename: 'diagram.svg' }
|
||||
|
||||
FakeUpload.last!.answer(201, JSON.stringify(item))
|
||||
await settle()
|
||||
|
||||
expect(wrapper!.emitted('uploaded')).toEqual([[item]])
|
||||
await vi.waitFor(() => expect(modalOpen()).toBe(false), { timeout: 2000 })
|
||||
})
|
||||
|
||||
it('chooses nothing when the server refuses the file', async () => {
|
||||
await openField(undefined, libraryRoutes())
|
||||
await clickTab('Upload')
|
||||
await chooseSvg()
|
||||
|
||||
FakeUpload.last!.answer(413, '')
|
||||
await settle()
|
||||
|
||||
expect(wrapper!.emitted('uploaded')).toBeUndefined()
|
||||
expect(wrapper!.emitted('chosen')).toBeUndefined()
|
||||
expect(dialogText()).toContain("can't be used")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,590 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { MediaImage, MediaSummary } from '@/lib/mediaField'
|
||||
import { drawsIcon, fieldLibrary } from '@/views/__tests__/mediaFieldKit'
|
||||
import MediaFieldControl from '../MediaFieldControl.vue'
|
||||
import { byText, holder, mountInApp, text, unmountApp } from './directoryKit'
|
||||
import { MOBILE_WIDTH } from './viewport'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/fileHash', () => ({ hashFile: vi.fn(async () => null) }))
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
const LIME: MediaImage = {
|
||||
id: 7,
|
||||
filename: 'lime-plaster-workshop.jpg',
|
||||
width: 1600,
|
||||
height: 900,
|
||||
size: 245760,
|
||||
thumbnail_url: '/media/lime-plaster-workshop.jpg',
|
||||
}
|
||||
const VECTOR: MediaImage = {
|
||||
id: 4,
|
||||
filename: 'logo-renew.svg',
|
||||
width: null,
|
||||
height: null,
|
||||
size: 8192,
|
||||
thumbnail_url: '/media/logo-renew.svg',
|
||||
}
|
||||
const MISSING: MediaSummary = { id: 7, missing: true }
|
||||
|
||||
beforeEach(() => {
|
||||
const items = fieldLibrary()
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn(),
|
||||
request: vi.fn().mockResolvedValue({
|
||||
items,
|
||||
total: 48,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 48, unused: 2, duplicates: 2 },
|
||||
}),
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setLocale('en')
|
||||
unmountApp()
|
||||
})
|
||||
|
||||
type Props = Record<string, unknown>
|
||||
|
||||
async function mountControl(props: Props = {}, width?: number) {
|
||||
const wrapper = await mountInApp(
|
||||
MediaFieldControl,
|
||||
{ label: 'Thumbnail', modelValue: null, image: null, ...props },
|
||||
{},
|
||||
width,
|
||||
)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function mountStateful(value: number | null, image: MediaSummary | null, required = false) {
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
const current = ref(value)
|
||||
const shown = ref(image)
|
||||
return () =>
|
||||
h(MediaFieldControl, {
|
||||
label: 'Thumbnail',
|
||||
required,
|
||||
modelValue: current.value,
|
||||
image: shown.value,
|
||||
onChange: (id: number | null, next: MediaSummary | null) => {
|
||||
current.value = id
|
||||
shown.value = next
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
return mountInApp(Host)
|
||||
}
|
||||
|
||||
const control = () => document.body.querySelector<HTMLElement>('[data-testid="media-control"]')!
|
||||
const buttons = () => [...control().querySelectorAll<HTMLButtonElement>('button')]
|
||||
const buttonNamed = (name: string) =>
|
||||
buttons().find((button) => (button.getAttribute('aria-label') ?? text(button)) === name)
|
||||
const thumbnail = () =>
|
||||
control().querySelector<HTMLButtonElement>('[data-testid="media-thumbnail"]')
|
||||
const live = () => text(document.body.querySelector('[data-testid="media-live"]'))
|
||||
const dialog = () => document.body.querySelector('[role="dialog"]')
|
||||
const main = () => control().querySelector<HTMLButtonElement>('[data-field-focus]')!
|
||||
|
||||
async function openFromMain() {
|
||||
main().click()
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function chooseInPicker(filename: string) {
|
||||
const tile = [...document.body.querySelectorAll<HTMLButtonElement>('[data-picker-tile]')].find(
|
||||
(element) => element.getAttribute('aria-label')!.startsWith(`${filename},`),
|
||||
)!
|
||||
tile.click()
|
||||
await flushPromises()
|
||||
byText('button', 'Use this image')!.click()
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('Empty, optional', () => {
|
||||
it('draws the label, a dashed box, the icon square, the text, the button and the help', async () => {
|
||||
await mountControl()
|
||||
|
||||
expect(text(document.body)).toContain('Thumbnail')
|
||||
expect(text(document.body)).not.toContain('Thumbnail *')
|
||||
const box = control()
|
||||
expect(box.className).toContain('border-dashed')
|
||||
expect(box.className).toContain('border-accented')
|
||||
expect(box.className).toContain('p-3')
|
||||
expect(text(box)).toContain('No image selected.')
|
||||
const square = box.querySelector('span.size-10')!
|
||||
expect(square.className).toContain('bg-elevated')
|
||||
expect(drawsIcon(square, 'image')).toBe(true)
|
||||
const pick = buttonNamed('Pick an image for Thumbnail')!
|
||||
expect(text(pick)).toBe('Pick an image')
|
||||
expect(drawsIcon(pick, 'image-plus')).toBe(true)
|
||||
expect(pick.className).toContain('border-accented')
|
||||
expect(text(document.body)).toContain('One image from your library.')
|
||||
expect(thumbnail()).toBeNull()
|
||||
})
|
||||
|
||||
it('stacks the box and makes the button 44 px high on mobile', async () => {
|
||||
await mountControl({}, MOBILE_WIDTH)
|
||||
|
||||
const pick = buttonNamed('Pick an image for Thumbnail')!
|
||||
expect(pick.className).toContain('h-11')
|
||||
expect(pick.className).not.toContain('flex-1')
|
||||
expect(pick.parentElement!.className).toContain('flex-col')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty, required', () => {
|
||||
it('reads Thumbnail * with the asterisk hidden from assistive technology', async () => {
|
||||
await mountControl({ required: true })
|
||||
|
||||
const label = document.body.querySelector('label')!
|
||||
expect(text(label)).toBe('Thumbnail *')
|
||||
expect(label.querySelector('[aria-hidden="true"]')!.textContent).toBe(' *')
|
||||
expect(text(control())).toContain('No image selected.')
|
||||
expect(text(document.body)).toContain('One image from your library.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Filled', () => {
|
||||
it('draws the thumbnail, the filename, the size line, the buttons and the help', async () => {
|
||||
await mountControl({ modelValue: 7, image: LIME })
|
||||
|
||||
const box = control()
|
||||
expect(box.className).toContain('border-accented')
|
||||
expect(box.className).not.toContain('border-dashed')
|
||||
const thumb = thumbnail()!
|
||||
expect(thumb.className).toContain('md:h-30')
|
||||
expect(thumb.className).toContain('md:w-40')
|
||||
expect(thumb.querySelector('img')!.getAttribute('src')).toBe(LIME.thumbnail_url)
|
||||
expect(text(box)).toContain('lime-plaster-workshop.jpg')
|
||||
expect(text(box.querySelector('[data-testid="media-meta"]'))).toBe('1600 × 900 · 240 KB')
|
||||
expect(box.querySelector('[data-testid="media-meta"]')!.className).toContain('text-xs/4.5')
|
||||
const change = buttonNamed('Change image')!
|
||||
expect(drawsIcon(change, 'image')).toBe(true)
|
||||
const remove = buttonNamed('Remove image from Thumbnail')!
|
||||
expect(text(remove)).toBe('Remove')
|
||||
expect(drawsIcon(remove, 'x')).toBe(true)
|
||||
expect(text(document.body)).toContain('One image from your library.')
|
||||
})
|
||||
|
||||
it('reads Vector for an image without dimensions', async () => {
|
||||
await mountControl({ modelValue: 4, image: VECTOR })
|
||||
|
||||
expect(text(control().querySelector('[data-testid="media-meta"]'))).toBe('Vector · 8 KB')
|
||||
})
|
||||
|
||||
it('fills the width at 16:9 with two 44 px buttons on mobile', async () => {
|
||||
await mountControl({ modelValue: 7, image: LIME }, MOBILE_WIDTH)
|
||||
|
||||
expect(thumbnail()!.className).toContain('max-md:aspect-video')
|
||||
expect(thumbnail()!.className).toContain('w-full')
|
||||
expect(buttonNamed('Change image')!.className).toContain('h-11')
|
||||
expect(buttonNamed('Remove image from Thumbnail')!.className).toContain('h-11')
|
||||
expect(buttonNamed('Change image')!.className).toContain('max-md:flex-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hover and focus', () => {
|
||||
it('darkens the border and shows the Change image bar on desktop only', async () => {
|
||||
await mountControl({ modelValue: 7, image: LIME })
|
||||
const thumb = thumbnail()!
|
||||
|
||||
thumb.dispatchEvent(new Event('pointerenter'))
|
||||
await flushPromises()
|
||||
|
||||
expect(thumb.className).toContain('border-(--ui-text-muted)')
|
||||
const bar = thumb.querySelector('[data-testid="media-hover-bar"]')!
|
||||
expect(text(bar)).toBe('Change image')
|
||||
expect(bar.className).toContain('h-7')
|
||||
expect(bar.className).toContain('bg-inverted')
|
||||
expect(bar.className).toContain('text-inverted')
|
||||
expect(drawsIcon(bar, 'image')).toBe(true)
|
||||
|
||||
thumb.dispatchEvent(new Event('pointerleave'))
|
||||
await flushPromises()
|
||||
expect(thumb.querySelector('[data-testid="media-hover-bar"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('has no hover state on mobile', async () => {
|
||||
await mountControl({ modelValue: 7, image: LIME }, MOBILE_WIDTH)
|
||||
|
||||
thumbnail()!.dispatchEvent(new Event('pointerenter'))
|
||||
await flushPromises()
|
||||
|
||||
expect(thumbnail()!.querySelector('[data-testid="media-hover-bar"]')).toBeNull()
|
||||
expect(thumbnail()!.className).not.toContain('border-(--ui-text-muted)')
|
||||
})
|
||||
|
||||
it('draws a 2 px primary outline with a 2 px offset when keyboard focused', async () => {
|
||||
await mountControl({ modelValue: 7, image: LIME })
|
||||
|
||||
const classes = thumbnail()!.className
|
||||
expect(classes).toContain('focus-visible:outline-2')
|
||||
expect(classes).toContain('focus-visible:outline-offset-2')
|
||||
expect(classes).toContain('focus-visible:outline-primary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error', () => {
|
||||
it('draws the error border and line, ties them to the main button and hides the help from sight', async () => {
|
||||
await mountControl({ required: true, error: 'Pick an image for Thumbnail.' })
|
||||
|
||||
expect(control().className).toContain('border-error')
|
||||
expect(control().className).toContain('shadow-[inset_0_0_0_1px_var(--ui-error)]')
|
||||
const alert = document.body.querySelector('[role="alert"]')!
|
||||
expect(text(alert)).toBe('Pick an image for Thumbnail.')
|
||||
expect(drawsIcon(alert, 'circle-alert')).toBe(true)
|
||||
const pick = main()
|
||||
expect(pick.getAttribute('aria-invalid')).toBe('true')
|
||||
const ids = pick.getAttribute('aria-describedby')!.split(' ')
|
||||
expect(ids).toHaveLength(2)
|
||||
expect(document.getElementById(ids[0]!)).toBe(alert)
|
||||
expect(text(document.getElementById(ids[1]!))).toBe('One image from your library.')
|
||||
expect(document.getElementById(ids[1]!)!.className).toContain('sr-only')
|
||||
})
|
||||
|
||||
it('has no aria-invalid and only the help id without an error', async () => {
|
||||
await mountControl()
|
||||
|
||||
expect(main().getAttribute('aria-invalid')).toBeNull()
|
||||
expect(main().getAttribute('aria-describedby')!.split(' ')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Missing image', () => {
|
||||
it('draws a dashed placeholder, the title, the optional help and the two buttons, with no help line below', async () => {
|
||||
await mountControl({ modelValue: 7, image: MISSING })
|
||||
|
||||
const placeholder = control().querySelector('[data-testid="media-placeholder"]')!
|
||||
expect(placeholder.getAttribute('role')).toBe('img')
|
||||
expect(placeholder.getAttribute('aria-label')).toBe('Image no longer in the library')
|
||||
expect(placeholder.className).toContain('border-dashed')
|
||||
expect(drawsIcon(placeholder, 'image-off')).toBe(true)
|
||||
expect(placeholder.querySelector('svg')!.getAttribute('class')).toContain('size-6')
|
||||
expect(text(control())).toContain('This image is no longer in the library')
|
||||
expect(text(control())).toContain(
|
||||
'You can still save. The entry keeps the link until you change it.',
|
||||
)
|
||||
expect(text(buttonNamed('Choose another image'))).toBe('Choose another image')
|
||||
expect(buttonNamed('Clear Thumbnail')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(text(buttonNamed('Clear Thumbnail'))).toBe('Clear')
|
||||
expect(document.body.querySelector('[data-testid="media-help"]')).toBeNull()
|
||||
expect(thumbnail()).toBeNull()
|
||||
})
|
||||
|
||||
it('reads the required help and keeps the main button first', async () => {
|
||||
await mountControl({ modelValue: 7, image: MISSING, required: true })
|
||||
|
||||
expect(text(control())).toContain('Thumbnail is required. Choose another image to save.')
|
||||
expect(main().textContent).toContain('Choose another image')
|
||||
expect(main().getAttribute('aria-required')).toBe('true')
|
||||
})
|
||||
|
||||
it('takes the error border and line when the field is required and saved', async () => {
|
||||
await mountControl({
|
||||
modelValue: 7,
|
||||
image: MISSING,
|
||||
required: true,
|
||||
error: 'Pick an image for Thumbnail.',
|
||||
})
|
||||
|
||||
expect(control().className).toContain('border-error')
|
||||
expect(text(document.body.querySelector('[role="alert"]'))).toBe(
|
||||
'Pick an image for Thumbnail.',
|
||||
)
|
||||
expect(document.body.querySelector('[data-testid="media-help"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('stacks a 16:9 placeholder and full width buttons on mobile', async () => {
|
||||
await mountControl({ modelValue: 7, image: MISSING }, MOBILE_WIDTH)
|
||||
|
||||
expect(control().querySelector('[data-testid="media-placeholder"]')!.className).toContain(
|
||||
'max-md:aspect-video',
|
||||
)
|
||||
expect(buttonNamed('Clear Thumbnail')!.className).toContain('h-11')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Saving and read-only', () => {
|
||||
it('puts the thumbnail at 60 % and disables the buttons, and the picker cannot open', async () => {
|
||||
await mountControl({ modelValue: 7, image: LIME, disabled: true })
|
||||
|
||||
expect(thumbnail()!.className).toContain('opacity-60')
|
||||
expect(thumbnail()!.disabled).toBe(true)
|
||||
expect(buttons().every((button) => button.disabled)).toBe(true)
|
||||
expect(buttonNamed('Change image')!.className).toContain('disabled:opacity-40')
|
||||
|
||||
thumbnail()!.click()
|
||||
buttonNamed('Change image')!.click()
|
||||
await flushPromises()
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
|
||||
it('disables the pick button of an empty control', async () => {
|
||||
await mountControl({ disabled: true })
|
||||
|
||||
expect(main().disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('puts the missing placeholder at 60 %', async () => {
|
||||
await mountControl({ modelValue: 7, image: MISSING, disabled: true })
|
||||
|
||||
expect(control().querySelector('[data-testid="media-placeholder"]')!.className).toContain(
|
||||
'opacity-60',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Events', () => {
|
||||
function emitted() {
|
||||
return holder.wrapper!.findComponent(MediaFieldControl).emitted()
|
||||
}
|
||||
|
||||
it('opens the picker in field mode from the thumbnail, Change image and Pick an image', async () => {
|
||||
await mountControl({ modelValue: 7, image: LIME })
|
||||
|
||||
thumbnail()!.click()
|
||||
await flushPromises()
|
||||
expect(text(dialog())).toContain('Choose an image')
|
||||
byText('button', 'Cancel')!.click()
|
||||
await vi.waitFor(() => expect(dialog()).toBeNull())
|
||||
|
||||
buttonNamed('Change image')!.click()
|
||||
await flushPromises()
|
||||
expect(text(dialog())).toContain('Choose an image')
|
||||
expect(emitted()['update:modelValue']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('opens from Pick an image on an empty control', async () => {
|
||||
await mountControl()
|
||||
|
||||
await openFromMain()
|
||||
|
||||
expect(text(dialog())).toContain('Choose an image')
|
||||
expect(text(dialog())).toContain('No image')
|
||||
})
|
||||
|
||||
it('hides No image in the picker of a required field', async () => {
|
||||
await mountControl({ required: true })
|
||||
|
||||
await openFromMain()
|
||||
|
||||
expect(text(dialog())).not.toContain('Leave this field empty.')
|
||||
})
|
||||
|
||||
it('emits the id and the library data of the picked image and announces it', async () => {
|
||||
await mountControl({ modelValue: 7, image: LIME })
|
||||
await openFromMain()
|
||||
|
||||
await chooseInPicker('bathroom-tiles.png')
|
||||
|
||||
expect(emitted()['update:modelValue']).toEqual([[8]])
|
||||
const [[id, image]] = emitted().change as [[number, MediaImage]]
|
||||
expect(id).toBe(8)
|
||||
expect(image).toEqual({
|
||||
id: 8,
|
||||
filename: 'bathroom-tiles.png',
|
||||
width: 800,
|
||||
height: 600,
|
||||
size: 51200,
|
||||
thumbnail_url: '/media/bathroom-tiles.png',
|
||||
})
|
||||
expect(live()).toBe('Image selected: bathroom-tiles.png')
|
||||
})
|
||||
|
||||
it('moves the focus to Change image after Use this image', async () => {
|
||||
await mountStateful(null, null)
|
||||
await openFromMain()
|
||||
await chooseInPicker('bathroom-tiles.png')
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(main()))
|
||||
|
||||
expect(text(main())).toBe('Change image')
|
||||
})
|
||||
|
||||
it('does not announce or emit when the current image is chosen again', async () => {
|
||||
await mountControl({ modelValue: 7, image: LIME })
|
||||
await openFromMain()
|
||||
|
||||
await chooseInPicker('lime-plaster-workshop.jpg')
|
||||
|
||||
expect(emitted()['update:modelValue']).toBeUndefined()
|
||||
expect(emitted().change).toBeUndefined()
|
||||
expect(live()).toBe('')
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(main()))
|
||||
})
|
||||
|
||||
it('takes a finished upload as the value, announces it and closes the dialog', async () => {
|
||||
await mountControl()
|
||||
await openFromMain()
|
||||
const uploaded = { ...fieldLibrary()[1]!, id: 13, filename: 'diagram.svg' }
|
||||
|
||||
holder.wrapper!.findComponent({ name: 'ImageUploader' }).vm.$emit('uploaded', uploaded)
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted()['update:modelValue']).toEqual([[13]])
|
||||
expect(live()).toBe('Image selected: diagram.svg')
|
||||
})
|
||||
|
||||
it('emits null for Remove, announces it and focuses Pick an image', async () => {
|
||||
await mountStateful(7, LIME)
|
||||
|
||||
buttonNamed('Remove image from Thumbnail')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted()['update:modelValue']).toEqual([[null]])
|
||||
expect(emitted().change).toEqual([[null, null]])
|
||||
expect(live()).toBe('Image removed from Thumbnail.')
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(main()))
|
||||
expect(text(main())).toBe('Pick an image')
|
||||
})
|
||||
|
||||
it('emits null for Clear, announces Thumbnail cleared and focuses Pick an image', async () => {
|
||||
await mountStateful(7, MISSING)
|
||||
|
||||
buttonNamed('Clear Thumbnail')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted()['update:modelValue']).toEqual([[null]])
|
||||
expect(live()).toBe('Thumbnail cleared.')
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(main()))
|
||||
})
|
||||
|
||||
it('emits null from the No image row with Remove image and focuses Pick an image', async () => {
|
||||
await mountControl({ modelValue: 7, image: LIME })
|
||||
await openFromMain()
|
||||
|
||||
document.body.querySelector<HTMLButtonElement>('[data-testid="picker-no-image"]')!.click()
|
||||
await flushPromises()
|
||||
byText('button', 'Remove image')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(emitted()['update:modelValue']).toEqual([[null]])
|
||||
expect(live()).toBe('Image removed from Thumbnail.')
|
||||
})
|
||||
|
||||
it('returns the focus to the opener after Cancel and after Escape', async () => {
|
||||
await mountControl({ modelValue: 7, image: LIME })
|
||||
const opener = buttonNamed('Change image')!
|
||||
opener.focus()
|
||||
|
||||
opener.click()
|
||||
await flushPromises()
|
||||
byText('button', 'Cancel')!.click()
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(opener))
|
||||
|
||||
thumbnail()!.focus()
|
||||
thumbnail()!.click()
|
||||
await flushPromises()
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(thumbnail()))
|
||||
})
|
||||
|
||||
it('announces nothing when the picker opens', async () => {
|
||||
await mountControl({ modelValue: 7, image: LIME })
|
||||
|
||||
await openFromMain()
|
||||
|
||||
expect(live()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Names and roles', () => {
|
||||
it('is a group labelled by the field label', async () => {
|
||||
await mountControl({ modelValue: 7, image: LIME })
|
||||
|
||||
const labelledBy = control().getAttribute('aria-labelledby')!
|
||||
expect(control().getAttribute('role')).toBe('group')
|
||||
expect(text(document.getElementById(labelledBy))).toBe('Thumbnail')
|
||||
})
|
||||
|
||||
it('carries the exact accessible names', async () => {
|
||||
await mountControl({ modelValue: 7, image: LIME })
|
||||
|
||||
expect(thumbnail()!.getAttribute('aria-label')).toBe(
|
||||
'Change image, lime-plaster-workshop.jpg, 1600 by 900, 240 KB',
|
||||
)
|
||||
expect(buttonNamed('Remove image from Thumbnail')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(buttonNamed('Change image')).toBeInstanceOf(HTMLButtonElement)
|
||||
})
|
||||
|
||||
it('names the placeholder, the empty button and the clear button', async () => {
|
||||
await mountControl({ modelValue: 7, image: MISSING })
|
||||
expect(control().querySelector('[role="img"]')!.getAttribute('aria-label')).toBe(
|
||||
'Image no longer in the library',
|
||||
)
|
||||
expect(buttonNamed('Clear Thumbnail')).toBeInstanceOf(HTMLButtonElement)
|
||||
unmountApp()
|
||||
|
||||
await mountControl()
|
||||
expect(buttonNamed('Pick an image for Thumbnail')).toBeInstanceOf(HTMLButtonElement)
|
||||
})
|
||||
|
||||
it('marks the main button required only when the field is', async () => {
|
||||
await mountControl({ required: true })
|
||||
expect(main().getAttribute('aria-required')).toBe('true')
|
||||
unmountApp()
|
||||
|
||||
await mountControl()
|
||||
expect(main().getAttribute('aria-required')).toBeNull()
|
||||
})
|
||||
|
||||
it('has two tab stops in order: thumbnail, Change image, Remove', async () => {
|
||||
await mountControl({ modelValue: 7, image: LIME })
|
||||
|
||||
expect(
|
||||
buttons().map((button) => button.getAttribute('aria-label') ?? text(button)),
|
||||
).toEqual([
|
||||
'Change image, lime-plaster-workshop.jpg, 1600 by 900, 240 KB',
|
||||
'Change image',
|
||||
'Remove image from Thumbnail',
|
||||
])
|
||||
expect(buttons().every((button) => button.tabIndex === 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps one polite live region', async () => {
|
||||
await mountControl({ modelValue: 7, image: LIME })
|
||||
|
||||
const regions = document.body.querySelectorAll('[aria-live="polite"][role="status"]')
|
||||
expect(regions).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('French', () => {
|
||||
it('reads the empty, filled and missing states in French', async () => {
|
||||
setLocale('fr')
|
||||
|
||||
await mountControl()
|
||||
expect(text(control())).toContain('Aucune image sélectionnée.')
|
||||
expect(buttonNamed('Choisir une image pour Thumbnail')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(text(document.body)).toContain('Une image de votre bibliothèque.')
|
||||
unmountApp()
|
||||
|
||||
await mountControl({ modelValue: 7, image: LIME })
|
||||
expect(buttonNamed('Changer d’image')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(buttonNamed('Retirer l’image de Thumbnail')).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(thumbnail()!.getAttribute('aria-label')).toBe(
|
||||
'Changer d’image, lime-plaster-workshop.jpg, 1600 par 900, 240 Ko',
|
||||
)
|
||||
unmountApp()
|
||||
|
||||
await mountControl({ modelValue: 7, image: MISSING })
|
||||
expect(text(control())).toContain('Cette image n’est plus dans la bibliothèque')
|
||||
expect(buttonNamed('Effacer Thumbnail')).toBeInstanceOf(HTMLButtonElement)
|
||||
})
|
||||
})
|
||||
@@ -78,8 +78,12 @@ export const Host = defineComponent({
|
||||
props: {
|
||||
initialFile: { type: Object as () => File | null, default: null },
|
||||
inArticleIds: { type: Array as () => number[], default: () => [1, 4] },
|
||||
field: {
|
||||
type: Object as () => { optional: boolean; currentId: number | null } | undefined,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ['uploaded', 'inserted', 'close'],
|
||||
emits: ['uploaded', 'inserted', 'close', 'chosen', 'closed'],
|
||||
setup(_props, { emit }) {
|
||||
const open = ref(false)
|
||||
return {
|
||||
@@ -88,13 +92,19 @@ export const Host = defineComponent({
|
||||
onInserted: (item: MediaItem, alt: string, src: string) =>
|
||||
emit('inserted', item, alt, src),
|
||||
onClose: () => emit('close'),
|
||||
onChosen: (item: MediaItem | null) => emit('chosen', item),
|
||||
onClosed: () => emit('closed'),
|
||||
}
|
||||
},
|
||||
template: `<ImageUploader v-model:open="open" :initial-file="initialFile" :in-article-ids="inArticleIds" @uploaded="onUploaded" @inserted="onInserted" @close="onClose" />`,
|
||||
template: `<ImageUploader v-model:open="open" :initial-file="initialFile" :in-article-ids="inArticleIds" :field="field" @uploaded="onUploaded" @inserted="onInserted" @close="onClose" @chosen="onChosen" @closed="onClosed" />`,
|
||||
})
|
||||
|
||||
export function mountUploader(
|
||||
props: { initialFile?: File | null; inArticleIds?: number[] } = {},
|
||||
props: {
|
||||
initialFile?: File | null
|
||||
inArticleIds?: number[]
|
||||
field?: { optional: boolean; currentId: number | null }
|
||||
} = {},
|
||||
width = DESKTOP_WIDTH,
|
||||
) {
|
||||
setViewportWidth(width)
|
||||
|
||||
Reference in new issue
Block a user