feat(createImage/Image): create new composable and component#191
Merged
johnleider merged 35 commits intomasterfrom Apr 20, 2026
Merged
feat(createImage/Image): create new composable and component#191johnleider merged 35 commits intomasterfrom
johnleider merged 35 commits intomasterfrom
Conversation
Introduces a state-driven image loading system: - `useImage` composable: idle/loading/loaded/error state machine with reactive `eager` gate, gated `source`, retry, and src-change reset. - `Image` compound component: Root/Img/Placeholder/Fallback with optional IntersectionObserver-based lazy loading via the `lazy` prop. First-class support for modern image attributes (width/height/decoding/fetchpriority). Refactors `AvatarImage` to use `useImage` internally, exposing `status`, `isLoaded`, `isError`, and `retry` as new slot props. Selection logic and priority-based multi-source behavior are unchanged. https://claude.ai/code/session_01NMjcSYYdX2yniURgEfGrh4
Restructures Image and useImage docs examples to be multi-file per docs.md rules — each non-usage example now demonstrates a concrete composition pattern with a reusable component or composable: - `useImage/lazy` — composes useImage + useIntersectionObserver into a `useLazyImage` hook plus a `LazyImage` component - `useImage/retry` — wraps useImage in a `RetryableImage` component - `Image/observer` — builds a `BlurUpImage` LQIP component - `Image/picture` — wraps Image primitives in a `PictureImage` component with format negotiation Prefers styling via `data-state` attributes over slot props: the blur-up example now drives opacity transitions with `data-[state=loaded]:` instead of threading `isLoaded` through slot props. Adds a Styling section and updates the fade-in FAQ to recommend data-attribute selectors. Moves all remaining imports out of `<script setup>` blocks into the regular `<script lang="ts">` per components.md rules (Image*.vue and AvatarImage.vue). https://claude.ai/code/session_01NMjcSYYdX2yniURgEfGrh4
- ImageImg: drop inheritAttrs:false and v-show so class/style reach the <img> and the documented opacity/data-state fade-in actually animates - ImageRoot: drop unused rootEl from context; warn via useLogger when lazy+renderless (previously silent never-loads) - useImage: remove redundant shallowReadonly wrappers on already-readonly derived refs; keep shallowReadonly only on the mutable status - tests: add class/style propagation regression test on Image.Img - maturity: backfill since=0.2.0 on useImage and Image entries
Match the project-wide on<Action> convention.
New headless primitive that reserves a box with a fixed width-to-height ratio. Thin wrapper around Atom that binds CSS aspect-ratio via inline style. Accepts ratio as number or string, default 1 (square). Useful for preventing CLS on media, iframe wrappers, thumbnail grids, and composing with Image.Root for a shared reserved frame. Ships with: - AspectRatio.vue, index.ts barrel, index.test.ts (10 tests) - Component export in components/index.ts - maturity.json entry (primitives, preview, 0.2.0) - Docs page at components/primitives/aspect-ratio.md - basic.vue interactive example + ResponsiveImage multi-file example - Entries in components/index.md and packages/0/README.md
9bda34b to
91f646d
Compare
Lengthen example descriptions for AspectRatio, Image, and useImage pages with when-to-reach-for, gotchas, and composition notes. Examples under ## Examples are featured — one-sentence summaries don't carry enough context for readers landing cold.
Composition tree for AspectRatio+Image, reactive signal pipeline for useImage+useIntersectionObserver, and the blur-up fade-in sequence on the Image page. Each diagram anchors the example's mental model up front so the prose below doesn't carry the whole load alone.
Previously only rootMargin was pluggable when lazy loading. Passing a scroll container via root or a visibility threshold required dropping to useIntersectionObserver + useImage manually. The new props forward directly to the observer so a scrollable gallery or partial-visibility trigger works on the compound component.
Synthesized from status transitions rather than the native <img> loadstart event (which browsers fire inconsistently for images). Fires when the image enters the loading state on initial mount, on intersection, or when src changes. Emits the source URL as the argument — matches VImg's loadstart payload shape.
Drop-in replacement for Image.Img when navigating between already-loaded sources — keeps the previous image visible while a new one loads, then crossfades via opacity. Built on the Presence primitive: the previous img layer's mount lifecycle (mounted → leaving → unmounted) is driven by Presence, with CSS targeting data-state='leaving' to fade opacity 1 → 0 and transitionend triggering done() to finalize the unmount. Shipped with a multi-file Gallery example, a mermaid sequence diagram in the Examples block, an accessibility table entry (previous img is aria-hidden), and an FAQ entry clarifying when to pick Presence over Img. Presence acts like Img on the initial load; the transition only engages on subsequent src changes.
…ssfade Cached images fire the load event fast enough that Vue batched the showPrevious true→false flip into one reactive cycle, so CSS never saw the 'mounted' state commit before 'leaving' — the transition appeared to do nothing on the second and subsequent navigations. Defer the flip via requestAnimationFrame (SSR-guarded) so the browser paints the previous img at opacity 1 for at least one frame before the data-state transitions to 'leaving' and the opacity fade animates. Also tighten the currentSrc watcher to skip updating previousSrc while a transition is in flight — rapid navigation through multiple sources before any load completes now keeps the original previous visible instead of flashing through intermediates.
Picsum's cached responses return fast enough that the 150ms default crossfade was nearly imperceptible and felt broken on repeat clicks. A 1200ms setTimeout between button press and src update stands in for a slow network, giving a clear window where the previous photo stays visible before the new one crossfades in. Also bumps the transition duration to 500ms so the crossfade itself reads as an animation rather than a jump, and disables the Prev/Next buttons during the delay so rapid clicks don't queue multiple transitions.
usePresence's immediate mode auto-resolves the leaving state on next tick if done() isn't called first — much faster than any CSS transition could complete. Explicitly set immediate=false so our transitionend handler drives the exit via done(). Also refactors the Presence gallery example to render Image.Img and Image.Presence side-by-side with shared navigation, so the behavioral difference (placeholder flash vs crossfade) is directly observable.
The Presence name borrowed from the underlying primitive but didn't
parallel Image.Img as a user-facing component name. Swap names the
action users pick the component for: swapping sources without a
placeholder flash. Doesn't imply a transition system — the component
stays narrowly scoped to this one UX concern.
- File renamed: ImagePresence.vue → ImageSwap.vue
- Types renamed: ImagePresence{Props,Emits,SlotProps} → ImageSwap*
- Compound member renamed: Image.Presence → Image.Swap
- Barrel, tests, docs page (Usage, Examples, Accessibility, FAQ),
and the Gallery example all updated
- Mermaid diagram in the Examples block scrubs "Presence mounts"
wording that conflated the sub-component with the underlying
primitive — the Presence composable is still used internally
and still referenced by name where that's the actual mechanism
Data-state-driven opacity caused a brief flicker when swapping to a cached source: status flipped 'loaded' → 'loading' for just long enough to start a 1 → 0 opacity transition before the load event fired and reversed it back toward 1. Previous layer covered the visual, but once it started fading out the flicker leaked through. Drive the current img's opacity from (showPrevious || isLoaded) instead of data-state alone. While a previous layer is mounted, the current img stays at opacity 1 — its visibility doesn't matter (blocked by previous), so there's no incentive to transition. Initial load still fades in via placeholder + isLoaded.
Opinionated utility classes (relative, absolute inset-0, transition- opacity, data-[state=leaving]:opacity-0, the has-previous flicker- guard conditional) had leaked into the component template. v0 owns behavior and data-state; visual CSS belongs to the consumer. - Only structural CSS remains, inlined via :style: position: relative on the wrapper, absolute + inset + 100% size on the previous layer. These aren't stylistic — they're required for two imgs to overlay. - Opacity, transitions, and timing move entirely to user CSS. Three class props let users target each layer without fighting selector disambiguation: img-class (shared), current-class, previous-class. - Current img exposes data-has-previous="true" while a swap is in flight — the flicker guard from before, now pure data contract, so user CSS can pin opacity with data-[has-previous]:opacity-100. - Slot props gain hasPrevious to match, for consumers styling via slot bindings instead of classes. - GalleryImage example updated to demonstrate the expected user CSS split across the three class props. Docs "Class routing" bullet rewritten to reflect the new contract.
- Broaden ImageSwap's transitionend handler to finalize done() on any property, not just opacity. Previously the previous layer leaked permanently if a consumer wrote a non-opacity transition on previous-class. Docs now call out that previous-class must include at least one transition for the exit to resolve. - Clarify in the docs that data-[has-previous]:opacity-100 on current-class is not optional — without it, the crossfade shows a background bleed-through during the 0→1 fade-in. Updated the Class routing bullet and added a dedicated bullet spelling out the pin as required. - Add a code comment in ImageSwap's currentSrc watcher explaining the cleared-then-restored-src edge case so a future reader sees the intentional trade-off. - Rename test describe block 'sSR' → 'SSR' (auto-capitalization tripped the Vitest reporter output).
currentSrc/previousSrc didn't match v0's own naming — useImage returns source as the canonical name for the resolved URL. Align the Swap slot props with that convention so source + previousSource form the pair. Also drops the redundant local currentSrc ref that just re-wrapped context.source, and renames the internal shallowRef from previousSrc to previousSource for consistency. Template now reads context.source directly. No behavior change.
Three of the four disables were leftover — basic/LazyImage/RetryableImage already set alt (statically or via :alt binding), so the rule was never going to flag them. PictureImage was the only case that genuinely needed the escape hatch: the inner <img v-bind="attrs"> hides alt from ESLint's static analysis. Fix PictureImage by binding :alt="attrs.alt" alongside the v-bind spread — the rule now sees the explicit alt attr and the consumer- visible markup is unambiguous. Also converts the <template #default> to the v-slot shorthand on Image.Img.
ef27c2c to
269fd78
Compare
Image.Swap was a self-contained component that rendered two stacked
imgs internally and exposed three novel class props (imgClass,
currentClass, previousClass) to style them separately. The v0 way is
sibling sub-components — each element gets its own class attr via
standard Vue prop inheritance.
Architecture now:
- Image.Root owns a hasPrevious ShallowRef and provides it on context.
- Image.Img reads context.hasPrevious and sets data-has-previous on
the rendered <img>, plus exposes hasPrevious in its slot props.
- Image.Swap is now a thin overlay-only sub-component. It renders
nothing on initial mount; only during a source change does it
mount a single <img> wrapped in Presence, covering Image.Img with
the previous source until the new load fires. Sets
context.hasPrevious while active.
Usage becomes two sibling sub-components instead of one:
<Image.Root :src>
<Image.Img class="...data-[has-previous]:opacity-100..." />
<Image.Swap class="...data-[state=leaving]:opacity-0..." />
</Image.Root>
Each class prop lives next to the element it describes. No novel API
surface. Gallery example and docs Examples block updated in kind;
FAQ and Accessibility rows rewritten.
Also removes `since` keys on unreleased maturity entries (useImage,
AspectRatio, Image) — the since marker applies to released versions,
not in-flight PR work.
Image.Swap's crossfade behavior wasn't settling in well:
- DOM/reactive timing around cached srcs was brittle (needed rAF
defers and pin-via-data-attr guards against bleed-through flashes)
- User-facing API thrashed through three iterations (three class
props → single imgClass + data-layer selectors → sibling components
with context.hasPrevious) without a version that felt right
- The feature was a niche UX fix (no-flash navigation between preloaded
sources) that didn't justify the context-level plumbing it required
Pulling it out entirely. Keeping the rest of the Image work — the
core Image.Root/Img/Placeholder/Fallback compound, threshold/root
observer options, loadstart event — since those are solid.
Removed:
- packages/0/src/components/Image/ImageSwap.vue
- apps/docs/src/examples/components/image/{GalleryImage,gallery}.vue
- `hasPrevious` from ImageContext, ImageImg slot props, data-has-previous
attribute on ImageImg
- Image.Swap entries from barrel exports and compound object
- Gallery example section, FAQ entry, and Accessibility row from the
Image docs page
Also removes `since` keys from unreleased useImage / AspectRatio / Image
entries in maturity.json — since is for released versions, not in-flight
PR work. (Previous commit's message claimed this but the file changes
weren't staged; landing them here.)
If the crossfade pattern surfaces as a real need later, revisit with a
fresh design — the Presence-primitive mechanism still works; the
question is whether to package it at the component layer at all or leave
it as a recipe.
- ImageRoot: warn-when-lazy-and-renderless guard (lines 97-98 were previously untested — covered with a console.warn spy that asserts the expected message) - ImageImg: default-slot rendering in renderless mode (line 160 — the slot only renders when Atom is non-self-closing or renderless) Brings Image + AspectRatio + useImage to 100% statements / branches / lines across the board. Remaining gap is an intentional no-op callback passed to useIntersectionObserver that happy-dom cannot exercise.
Setting status directly from 'error' to 'loading' kept source pinned to the same URL, and browsers skip the re-request when an <img>'s src attribute is assigned its existing value. Retries visibly hung in the loading state: no new network request, no error/load event, forever. Cycle status through 'idle' on the next tick so source briefly returns undefined, clearing the <img>'s src attribute. When status flips back to 'loading' the following tick, source resolves to the URL again and the browser treats it as a fresh request. Updated tests to reflect the new two-tick transition — retry() is still fire-and-forget from the caller's perspective, but observed state now transitions error → idle → loading across one nextTick. Verified in the docs dev preview: the retry button on the broken photo now completes the cycle and returns to error state as expected.
Each click on Retry now has a 25% chance of swapping in the real src (success) and a 75% chance of re-requesting the broken URL (another failure). When a retry lands on success, the image is loaded and the retry UI disappears — matching real-world behavior where a transient failure eventually resolves after one or more attempts. Simplified retry.vue to a single RetryableImage card — with the flaky behavior baked into the component, the previous side-by-side layout (one working, one broken) no longer illustrated distinct stories.
ImageImgEmits.loadstart JSDoc omitted retry as a trigger, but retry has always emitted it: retry() transitions status into 'loading' (via 'idle' now, directly before) and the watcher fires on that transition with context.source as the URL. Semantically correct — a retry is a new network request and analytics should count it — just under-documented. Add retry to the trigger list and back it with a test so the contract is explicit.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Introduces a state-driven image loading system:
useImagecomposable: idle/loading/loaded/error state machine withreactive
eagergate, gatedsource, retry, and src-change reset.Imagecompound component: Root/Img/Placeholder/Fallback with optionalIntersectionObserver-based lazy loading via the
lazyprop. First-classsupport for modern image attributes (width/height/decoding/fetchpriority).
Refactors
AvatarImageto useuseImageinternally, exposingstatus,isLoaded,isError, andretryas new slot props. Selection logic andpriority-based multi-source behavior are unchanged.