useCwaFileField
useCwaFileField resolves one uploadable file field on a component backed by a PHP entity with #[Silverback\Uploadable]. It returns the display media, its content URL, and load state for that field. Because you name the field at the call site, one component can wire several file fields without them colliding.
It's the standalone form of the withFile() plugin — the same resolver under the hood, called directly instead of through useCwaComponent's plugin array.
The file APIs handle any uploadable file, not just images. imagineFilterName is an image-specific convenience.
import { useTemplateRef } from 'vue'
import { useCwaFileField } from '#imports'
import type { IriProp } from '#cwa/composables/cwa-resource'
const props = defineProps<IriProp>()
const imageRef = useTemplateRef<unknown>('file')
const { contentUrl, displayMedia, handleLoad, loaded } = useCwaFileField(props, {
imagineFilterName: 'thumbnail',
imageRef
})
Signature
useCwaFileField(
props: IriProp, // your component's props (with `iri`)
fileOps?: {
fileProp?: string // the PHP #[UploadableField] property (default: 'file')
imagineFilterName?: string // Imagine filter variant to surface as displayMedia
imageRef?: Readonly<ShallowRef<unknown>> // opt in to cached-image load detection
}
)
Parameters
| Parameter | Type | Description |
|---|---|---|
props | IriProp | Your component's props, passed directly. No toRef needed; the composable resolves the resource itself |
fileOps.fileProp | string | Which uploadable field to read, i.e. which key of _metadata.mediaObjects to resolve. Default 'file' |
fileOps.imagineFilterName | string | The LiipImagineBundle filter to surface as displayMedia/contentUrl (e.g. 'thumbnail', 'hero'). Omit for the original uploaded file |
fileOps.imageRef | Readonly<ShallowRef<unknown>> | A template ref pointing at the <img> that renders this file. Optional, and never registered for you. See Why imageRef exists |
Return Values
| Return | Type | Purpose |
|---|---|---|
contentUrl | ComputedRef<string | undefined> | URL of the (optionally filtered) file |
displayMedia | ComputedRef<MediaFile | undefined> | The media object to display — has contentUrl, mimeType, width, height; undefined until available |
srcset | ComputedRef<string | undefined> | Every sized variant of the field, for an <img>'s srcset. See srcset |
handleLoad | () => void | Call on the <img>'s @load event |
loaded | Ref<boolean> | true once handleLoad() fires |
Example
<template>
<div class="relative">
<NuxtImg
v-show="loaded"
ref="file"
:src="contentUrl"
:width="displayMedia?.width"
:height="displayMedia?.height"
class="w-full object-cover"
@load="handleLoad"
/>
<div v-if="!loaded" class="skeleton h-48 w-full bg-gray-200 animate-pulse" />
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { useCwaFileField } from '#imports'
import type { IriProp } from '#cwa/composables/cwa-resource'
const props = defineProps<IriProp>()
const imageRef = useTemplateRef<unknown>('file')
const { contentUrl, displayMedia, handleLoad, loaded } = useCwaFileField(props, {
imagineFilterName: 'thumbnail',
imageRef
})
</script>
v-show, not v-if, when gating the image on loaded.loaded is set by that element's own @load, so v-if="loaded" is a deadlock — the element is never created, so it never loads, so loaded never becomes true. v-show keeps it in the DOM so it can load and fade in.srcset
srcset lists the field's variants for a responsive <img>, so the browser can pick the smallest file that fits. Bind it next to a sizes of your own:
- Each entry is
"<url> <width>w", for the original and every Imagine filter with a known width, smallest first, joined with,. - Every URL carries the same
?published=query ascontentUrl. - A variant with no known width is left out. That covers non-image files, an SVG without width attributes, and a variant the API sends with a width of
-1because it has no file info stored. - When two variants have the same width, the smaller file is kept. A filter never upscales, so a small original and its filter can match.
- It is
undefinedwith fewer than two usable variants. That is always the case for an SVG, because the API makes no filters for it. Bind it as:srcset="srcset", and Vue leaves the attribute out when it'sundefined.
imagineFilterName still picks contentUrl and displayMedia, so it sets the src. srcset lists every variant, whichever filter you name. See Images & Media › Responsive images for the sizes to use.
Why imageRef Exists
An <img> served from cache can finish loading before Vue attaches the @load listener. When that happens @load never fires, loaded stays false forever, and any placeholder gated on it is stuck over a fully-loaded image.
imageRef exists solely to close that gap: on mount, the composable reads the element's own load state (complete / naturalHeight) and calls handleLoad() itself if the image is already there. That is its only job — contentUrl and displayMedia come from resource data and never touch it.
So the rule is simple:
- Rendering an
<img>and want the cached case handled? PassimageRef. - Anything else — a PDF, a zip, any non-image field — omit it. Only an
<img>can report its own load state, so@loadis the sole source of truth. A ref that can't report it (a<div>, an unmatched ref name) is ignored at runtime.
Both a bare <img> and a component wrapping one (<NuxtImg>) work — a component ref resolves to the instance and is unwrapped to its root <img>.
fileProp has no relationship to a template ref name, so name the ref whatever you like. ref="file" is convention, not a requirement.
Type the Ref as unknown
Declare the ref as useTemplateRef<unknown>('file'). Without the annotation, useTemplateRef infers its type from the template — and if the template also reads this composable's own result, that inference is circular and vue-tsc fails:
TS7022: 'imageRef' implicitly has type 'any' because it does not have a type annotation
and is referenced directly or indirectly in its own initializer.
unknown is also honest: on a component like <NuxtImg> the ref's value is the component instance, not an HTMLImageElement.
Multiple File Fields
Call it once per field, naming each with fileProp:
const hero = useCwaFileField(props, { fileProp: 'heroImage' })
const thumb = useCwaFileField(props, { fileProp: 'thumbnail' })
// hero.contentUrl, thumb.contentUrl, …
Each call needs its own imageRef (pointing at its own template ref) if you want load detection for that field — they're independent.
If you're already calling useCwaComponent, prefer the plugin form — useCwaComponent(props, [withFile({ fileProp: 'heroImage' }), withFile({ fileProp: 'thumbnail' })]) exposes the same data under a files map.