diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 20a439133..550d1d96b 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -1,4 +1,6 @@ name: quality +env: + FORCE_COLOR: 3 on: pull_request: branches: diff --git a/.storybook/preview.jsx b/.storybook/preview.jsx index 3cf9c81e1..4b3c1809b 100644 --- a/.storybook/preview.jsx +++ b/.storybook/preview.jsx @@ -6,6 +6,7 @@ import { ShareExpand } from '@spark-ui/icons/dist/icons/ShareExpand' import '../src/tailwind.css' import './sb-theming.css' import { ToC } from '@docs/helpers/ToC' +import { A11yReport } from '@docs/helpers/A11yReport' const ExampleContainer = ({ children, ...props }) => { const shouldDisplayExperimentalBanner = (() => { @@ -21,12 +22,18 @@ const ExampleContainer = ({ children, ...props }) => { return (
- {shouldDisplayExperimentalBanner &&

- This component is still experimental. Avoid usage in production features -

} + {shouldDisplayExperimentalBanner && ( +

+ This component is still experimental. Avoid usage in production features +

+ )} {children}
+ +
) } diff --git a/bin/check-a11y.js b/bin/check-a11y.js index 0049401b7..9f9722645 100644 --- a/bin/check-a11y.js +++ b/bin/check-a11y.js @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { readFileSync } from 'fs' +import { readFileSync, writeFileSync, mkdirSync } from 'fs' import chalk from 'chalk' import AxeBuilder from '@axe-core/webdriverjs' @@ -14,7 +14,7 @@ const impactColor = { critical: chalk.red, } -const { entries: stories } = JSON.parse(readFileSync('dist/index.json', 'utf8')) +const { entries } = JSON.parse(readFileSync('dist/index.json', 'utf8')) const hasFilter = process.argv.indexOf('-f') > -1 const pkgFilter = hasFilter @@ -23,85 +23,132 @@ const pkgFilter = hasFilter .map(pkgPath => `./${pkgPath.split('/src')[0]}`) ?? [] : [] -const storiesList = Object.keys(stories).reduce((acc, cur) => { - const isComponentStory = story => - story.importPath.startsWith('./packages/components/') && !story.id.endsWith('--docs') - const belongsToFilter = (story, filter) => - filter.includes(stories[cur].importPath.split('/src')[0]) +const docsList = Object.keys(entries).reduce((acc, cur) => { + const isComponentDoc = entry => + entry.importPath.startsWith('./packages/components/') && !!entry.id.endsWith('--docs') - if (isComponentStory(stories[cur])) { - if (pkgFilter.length && !belongsToFilter(stories[cur], pkgFilter)) { + const belongsToFilter = (entry, filter) => + filter.includes(entries[cur].importPath.split('/src')[0]) + + if (isComponentDoc(entries[cur])) { + if (pkgFilter.length && !belongsToFilter(entries[cur], pkgFilter)) { return acc } - // We'll test the whole stories collection if no filter is provided. - acc.push(stories[cur].id) + // We'll test the whole docs if no filter is provided. + acc.push(entries[cur].id) } return acc }, []) -let issues = { - minor: 0, - moderate: 0, - serious: 0, - critical: 0, +const mergeResultArrays = (newArray, originArray = []) => { + const mergedNodes = (a, b = []) => + [...a, ...b].reduce((acc, curr) => { + const { target, ...rest } = curr + + const mergedNode = { + ...rest, + target: [ + ...new Set([...acc.reduce((prev, curr) => [...prev, ...curr.target], []), ...target]), + ], + } + + return [...acc, mergedNode] + }, []) + + return [...originArray, ...newArray].reduce((acc, currentRule) => { + const duplicateRule = acc.filter(item => item.id === currentRule.id) + + if (duplicateRule.length > 0) { + const uniqRules = acc.filter(item => item.id !== currentRule.id) + + const duplicateRuleNodes = duplicateRule.reduce((acc, curr) => { + const { nodes, ...rest } = curr + return [...acc, ...nodes] + }, []) + + const nodes = mergedNodes(duplicateRuleNodes, currentRule.nodes) + + return [ + ...uniqRules, + { + ...currentRule, + nodes: [nodes[nodes.length - 1]], + }, + ] + } else { + const nodes = mergedNodes(currentRule.nodes) + + return [ + ...acc, + { + ...currentRule, + nodes: [nodes[nodes.length - 1]], + }, + ] + } + }, []) } +let report = {} + +/** + * + * Informations about Axe API available here: + * https://github.com/dequelabs/axe-core/blob/master/doc/API.md + */ const checkA11y = async () => { const driver = new Builder() .forBrowser('chrome') .setChromeOptions(new Options().addArguments('-headless')) .build() - let testedStory + let testedComponent - for (const storyId of storiesList) { - if (testedStory !== stories[storyId].title) - console.log(`Checking accessibility for ${stories[storyId].title}...`) + for (const docId of docsList) { + if (testedComponent !== entries[docId].title) { + console.log(`Checking accessibility for ${entries[docId].title}...`) + } - testedStory = stories[storyId].title + testedComponent = entries[docId].title.toLowerCase() try { - await driver.get(`http://localhost:6006/iframe.html?viewMode=story&id=${storyId}`) + await driver.get(`http://localhost:6006/?path=/docs/${docId}`) const results = await new AxeBuilder(driver) .options({ resultTypes: ['violations', 'incomplete'], rules: { + // 'meta-viewport': { enabled: false }, 'page-has-heading-one': { enabled: false }, 'landmark-one-main': { enabled: false }, region: { enabled: false }, 'color-contrast': { enabled: false }, }, runOnly: ['best-practice', 'wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'], - reporter: 'no-passes', + reporter: 'v2', }) - .exclude('.docblock-argstable') + .exclude('*:not(.docs-story)') .analyze() - if (results.violations.length) { - issues = results.violations.reduce( - (acc, cur) => { - acc[cur.impact] += cur.nodes.length - return acc - }, - { - minor: 0, - moderate: 0, - serious: 0, - critical: 0, - } - ) - - const totalIssues = Object.values(issues).reduce((a, b) => a + b, 0) - console.log(chalk.red(`Found ${totalIssues} ${stories[storyId].title} issue(s)):`)) - - results.violations.forEach(issue => { - console.log( - impactColor[issue.impact](`- [${issue.impact}] ${issue.help} (${issue.nodes.length})`) - ) - }) + const duplicate = report[testedComponent] + const { [testedComponent]: _, ...rest } = report + + const { timestamp, url, incomplete, violations } = results + + report = { + ...rest, + [testedComponent]: { + timestamp, + ...(duplicate ? { url: [...duplicate.url, url] } : { url: [url] }), + ...(duplicate + ? { incomplete: mergeResultArrays(incomplete, duplicate.incomplete) } + : { incomplete: mergeResultArrays(incomplete) }), + ...(duplicate + ? { violations: mergeResultArrays(violations, duplicate.violations) } + : { violations: mergeResultArrays(violations) }), + }, } } catch (e) { console.error(chalk.bold.red(e.message)) @@ -109,16 +156,48 @@ const checkA11y = async () => { } } - const totalIssues = Object.values(issues).reduce((a, b) => a + b, 0) - if (totalIssues > 0 && issues.critical) { + /** + * Export results as JSON file to Storybook static directory + */ + writeFileSync(`./public/a11y-report.json`, JSON.stringify(report, null, 2), 'utf8') + + /** + * Log violations & incompletes for each component tested + */ + Object.keys(report).forEach(componentName => { + if (!report[componentName].violations.length && !report[componentName].incomplete.length) return + + console.log(chalk.bold.red(`[${componentName}]:`)) + + const { violations, incomplete } = report[componentName] + const issues = [...violations, ...incomplete] + + issues.forEach(issue => { + console.log( + impactColor[issue.impact]( + `- [${issue.id}]: ${issue.help} [${issue.impact}] (${issue.nodes[0].target.length})` + ) + ) + }) + }) + + /** + * Exit and fail if any critical issue is found + */ + const criticalIssues = Object.keys(report).reduce((sum, componentName) => { + const { violations, incomplete } = report[componentName] + const issues = [...violations, ...incomplete].filter(issue => issue.impact === 'critical') + + return sum + issues.reduce((acc, curr) => acc + curr.nodes[0].target.length, 0) + }, 0) + + if (criticalIssues > 0) { console.error() - console.error(chalk.bold.red(`Exiting with ${issues.critical} critical issue(s)`)) + console.error(chalk.bold.red(`💥 Exiting with ${criticalIssues} critical issue(s)`)) process.exit(1) } else { console.log() - console.log( - chalk.bold.green('🎉 Congratulations, no critical accessibility issue has been found!') - ) + console.log(chalk.bold.green('🎉 No critical accessibility issue has been found')) } await driver.quit() diff --git a/documentation/helpers/A11yReport/index.tsx b/documentation/helpers/A11yReport/index.tsx new file mode 100644 index 000000000..df866fe4f --- /dev/null +++ b/documentation/helpers/A11yReport/index.tsx @@ -0,0 +1,24 @@ +import { useEffect } from 'react' + +interface A11yReportProps { + of: string +} + +/** + * For now we only will display accessibility report + * in browser console. + * This component may evolve and display data in a more visible way. + */ +export const A11yReport = ({ of }: A11yReportProps) => { + useEffect(() => { + const fetchReport = async (name: string) => { + const report = await fetch('/a11y-report.json') + .then(response => response.json()) + .catch(() => console.error('Unable to find accessibility report')) + + console.log(`${name} accessibility report:`, report[name]) + } + + fetchReport(of) + }, [of]) +} diff --git a/package.json b/package.json index 604dbb4e7..0327ea344 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "prettier": "prettier --config .prettierrc.cjs --write \"{packages,documentation,src}/**/*.{ts,tsx,js}\"", "prettify": "npm run lint && npm run prettier", "node:check": "node bin/check-node-version.js", - "a11y:check": "concurrently -P --kill-others -g -s first -n storybook,a11y \"storybook build -o dist --loglevel silent && npx http-server dist -p 6006 -s\" \"wait-on tcp:6006 && node bin/check-a11y.js -- {@} \"", + "a11y:check": "concurrently -P --kill-others -g -s first -n storybook,a11y \"storybook build -o dist --loglevel silent && npx http-server dist -p 6008 -s\" \"wait-on tcp:6008 && node bin/check-a11y.js -- {@} \"", "generate": "spark-generate", "codecov:check-yml": "cat .codecov.yml | curl --data-binary @- https://codecov.io/validate" }, diff --git a/public/a11y-report.json b/public/a11y-report.json new file mode 100644 index 000000000..46e541352 --- /dev/null +++ b/public/a11y-report.json @@ -0,0 +1,282 @@ +{ + "components/alertdialog": { + "timestamp": "2024-04-23T15:39:29.371Z", + "url": [ + "http://localhost:6006/?path=/docs/components-alertdialog--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/badge": { + "timestamp": "2024-04-23T15:39:30.039Z", + "url": [ + "http://localhost:6006/?path=/docs/components-badge--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/button": { + "timestamp": "2024-04-23T15:39:30.684Z", + "url": [ + "http://localhost:6006/?path=/docs/components-button--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/checkbox": { + "timestamp": "2024-04-23T15:39:31.256Z", + "url": [ + "http://localhost:6006/?path=/docs/components-checkbox--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/chip": { + "timestamp": "2024-04-23T15:39:31.655Z", + "url": [ + "http://localhost:6006/?path=/docs/components-chip--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/dialog": { + "timestamp": "2024-04-23T15:39:32.129Z", + "url": [ + "http://localhost:6006/?path=/docs/components-dialog--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/divider": { + "timestamp": "2024-04-23T15:39:32.583Z", + "url": [ + "http://localhost:6006/?path=/docs/components-divider--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/drawer": { + "timestamp": "2024-04-23T15:39:33.033Z", + "url": [ + "http://localhost:6006/?path=/docs/components-drawer--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/dropdown": { + "timestamp": "2024-04-23T15:39:33.432Z", + "url": [ + "http://localhost:6006/?path=/docs/components-dropdown--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/formfield": { + "timestamp": "2024-04-23T15:39:33.864Z", + "url": [ + "http://localhost:6006/?path=/docs/components-formfield--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/iconbutton": { + "timestamp": "2024-04-23T15:39:34.467Z", + "url": [ + "http://localhost:6006/?path=/docs/components-iconbutton--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/icon": { + "timestamp": "2024-04-23T15:39:35.103Z", + "url": [ + "http://localhost:6006/?path=/docs/components-icon--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/icons": { + "timestamp": "2024-04-23T15:39:35.861Z", + "url": [ + "http://localhost:6006/?path=/docs/components-icons--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/input": { + "timestamp": "2024-04-23T15:39:36.624Z", + "url": [ + "http://localhost:6006/?path=/docs/components-input--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/kbd": { + "timestamp": "2024-04-23T15:39:37.251Z", + "url": [ + "http://localhost:6006/?path=/docs/components-kbd--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/label": { + "timestamp": "2024-04-23T15:39:38.118Z", + "url": [ + "http://localhost:6006/?path=/docs/components-label--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/popover": { + "timestamp": "2024-04-23T15:39:38.756Z", + "url": [ + "http://localhost:6006/?path=/docs/components-popover--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/portal": { + "timestamp": "2024-04-23T15:39:39.466Z", + "url": [ + "http://localhost:6006/?path=/docs/components-portal--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/progresstracker": { + "timestamp": "2024-04-23T15:39:40.109Z", + "url": [ + "http://localhost:6006/?path=/docs/components-progresstracker--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/progress": { + "timestamp": "2024-04-23T15:39:40.912Z", + "url": [ + "http://localhost:6006/?path=/docs/components-progress--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/radiogroup": { + "timestamp": "2024-04-23T15:39:41.601Z", + "url": [ + "http://localhost:6006/?path=/docs/components-radiogroup--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/rating": { + "timestamp": "2024-04-23T15:39:42.543Z", + "url": [ + "http://localhost:6006/?path=/docs/components-rating--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/select": { + "timestamp": "2024-04-23T15:39:43.236Z", + "url": [ + "http://localhost:6006/?path=/docs/components-select--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/slider": { + "timestamp": "2024-04-23T15:39:43.863Z", + "url": [ + "http://localhost:6006/?path=/docs/components-slider--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/slot": { + "timestamp": "2024-04-23T15:39:44.555Z", + "url": [ + "http://localhost:6006/?path=/docs/components-slot--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/snackbar": { + "timestamp": "2024-04-23T15:39:45.266Z", + "url": [ + "http://localhost:6006/?path=/docs/components-snackbar--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/spinner": { + "timestamp": "2024-04-23T15:39:45.911Z", + "url": [ + "http://localhost:6006/?path=/docs/components-spinner--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/switch": { + "timestamp": "2024-04-23T15:39:46.572Z", + "url": [ + "http://localhost:6006/?path=/docs/components-switch--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/tabs": { + "timestamp": "2024-04-23T15:39:47.293Z", + "url": [ + "http://localhost:6006/?path=/docs/components-tabs--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/tag": { + "timestamp": "2024-04-23T15:39:48.037Z", + "url": [ + "http://localhost:6006/?path=/docs/components-tag--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/textlink": { + "timestamp": "2024-04-23T15:39:48.724Z", + "url": [ + "http://localhost:6006/?path=/docs/components-textlink--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/textarea": { + "timestamp": "2024-04-23T15:39:49.468Z", + "url": [ + "http://localhost:6006/?path=/docs/components-textarea--docs" + ], + "incomplete": [], + "violations": [] + }, + "components/visuallyhidden": { + "timestamp": "2024-04-23T15:39:50.176Z", + "url": [ + "http://localhost:6006/?path=/docs/components-visuallyhidden--docs" + ], + "incomplete": [], + "violations": [] + }, + "experimental/combobox": { + "timestamp": "2024-04-23T15:39:51.073Z", + "url": [ + "http://localhost:6006/?path=/docs/experimental-combobox--docs" + ], + "incomplete": [], + "violations": [] + }, + "experimental/linkbox": { + "timestamp": "2024-04-23T15:39:52.182Z", + "url": [ + "http://localhost:6006/?path=/docs/experimental-linkbox--docs" + ], + "incomplete": [], + "violations": [] + } +} \ No newline at end of file