Commit 1c77c59e by Moorthy G

refactor(shared/responsive-image): update to process the given sizes & srcSet values

parent 7179500a
'use client';
import Link from 'next/link';
import { Button } from '@heroui/button';
export const AnotherButton = () => {
return (
<Button as={Link} href="/another" color="primary" size="lg">
Navigate to another page
</Button>
);
};
'use client';
import Link from 'next/link';
import { Button } from '@heroui/button';
export const HomeButton = () => {
return (
<Button as={Link} href="/" color="primary" size="lg">
Navigate home page
</Button>
);
};
......@@ -24,7 +24,7 @@ export const ArtDirection = {
src: 'https://picsum.photos/576/570?jpg',
width: 576,
height: 540,
sizes: '100vw'
sizes: 'lg 50vw, md 640px, sm 100vw'
},
render: (args: ResponsiveImageProps) => (
<ResponsiveImage {...args}>
......@@ -33,42 +33,42 @@ export const ArtDirection = {
src="https://picsum.photos/1920/540?jpg"
width={1920}
height={540}
sizes="100vw"
sizes="xxl 100vw"
/>
<Source
media="xl"
src="https://picsum.photos/1536/540?jpg"
width={1536}
height={540}
sizes="100vw"
sizes="xl 100vw"
/>
<Source
media="lg"
src="https://picsum.photos/1280/540?jpg"
width={1280}
height={540}
sizes="100vw"
sizes="lg 100vw"
/>
<Source
media="md"
src="https://picsum.photos/1024/540?jpg"
width={1024}
height={540}
sizes="100vw"
sizes="md 100vw"
/>
<Source
media="sm"
src="https://picsum.photos/768/540?jpg"
width={768}
height={540}
sizes="100vw"
sizes="sm 100vw"
/>
<Source
media="(min-width: 576px)"
src="https://picsum.photos/640/540?jpg"
width={640}
height={540}
sizes="100vw"
sizes="sm 100vw"
/>
</ResponsiveImage>
)
......
import React, { forwardRef } from 'react';
import { forwardRef } from 'react';
import { getImageProps } from 'next/image';
import { processSizes } from './processSizes';
import { processSrcSet } from './processSrcSet';
export type Image = {
url: string;
width: number;
height: number;
alternativeText?: string;
};
/**
* ResponsiveImage component for displaying optimized images with responsive srcSet
*
* This component wraps Next.js Image optimization with custom srcSet filtering
* to ensure only image sizes <= actual image width are included in the srcSet.
*
* @example
* ```tsx
* <ResponsiveImage
* src="/image.jpg"
* width={1920}
* height={1080}
* sizes="100vw"
* alt="Description"
* />
* ```
*
* @example With art direction using Source children
* ```tsx
* <ResponsiveImage
* src="/image.jpg"
* width={576}
* height={540}
* sizes="lg 50vw, md 640px, sm 100vw"
* >
* <Source media="lg" src="/large.jpg" width={1280} height={540} sizes="100vw" />
* <Source media="md" src="/medium.jpg" width={1024} height={540} sizes="100vw" />
* </ResponsiveImage>
* ```
*/
export interface ResponsiveImageProps
extends React.HTMLAttributes<HTMLImageElement> {
src: string;
......@@ -20,31 +45,45 @@ export interface ResponsiveImageProps
children?: React.ReactNode;
}
export const ResponsiveImage = forwardRef<
HTMLImageElement,
ResponsiveImageProps
>(
export const ResponsiveImage = forwardRef<HTMLImageElement, ResponsiveImageProps>(
(
{ src, width, height, alt, sizes, quality, priority, children, ...rest },
{
src,
width,
height,
alt = '',
sizes,
quality,
priority,
children,
...rest
},
ref
) => {
if (src) {
const { props } = getImageProps({
src,
width,
height,
sizes,
quality,
priority,
alt: alt || ''
});
return (
<picture>
{children}
<img ref={ref} {...props} {...rest} />
</picture>
);
if (!src) {
return null;
}
const processedSizes = processSizes(sizes);
const { props } = getImageProps({
src,
width,
height,
sizes: processedSizes,
quality,
priority,
alt
});
const filteredSrcSet = processSrcSet(props.srcSet, width);
return (
<picture>
{children}
<img {...props} {...rest} ref={ref} srcSet={filteredSrcSet} alt={alt} />
</picture>
);
}
);
ResponsiveImage.displayName = 'ResponsiveImage';
import React, { memo } from 'react';
import { memo } from 'react';
import { getImageProps } from 'next/image';
import breakpoints from './breakpoints';
import breakpoints, { type BreakpointKey } from './breakpoints';
import { processSizes } from './processSizes';
import { processSrcSet } from './processSrcSet';
export type IResponsiveImageSourceProps =
React.HTMLAttributes<HTMLSourceElement> & {
src: string;
width: number;
height: number;
media: string;
sizes: string;
quality?: number;
};
export interface SourceProps extends React.HTMLAttributes<HTMLSourceElement> {
src: string;
width: number;
height: number;
media: string;
sizes: string;
quality?: number;
}
/**
* Source component for art direction with ResponsiveImage
*
* Used as a child of ResponsiveImage to provide different image sources
* for different media queries. The srcSet is automatically filtered to exclude
* widths greater than the actual image width to exclude redundant image sizes.
*
* @example
* ```tsx
* <ResponsiveImage src="/default.jpg" width={576} height={540} sizes="100vw">
* <Source
* media="lg"
* src="/large.jpg"
* width={1280}
* height={540}
* sizes="100vw"
* />
* <Source
* media="md"
* src="/medium.jpg"
* width={1024}
* height={540}
* sizes="100vw"
* />
* </ResponsiveImage>
* ```
*
* @example With custom media query
* ```tsx
* <Source
* media="(min-width: 900px)"
* src="/custom.jpg"
* width={1200}
* height={600}
* sizes="50vw"
* />
* ```
*/
export const Source = memo(
({
src,
width,
height,
sizes,
media,
quality,
...rest
}: IResponsiveImageSourceProps) => {
if (src) {
const query = breakpoints[media] || media;
const { props } = getImageProps({
width,
height,
src,
sizes,
quality,
alt: ''
});
return (
<source
media={query}
src={props.src}
srcSet={props.srcSet}
width={props.width}
height={props.height}
sizes={props.sizes}
{...rest}
/>
);
({ src, width, height, sizes, media, quality, ...rest }: SourceProps) => {
if (!src) {
return null;
}
return null;
const query = (media in breakpoints ? breakpoints[media as BreakpointKey] : null) || media;
const processedSizes = processSizes(sizes);
const { props } = getImageProps({
width,
height,
src,
sizes: processedSizes,
quality,
alt: ''
});
const filteredSrcSet = processSrcSet(props.srcSet, width);
return (
<source
media={query}
srcSet={filteredSrcSet || props.src}
width={props.width}
height={props.height}
sizes={props.sizes}
{...rest}
/>
);
}
);
Source.displayName = 'ResponsiveImageSource';
Source.displayName = 'Source';
type Breakpoints = {
[key: string]: string;
};
const breakpoints: Breakpoints = {
const breakpoints = {
sm: '(min-width: 640px)',
md: '(min-width: 768px)',
lg: '(min-width: 1024px)',
xl: '(min-width: 1280px)',
xxl: '(min-width: 1536px)'
};
} as const;
export type BreakpointKey = keyof typeof breakpoints;
export default breakpoints;
import breakpoints, { type BreakpointKey } from './breakpoints';
// Breakpoint order from largest to smallest for proper sizes attribute ordering
const breakpointOrderMap: Record<BreakpointKey, number> = {
xxl: 0,
xl: 1,
lg: 2,
md: 3,
sm: 4
};
/**
* Processes the sizes attribute by replacing breakpoint names with media queries
* and ordering them from largest to smallest breakpoint (required for correct evaluation)
* Example: "sm 640px, lg 50vw, 85vw" -> "(min-width: 1024px) 50vw, (min-width: 640px) 640px, 85vw"
*/
export function processSizes(sizes: string): string {
if (!sizes) return sizes;
const parts = sizes.split(',');
const breakpointEntries: Array<{ order: number; value: string }> = [];
const defaultEntries: string[] = [];
// Parse each part
for (const part of parts) {
const trimmed = part.trim();
if (!trimmed) continue;
// Check if the part starts with a breakpoint name
const breakpointMatch = trimmed.match(/^(sm|md|lg|xl|xxl)\s+/);
if (breakpointMatch) {
const breakpointKey = breakpointMatch[1] as BreakpointKey;
// Runtime validation: ensure breakpoint exists
if (breakpointKey in breakpoints) {
const mediaQuery = breakpoints[breakpointKey];
const sizeValue = trimmed.substring(breakpointMatch[0].length).trim();
const processedValue = `${mediaQuery} ${sizeValue}`;
breakpointEntries.push({
order: breakpointOrderMap[breakpointKey],
value: processedValue
});
}
} else {
// Default entry (no breakpoint) - keep at the end
defaultEntries.push(trimmed);
}
}
// Sort breakpoint entries by order (smallest order number = largest breakpoint first)
breakpointEntries.sort((a, b) => a.order - b.order);
// Combine: breakpoints (largest to smallest) + defaults
return [...breakpointEntries.map((e) => e.value), ...defaultEntries].join(
', '
);
}
/**
* Processes the srcSet string to exclude width descriptors greater than the actual image width
* @param srcSet - The srcSet string from Next.js getImageProps (e.g., "/image.jpg?w=640 640w, /image.jpg?w=1024 1024w")
* @param maxWidth - The actual width of the image
* @returns Filtered srcSet string with only widths <= maxWidth
*/
export function processSrcSet(srcSet: string | undefined, maxWidth: number): string | undefined {
if (!srcSet || typeof srcSet !== 'string') {
return srcSet;
}
// Split srcSet by comma to get individual entries
const entries = srcSet.split(',').map(entry => entry.trim()).filter(Boolean);
// Filter and process each entry
const filteredEntries = entries
.map(entry => {
// Extract width descriptor (e.g., "640w" from "/image.jpg?w=640 640w")
// Also handle density descriptors (e.g., "2x") - these should be included as-is
const widthMatch = entry.match(/\s+(\d+)w$/);
const densityMatch = entry.match(/\s+(\d+(?:\.\d+)?)x$/);
// If it's a density descriptor (e.g., "2x"), include it as-is
if (densityMatch) {
return entry;
}
// If it's a width descriptor, check against maxWidth
if (widthMatch) {
const width = parseInt(widthMatch[1], 10);
// Include entry only if width is less than or equal to maxWidth
if (width <= maxWidth) {
return entry;
}
return null;
}
// If no descriptor found, include the entry as-is (fallback)
return entry;
})
.filter((entry): entry is string => entry !== null);
// Return the filtered srcSet or undefined if empty
return filteredEntries.length > 0 ? filteredEntries.join(', ') : undefined;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment