feat(SPA): media field type

This commit is contained in:
Florian Sylvain committed 2026-10-21 15:17:15 +02:00
1 parent a2d596f4ca
commit 3e7fc9a4a5
57 files changed
+6109 -75

No files matched your search

+1
View File
@@ -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"
/>
+14 -2
View File
@@ -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>
+14 -3
View File
@@ -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>
+33 -3
View File
@@ -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">
+1
View File
@@ -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"
+18 -1
View File
@@ -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"
+9 -3
View File
@@ -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>
+9 -2
View File
@@ -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 -1
View File
@@ -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 }">
+52 -3
View File
@@ -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'"
+24 -4
View File
@@ -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"
+56 -6
View File
@@ -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"
+348
View File
@@ -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>
+56 -6
View File
@@ -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(
+3
View File
@@ -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')
}
+13 -2
View File
@@ -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)
})
})
+13 -3
View File
@@ -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)