How do I test @media in Storybook?

60 Views Asked by At

I want to write different stories, one for each @media query in my CSS.

For example, I hide certain elements for @media print. What I would like to achieve is something like this:

export const PrintFoo: Story = {
  render: (args) => (
    <MediaWrapper media="print">
      <Foo .../>
    </List>
  ),
};

This should be a common task in Storybook (for testing different screen sizes) but I couldn't find any documentation how to do this.

I found JavaScript which modifies the style sheet (replaces print with screen: https://github.com/RRMoelker/print-css-toggle/blob/master/src/index.js) but for this, I would need a "post rendering hook but before compare" where I can call this from a story.

Or do I have to write an addon for this?

1

There are 1 best solutions below

0
Aaron Digulla On BEST ANSWER

Here is a wrapper element (React with TypeScript) which can simulate the print mode. The wrapper collects all media rules of the CSS stylesheets attached to the document. If you switch to @media print, it will change all @media screen rules to @media disabled (effectively rendering them uselss) and replace print with screen in all @media print rules, making them the default.

import { ReactElement, ReactNode } from "react";

type MediaWrapperProps = {
    children: ReactNode;
    media: string;
};

export const MediaWrapper = ({ children, media }: MediaWrapperProps): ReactElement => {
    const result = <>{children}</>;

    console.log("MediaWrapper", media, result);
    const mediaRules = collectRules();

    if (media === "print") {
        console.log("MediaWrapper: Simulating print mode");
        replaceInAllRules(mediaRules.screen, "disabled");
        replaceInAllRules(mediaRules.print, "screen");
        console.log(mediaRules);
    } else if (media === "screen") {
        console.log("MediaWrapper: Simulating screen mode");
        replaceInAllRules(mediaRules.screen, "screen");
        replaceInAllRules(mediaRules.print, "print");
        console.log(mediaRules);
    }

    return result;
};

type MediaRules = {
    print: CSSMediaRuleWithConditionText[];
    screen: CSSMediaRuleWithConditionText[];
};

const replaceInAllRules = (rules: CSSMediaRuleWithConditionText[], replacement: string): void => {
    rules.forEach((rule) => {
        rule.media.mediaText = replacement;
    });
};

// node_modules/typescript/lib/lib.dom.d.ts doesn't have all the properties of the browser's CSSMediaRule
type CSSMediaRuleWithConditionText = CSSMediaRule & {
    conditionText: string;
};

const collectRules = (): MediaRules => {
    const styleSheets = document.styleSheets;
    const printRules = [];
    const screenRules = [];
    const disabledRules = [];

    for (const sheet of styleSheets) {
        const rules = sheet.cssRules || sheet.rules; // IE <= 8 use "rules" property

        for (const rule of rules) {
            if (rule.constructor.name === "CSSMediaRule") {
                const cast = rule as CSSMediaRuleWithConditionText;

                const condition = cast.conditionText;
                if (condition.includes("screen")) {
                    screenRules.push(cast);
                } else if (condition.includes("print")) {
                    printRules.push(cast);
                } else if (cast.media.mediaText === "disabled") {
                    disabledRules.push(cast);
                }
            }
        }
    }

    if (disabledRules.length) {
        // Storybook doesn't always reset the CSS stylesheets in the iframe.
        // if this happened, then there will be "disabled" rules from the last "print mode" story.
        // Return the disabled rules as "screen" and "screen" rules as "print".
        return {
            print: screenRules,
            screen: disabledRules,
        };
    }

    return {
        print: printRules,
        screen: screenRules,
    };
};

If you have a component that uses @media print like this one:

@media print {
    .printMe {
        display: block;
        background-color: white;
    }

    .hideMe {
        display: none;
    }
}

@media screen {
    .printMe {
        background-color: blue;
    }

    .hideMe {
        background-color: green;
    }
}

export const PrintTestComponent = (): ReactElement => {
    return (
        <div>
            <div className="printMe">Print Mode</div>
            <div className="hideMe">Screen Mode</div>
        </div>
    );
};

then you can test this in two stories:

import { MediaWrapper } from "./MediaWrapper";
import { PrintTestComponent } from "./PrintTestComponent";
import { ReactElement } from "react";

type StoryProps = {
    media: "screen" | "print";
};

export default {
    title: "Print Test",
    component: (args: StoryProps): ReactElement => (
        <MediaWrapper media={args.media}>
            <PrintTestComponent />
        </MediaWrapper>
    ),
    parameters: {
        media: "print",
    },
};

export const Screen = {
    storyName: "Screen Mode",
    args: {
        media: "screen",
    },
};

export const Print = {
    storyName: "Print Mode",
    args: {
        media: "print",
    },
};