Search code examples
cssmedia-queriesstorybook

How do I test @media in Storybook?


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?


Solution

  • 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",
        },
    };