UNPKG

storybook-mobile-addon

Version:

This addon offers suggestions on how you can improve the HTML, CSS and UX of your components to be more mobile-friendly.

937 lines (921 loc) 41.9 kB
import React, { Fragment, useRef, useEffect, useState, cloneElement, useMemo } from 'react'; import { addons, types, useAddonState, useChannel } from 'storybook/manager-api'; import { AddonPanel } from 'storybook/internal/components'; import { styled } from 'storybook/theming'; import { createScheduler } from 'lrt'; import { STORY_RENDERED } from 'storybook/internal/core-events'; // src/manager.tsx // src/constants.ts var ADDON_ID = "storybook/mobile-addon"; var PARAM_KEY = "storybook/mobile-addon"; var PANEL_ID = `${ADDON_ID}/panel`; var VIEWPORT_ID = "storybook/viewport"; var NO_VIEWPORT = "reset"; var DEFAULT_VIEWPORT = "mobile1"; // src/Panel/Content/Hints/get-dom-path.ts var getDomPath = (element) => { const stack = []; while (element.parentNode) { let sibCount = 0; let sibIndex = 0; for (let index = 0; index < element.parentNode.childNodes.length; index++) { const sib = element.parentNode.childNodes[index]; if (sib?.nodeName === element.nodeName) { if (sib === element) { sibIndex = sibCount; } sibCount++; } } if (element.hasAttribute("id") && element.id !== "") { stack.unshift(`${element.nodeName.toLowerCase()}#${element.id}`); } else if (element.classList.toString() !== "" && element.tagName !== "BODY") { stack.unshift( `${element.nodeName.toLowerCase()}.${element.classList.toString()}` ); } else if (sibCount > 1) { stack.unshift(`${element.nodeName.toLowerCase()}:eq(${sibIndex})`); } else { stack.unshift(element.nodeName.toLowerCase()); } element = element.parentNode; } const toFilter = ["html", "body", "div#root"]; return stack.filter((stackElement) => !toFilter.includes(stackElement)).join(" > "); }; var get_dom_path_default = getDomPath; // src/Panel/Content/Hints/utils.ts var getElements = (container, tag) => Array.from(container.querySelectorAll(tag)); var getStylesheetRules = (sheets, k) => { let rules = []; try { if (sheets[k]?.cssRules) { rules = Array.from(sheets[k].cssRules); } } catch { } return rules; }; var getNodeName = (element) => element.nodeName === "A" ? "a" : element.nodeName === "BUTTON" ? "button" : `${element.nodeName.toLowerCase()}[role="button"]`; var attachLabels = (inputs, container) => inputs.map((input) => { let labelText = ""; if (input.labels && input.labels[0]) { labelText = input.labels[0].textContent; } else if (input.parentElement?.nodeName === "LABEL") { labelText = input.parentElement.textContent; } else if (input.id) { const label = container.querySelector(`label[for="${input.id}"]`); if (label) labelText = label.textContent; } return { labelText, path: get_dom_path_default(input), type: input.type }; }); var textInputs = { email: true, number: true, password: true, search: true, tel: true, text: true, url: true }; var getAutocompleteWarnings = (container) => { const inputs = getElements(container, "input"); const warnings = inputs.filter((input) => { const currentType = input.getAttribute("type"); const autocomplete = input.getAttribute("autocomplete"); return currentType && textInputs[currentType] && !autocomplete; }); return attachLabels(warnings, container); }; var getInputTypeNumberWarnings = (container) => { const inputs = getElements( container, 'input[type="number"]' ); return attachLabels(inputs, container); }; var getInputTypeWarnings = (container) => { const inputs = getElements(container, 'input[type="text"]').concat(getElements(container, "input:not([type])")).filter( (input) => !input.getAttribute("inputmode") ); return attachLabels(inputs, container); }; var getInstantWarnings = (container) => ({ autocomplete: getAutocompleteWarnings(container), inputType: getInputTypeWarnings(container), inputTypeNumber: getInputTypeNumberWarnings(container) }); var isInside = (dangerZone, bounding) => bounding.top <= dangerZone.bottom && bounding.bottom >= dangerZone.top && bounding.left <= dangerZone.right && bounding.right >= dangerZone.left; var toTouchTarget = ({ bounding: { height, width }, close, el }) => ({ close, height: Math.floor(height), html: el.innerHTML, path: get_dom_path_default(el), text: el.textContent, type: el.nodeName === "A" ? "a" : el.nodeName === "BUTTON" ? "button" : `${el.nodeName.toLowerCase()}[role="button"]`, width: Math.floor(width) }); var MIN_SIZE = 32; var RECOMMENDED_DISTANCE = 8; var checkMinSize = ({ height, width }) => height < MIN_SIZE || width < MIN_SIZE; function* getTouchTargetSizeWarning(container) { const elements = getElements(container, "button").concat(getElements(container, '[role="button"]')).concat(getElements(container, "a")); const suspectElements = Array.from(new Set(elements)).map( (element) => [ element, element.getBoundingClientRect() ] ); const { length } = elements; const underMinSize = []; const tooClose = []; for (let index = 0; index < length; index++) { const element = elements[index]; if (element) { const bounding = element.getBoundingClientRect(); const dangerZone = { bottom: bounding.bottom + RECOMMENDED_DISTANCE, left: bounding.left - RECOMMENDED_DISTANCE, right: bounding.right + RECOMMENDED_DISTANCE, top: bounding.top - RECOMMENDED_DISTANCE }; const close = suspectElements.filter( ([susElement, susBounding]) => susElement !== element && isInside(dangerZone, susBounding) ); const isUnderMinSize = checkMinSize(bounding); if (isUnderMinSize || close.length > 0) { const touchTarget = toTouchTarget({ bounding, close, el: element }); if (isUnderMinSize) { underMinSize.push(touchTarget); } if (close.length > 0) { tooClose.push(touchTarget); } } } yield index; } return { tooClose, underMinSize }; } function* getTapHighlightWarnings(container) { const buttons = getElements(container, "button").concat( getElements(container, '[role="button"]') ); const links = getElements(container, "a"); const elements = buttons.concat(links); const { length } = elements; const result = []; for (let index = 0; index < length; index++) { const element = elements[index]; if ( // @ts-ignore getComputedStyle(element)["-webkit-tap-highlight-color"] === "rgba(0, 0, 0, 0)" ) { result.push({ html: element.innerHTML, path: get_dom_path_default(element), text: element.textContent, type: getNodeName(element) }); } yield index; } return result; } var MAX_WIDTH = 600; function* getSrcsetWarnings(container) { const images = getElements(container, "img"); const { length } = images; const result = []; for (let index = 0; index < length; index++) { const img = images[index]; const sourceSet = img.getAttribute("srcset"); const source = img.getAttribute("src"); if (!sourceSet && source) { const isSVG = Boolean(source.endsWith("svg")); if (!isSVG) { const isLarge = Number.parseInt(getComputedStyle(img).width, 10) > MAX_WIDTH || img.naturalWidth > MAX_WIDTH; if (isLarge) { result.push({ alt: img.alt, path: get_dom_path_default(img), src: img.src }); } } } yield index; } return result; } function* getBackgroundImageWarnings(container) { const backgroundImageRegex = /url\(".*?(.png|.jpg|.jpeg)"\)/; const elsWithBackgroundImage = getElements(container, "#root *").filter( (element) => { const style = getComputedStyle(element); const backgroundImageStyle = style["background-image"]; return backgroundImageStyle && backgroundImageRegex.test(backgroundImageStyle) && // HACK // ideally, we would make a new image element and check its "naturalWidth" // to get a better idea of the size of the background image, this is a hack element.clientWidth > 200; } ); if (elsWithBackgroundImage.length === 0) return []; const styleDict = /* @__PURE__ */ new Map(); Object.keys(container.styleSheets).forEach((k) => { getStylesheetRules(container.styleSheets, k).forEach((rule) => { if (rule) { try { elsWithBackgroundImage.forEach((element) => { if (element.matches(rule.selectorText)) { styleDict.set( element, (styleDict.get(element) || []).concat(rule) ); } }); } catch { } } }); }); const responsiveBackgroundImgRegex = /-webkit-min-device-pixel-ratio|min-resolution|image-set/; const result = []; const elements = Array.from(styleDict.entries()); const { length } = elements; for (let index = 0; index < length; index++) { const [element, styles] = elements[index]; if (styles) { const requiresResponsiveWarning = styles.some( (style) => !responsiveBackgroundImgRegex.test(style) ); if (requiresResponsiveWarning) { const bg = getComputedStyle(element).backgroundImage; const source = /url\("(.*)"\)/.test(bg) ? bg.match(/url\("(.*)"\)/)?.[1] : void 0; result.push({ path: get_dom_path_default(element), src: source }); } } yield index; } return result; } var getActiveStyles = (container, element) => { const sheets = container.styleSheets; const result = []; const activeRegex = /:active$/; Object.keys(sheets).forEach((k) => { getStylesheetRules(sheets, k).forEach((rule) => { if (rule && // @ts-ignore rule.selectorText && // @ts-ignore activeRegex.test(rule.selectorText)) { const ruleNoPseudoClass = rule.selectorText.replace( activeRegex, "" ); try { if (element.matches(ruleNoPseudoClass)) { result.push(rule); } } catch { } } }); }); return result; }; function* getActiveWarnings(container) { const buttons = getElements(container, "button").concat( getElements(container, '[role="button"]') ); const links = getElements(container, "a"); const elements = buttons.concat(links); const { length } = elements; const result = []; for (let index = 0; index < length; index++) { const element = elements[index]; if (element) { const hasActive = getActiveStyles(container, element); if (hasActive.length > 0) { result.push({ html: element.innerHTML, path: get_dom_path_default(element), text: element.textContent, type: getNodeName(element) }); } } yield index; } return result; } var getOriginalStyles = (container, element) => { const sheets = container.styleSheets; const result = []; Object.keys(sheets).forEach((k) => { const rules = getStylesheetRules(sheets, k); rules.forEach((rule) => { if (rule) { try { if (element.matches(rule.selectorText)) { result.push(rule.cssText); } } catch { } } }); }); return result; }; function* get100vhWarnings(container) { const elements = getElements(container, "#root *"); const { length } = elements; const result = []; for (let index = 0; index < length; index++) { const element = elements[index]; if (element) { const styles = getOriginalStyles(container, element); const vhWarning = styles.find((style) => /100vh/.test(style)); if (vhWarning) { result.push({ css: vhWarning, el: element, path: get_dom_path_default(element) }); } } yield index; } return result; } var schedule = (iterator) => { const scheduler = createScheduler({ chunkBudget: 100 }); const task = scheduler.runTask(iterator); return { abort: () => scheduler.abortTask(task), task }; }; var getScheduledWarnings = (container, setState, setComplete) => { const analyses = { active: schedule(getActiveWarnings(container)), backgroundImg: schedule(getBackgroundImageWarnings(container)), height: schedule(get100vhWarnings(container)), srcset: schedule(getSrcsetWarnings(container)), tapHighlight: schedule(getTapHighlightWarnings(container)), touchTarget: schedule(getTouchTargetSizeWarning(container)) }; const analysesArray = Object.keys(analyses); let remaining = analysesArray.length; analysesArray.forEach((key) => { analyses[key]?.task.then((result) => { setState((prev) => ({ ...prev, [key]: result })); if (--remaining === 0) { setComplete(true); } }); }); return () => analysesArray.forEach((key) => analyses[key]?.abort()); }; // src/Panel/Content/Hints/index.tsx var accessibleBlue = "#0965df"; var warning = "#bd4700"; var tagStyles = ` padding: .25rem .5rem; font-weight: bold; display:inline-block; border-radius: 10px; margin-bottom: 1rem; svg { margin-right: .25rem; display: inline-block; height: .7rem; line-height: 1; position: relative; top: .03rem; letter-spacing: .01rem; } `; var StyledWarningTag = styled.div` color: ${warning}; background-color: hsl(41, 100%, 92%); ${tagStyles} `; var Warning = () => /* @__PURE__ */ React.createElement(StyledWarningTag, null, /* @__PURE__ */ React.createElement( "svg", { "aria-hidden": "true", focusable: "false", role: "img", viewBox: "0 0 576 512", xmlns: "http://www.w3.org/2000/svg" }, /* @__PURE__ */ React.createElement( "path", { d: "M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z", fill: "currentColor" } ) ), "warning"); var StyledInfoTag = styled.div` background-color: hsla(214, 92%, 45%, 0.1); color: ${accessibleBlue}; ${tagStyles} `; var Hint = () => /* @__PURE__ */ React.createElement(StyledInfoTag, null, /* @__PURE__ */ React.createElement( "svg", { "aria-hidden": "true", className: "svg-inline--fa fa-magic fa-w-16 fa-5x", "data-icon": "magic", "data-prefix": "fas", focusable: "false", role: "img", viewBox: "0 0 512 512", xmlns: "http://www.w3.org/2000/svg" }, /* @__PURE__ */ React.createElement( "path", { className: "", d: "M224 96l16-32 32-16-32-16-16-32-16 32-32 16 32 16 16 32zM80 160l26.66-53.33L160 80l-53.34-26.67L80 0 53.34 53.33 0 80l53.34 26.67L80 160zm352 128l-26.66 53.33L352 368l53.34 26.67L432 448l26.66-53.33L512 368l-53.34-26.67L432 288zm70.62-193.77L417.77 9.38C411.53 3.12 403.34 0 395.15 0c-8.19 0-16.38 3.12-22.63 9.38L9.38 372.52c-12.5 12.5-12.5 32.76 0 45.25l84.85 84.85c6.25 6.25 14.44 9.37 22.62 9.37 8.19 0 16.38-3.12 22.63-9.37l363.14-363.15c12.5-12.48 12.5-32.75 0-45.24zM359.45 203.46l-50.91-50.91 86.6-86.6 50.91 50.91-86.6 86.6z", fill: "currentColor" } ) ), "hint"); var Spacer = styled.div` padding: 1rem; `; var StyledTappableContents = styled.div` display: inline-block; padding-top: 0.25rem; height: 2rem; min-width: 1rem; width: auto; background-color: hsla(0, 0%, 50%, 0.1); border-radius: 3px; li { list-style-type: none; } img, svg { max-height: 2rem !important; min-height: 1rem !important; width: auto !important; } `; var DemoImg = styled.img` height: 4rem; width: auto; max-width: 100%; background-color: hsla(0, 0%, 0%, 0.2); `; var ListEntry = styled.li` margin-bottom: 0.5rem; ${(props) => props.noStyle ? "list-style-type: none;" : ""}; `; var Container = styled.div` display: grid; grid-template-columns: repeat(auto-fit, minmax(25rem, 1fr)); font-size: ${(props) => props.theme.typography.size.s2}px; p { line-height: 1.4; } h3 { font-size: ${(props) => props.theme.typography.size.s2}px; font-weight: bold; margin-bottom: 0.5rem; margin-top: 0; } code { background: hsla(0, 0%, 50%, 0.1); border-radius: 3px; } summary { cursor: pointer; display: block; margin-right: 1rem; padding: 0.2rem 0.3rem; border-radius: 5px; color: ${accessibleBlue}; &:focus { outline: none; box-shadow: 0 0 0 3px ${(props) => props.theme.color.mediumlight}; } } ul { padding-left: 1.25rem; max-height: 12rem; overflow: auto; padding-bottom: 0.5rem; li { margin-bottom: 0.3rem; } } a { text-decoration: none; color: ${accessibleBlue}; &:hover { border-bottom: 1px solid ${accessibleBlue}; } } > div { border-bottom: 1px solid ${(props) => props.theme.color.medium}; border-right: 1px solid ${(props) => props.theme.color.medium}; } `; var StyledBanner = styled.div` display: flex; align-items: center; padding: 0 0.75rem; grid-column: 1 / -1; height: 2.875rem; `; var StyledRescanButton = styled.button` margin-left: 0.5rem; border-radius: 3px; padding: 0.2rem 0.5rem; cursor: pointer; font-family: inherit; color: inherit; font-size: 100%; background-color: transparent; appearance: none; box-shadow: none; border: 1px solid; &:hover { background-color: hsla(0, 0%, 0%, 0.15); } `; var Spinner = styled.div` cursor: progress; display: inline-block; overflow: hidden; position: relative; margin-right: 0.7rem; height: 1.25rem; width: 1.25rem; border-width: 2px; border-style: solid; border-radius: 50%; border-color: rgba(97, 97, 97, 0.29); border-top-color: rgb(100, 100, 100); animation: spinner 0.7s linear infinite; mix-blend-mode: difference; @keyframes spinner { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } `; var fixText = "Learn more"; var ActiveWarnings = ({ warnings }) => { if (!warnings || warnings.length === 0) return null; return /* @__PURE__ */ React.createElement(Spacer, null, /* @__PURE__ */ React.createElement(Hint, null), /* @__PURE__ */ React.createElement("h3", null, /* @__PURE__ */ React.createElement("code", null, ":active"), " styles on iOS"), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("code", null, ":active"), " styles will only appear in iOS", " ", /* @__PURE__ */ React.createElement( "a", { href: "https://stackoverflow.com/questions/3885018/active-pseudo-class-doesnt-work-in-mobile-safari", rel: "noopener noreferrer", target: "_blank" }, "if a touch listener is added to the element or one of its ancestors" ), ". Once activated in this manner, ", /* @__PURE__ */ React.createElement("code", null, ":active"), " styles (along with ", /* @__PURE__ */ React.createElement("code", null, ":hover"), " styles) will be applied immediately in iOS when a user taps, possibly creating a confusing UX. (On Android, ", /* @__PURE__ */ React.createElement("code", null, ":active"), " styles are applied with a slight delay to allow the user to use gestures like scroll without necessarily activating ", /* @__PURE__ */ React.createElement("code", null, ":active"), " ", "styles.)"), /* @__PURE__ */ React.createElement("ul", null, warnings.map((w, index) => /* @__PURE__ */ React.createElement(ListEntry, { key: index }, /* @__PURE__ */ React.createElement("code", null, w.type), " with content\xA0\xA0", w.text ? /* @__PURE__ */ React.createElement("b", null, w.text) : w.html ? /* @__PURE__ */ React.createElement( StyledTappableContents, { dangerouslySetInnerHTML: { __html: w.html } } ) : "[no text found]"))), /* @__PURE__ */ React.createElement("details", null, /* @__PURE__ */ React.createElement("summary", null, fixText), /* @__PURE__ */ React.createElement("p", { style: { marginTop: "1rem" } }, /* @__PURE__ */ React.createElement( "a", { href: "https://stackoverflow.com/questions/3885018/active-pseudo-class-doesnt-work-in-mobile-safari/33681490#33681490", rel: "noopener noreferrer", target: "_blank" }, "Relevant Stack Overflow thread" )))); }; var TapWarnings = ({ warnings }) => { if (!warnings || warnings.length === 0) return null; return /* @__PURE__ */ React.createElement(Spacer, null, /* @__PURE__ */ React.createElement(Hint, null), /* @__PURE__ */ React.createElement("h3", null, "Tap style removed from tappable element"), /* @__PURE__ */ React.createElement("p", null, "These elements have an invisible", " ", /* @__PURE__ */ React.createElement("code", null, "-webkit-tap-highlight-color"), ". While this might be intentional, please verify that they have appropriate tap indication styles added through other means."), /* @__PURE__ */ React.createElement("ul", null, warnings.map((w, index) => /* @__PURE__ */ React.createElement(ListEntry, { key: index }, /* @__PURE__ */ React.createElement("code", null, w.type), " with content\xA0\xA0", w.text ? /* @__PURE__ */ React.createElement("b", null, w.text) : w.html ? /* @__PURE__ */ React.createElement( StyledTappableContents, { dangerouslySetInnerHTML: { __html: w.html } } ) : "[no text found]"))), /* @__PURE__ */ React.createElement("details", null, /* @__PURE__ */ React.createElement("summary", null, fixText), /* @__PURE__ */ React.createElement("p", null, "Some stylesheets remove the tap indication highlight shown on iOS and Android browsers by adding the code", " ", /* @__PURE__ */ React.createElement("code", null, "-webkit-tap-highlight-color: transparent"), ". In order to maintain a good mobile experience, tap styles should be added via appropriate ", /* @__PURE__ */ React.createElement("code", null, ":active"), " CSS styles (though, note that", " ", /* @__PURE__ */ React.createElement( "a", { href: "https://stackoverflow.com/questions/3885018/active-pseudo-class-doesnt-work-in-mobile-safari", rel: "noopener noreferrer", target: "_blank" }, /* @__PURE__ */ React.createElement("code", null, ":active"), " styles work inconsistently in iOS" ), ") , or via JavaScript on the ", /* @__PURE__ */ React.createElement("code", null, "touchstart"), " event."))); }; var AutocompleteWarnings = ({ warnings }) => { if (!warnings || warnings.length === 0) return null; return /* @__PURE__ */ React.createElement(Spacer, null, /* @__PURE__ */ React.createElement(Warning, null), /* @__PURE__ */ React.createElement("h3", null, "Input with no ", /* @__PURE__ */ React.createElement("code", null, "autocomplete"), " attribute"), /* @__PURE__ */ React.createElement("p", null, "Most textual inputs should have an explicit", " ", /* @__PURE__ */ React.createElement("code", null, "autocomplete"), " attribute."), /* @__PURE__ */ React.createElement("p", null, "If you truly want to disable autocomplete, try using a", " ", /* @__PURE__ */ React.createElement( "a", { href: "https://bugs.chromium.org/p/chromium/issues/detail?id=468153#c164", rel: "noopener noreferrer", target: "_blank" }, "semantically valid but unique value rather than", " ", /* @__PURE__ */ React.createElement("code", null, 'autocomplete="off"') ), ", which doesn't work in Chrome."), /* @__PURE__ */ React.createElement("p", null, "Note: ", /* @__PURE__ */ React.createElement("code", null, "autocomplete"), " is styled as", " ", /* @__PURE__ */ React.createElement("code", null, "autoComplete"), " in JSX."), /* @__PURE__ */ React.createElement("ul", null, warnings.map((w, index) => /* @__PURE__ */ React.createElement(ListEntry, { key: index }, /* @__PURE__ */ React.createElement("code", null, 'input type="', w.type, '"'), " and label", " ", /* @__PURE__ */ React.createElement("b", null, w.labelText || "[no label found]")))), /* @__PURE__ */ React.createElement("details", null, /* @__PURE__ */ React.createElement("summary", null, fixText), /* @__PURE__ */ React.createElement("ul", null, /* @__PURE__ */ React.createElement("li", null, /* @__PURE__ */ React.createElement( "a", { href: "https://developers.google.com/web/updates/2015/06/checkout-faster-with-autofill", rel: "noopener noreferrer", target: "_blank" }, "Autocomplete documentation by Google" )), /* @__PURE__ */ React.createElement("li", null, /* @__PURE__ */ React.createElement( "a", { href: "https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete", rel: "noopener noreferrer", target: "_blank" }, "Autocomplete documentation by Mozilla" ))))); }; var InputTypeWarnings = ({ warnings }) => { if (!warnings || warnings.length === 0) return null; return /* @__PURE__ */ React.createElement(Spacer, null, /* @__PURE__ */ React.createElement(Hint, null), /* @__PURE__ */ React.createElement("h3", null, "Plain input type ", /* @__PURE__ */ React.createElement("code", null, "text"), " detected"), /* @__PURE__ */ React.createElement("p", null, "This will render the default text keyboard on mobile (which could very well be what you want!) If you haven't already, take a moment to make sure this is correct. You can use", " ", /* @__PURE__ */ React.createElement( "a", { href: "https://better-mobile-inputs.netlify.com/", rel: "noopener noreferrer", target: "_blank" }, "this tool" ), " ", "to explore keyboard options."), /* @__PURE__ */ React.createElement("ul", null, warnings.map((w, index) => /* @__PURE__ */ React.createElement(ListEntry, { key: index }, /* @__PURE__ */ React.createElement("code", null, 'input type="', w.type, '"'), " and label", " ", /* @__PURE__ */ React.createElement("b", null, w.labelText || "[no label found]")))), /* @__PURE__ */ React.createElement("details", null, /* @__PURE__ */ React.createElement("summary", null, fixText), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement( "a", { href: "https://css-tricks.com/better-form-inputs-for-better-mobile-user-experiences/", rel: "noopener noreferrer", target: "_blank" }, "Article reviewing the importance of using correct input types on the mobile web from CSS Tricks." )))); }; var InputTypeNumberWarnings = ({ warnings }) => { if (!warnings || warnings.length === 0) return null; return /* @__PURE__ */ React.createElement(Spacer, null, /* @__PURE__ */ React.createElement(Hint, null), /* @__PURE__ */ React.createElement("h3", null, "Input type ", /* @__PURE__ */ React.createElement("code", null, "number"), " detected"), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("code", null, '<input type="text" inputmode="decimal"/>'), " ", "could give you improved usability over", " ", /* @__PURE__ */ React.createElement("code", null, '<input type="number" />'), "."), /* @__PURE__ */ React.createElement("p", null, "Note: ", /* @__PURE__ */ React.createElement("code", null, "inputmode"), " is styled as ", /* @__PURE__ */ React.createElement("code", null, "inputMode"), " ", "in JSX.", " "), /* @__PURE__ */ React.createElement("ul", null, warnings.map((w, index) => /* @__PURE__ */ React.createElement(ListEntry, { key: index }, /* @__PURE__ */ React.createElement("code", null, 'input type="', w.type, '"'), " and label", " ", /* @__PURE__ */ React.createElement("b", null, w.labelText || "[no label found]")))), /* @__PURE__ */ React.createElement("details", null, /* @__PURE__ */ React.createElement("summary", null, fixText), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement( "a", { href: "https://technology.blog.gov.uk/2020/02/24/why-the-gov-uk-design-system-team-changed-the-input-type-for-numbers/", rel: "noopener noreferrer", target: "_blank" }, "Overview of the issues with", " ", /* @__PURE__ */ React.createElement("code", null, 'input type="number"'), " from gov.uk." )))); }; var HeightWarnings = ({ warnings }) => { if (!warnings || warnings.length === 0) return null; return /* @__PURE__ */ React.createElement(Spacer, null, /* @__PURE__ */ React.createElement(Hint, null), /* @__PURE__ */ React.createElement("h3", null, "Usage of ", /* @__PURE__ */ React.createElement("code", null, "100vh"), " CSS"), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement( "a", { href: "https://chanind.github.io/javascript/2019/09/28/avoid-100vh-on-mobile-web.html", rel: "noopener noreferrer", target: "_blank" }, "Viewport units are tricky on mobile." ), " ", "On some mobile browers, depending on scroll position,", " ", /* @__PURE__ */ React.createElement("code", null, "100vh"), " might take up more than 100% of screen height due to browser chrome like the address bar."), /* @__PURE__ */ React.createElement("ul", null, warnings.map(({ path }, index) => /* @__PURE__ */ React.createElement(ListEntry, { key: index }, /* @__PURE__ */ React.createElement("code", null, path))))); }; var BackgroundImageWarnings = ({ warnings }) => { if (!warnings || warnings.length === 0) return null; return /* @__PURE__ */ React.createElement(Spacer, null, /* @__PURE__ */ React.createElement(Warning, null), /* @__PURE__ */ React.createElement("h3", null, "Non-dynamic background image"), /* @__PURE__ */ React.createElement("p", null, "Downloading larger-than-necessary images hurts performance for users on mobile. You can use", " ", /* @__PURE__ */ React.createElement( "a", { href: "https://developer.mozilla.org/en-US/docs/Web/CSS/image-set", rel: "noopener noreferrer", target: "_blank" }, /* @__PURE__ */ React.createElement("code", null, "image-set") ), " ", "to serve an appropriate background image based on the user's device resolution."), /* @__PURE__ */ React.createElement("ul", null, warnings.map(({ alt, src }, index) => /* @__PURE__ */ React.createElement(ListEntry, { key: index, noStyle: true }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement(DemoImg, { alt, src }))))), /* @__PURE__ */ React.createElement("details", null, /* @__PURE__ */ React.createElement("summary", null, fixText), /* @__PURE__ */ React.createElement("ul", null, /* @__PURE__ */ React.createElement("li", null, /* @__PURE__ */ React.createElement( "a", { href: "https://css-tricks.com/responsive-images-css/", rel: "noopener noreferrer", target: "_blank" }, "Article discussing responsive background images in greater detail, including the interaction of", " ", /* @__PURE__ */ React.createElement("code", null, "image-set"), " with media queries, from CSS Tricks." ))))); }; var SrcsetWarnings = ({ warnings }) => { if (!warnings || warnings.length === 0) return null; return /* @__PURE__ */ React.createElement(Spacer, null, /* @__PURE__ */ React.createElement(Warning, null), /* @__PURE__ */ React.createElement("h3", null, "Large image without ", /* @__PURE__ */ React.createElement("code", null, "srcset")), /* @__PURE__ */ React.createElement("p", null, "Downloading larger-than-necessary images hurts performance for users on mobile. You can use ", /* @__PURE__ */ React.createElement("code", null, "srcset"), " to customize image sizes for different device resolutions and sizes."), /* @__PURE__ */ React.createElement("ul", null, warnings.map(({ alt, src }, index) => /* @__PURE__ */ React.createElement(ListEntry, { key: index, noStyle: true }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement(DemoImg, { alt, src }))))), /* @__PURE__ */ React.createElement("details", null, /* @__PURE__ */ React.createElement("summary", null, fixText), /* @__PURE__ */ React.createElement("ul", null, /* @__PURE__ */ React.createElement("li", null, /* @__PURE__ */ React.createElement( "a", { href: "https://cloudfour.com/thinks/responsive-images-the-simple-way", rel: "noopener noreferrer", target: "_blank" }, "Summary of the why and how of responsive images" )), /* @__PURE__ */ React.createElement("li", null, /* @__PURE__ */ React.createElement( "a", { href: "https://www.responsivebreakpoints.com/", rel: "noopener noreferrer", target: "_blank" }, "A tool to generate responsive images" ))))); }; var TouchTargetWarnings = ({ warnings }) => { if (!warnings) return null; const { tooClose, underMinSize } = warnings; if (underMinSize.length === 0 && tooClose.length === 0) return null; return /* @__PURE__ */ React.createElement(Spacer, null, /* @__PURE__ */ React.createElement(Warning, null), underMinSize.length > 0 && /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h3", null, "Small touch target"), /* @__PURE__ */ React.createElement("p", null, "With heights and/or widths of less than ", MIN_SIZE, "px, these tappable elements could be difficult for users to press:"), /* @__PURE__ */ React.createElement("ul", null, underMinSize.map((w, index) => /* @__PURE__ */ React.createElement(ListEntry, { key: index }, /* @__PURE__ */ React.createElement("code", null, w.type), " with content\xA0\xA0", w.text ? /* @__PURE__ */ React.createElement("b", null, w.text) : w.html ? /* @__PURE__ */ React.createElement( StyledTappableContents, { dangerouslySetInnerHTML: { __html: w.html } } ) : "[no text found]")))), tooClose.length > 0 && /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement( "h3", { style: { marginTop: underMinSize.length > 0 ? ".5rem" : "0" } }, "Touch targets close together", " " ), /* @__PURE__ */ React.createElement("p", null, "These tappable elements are less than", " ", RECOMMENDED_DISTANCE, "px from at least one other tappable element:"), /* @__PURE__ */ React.createElement("ul", null, tooClose.map((w, index) => /* @__PURE__ */ React.createElement(ListEntry, { key: index }, /* @__PURE__ */ React.createElement("code", null, w.type), " with content\xA0\xA0", w.text ? /* @__PURE__ */ React.createElement("b", null, w.text) : w.html ? /* @__PURE__ */ React.createElement( StyledTappableContents, { dangerouslySetInnerHTML: { __html: w.html } } ) : "[no text found]")))), /* @__PURE__ */ React.createElement("details", null, /* @__PURE__ */ React.createElement("summary", null, fixText), /* @__PURE__ */ React.createElement("ul", null, /* @__PURE__ */ React.createElement("li", null, /* @__PURE__ */ React.createElement( "a", { href: "https://www.nngroup.com/articles/touch-target-size/", rel: "noopener noreferrer", target: "_blank" }, "Touch target size article from the Nielsen Norman Group" )), /* @__PURE__ */ React.createElement("li", null, /* @__PURE__ */ React.createElement( "a", { href: "https://web.dev/accessible-tap-targets/", rel: "noopener noreferrer", target: "_blank" }, "Tap target size recommendations from Google" ))))); }; var getIssuesFound = (warningCount) => `${warningCount} issue${warningCount !== 1 ? "s" : ""} found`; var Loading = () => /* @__PURE__ */ React.createElement(StyledBanner, null, /* @__PURE__ */ React.createElement(Spinner, null), /* @__PURE__ */ React.createElement("span", null, "Running scan...")); var Hints = ({ container }) => { const [warnings, setWarnings] = useState(void 0); const [scanComplete, setScanComplete] = useState(false); const [rescan, setRescan] = useState(0); useEffect(() => { setScanComplete(false); setWarnings(getInstantWarnings(container)); return getScheduledWarnings(container, setWarnings, setScanComplete); }, [container, rescan]); const warningCount = useMemo( () => warnings ? Object.keys(warnings).reduce((acc, key) => { const current = warnings[key]; const count = Array.isArray(current) ? Number(current.length > 0) : ( //touchTarget returns an object not an array Object.keys(current).map((k) => current[k]).reduce( (acc2, current2) => acc2 + Number(current2.length > 0), 0 ) ); return acc + count; }, 0) : 0, [warnings] ); if (!warnings) { return /* @__PURE__ */ React.createElement(Loading, null); } const onRescanClick = () => setRescan((prev) => prev + 1); if (warningCount === 0 && scanComplete) { return /* @__PURE__ */ React.createElement(StyledBanner, null, /* @__PURE__ */ React.createElement("span", null, "Scan complete! No issues found."), /* @__PURE__ */ React.createElement(StyledRescanButton, { onClick: onRescanClick, type: "button" }, "Rescan")); } const issuesFound = getIssuesFound(warningCount); return /* @__PURE__ */ React.createElement(Container, null, /* @__PURE__ */ React.createElement(StyledBanner, null, scanComplete ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("span", null, "Scan complete! ", issuesFound, "."), /* @__PURE__ */ React.createElement( StyledRescanButton, { onClick: onRescanClick, type: "button" }, "Rescan" )) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Spinner, null), /* @__PURE__ */ React.createElement("span", null, warningCount > 0 ? `Running scan - ${issuesFound} so far` : "Running scan", "..."))), /* @__PURE__ */ React.createElement(TouchTargetWarnings, { warnings: warnings.touchTarget }), /* @__PURE__ */ React.createElement(AutocompleteWarnings, { warnings: warnings.autocomplete }), /* @__PURE__ */ React.createElement(InputTypeWarnings, { warnings: warnings.inputType }), /* @__PURE__ */ React.createElement(InputTypeNumberWarnings, { warnings: warnings.inputTypeNumber }), /* @__PURE__ */ React.createElement(TapWarnings, { warnings: warnings.tapHighlight }), /* @__PURE__ */ React.createElement(ActiveWarnings, { warnings: warnings.active }), /* @__PURE__ */ React.createElement(SrcsetWarnings, { warnings: warnings.srcset }), /* @__PURE__ */ React.createElement(BackgroundImageWarnings, { warnings: warnings.backgroundImg }), /* @__PURE__ */ React.createElement(HeightWarnings, { warnings: warnings.height })); }; var Hints_default = Hints; // src/Panel/Content/index.tsx var DELAY = 2e3; var getContainer = () => { const iframe = document.querySelector("#storybook-preview-iframe"); if (!iframe) return null; return iframe.contentDocument; }; var Content = ({ active, storyId }) => { const [html, setHTML] = useState(void 0); const timeoutRef = useRef(null); useEffect(() => { setHTML(void 0); const checkContainer = () => { const container2 = getContainer(); if (timeoutRef.current) { clearTimeout(timeoutRef.current); } if (!container2 || !container2.body) { timeoutRef.current = window.setTimeout(checkContainer, DELAY); } else { setHTML(container2.body.innerHTML); } }; if (timeoutRef.current) { clearTimeout(timeoutRef.current); } timeoutRef.current = window.setTimeout(checkContainer, DELAY); return () => clearTimeout(timeoutRef.current); }, [storyId]); const container = getContainer(); if (!active) return null; if (!html || !container) { return /* @__PURE__ */ React.createElement(Loading, null); } return /* @__PURE__ */ React.createElement(Hints_default, { container }); }; var Content_default = Content; var StateWrapper = ({ children }) => { const [storyId, setStoryId] = useState(""); useChannel({ [STORY_RENDERED]: (...args) => { setStoryId(String(args)); } }); return cloneElement(children, { storyId }); }; var StateWrapper_default = StateWrapper; var ViewportManager = ({ active }) => { const [viewportState, setViewportState] = useAddonState(VIEWPORT_ID); const cachedState = useRef(null); useEffect(() => { if (cachedState.current && !active) { setViewportState({ selected: cachedState.current }); cachedState.current = null; } else if (active && // @ts-ignore (!viewportState || viewportState.selected === NO_VIEWPORT)) { cachedState.current = NO_VIEWPORT; setViewportState({ selected: DEFAULT_VIEWPORT }); } }, [active]); return null; }; var ViewportManager_default = ViewportManager; // src/Panel/index.tsx var Panel = ({ active }) => /* @__PURE__ */ React.createElement(Fragment, { key: "storybook-mobile-addon" }, /* @__PURE__ */ React.createElement(ViewportManager_default, { active }), /* @__PURE__ */ React.createElement(AddonPanel, { active }, /* @__PURE__ */ React.createElement(StateWrapper_default, null, /* @__PURE__ */ React.createElement(Content_default, { active })))); var Panel_default = Panel; // src/manager.tsx addons.register(ADDON_ID, () => { addons.add(PANEL_ID, { match: ({ viewMode }) => !!viewMode?.match(/^story$/), paramKey: PARAM_KEY, render: ({ active }) => /* @__PURE__ */ React.createElement(Panel_default, { active: !!active }), title: "Mobile", type: types.PANEL }); });