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
JavaScript
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
});
});