diff --git a/docs/content/1.get-started/2.configuration.md b/docs/content/1.get-started/2.configuration.md index 340167bd2..358c80510 100644 --- a/docs/content/1.get-started/2.configuration.md +++ b/docs/content/1.get-started/2.configuration.md @@ -55,6 +55,34 @@ export default defineNuxtConfig({ }) ``` +## `responsiveBreakpoints` + +Default: `'max-width'` + +Controls whether responsive image media queries use `max-width` or `min-width` breakpoints. + +For a component with `sizes="sm:100vw md:50vw lg:400px"`: + +- with `max-width` (default), the generated attribute for `sizes` looks like: + ```html + sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 400px" + ``` + +- with `min-width`, the order is reversed and the smallest breakpoint becomes the fallback: + ```html + sizes="(min-width: 1024px) 400px, (min-width: 768px) 50vw, 100vw" + ``` + +You can also override this option at the component level by using the [`responsive-breakpoints` prop](/usage/nuxt-img#responsive-breakpoints). + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + image: { + responsiveBreakpoints: 'min-width' + } +}) +``` + ## `screens` List of predefined screen sizes. diff --git a/docs/content/2.usage/1.nuxt-img.md b/docs/content/2.usage/1.nuxt-img.md index a252e11fb..d0a73738a 100644 --- a/docs/content/2.usage/1.nuxt-img.md +++ b/docs/content/2.usage/1.nuxt-img.md @@ -132,6 +132,26 @@ By default Nuxt generates *responsive-first* sizing. /> ``` +### `responsive-breakpoints` + +Controls whether responsive image media queries use `max-width` (desktop-first) or `min-width` (mobile-first) breakpoints. Overrides the global [`responsiveBreakpoints`](/get-started/configuration#responsivebreakpoints) option. + +**Example:** + +```vue + +``` + +This generates the following `sizes` attribute: + +```html +sizes="(min-width: 1024px) 400px, (min-width: 768px) 50vw, 100vw" +``` + ### `densities` The `densities` prop serves high-resolution images for Retina/HiDPI screens. diff --git a/src/module.ts b/src/module.ts index 96ece6f38..4ca7cf631 100644 --- a/src/module.ts +++ b/src/module.ts @@ -16,6 +16,7 @@ export interface ModuleOptions extends ImageProviders { domains: string[] alias: Record screens: CreateImageOptions['screens'] + responsiveBreakpoints: CreateImageOptions['responsiveBreakpoints'] providers: { [name: string]: InputProvider | any } densities: number[] format: CreateImageOptions['format'] @@ -45,6 +46,7 @@ export default defineNuxtModule({ providers: {}, alias: {}, densities: [1, 2], + responsiveBreakpoints: 'max-width', }), meta: { name: '@nuxt/image', @@ -113,6 +115,7 @@ export default defineNuxtModule({ 'densities', 'format', 'quality', + 'responsiveBreakpoints', ]) const providers = await resolveProviders(nuxt, options) @@ -235,16 +238,19 @@ function pick, K extends keyof O>(obj: O, keys: K[]): } function generateImageOptions(providers: ImageModuleProvider[], imageOptions: Omit): string { + const { responsiveBreakpoints, ...serializableOptions } = imageOptions return ` ${providers.map(p => `import ${p.importName} from '${p.runtime}'`).join('\n')} export const imageOptions = { - ...${JSON.stringify(imageOptions, null, 2)}, + ...${JSON.stringify(serializableOptions, null, 2)}, /** @type {${JSON.stringify(imageOptions.provider)}} */ provider: ${JSON.stringify(imageOptions.provider)}, providers: { ${providers.map(p => ` ['${p.name}']: { setup: ${p.importName}, defaults: ${JSON.stringify(p.runtimeOptions)} }`).join(',\n')} - } + }, + /** @type {import('@nuxt/image').ResponsiveBreakpoints} */ + responsiveBreakpoints: ${JSON.stringify(responsiveBreakpoints)}, } ` } diff --git a/src/runtime/components/NuxtImg.vue b/src/runtime/components/NuxtImg.vue index 53ecad0eb..5f6a96f91 100644 --- a/src/runtime/components/NuxtImg.vue +++ b/src/runtime/components/NuxtImg.vue @@ -51,6 +51,7 @@ const sizes = computed(() => $img.getSizes(props.src!, { ...providerOptions.value, sizes: props.sizes, densities: props.densities, + responsiveBreakpoints: props.responsiveBreakpoints, modifiers: imageModifiers.value, })) diff --git a/src/runtime/components/NuxtPicture.vue b/src/runtime/components/NuxtPicture.vue index 04653486b..fc0a18a89 100644 --- a/src/runtime/components/NuxtPicture.vue +++ b/src/runtime/components/NuxtPicture.vue @@ -113,6 +113,7 @@ const sources = computed(() => { ...providerOptions.value, sizes: props.sizes || $img.options.screens, densities: props.densities, + responsiveBreakpoints: props.responsiveBreakpoints, modifiers: { ...imageModifiers.value, format }, }) diff --git a/src/runtime/image.ts b/src/runtime/image.ts index 4bd0ba6db..cd5f50b4f 100644 --- a/src/runtime/image.ts +++ b/src/runtime/image.ts @@ -3,7 +3,7 @@ import { hasProtocol, parseURL, joinURL, withLeadingSlash } from 'ufo' import { imageMeta } from './utils/meta' import { checkDensities, parseDensities, parseSize, parseSizes } from './utils' import { prerenderStaticImages } from './utils/prerender' -import type { ImageOptions, ImageSizesOptions, CreateImageOptions, ResolvedImage, ImageCTX, $Img, ImageSizes, ImageSizesVariant, ConfiguredImageProviders } from '@nuxt/image' +import type { ImageOptions, ImageSizesOptions, CreateImageOptions, ResolvedImage, ImageCTX, $Img, ImageSizes, ImageSizesVariant, ConfiguredImageProviders, ResponsiveBreakpoints } from '@nuxt/image' export function createImage(globalOptions: CreateImageOptions) { const ctx: ImageCTX = { @@ -141,6 +141,8 @@ function getSizes(ctx: ImageCTX, input: string, opts: ImageSizesOptions): ImageS const sizeVariants = [] const srcsetVariants = [] + const responsiveBreakpoints = merged.responsiveBreakpoints || ctx.options.responsiveBreakpoints + if (Object.keys(sizes).length >= 1) { // 'sizes path' for (const key in sizes) { @@ -153,7 +155,7 @@ function getSizes(ctx: ImageCTX, input: string, opts: ImageSizesOptions): ImageS sizeVariants.push({ size: variant.size, screenMaxWidth: variant.screenMaxWidth, - media: `(max-width: ${variant.screenMaxWidth}px)`, + media: `(${responsiveBreakpoints}: ${variant.screenMaxWidth}px)`, }) // add srcset variants for all densities (for current 'size' processed) @@ -165,7 +167,7 @@ function getSizes(ctx: ImageCTX, input: string, opts: ImageSizesOptions): ImageS } } - finaliseSizeVariants(sizeVariants) + finaliseSizeVariants(sizeVariants, responsiveBreakpoints) } else { // 'densities path' @@ -242,7 +244,7 @@ function getVariantSrc(ctx: ImageCTX, input: string, opts: ImageSizesOptions, va opts) } -function finaliseSizeVariants(sizeVariants: any[]) { +function finaliseSizeVariants(sizeVariants: any[], responsiveBreakpoints: ResponsiveBreakpoints) { sizeVariants.sort((v1, v2) => v1.screenMaxWidth - v2.screenMaxWidth) // de-duplicate size variants (by key `media`) @@ -255,8 +257,19 @@ function finaliseSizeVariants(sizeVariants: any[]) { previousMedia = sizeVariant.media } - for (let i = 0; i < sizeVariants.length; i++) { - sizeVariants[i].media = sizeVariants[i + 1]?.media || '' + if (responsiveBreakpoints === 'min-width') { + // Reverse to descending order (largest breakpoint first) + sizeVariants.reverse() + // Last variant (smallest screen) becomes the fallback (no media query) + if (sizeVariants.length > 0) { + sizeVariants[sizeVariants.length - 1].media = '' + } + } + else { + // max-width: shift media values so each variant uses the next breakpoint's media + for (let i = 0; i < sizeVariants.length; i++) { + sizeVariants[i].media = sizeVariants[i + 1]?.media || '' + } } } diff --git a/src/runtime/utils/props.ts b/src/runtime/utils/props.ts index dee657590..708bf05a0 100644 --- a/src/runtime/utils/props.ts +++ b/src/runtime/utils/props.ts @@ -1,6 +1,6 @@ import { computed } from 'vue' -import type { ConfiguredImageProviders, ImageModifiers } from '@nuxt/image' +import type { ConfiguredImageProviders, ImageModifiers, ResponsiveBreakpoints } from '@nuxt/image' import { parseSize } from '.' import { useImage } from '#imports' @@ -22,6 +22,7 @@ export interface BaseImageProps sizes?: string | Record densities?: string + responsiveBreakpoints?: ResponsiveBreakpoints preload?: boolean | { fetchPriority: 'auto' | 'high' | 'low' } // attributes diff --git a/src/types/image.ts b/src/types/image.ts index ae0f06ffc..9ec50561f 100644 --- a/src/types/image.ts +++ b/src/types/image.ts @@ -19,10 +19,13 @@ export interface ResolvedImageModifiers extends ImageModifiers { type DefaultProvider = ProviderDefaults extends Record<'provider', unknown> ? ProviderDefaults['provider'] : never +export type ResponsiveBreakpoints = 'max-width' | 'min-width' + export interface ImageOptions { provider?: Provider preset?: string densities?: string + responsiveBreakpoints?: ResponsiveBreakpoints modifiers?: Partial> & ('modifiers' extends keyof ConfiguredImageProviders[Provider] ? ConfiguredImageProviders[Provider]['modifiers'] : Record) sizes?: string | Record @@ -57,6 +60,7 @@ export interface CreateImageOptions { presets: { [name: string]: ImageOptions } provider: (string & {}) | keyof ImageProviders screens: Record + responsiveBreakpoints: ResponsiveBreakpoints alias: Record domains: string[] densities: number[] diff --git a/test/nuxt/image.test.ts b/test/nuxt/image.test.ts index 862fd95e2..d27de27fd 100644 --- a/test/nuxt/image.test.ts +++ b/test/nuxt/image.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, it, expect, vi } from 'vitest' +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest' import { mount } from '@vue/test-utils' import type { ComponentMountingOptions, VueWrapper } from '@vue/test-utils' import { imageOptions } from '#build/image-options.mjs' @@ -178,6 +178,88 @@ describe('Renders simple image', () => { }) }) +describe('Renders image with min-width responsive breakpoints', () => { + it('uses min-width media queries via prop', () => { + const img = mountImage({ + width: 200, + height: 200, + sizes: '200,500:500,900:900', + responsiveBreakpoints: 'min-width', + src: '/image.png', + }) + const sizes = img.find('img').element.getAttribute('sizes') + expect(sizes).toMatchInlineSnapshot('"(min-width: 900px) 900px, (min-width: 500px) 500px, 200px"') + }) + + it('uses min-width with named breakpoints', () => { + const img = mountImage({ + src: '/image.png', + width: 1000, + height: 2000, + sizes: 'sm:100vw md:300px lg:350px', + responsiveBreakpoints: 'min-width', + }) + const sizes = img.find('img').element.getAttribute('sizes') + expect(sizes).toMatchInlineSnapshot('"(min-width: 1024px) 350px, (min-width: 768px) 300px, 100vw"') + }) + + it('with single sizes entry and min-width', () => { + const img = mountImage({ + src: '/image.png', + width: 300, + height: 400, + sizes: 'sm:150', + responsiveBreakpoints: 'min-width', + }) + const sizes = img.find('img').element.getAttribute('sizes') + expect(sizes).toMatchInlineSnapshot('"150px"') + }) +}) + +describe('Module-level responsiveBreakpoints config', () => { + const nuxtApp = useNuxtApp() + const src = '/image.png' + + beforeEach(() => { + delete nuxtApp._img + delete nuxtApp.$img + }) + + afterEach(() => { + delete nuxtApp._img + delete nuxtApp.$img + }) + + it('module config responsiveBreakpoints=min-width applies globally', () => { + setImageContext({ responsiveBreakpoints: 'min-width' }) + const img = mount(NuxtImg, { + propsData: { + src, + width: 200, + height: 200, + sizes: '200,500:500,900:900', + }, + }) + const sizes = img.find('img').element.getAttribute('sizes') + expect(sizes).toMatchInlineSnapshot('"(min-width: 900px) 900px, (min-width: 500px) 500px, 200px"') + }) + + it('component prop overrides module config', () => { + setImageContext({ responsiveBreakpoints: 'min-width' }) + const img = mount(NuxtImg, { + propsData: { + src, + width: 200, + height: 200, + sizes: '200,500:500,900:900', + responsiveBreakpoints: 'max-width', + }, + }) + const sizes = img.find('img').element.getAttribute('sizes') + expect(sizes).toMatchInlineSnapshot('"(max-width: 500px) 200px, (max-width: 900px) 500px, 900px"') + }) +}) + const getImageLoad = (cb = () => {}) => { let resolve = () => {} let reject = () => {} diff --git a/test/nuxt/picture.test.ts b/test/nuxt/picture.test.ts index 86c21406e..79009d18d 100644 --- a/test/nuxt/picture.test.ts +++ b/test/nuxt/picture.test.ts @@ -1,6 +1,6 @@ import type { VueWrapper } from '@vue/test-utils' import { mount } from '@vue/test-utils' -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { NuxtPicture } from '#components' import { useNuxtApp, useRuntimeConfig, nextTick } from '#imports' import { imageOptions } from '#build/image-options.mjs' @@ -321,3 +321,52 @@ describe('Renders image, applies module config', () => { `) }) }) + +describe('NuxtPicture with min-width responsive breakpoints', () => { + const nuxtApp = useNuxtApp() + const config = useRuntimeConfig() + const src = '/image.png' + + beforeEach(() => { + delete nuxtApp._img + delete nuxtApp.$img + }) + + afterEach(() => { + delete nuxtApp._img + delete nuxtApp.$img + }) + + it('uses min-width media queries via prop', () => { + const picture = mount(NuxtPicture, { + propsData: { + width: 200, + height: 200, + sizes: '200,500:500,900:900', + responsiveBreakpoints: 'min-width', + src, + }, + }) + const sizes = picture.find('source').element.getAttribute('sizes') + expect(sizes).toMatchInlineSnapshot('"(min-width: 900px) 900px, (min-width: 500px) 500px, 200px"') + }) + + it('module config responsiveBreakpoints=min-width applies globally', () => { + nuxtApp._img = createImage({ + runtimeConfig: {} as any, + ...imageOptions, + nuxt: { baseURL: config.app.baseURL }, + responsiveBreakpoints: 'min-width', + }) + const picture = mount(NuxtPicture, { + propsData: { + width: 200, + height: 200, + sizes: '200,500:500,900:900', + src, + }, + }) + const sizes = picture.find('source').element.getAttribute('sizes') + expect(sizes).toMatchInlineSnapshot('"(min-width: 900px) 900px, (min-width: 500px) 500px, 200px"') + }) +}) diff --git a/test/unit/bundle.test.ts b/test/unit/bundle.test.ts index c1fe86f7c..c998c7923 100644 --- a/test/unit/bundle.test.ts +++ b/test/unit/bundle.test.ts @@ -22,7 +22,7 @@ describe.skipIf(process.env.ECOSYSTEM_CI || isWindows)('nuxt image bundle size', }), ]) - expect(roundToKilobytes(withImage.totalBytes - withoutImage.totalBytes)).toMatchInlineSnapshot(`"12.8k"`) + expect(roundToKilobytes(withImage.totalBytes - withoutImage.totalBytes)).toMatchInlineSnapshot(`"13.1k"`) }) })