storybook-mobile
Version:
This addon offers suggestions on how you can improve the HTML, CSS and UX of your components to be more mobile-friendly.
401 lines (349 loc) • 10.8 kB
JavaScript
import getDomPath from './getDomPath'
import { createScheduler } from 'lrt'
const getElements = (container, tag) =>
Array.from(container.querySelectorAll(tag))
const getStylesheetRules = (sheets, k) => {
let rules = []
try {
rules = Array.from(sheets[k].rules || sheets[k].cssRules)
} catch (e) {
//
}
return rules
}
const getNodeName = (el) =>
el.nodeName === 'A'
? 'a'
: el.nodeName === 'BUTTON'
? 'button'
: `${el.nodeName.toLowerCase()}[role="button"]`
const attachLabels = (inputs, container) =>
inputs.map((input) => {
let labelText = ''
if (input.labels && input.labels[0]) {
labelText = input.labels[0].innerText
} else if (input.parentElement.nodeName === 'LABEL') {
labelText = input.parentElement.innerText
} else if (input.id) {
const label = container.querySelector(`label[for="${input.id}"]`)
if (label) labelText = label.innerText
}
return {
labelText,
path: getDomPath(input),
type: input.type,
}
})
const textInputs = {
text: true,
search: true,
tel: true,
url: true,
email: true,
number: true,
password: true,
}
const getAutocompleteWarnings = (container) => {
const inputs = getElements(container, 'input')
const warnings = inputs.filter((input) => {
const currentType = input.getAttribute('type')
const autocomplete = input.getAttribute('autocomplete')
return textInputs[currentType] && !autocomplete
})
return attachLabels(warnings, container)
}
const getInputTypeNumberWarnings = (container) => {
const inputs = getElements(container, 'input[type="number"]')
return attachLabels(inputs)
}
const getInputTypeWarnings = (container) => {
const inputs = getElements(container, 'input[type="text"]')
.concat(getElements(container, 'input:not([type])'))
.filter((input) => !input.getAttribute('inputmode'))
return attachLabels(inputs, container)
}
export const getInstantWarnings = (container) => ({
autocomplete: getAutocompleteWarnings(container),
inputType: getInputTypeWarnings(container),
inputTypeNumber: getInputTypeNumberWarnings(container),
})
// SCHEDULED ANALYSES
// We schedule these so the UI does not lock up while they're running
const isInside = (dangerZone, bounding) =>
bounding.top <= dangerZone.bottom &&
bounding.bottom >= dangerZone.top &&
bounding.left <= dangerZone.right &&
bounding.right >= dangerZone.left
const toPresent = ({ el, bounding: { width, height }, close }) => ({
type:
el.nodeName === 'A'
? 'a'
: el.nodeName === 'BUTTON'
? 'button'
: `${el.nodeName.toLowerCase()}[role="button"]`,
path: getDomPath(el),
text: el.innerText,
html: el.innerHTML,
width: Math.floor(width),
height: Math.floor(height),
close,
})
export const MIN_SIZE = 32
export const RECOMMENDED_DISTANCE = 8
//const RECOMMENDED_SIZE = 48
const 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((el) => [
el,
el.getBoundingClientRect(),
])
const len = elements.length
const underMinSize = []
const tooClose = []
for (let i = 0; i < len; i++) {
const el = elements[i]
const bounding = el.getBoundingClientRect()
const dangerZone = {
top: bounding.top - RECOMMENDED_DISTANCE,
left: bounding.left - RECOMMENDED_DISTANCE,
right: bounding.right + RECOMMENDED_DISTANCE,
bottom: bounding.bottom + RECOMMENDED_DISTANCE,
}
const close = suspectElements.filter(
([susEl, susBounding]) =>
susEl !== el && isInside(dangerZone, susBounding)
)
const isUnderMinSize = checkMinSize(bounding)
if (isUnderMinSize || close.length > 0) {
const present = toPresent({ el, bounding, close })
if (isUnderMinSize) {
underMinSize.push(present)
}
if (close.length > 0) {
tooClose.push(present)
}
}
yield i
}
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 len = elements.length
const result = []
for (let i = 0; i < len; i++) {
const el = elements[i]
if (
getComputedStyle(el)['-webkit-tap-highlight-color'] === 'rgba(0, 0, 0, 0)'
) {
result.push({
type: getNodeName(el),
text: el.innerText,
html: el.innerHTML,
path: getDomPath(el),
})
}
yield i
}
return result
}
const MAX_WIDTH = 600
function* getSrcsetWarnings(container) {
const images = getElements(container, 'img')
const len = images.length
const result = []
for (let i = 0; i < len; i++) {
const img = images[i]
const srcSet = img.getAttribute('srcset')
const src = img.getAttribute('src')
if (!srcSet && src) {
const isSVG = Boolean(src.match(/svg$/))
if (!isSVG) {
const isLarge =
parseInt(getComputedStyle(img).width, 10) > MAX_WIDTH ||
img.naturalWidth > MAX_WIDTH
if (isLarge) {
result.push({
src: img.src,
path: getDomPath(img),
alt: img.alt,
})
}
}
}
yield i
}
return result
}
function* getBackgroundImageWarnings(container) {
const backgroundImageRegex = /url\(".*?(.png|.jpg|.jpeg)"\)/
const elsWithBackgroundImage = getElements(container, '#root *').filter(
(el) => {
const style = getComputedStyle(el)
return (
style['background-image'] &&
backgroundImageRegex.test(style['background-image']) &&
// 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
el.clientWidth > 200
)
}
)
if (!elsWithBackgroundImage.length) return []
const styleDict = new Map()
Object.keys(container.styleSheets).forEach((k) => {
getStylesheetRules(container.styleSheets, k).forEach((rule) => {
if (rule) {
try {
elsWithBackgroundImage.forEach((el) => {
if (el.matches(rule.selectorText)) {
styleDict.set(el, (styleDict.get(el) || []).concat(rule))
}
})
} catch (e) {
// catch errors in safari
}
}
})
})
const responsiveBackgroundImgRegex = /-webkit-min-device-pixel-ratio|min-resolution|image-set/
const result = []
const elements = Array.from(styleDict.entries())
const len = elements.length
for (let i = 0; i < len; i++) {
const [el, styles] = elements[i]
if (styles) {
const requiresResponsiveWarning = styles.some(
(style) => !responsiveBackgroundImgRegex.test(style)
)
if (requiresResponsiveWarning) {
const bg = getComputedStyle(el).backgroundImage
const src = bg.match(/url\("(.*)"\)/)
? bg.match(/url\("(.*)"\)/)[1]
: undefined
result.push({
path: getDomPath(el),
src,
})
}
}
yield i
}
return result
}
export const getActiveStyles = function (container, el) {
const sheets = container.styleSheets
const result = []
const activeRegex = /:active$/
Object.keys(sheets).forEach((k) => {
getStylesheetRules(sheets, k).forEach((rule) => {
if (rule && rule.selectorText && rule.selectorText.match(activeRegex)) {
const ruleNoPseudoClass = rule.selectorText.replace(activeRegex, '')
try {
if (el.matches(ruleNoPseudoClass)) {
result.push(rule)
}
} catch (e) {
// safari
}
}
})
})
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 len = elements.length
const result = []
for (let i = 0; i < len; i++) {
const el = elements[i]
const hasActive = getActiveStyles(container, el)
if (hasActive.length) {
result.push({
type: getNodeName(el),
text: el.innerText,
html: el.innerHTML,
path: getDomPath(el),
})
}
yield i
}
return result
}
export const getOriginalStyles = (container, el) => {
const sheets = container.styleSheets
let result = []
Object.keys(sheets).forEach((k) => {
const rules = getStylesheetRules(sheets, k)
rules.forEach((rule) => {
if (rule) {
try {
if (el.matches(rule.selectorText)) {
result.push(rule.cssText)
}
} catch (e) {
// catch errors in safari
}
}
})
})
return result
}
function* get100vhWarnings(container) {
const elements = getElements(container, '#root *')
const len = elements.length
const result = []
for (let i = 0; i < len; i++) {
const el = elements[i]
const styles = getOriginalStyles(container, el)
const vhWarning = styles.find((style) => /100vh/.test(style))
if (vhWarning) {
result.push({ el, css: vhWarning, path: getDomPath(el) })
}
yield i
}
return result
}
const schedule = (iterator) => {
// 100ms is the threshold where users start to notice UI lag
// higher values increase lag but do not noticeably improve processing time so 100ms is the sweet spot
const scheduler = createScheduler({ chunkBudget: 100 })
const task = scheduler.runTask(iterator)
return { task, abort: () => scheduler.abortTask(task) }
}
export const getScheduledWarnings = (container, setState, setComplete) => {
const analyses = {
tapHighlight: schedule(getTapHighlightWarnings(container)),
srcset: schedule(getSrcsetWarnings(container)),
backgroundImg: schedule(getBackgroundImageWarnings(container)),
touchTarget: schedule(getTouchTargetSizeWarning(container)),
active: schedule(getActiveWarnings(container)),
height: schedule(get100vhWarnings(container)),
}
const analysesArray = Object.keys(analyses)
let remaining = analysesArray.length
analysesArray.forEach((key) => {
//const start = performance.now()
analyses[key].task.then((result) => {
//console.log(key, performance.now() - start)
setState((prev) => ({ ...prev, [key]: result }))
if (--remaining === 0) {
setComplete(true)
}
})
})
return () => analysesArray.forEach((key) => analyses[key].abort())
}