Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions packages/react/cypress/component/VisualWrapper.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,51 @@ it("supports respponsive aspect function", () => {
cy.get(".wrapper").hasDimensions(400, 400);
});

it("generates CSS classes using useId() for responsive aspects", () => {
cy.mount(
<VisualWrapper
{...sharedProps}
image={{
landscape: {
aspect: 2,
},
portrait: {
aspect: 1,
},
}}
sourceMedia={["(orientation: landscape)", "(orientation: portrait)"]}
aspect={({ image, media }) => {
return media?.includes("landscape")
? image.landscape.aspect
: image.portrait.aspect;
}}
/>,
);

// Check that a single CSS class is generated using useId format
cy.get(".wrapper").should(($wrapper) => {
const className = $wrapper.attr("class") || "";
const classes = className.split(/\s+/).filter(cls => cls !== "wrapper");

// Should have exactly one generated class for responsive aspects
expect(classes.length).to.equal(1);

// The class should be a useId generated class (not the old rv-* format)
const responsiveClass = classes[0];
expect(responsiveClass).to.not.be.empty;
expect(responsiveClass).to.not.start.with("rv-");

// Should NOT have the old bespoke format classes starting with rv-
const oldFormatClasses = classes.filter(cls =>
cls.startsWith("rv-") && (cls.includes("landscape") || cls.includes("portrait"))
);
expect(oldFormatClasses.length).to.equal(0);
});

// Verify that a style tag was created with the responsive CSS
cy.get("style").should("exist").and("contain", "aspect-ratio");
});

it("supports children", () => {
cy.mount(
<VisualWrapper {...sharedProps}>
Expand Down
52 changes: 19 additions & 33 deletions packages/react/src/VisualWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CSSProperties, ReactNode } from "react";
import { useId } from "react";
import { fillStyles, cx } from "./lib/styles";
import { isNumeric } from "./lib/values";
import type { VisualWrapperProps } from "./types/visualWrapperTypes";
Expand All @@ -10,6 +11,7 @@ type MakeResponsiveAspectsProps = Pick<
> & {
sourceMedia: Required<VisualWrapperProps>["sourceMedia"];
aspectCalculator: AspectCalculator;
responsiveClassname: string;
};

// Wraps media elements and applys layout and other functionality
Expand All @@ -26,15 +28,19 @@ export default function VisualWrapper({
style,
dataAttributes,
}: VisualWrapperProps): ReactNode {
// Generate unique CSS class name for responsive aspects
const responsiveClassname = useId().replace(/:/g, "");

// If aspect is a function, invoke it to determine the aspect ratio
let aspectRatio, aspectStyleTag, aspectClasses;
let aspectRatio, aspectStyleTag;
if (typeof aspect == "function" && sourceMedia && sourceMedia.length) {
({ aspectStyleTag, aspectClasses } = makeResponsiveAspects({
aspectStyleTag = makeResponsiveAspects({
aspectCalculator: aspect,
sourceMedia,
image,
video,
}));
responsiveClassname,
});
} else aspectRatio = aspect;

// Make the wrapper style. If expanding, use normal fill rules. Otherwise,
Expand All @@ -52,7 +58,7 @@ export default function VisualWrapper({
// Render wrapping component
return (
<div
className={cx(className, aspectClasses)}
className={cx(className, aspectStyleTag ? responsiveClassname : undefined)}
style={{ ...layoutStyles, ...style }}
{...dataAttributes}
>
Expand All @@ -68,41 +74,21 @@ function makeResponsiveAspects({
sourceMedia,
image,
video,
}: MakeResponsiveAspectsProps): {
aspectClasses: string;
aspectStyleTag: ReactNode;
} {
// Make CSS classes and related rules that are specific to the query and
// aspect value.
const styles = sourceMedia.map((mediaQuery) => {
responsiveClassname,
}: MakeResponsiveAspectsProps): ReactNode {
// Make CSS rules that use the same class name for all media queries
const cssRules = sourceMedia.map((mediaQuery) => {
// Calculate the aspect for this query state
const aspect = aspectCalculator({ media: mediaQuery, image, video });

// Make a CSS class name from the media query string
const mediaClass = mediaQuery.replace(/[^\w]/gi, "-"); // Replace special chars with "-"
const aspectClass = aspect
.toFixed(3)
.replace(/\./gi, "_") // Replace decimals
.replace(/_?0*$/, ""); // Remove trailing 0s
const cssClass = `rv-${mediaClass}-${aspectClass}`.replace(/\-{2,}/g, "-"); // Reduce multiples of `-`

// Make the CSS rule
const cssRule = `@media ${mediaQuery} {
.${cssClass} {
// Make the CSS rule using the shared class name
return `@media ${mediaQuery} {
.${responsiveClassname} {
aspect-ratio: ${aspect};
}
}`;
return { cssClass, cssRule };
});

// Make an array of the classes to add
const aspectClasses = styles.map(({ cssClass }) => cssClass).join(" ");

// Make the style tag
const aspectStyleTag = (
<style>{styles.map(({ cssRule }) => cssRule).join(" ")}</style>
);

// Return completed objects
return { aspectClasses, aspectStyleTag };
// Make the style tag with all rules
return <style>{cssRules.join(" ")}</style>;
}