Skip to content

Commit 3f81660

Browse files
author
Lotfi Habbiche
committed
feat(content-media): add size and ratio variants
- Add support for sm and lg media sizes - Add ratio prop for image and SVG media - Wrap SVGs in media container to support DSFR ratio utilities - Simplify caption link API with a single captionLink prop - Enforce accessible caption links by requiring label and link props together - Update stories and integration examples
1 parent d095b52 commit 3f81660

3 files changed

Lines changed: 168 additions & 126 deletions

File tree

src/ContentMedia.tsx

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,29 @@ export type ContentMediaProps = {
2020
label?: string;
2121
/** Caption text shown below the media (description / source). */
2222
caption?: ReactNode;
23-
/** Text of the optional link inside the figcaption. */
24-
captionLinkLabel?: ReactNode;
25-
/** Props of the optional link inside the figcaption. */
26-
captionLinkProps?: RegisteredLinkProps;
23+
/**
24+
* Label and props of the optional link inside the figcaption.
25+
* Both properties are required together so the link always has an accessible
26+
* name (RGAA 6.1 / WCAG 2.4.4 – Link Purpose).
27+
*/
28+
captionLink?: {
29+
label: ReactNode;
30+
linkProps: RegisteredLinkProps;
31+
};
32+
/**
33+
* Size variant of the media container.
34+
* - `"sm"` → `fr-content-media--sm`
35+
* - `"lg"` → `fr-content-media--lg`
36+
* - `"md"` (default) → no modifier class
37+
*/
38+
size?: "sm" | "md" | "lg";
39+
/**
40+
* Aspect-ratio utility class applied to the image/SVG wrapper (`fr-ratio-*`).
41+
* Only applies to `type="img"` and `type="svg"`.
42+
* Example values: `"16x9"`, `"4x3"`, `"1x1"`, `"3x2"`, `"3x4"`, `"2x3"`.
43+
*/
44+
ratio?: "16x9" | "3x2" | "4x3" | "1x1" | "3x4" | "2x3";
2745
} & ContentMediaProps.Media;
28-
2946
export namespace ContentMediaProps {
3047
/** Image (`<img>`) media. */
3148
export type ImageMedia = {
@@ -41,7 +58,6 @@ export namespace ContentMediaProps {
4158
alt: string;
4259
};
4360
};
44-
4561
/** SVG media — pass your `<svg>` element as the `svg` prop. */
4662
export type SvgMedia = {
4763
type: "svg";
@@ -52,7 +68,6 @@ export namespace ContentMediaProps {
5268
*/
5369
svg: ReactNode;
5470
};
55-
5671
/** Embedded video via `<iframe>` (e.g. YouTube). */
5772
export type IframeMedia = {
5873
type: "iframe";
@@ -66,7 +81,6 @@ export namespace ContentMediaProps {
6681
title: string;
6782
};
6883
};
69-
7084
/** Native HTML5 `<video>`. */
7185
export type VideoMedia = {
7286
type: "video";
@@ -78,7 +92,6 @@ export namespace ContentMediaProps {
7892
/** Props forwarded to the `<video>` element. `controls` is always set. */
7993
videoProps: React.VideoHTMLAttributes<HTMLVideoElement> & { src: string };
8094
};
81-
8295
/** Native HTML5 `<audio>`. */
8396
export type AudioMedia = {
8497
type: "audio";
@@ -90,10 +103,8 @@ export namespace ContentMediaProps {
90103
/** Props forwarded to the `<audio>` element. `controls` is always set. */
91104
audioProps: React.AudioHTMLAttributes<HTMLAudioElement> & { src: string };
92105
};
93-
94106
export type Media = ImageMedia | SvgMedia | IframeMedia | VideoMedia | AudioMedia;
95107
}
96-
97108
/** @see <https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/contenu-medias> */
98109
export const ContentMedia = memo(
99110
forwardRef<HTMLElement, ContentMediaProps>((props, ref) => {
@@ -104,32 +115,37 @@ export const ContentMedia = memo(
104115
classes = {},
105116
label,
106117
caption,
107-
captionLinkLabel,
108-
captionLinkProps
118+
captionLink,
119+
size,
120+
ratio
109121
} = props;
110-
111122
const id = useAnalyticsId({
112123
"defaultIdPrefix": "fr-content-media",
113124
"explicitlyProvidedId": id_props
114125
});
115-
116126
const { Link } = getLink();
117-
118127
const ariaLabel = label ?? (typeof caption === "string" ? caption : undefined);
119-
128+
const imgWrapperClassName = cx(
129+
fr.cx("fr-content-media__img"),
130+
ratio !== undefined ? (`fr-ratio-${ratio}` as string) : undefined,
131+
classes.img
132+
);
120133
const renderMedia = () => {
121134
switch (props.type) {
122135
case "img":
123136
return (
124-
<div className={cx(fr.cx("fr-content-media__img"), classes.img)}>
137+
<div className={imgWrapperClassName}>
125138
<img
126139
{...props.imgProps}
127140
className={cx(fr.cx("fr-responsive-img"), props.imgProps.className)}
128141
/>
129142
</div>
130143
);
131144
case "svg":
132-
return <>{props.svg}</>;
145+
// The DSFR reference implementation wraps SVGs in fr-content-media__img
146+
// (example/component/content/index.html). The wrapper provides width:100%
147+
// and serves as an anchor for the fr-ratio-* size utilities.
148+
return <div className={imgWrapperClassName}>{props.svg}</div>;
133149
case "iframe":
134150
return (
135151
<iframe
@@ -159,14 +175,19 @@ export const ContentMedia = memo(
159175
);
160176
}
161177
};
162-
163-
const hasCaption = caption !== undefined || captionLinkProps !== undefined;
164-
178+
const hasCaption = caption !== undefined || captionLink !== undefined;
165179
return (
166180
<figure
167181
id={id}
168182
role="group"
169-
className={cx(fr.cx("fr-content-media"), classes.root, className)}
183+
className={cx(
184+
fr.cx("fr-content-media"),
185+
size !== undefined && size !== "md"
186+
? (`fr-content-media--${size}` as string)
187+
: undefined,
188+
classes.root,
189+
className
190+
)}
170191
style={style}
171192
{...(ariaLabel !== undefined ? { "aria-label": ariaLabel } : {})}
172193
ref={ref}
@@ -175,9 +196,9 @@ export const ContentMedia = memo(
175196
{hasCaption && (
176197
<figcaption className={cx(fr.cx("fr-content-media__caption"), classes.caption)}>
177198
{caption}
178-
{captionLinkProps !== undefined && (
179-
<Link {...captionLinkProps} className={fr.cx("fr-link")}>
180-
{captionLinkLabel}
199+
{captionLink !== undefined && (
200+
<Link {...captionLink.linkProps} className={fr.cx("fr-link")}>
201+
{captionLink.label}
181202
</Link>
182203
)}
183204
</figcaption>
@@ -186,7 +207,5 @@ export const ContentMedia = memo(
186207
);
187208
})
188209
);
189-
190210
ContentMedia.displayName = symToStr({ ContentMedia });
191-
192211
export default ContentMedia;

0 commit comments

Comments
 (0)