0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-03-31 23:31:30 -05:00

feat(audits): Add new UI (#10217)

* feat(audits): Add selected style for audits

* feat(audits): Fully add new style

* feat: new iteration

* chore: changeset

* feat: separate audits by category

* fred pass

* feat: new UI

* refactor: fix everything

* fix: recreate UI on further lints

* fix: remove unnecessary changes

* chore: lockfile

* fix: tryout for descriptions

* fix: remove change, will do in other PR

* Apply suggestions from code review

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* test: fix

* nit: format styles

---------

Co-authored-by: Fred K. Schott <fkschott@gmail.com>
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Erika 2024-03-08 12:10:38 +01:00 committed by GitHub
parent c7edb22b4b
commit 5c7862a9fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 929 additions and 345 deletions

View file

@ -0,0 +1,5 @@
---
"astro": minor
---
Updates the UI for dev toolbar audits with new information

View file

@ -151,11 +151,11 @@ test.describe('Dev Toolbar', () => {
const auditHighlight = auditCanvas.locator('astro-dev-toolbar-highlight');
await expect(auditHighlight).not.toBeVisible();
const auditWindow = auditCanvas.locator('astro-dev-toolbar-window');
const auditWindow = auditCanvas.locator('astro-dev-toolbar-audit-window');
await expect(auditWindow).toHaveCount(1);
await expect(auditWindow).toBeVisible();
await expect(auditWindow.locator('astro-dev-toolbar-icon[icon=check-circle]')).toBeVisible();
await expect(auditWindow.locator('.no-audit-container')).toBeVisible();
});
test('audit shows a window with list of problems', async ({ page, astro }) => {
@ -166,7 +166,7 @@ test.describe('Dev Toolbar', () => {
await appButton.click();
const auditCanvas = toolbar.locator('astro-dev-toolbar-app-canvas[data-app-id="astro:audit"]');
const auditWindow = auditCanvas.locator('astro-dev-toolbar-window');
const auditWindow = auditCanvas.locator('astro-dev-toolbar-audit-window');
await expect(auditWindow).toHaveCount(1);
await expect(auditWindow).toBeVisible();

View file

@ -124,7 +124,6 @@
"@babel/plugin-transform-react-jsx": "^7.22.5",
"@babel/traverse": "^7.23.3",
"@babel/types": "^7.23.3",
"@medv/finder": "^3.1.0",
"@shikijs/core": "^1.1.2",
"@types/babel__core": "^7.20.4",
"acorn": "^8.11.2",

View file

@ -1,72 +1,40 @@
import { finder } from '@medv/finder';
import type { DevToolbarApp, DevToolbarMetadata } from '../../../../../@types/astro.js';
import type { DevToolbarApp } from '../../../../../@types/astro.js';
import { settings } from '../../settings.js';
import type { DevToolbarHighlight } from '../../ui-library/highlight.js';
import {
attachTooltipToHighlight,
createHighlight,
getElementsPositionInDocument,
positionHighlight,
} from '../utils/highlight.js';
import { closeOnOutsideClick, createWindowElement } from '../utils/window.js';
import { a11y } from './a11y.js';
import { perf } from './perf.js';
import { positionHighlight } from '../utils/highlight.js';
import { closeOnOutsideClick } from '../utils/window.js';
import { rulesCategories, type AuditRule } from './rules/index.js';
import { DevToolbarAuditListItem } from './ui/audit-list-item.js';
import { DevToolbarAuditListWindow } from './ui/audit-list-window.js';
import { createAuditUI } from './ui/audit-ui.js';
const icon =
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 1 20 16"><path fill="#fff" d="M.6 2A1.1 1.1 0 0 1 1.7.9h16.6a1.1 1.1 0 1 1 0 2.2H1.6A1.1 1.1 0 0 1 .8 2Zm1.1 7.1h6a1.1 1.1 0 0 0 0-2.2h-6a1.1 1.1 0 0 0 0 2.2ZM9.3 13H1.8a1.1 1.1 0 1 0 0 2.2h7.5a1.1 1.1 0 1 0 0-2.2Zm11.3 1.9a1.1 1.1 0 0 1-1.5 0l-1.7-1.7a4.1 4.1 0 1 1 1.6-1.6l1.6 1.7a1.1 1.1 0 0 1 0 1.6Zm-5.3-3.4a1.9 1.9 0 1 0 0-3.8 1.9 1.9 0 0 0 0 3.8Z"/></svg>';
type DynamicString = string | ((element: Element) => string);
export type Audit = {
auditedElement: HTMLElement;
rule: AuditRule;
highlight: DevToolbarHighlight | null;
card: HTMLElement | null;
};
export interface AuditRule {
code: string;
title: DynamicString;
message: DynamicString;
}
export interface ResolvedAuditRule {
code: string;
title: string;
message: string;
}
export interface AuditRuleWithSelector extends AuditRule {
selector: string;
match?: (
element: Element
) =>
| boolean
| null
| undefined
| void
| Promise<boolean>
| Promise<void>
| Promise<null>
| Promise<undefined>;
}
const rules = [...a11y, ...perf];
const dynamicAuditRuleKeys: Array<keyof AuditRule> = ['title', 'message'];
function resolveAuditRule(rule: AuditRule, element: Element): ResolvedAuditRule {
let resolved: ResolvedAuditRule = { ...rule } as any;
for (const key of dynamicAuditRuleKeys) {
const value = rule[key];
if (typeof value === 'string') continue;
resolved[key] = value(element);
}
return resolved;
}
try {
customElements.define('astro-dev-toolbar-audit-window', DevToolbarAuditListWindow);
customElements.define('astro-dev-toolbar-audit-list-item', DevToolbarAuditListItem);
} catch (e) {}
export default {
id: 'astro:audit',
name: 'Audit',
icon: icon,
async init(canvas, eventTarget) {
let audits: {
highlightElement: DevToolbarHighlight;
auditedElement: HTMLElement;
rule: AuditRule;
}[] = [];
let audits: Audit[] = [];
let auditWindow = document.createElement(
'astro-dev-toolbar-audit-window'
) as DevToolbarAuditListWindow;
let hasCreatedUI = false;
canvas.appendChild(auditWindow);
await lint();
@ -116,227 +84,89 @@ export default {
}, 100);
});
closeOnOutsideClick(eventTarget);
async function lint() {
audits.forEach(({ highlightElement }) => {
highlightElement.remove();
});
audits = [];
canvas.getElementById('no-audit')?.remove();
const selectorCache = new Map<string, NodeListOf<Element>>();
for (const rule of rules) {
const elements =
selectorCache.get(rule.selector) ?? document.querySelectorAll(rule.selector);
let matches: Element[] = [];
if (typeof rule.match === 'undefined') {
matches = Array.from(elements);
} else {
for (const element of elements) {
if (await rule.match(element)) {
matches.push(element);
}
}
}
for (const element of matches) {
// Don't audit elements that already have an audit on them
// TODO: This is a naive implementation, it'd be good to show all the audits for an element at the same time.
if (audits.some((audit) => audit.auditedElement === element)) continue;
await createAuditProblem(rule, element);
}
eventTarget.addEventListener('app-toggled', (event: any) => {
if (event.detail.state === true) {
createAuditsUI();
}
});
if (audits.length > 0) {
eventTarget.dispatchEvent(
new CustomEvent('toggle-notification', {
detail: {
state: true,
},
})
);
closeOnOutsideClick(eventTarget, () => {
const activeAudits = audits.filter((audit) => audit.card?.hasAttribute('active'));
const auditListWindow = createWindowElement(
`
<style>
astro-dev-toolbar-window {
left: initial;
top: 8px;
right: 8px;
transform: none;
width: 320px;
max-height: 320px;
padding: 0;
overflow: hidden;
}
hr {
margin: 0;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px;
}
h1 {
font-size: 22px;
font-weight: 600;
color: #fff;
}
ul, li {
margin: 0;
padding: 0;
list-style: none;
}
h1, h2 {
margin: 0;
}
h3 {
margin: 0;
margin-bottom: 8px;
color: white;
white-space: nowrap;
}
.audit-title {
font-weight: bold;
color: white;
margin-right: 1ch;
}
#audit-list {
display: flex;
flex-direction: column;
overflow: auto;
}
</style>
<header>
<h1>Audits</h1>
<astro-dev-toolbar-badge size="large">${audits.length} problem${
audits.length > 1 ? 's' : ''
} found</astro-dev-toolbar-badge>
</header>
<hr />`
);
const auditListUl = document.createElement('ul');
auditListUl.id = 'audit-list';
audits.forEach((audit, index) => {
const resolvedRule = resolveAuditRule(audit.rule, audit.auditedElement);
const card = document.createElement('astro-dev-toolbar-card');
card.shadowRoot.innerHTML = `
<style>
:host>button {
text-align: left;
box-shadow: none !important;
${
index + 1 < audits.length
? 'border-radius: 0 !important;'
: 'border-radius: 0 0 8px 8px !important;'
}
}
:host>button:hover {
cursor: pointer;
}
</style>`;
card.clickAction = () => {
audit.highlightElement.scrollIntoView();
audit.highlightElement.focus();
};
const h3 = document.createElement('h3');
h3.innerText = finder(audit.auditedElement);
card.appendChild(h3);
const div = document.createElement('div');
const title = document.createElement('span');
title.classList.add('audit-title');
title.innerHTML = resolvedRule.title;
div.appendChild(title);
card.appendChild(div);
auditListUl.appendChild(card);
if (activeAudits.length > 0) {
activeAudits.forEach((audit) => {
audit.card?.toggleAttribute('active', false);
});
auditListWindow.appendChild(auditListUl);
canvas.append(auditListWindow);
} else {
eventTarget.dispatchEvent(
new CustomEvent('toggle-notification', {
detail: {
state: false,
},
})
);
const window = createWindowElement(
`<style>
header {
display: flex;
}
h1 {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #fff;
margin: 0;
font-size: 22px;
}
astro-dev-toolbar-icon {
width: 1em;
height: 1em;
padding: 8px;
display: block;
background: green;
border-radius: 9999px;
}
</style>
<header>
<h1><astro-dev-toolbar-icon icon="check-circle"></astro-dev-toolbar-icon>No accessibility or performance issues detected.</h1>
</header>
<p>
Nice work! This app scans the page and highlights common accessibility and performance issues for you, like a missing "alt" attribute on an image, or a image not using performant attributes.
</p>
`
);
canvas.append(window);
return true;
}
(['scroll', 'resize'] as const).forEach((event) => {
window.addEventListener(event, refreshLintPositions);
});
return false;
});
async function createAuditsUI() {
if (hasCreatedUI) return;
const fragment = document.createDocumentFragment();
for (const audit of audits) {
const { card, highlight } = createAuditUI(audit, audits);
audit.card = card;
audit.highlight = highlight;
fragment.appendChild(highlight);
}
auditWindow.audits = audits;
canvas.appendChild(fragment);
hasCreatedUI = true;
}
function refreshLintPositions() {
const noAuditBlock = canvas.getElementById('no-audit');
if (noAuditBlock) {
const devOverlayRect = document
.querySelector('astro-dev-toolbar')
?.shadowRoot.querySelector('#dev-toolbar-root')
?.getBoundingClientRect();
noAuditBlock.style.top = `${
(devOverlayRect?.top ?? 0) - (devOverlayRect?.height ?? 0) - 16
}px`;
async function lint() {
// Clear the previous audits
if (audits.length > 0) {
audits = [];
audits.forEach((audit) => {
audit.highlight?.remove();
audit.card?.remove();
});
hasCreatedUI = false;
}
audits.forEach(({ highlightElement, auditedElement }) => {
const rect = auditedElement.getBoundingClientRect();
positionHighlight(highlightElement, rect);
});
const selectorCache = new Map<string, NodeListOf<Element>>();
for (const ruleCategory of rulesCategories) {
for (const rule of ruleCategory.rules) {
const elements =
selectorCache.get(rule.selector) ?? document.querySelectorAll(rule.selector);
let matches: Element[] = [];
if (typeof rule.match === 'undefined') {
matches = Array.from(elements);
} else {
for (const element of elements) {
try {
if (await rule.match(element)) {
matches.push(element);
}
} catch (e) {
settings.logger.error(`Error while running audit's match function: ${e}`);
}
}
}
for (const element of matches) {
// Don't audit elements that already have an audit on them
// TODO: This is a naive implementation, it'd be good to show all the audits for an element at the same time.
if (audits.some((audit) => audit.auditedElement === element)) continue;
await createAuditProblem(rule, element);
}
}
}
eventTarget.dispatchEvent(
new CustomEvent('toggle-notification', {
detail: {
state: audits.length > 0,
},
})
);
}
async function createAuditProblem(rule: AuditRule, originalElement: Element) {
@ -354,76 +184,24 @@ export default {
return;
}
const rect = originalElement.getBoundingClientRect();
const highlight = createHighlight(rect, 'warning', { 'data-audit-code': rule.code });
const tooltip = buildAuditTooltip(rule, originalElement);
// Set the highlight/tooltip as being fixed position the highlighted element
// is fixed. We do this so that we don't mistakenly take scroll position
// into account when setting the tooltip/highlight positioning.
//
// We only do this once due to how expensive computed styles are to calculate,
// and are unlikely to change. If that turns out to be wrong, reconsider this.
const { isFixed } = getElementsPositionInDocument(originalElement);
if (isFixed) {
tooltip.style.position = highlight.style.position = 'fixed';
}
attachTooltipToHighlight(highlight, tooltip, originalElement);
canvas.append(highlight);
audits.push({
highlightElement: highlight,
auditedElement: originalElement as HTMLElement,
rule: rule,
card: null,
highlight: null,
});
}
function buildAuditTooltip(rule: AuditRule, element: Element) {
const tooltip = document.createElement('astro-dev-toolbar-tooltip');
const { title, message } = resolveAuditRule(rule, element);
tooltip.sections = [
{
icon: 'warning',
title: escapeHtml(title),
},
{
content: escapeHtml(message),
},
];
const elementFile = element.getAttribute('data-astro-source-file');
const elementPosition = element.getAttribute('data-astro-source-loc');
if (elementFile) {
const elementFileWithPosition =
elementFile + (elementPosition ? ':' + elementPosition : '');
tooltip.sections.push({
content: elementFileWithPosition.slice(
(window as DevToolbarMetadata).__astro_dev_toolbar__.root.length - 1 // We want to keep the final slash, so minus one.
),
clickDescription: 'Click to go to file',
async clickAction() {
// NOTE: The path here has to be absolute and without any errors (no double slashes etc)
// or Vite will silently fail to open the file. Quite annoying.
await fetch('/__open-in-editor?file=' + encodeURIComponent(elementFileWithPosition));
},
});
}
return tooltip;
function refreshLintPositions() {
audits.forEach(({ highlight, auditedElement }) => {
const rect = auditedElement.getBoundingClientRect();
if (highlight) positionHighlight(highlight, rect);
});
}
function escapeHtml(unsafe: string) {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
(['scroll', 'resize'] as const).forEach((event) => {
window.addEventListener(event, refreshLintPositions);
});
function setupObserver() {
observer.observe(document.body, {

View file

@ -283,8 +283,8 @@ export const a11y: AuditRuleWithSelector[] = [
},
},
{
code: 'a11y-invalid-attribute',
title: 'Attributes important for accessibility should have a valid value',
code: 'a11y-invalid-href',
title: 'Invalid `href` attribute',
message: "`href` should not be empty, `'#'`, or `javascript:`.",
selector: 'a[href]:is([href=""], [href="#"], [href^="javascript:" i])',
},
@ -332,6 +332,8 @@ export const a11y: AuditRuleWithSelector[] = [
{
code: 'a11y-missing-attribute',
title: 'Required attributes missing.',
description:
'Some HTML elements require additional attributes for accessibility. For example, an `img` element requires an `alt` attribute, this attribute is used to describe the content of the image for screen readers.',
message: (element) => {
const requiredAttributes =
a11y_required_attributes[element.localName as keyof typeof a11y_required_attributes];
@ -492,6 +494,8 @@ export const a11y: AuditRuleWithSelector[] = [
{
code: 'a11y-no-noninteractive-tabindex',
title: 'Invalid `tabindex` on non-interactive element',
description:
'The `tabindex` attribute should only be used on interactive elements, as it can be confusing for keyboard-only users to navigate through non-interactive elements. If your element is only conditionally interactive, consider using `tabindex="-1"` to make it focusable only when it is actually interactive.',
message: (element) => `${element.localName} elements should not have \`tabindex\` attribute`,
selector: '[tabindex]',
match(element) {

View file

@ -0,0 +1,78 @@
import { settings } from '../../../settings.js';
import type { DefinedIcon } from '../../../ui-library/icons.js';
import { a11y } from './a11y.js';
import { perf } from './perf.js';
type DynamicString = string | ((element: Element) => string);
export interface AuditRule {
code: string;
title: DynamicString;
message: DynamicString;
description?: DynamicString;
}
export interface ResolvedAuditRule {
code: string;
title: string;
message: string;
description?: string;
}
export interface AuditRuleWithSelector extends AuditRule {
selector: string;
match?: (
element: Element
) =>
| boolean
| null
| undefined
| void
| Promise<boolean>
| Promise<void>
| Promise<null>
| Promise<undefined>;
}
interface RuleCategory {
code: string;
name: string;
icon: DefinedIcon;
rules: AuditRule[];
}
export const rulesCategories = [
{ code: 'a11y', name: 'Accessibility', icon: 'person-arms-spread', rules: a11y },
{ code: 'perf', name: 'Performance', icon: 'gauge', rules: perf },
] satisfies RuleCategory[];
const dynamicAuditRuleKeys: Array<keyof AuditRule> = ['title', 'message', 'description'];
export function resolveAuditRule(rule: AuditRule, element: Element): ResolvedAuditRule {
let resolved: ResolvedAuditRule = { ...rule } as any;
for (const key of dynamicAuditRuleKeys) {
const value = rule[key];
if (typeof value === 'string') continue;
try {
if (!value) {
resolved[key] = '';
continue;
}
resolved[key] = value(element);
} catch (err) {
settings.logger.error(`Error resolving dynamic audit rule ${rule.code}'s ${key}: ${err}`);
resolved[key] = 'Error resolving dynamic rule';
}
}
return resolved;
}
export function getAuditCategory(rule: AuditRule): 'perf' | 'a11y' {
return rule.code.split('-')[0] as 'perf' | 'a11y';
}
export const categoryLabel = {
perf: 'performance',
a11y: 'accessibility',
};

View file

@ -28,7 +28,7 @@ export const perf: AuditRuleWithSelector[] = [
},
{
code: 'perf-use-loading-lazy',
title: 'Use the loading="lazy" attribute',
title: 'Unoptimized loading attribute',
message: (element) =>
`This ${element.nodeName} tag is below the fold and could be lazy-loaded to improve performance.`,
selector:
@ -46,7 +46,7 @@ export const perf: AuditRuleWithSelector[] = [
},
{
code: 'perf-use-loading-eager',
title: 'Use the loading="eager" attribute',
title: 'Unoptimized loading attribute',
message: (element) =>
`This ${element.nodeName} tag is above the fold and could be eagerly-loaded to improve performance.`,
selector: 'img[loading="lazy"], iframe[loading="lazy"]',

View file

@ -0,0 +1,139 @@
export class DevToolbarAuditListItem extends HTMLElement {
clickAction?: () => void | (() => Promise<void>);
shadowRoot: ShadowRoot;
isManualFocus: boolean;
constructor() {
super();
this.shadowRoot = this.attachShadow({ mode: 'open' });
this.isManualFocus = false;
this.shadowRoot.innerHTML = `
<style>
:host>button, :host>div {
box-sizing: border-box;
padding: 16px;
background: transparent;
border: none;
border-bottom: 1px solid #1F2433;
text-decoration: none;
width: 100%;
height: 100%;
}
h1, h2, h3, h4, h5, h6 {
color: #fff;
font-weight: 600;
}
:host>button:hover, :host([hovered])>button {
background: #FFFFFF20;
}
svg {
display: block;
margin: 0 auto;
}
:host>button#astro-overlay-card {
text-align: left;
box-shadow: none;
display: flex;
align-items: center;
overflow: hidden;
gap: 8px;
}
:host(:not([active]))>button:hover {
cursor: pointer;
}
.extended-info {
display: none;
color: white;
font-size: 14px;
}
.extended-info hr {
border: 1px solid rgba(27, 30, 36, 1);
}
:host([active]) .extended-info {
display: block;
position: absolute;
height: 100%;
top: 98px;
height: calc(100% - 98px);
background: #0d0e12;
user-select: text;
overflow: auto;
border: none;
z-index: 1000000000;
flex-direction: column;
}
:host([active])>button#astro-overlay-card {
display: none;
}
.audit-title {
margin: 0;
margin-bottom: 4px;
}
.extended-info .audit-selector {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
display: flex;
align-items: center;
border-bottom: 1px solid transparent;
user-select: none;
color: rgba(191, 193, 201, 1);
}
.extended-info .audit-selector:hover {
border-bottom: 1px solid rgba(255, 255, 255);
cursor: pointer;
color: #fff;
}
.audit-selector svg {
width: 16px;
height: 16px;
display: inline;
}
.extended-info .audit-description {
color: rgba(191, 193, 201, 1);
}
.reset-button {
text-align: left;
border: none;
margin: 0;
width: auto;
overflow: visible;
background: transparent;
font: inherit;
line-height: normal;
-webkit-font-smoothing: inherit;
-moz-osx-font-smoothing: inherit;
-webkit-appearance: none;
padding: 0;
color: white;
}
</style>
<button id="astro-overlay-card">
<slot />
</button>
`;
}
connectedCallback() {
if (this.clickAction) {
this.shadowRoot
.getElementById('astro-overlay-card')
?.addEventListener('click', this.clickAction);
}
}
}

View file

@ -0,0 +1,411 @@
import type { Icon } from '../../../ui-library/icons.js';
import type { Audit } from '../index.js';
import { getAuditCategory, rulesCategories } from '../rules/index.js';
export function createRoundedBadge(icon: Icon) {
const badge = document.createElement('astro-dev-toolbar-badge');
badge.shadowRoot.innerHTML = `
<style>
:host>div {
padding: 12px 8px;
font-size: 14px;
display: flex;
gap: 4px;
}
</style>
`;
badge.innerHTML = `<astro-dev-toolbar-icon icon="${icon}"></astro-dev-toolbar-icon>0`;
return {
badge,
updateCount: (count: number) => {
if (count === 0) {
badge.badgeStyle = 'green';
} else {
badge.badgeStyle = 'purple';
}
badge.innerHTML = `<astro-dev-toolbar-icon icon="${icon}"></astro-dev-toolbar-icon>${count}`;
},
};
}
export class DevToolbarAuditListWindow extends HTMLElement {
_audits: Audit[] = [];
shadowRoot: ShadowRoot;
badges: {
[key: string]: {
badge: HTMLElement;
updateCount: (count: number) => void;
};
} = {};
get audits() {
return this._audits;
}
set audits(value) {
this._audits = value;
this.render();
}
constructor() {
super();
this.shadowRoot = this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<style>
:host {
box-sizing: border-box;
display: flex;
flex-direction: column;
background: linear-gradient(0deg, #13151a, #13151a), linear-gradient(0deg, #343841, #343841);
border: 1px solid rgba(52, 56, 65, 1);
width: min(640px, 100%);
max-height: 480px;
border-radius: 12px;
padding: 24px;
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
"Noto Sans",
sans-serif,
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji";
color: rgba(191, 193, 201, 1);
position: fixed;
z-index: 999999999;
bottom: 72px;
left: 50%;
transform: translateX(-50%);
box-shadow:
0px 0px 0px 0px rgba(19, 21, 26, 0.3),
0px 1px 2px 0px rgba(19, 21, 26, 0.29),
0px 4px 4px 0px rgba(19, 21, 26, 0.26),
0px 10px 6px 0px rgba(19, 21, 26, 0.15),
0px 17px 7px 0px rgba(19, 21, 26, 0.04),
0px 26px 7px 0px rgba(19, 21, 26, 0.01);
}
@media (forced-colors: active) {
:host {
background: white;
}
}
@media (max-width: 640px) {
:host {
border-radius: 0;
}
}
hr,
::slotted(hr) {
border: 1px solid rgba(27, 30, 36, 1);
margin: 1em 0;
}
.reset-button {
text-align: left;
border: none;
margin: 0;
width: auto;
overflow: visible;
background: transparent;
font: inherit;
line-height: normal;
-webkit-font-smoothing: inherit;
-moz-osx-font-smoothing: inherit;
-webkit-appearance: none;
padding: 0;
}
:host {
left: initial;
top: 8px;
right: 8px;
transform: none;
width: 350px;
min-height: 350px;
max-height: 420px;
padding: 0;
overflow: hidden;
}
hr {
margin: 0;
}
header {
display: flex;
align-items: center;
gap: 4px;
}
header > section {
display: flex;
align-items: center;
gap: 1em;
padding: 18px;
}
header.category-header {
background: rgba(27, 30, 36, 1);
padding: 10px 16px;
position: sticky;
top: 0;
}
header.category-header astro-dev-toolbar-icon {
opacity: 0.6;
}
#audit-counts {
display: flex;
gap: 0.5em;
}
#audit-counts > div {
display: flex;
gap: 8px;
align-items: center;
}
ul,
li {
margin: 0;
padding: 0;
list-style: none;
}
h1 {
font-size: 24px;
font-weight: 600;
color: #fff;
margin: 0;
}
h2 {
font-weight: 600;
margin: 0;
color: white;
font-size: 14px;
}
h3 {
font-weight: normal;
margin: 0;
color: white;
font-size: 14px;
}
.audit-header {
display: flex;
gap: 8px;
align-items: center;
}
.audit-selector {
color: white;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: 4px 6px;
}
[active] .audit-selector:hover {
text-decoration: underline;
cursor: pointer;
}
.selector-title-container {
display: flex;
align-items: center;
gap: 8px;
}
astro-dev-toolbar-icon {
color: white;
fill: white;
display: inline-block;
height: 16px;
width: 16px;
}
#audit-list {
display: flex;
flex-direction: column;
overflow: auto;
overscroll-behavior: contain;
height: 100%;
}
#back-to-list {
display: none;
align-items: center;
justify-content: center;
background: rgba(27, 30, 36, 1);
gap: 8px;
padding: 8px;
color: white;
font-size: 14px;
padding-right: 24px;
}
#back-to-list:hover {
cursor: pointer;
background: #313236;
}
#back-to-list:has(+ #audit-list astro-dev-toolbar-audit-list-item[active]) {
display: flex;
}
.no-audit-container {
display: flex;
flex-direction: column;
padding: 24px;
}
.no-audit-container h1 {
font-size: 20px;
}
.no-audit-container astro-dev-toolbar-icon {
height: auto;
margin: 0 auto;
}
</style>
<template id="category-template">
<div>
<header class="category-header">
</header>
<div class="category-content"></div>
</div>
</template>
<header>
<section id="header-left">
<h1>Audit</h1>
<section id="audit-counts"></section>
</section>
</header>
<hr />
<button id="back-to-list" class="reset-button">
<astro-dev-toolbar-icon icon="arrow-left"></astro-dev-toolbar-icon>
Back to list
</button>
<div id="audit-list"></div>
`;
// Create badges
const auditCounts = this.shadowRoot.getElementById('audit-counts');
if (auditCounts) {
rulesCategories.forEach((category) => {
const headerEntryContainer = document.createElement('div');
const auditCount = this.audits.filter(
(audit) => getAuditCategory(audit.rule) === category.code
).length;
const categoryBadge = createRoundedBadge(category.icon);
categoryBadge.updateCount(auditCount);
headerEntryContainer.append(categoryBadge.badge);
auditCounts.append(headerEntryContainer);
this.badges[category.code] = categoryBadge;
});
}
// Back to list button
const backToListButton = this.shadowRoot.getElementById('back-to-list');
if (backToListButton) {
backToListButton.addEventListener('click', () => {
const activeAudit = this.shadowRoot.querySelector(
'astro-dev-toolbar-audit-list-item[active]'
);
if (activeAudit) {
activeAudit.toggleAttribute('active', false);
}
});
}
}
connectedCallback() {
this.render();
}
updateAuditList() {
const auditListContainer = this.shadowRoot.getElementById('audit-list');
if (auditListContainer) {
auditListContainer.innerHTML = '';
if (this.audits.length > 0) {
for (const category of rulesCategories) {
const template = this.shadowRoot.getElementById(
'category-template'
) as HTMLTemplateElement;
if (!template) return;
const clone = document.importNode(template.content, true);
const categoryContainer = clone.querySelector('div')!;
const categoryHeader = clone.querySelector('.category-header')!;
categoryHeader.innerHTML = `<astro-dev-toolbar-icon icon="${category.icon}"></astro-dev-toolbar-icon><h2>${category.name}</h2>`;
categoryContainer.append(categoryHeader);
const categoryContent = clone.querySelector('.category-content')!;
const categoryAudits = this.audits.filter(
(audit) => getAuditCategory(audit.rule) === category.code
);
for (const audit of categoryAudits) {
if (audit.card) categoryContent.append(audit.card);
}
categoryContainer.append(categoryContent);
auditListContainer.append(categoryContainer);
}
} else {
const noAuditContainer = document.createElement('div');
noAuditContainer.classList.add('no-audit-container');
noAuditContainer.innerHTML = `
<header>
<h1></astro-dev-toolbar-icon>No accessibility or performance issues detected.</h1>
</header>
<p>
Nice work! This app scans the page and highlights common accessibility and performance issues for you, like a missing "alt" attribute on an image, or a image not using performant attributes.
</p>
<astro-dev-toolbar-icon icon="houston-detective"></astro-dev-toolbar-icon>
`;
auditListContainer.append(noAuditContainer);
}
}
}
updateBadgeCounts() {
for (const category of rulesCategories) {
const auditCount = this.audits.filter(
(audit) => getAuditCategory(audit.rule) === category.code
).length;
this.badges[category.code].updateCount(auditCount);
}
}
render() {
this.updateAuditList();
this.updateBadgeCounts();
}
}

View file

@ -0,0 +1,164 @@
import type { DevToolbarMetadata } from '../../../../../../@types/astro.js';
import { escape as escapeHTML } from 'html-escaper';
import {
createHighlight,
getElementsPositionInDocument,
attachTooltipToHighlight,
} from '../../utils/highlight.js';
import type { Audit } from '../index.js';
import { resolveAuditRule, type ResolvedAuditRule } from '../rules/index.js';
import type { DevToolbarAuditListItem } from './audit-list-item.js';
function truncate(val: string, maxLength: number): string {
return val.length > maxLength ? val.slice(0, maxLength - 1) + '&hellip;' : val;
}
export function createAuditUI(audit: Audit, audits: Audit[]) {
const rect = audit.auditedElement.getBoundingClientRect();
const highlight = createHighlight(rect, 'warning', { 'data-audit-code': audit.rule.code });
const resolvedAuditRule = resolveAuditRule(audit.rule, audit.auditedElement);
const tooltip = buildAuditTooltip(resolvedAuditRule, audit.auditedElement);
const card = buildAuditCard(resolvedAuditRule, highlight, audit.auditedElement, audits);
// If a highlight is hovered or focused, highlight the corresponding card for it
(['focus', 'mouseover'] as const).forEach((event) => {
const attribute = event === 'focus' ? 'active' : 'hovered';
highlight.addEventListener(event, () => {
if (event === 'focus') {
audits.forEach((adt) => {
if (adt.card) adt.card.toggleAttribute('active', false);
});
if (!card.isManualFocus) card.scrollIntoView();
card.toggleAttribute('active', true);
} else {
card.toggleAttribute(attribute, true);
}
});
});
highlight.addEventListener('mouseout', () => {
card.toggleAttribute('hovered', false);
});
// Set the highlight/tooltip as being fixed position the highlighted element
// is fixed. We do this so that we don't mistakenly take scroll position
// into account when setting the tooltip/highlight positioning.
//
// We only do this once due to how expensive computed styles are to calculate,
// and are unlikely to change. If that turns out to be wrong, reconsider this.
const { isFixed } = getElementsPositionInDocument(audit.auditedElement);
if (isFixed) {
tooltip.style.position = highlight.style.position = 'fixed';
}
attachTooltipToHighlight(highlight, tooltip, audit.auditedElement);
return { highlight, card };
}
function buildAuditTooltip(rule: ResolvedAuditRule, element: Element) {
const tooltip = document.createElement('astro-dev-toolbar-tooltip');
const { title, message } = rule;
tooltip.sections = [
{
icon: 'warning',
title: escapeHTML(title),
},
{
content: escapeHTML(message),
},
];
const elementFile = element.getAttribute('data-astro-source-file');
const elementPosition = element.getAttribute('data-astro-source-loc');
if (elementFile) {
const elementFileWithPosition = elementFile + (elementPosition ? ':' + elementPosition : '');
tooltip.sections.push({
content: elementFileWithPosition.slice(
(window as DevToolbarMetadata).__astro_dev_toolbar__.root.length - 1 // We want to keep the final slash, so minus one.
),
clickDescription: 'Click to go to file',
async clickAction() {
// NOTE: The path here has to be absolute and without any errors (no double slashes etc)
// or Vite will silently fail to open the file. Quite annoying.
await fetch('/__open-in-editor?file=' + encodeURIComponent(elementFileWithPosition));
},
});
}
return tooltip;
}
function buildAuditCard(
rule: ResolvedAuditRule,
highlightElement: HTMLElement,
auditedElement: Element,
audits: Audit[]
) {
const card = document.createElement(
'astro-dev-toolbar-audit-list-item'
) as DevToolbarAuditListItem;
card.clickAction = () => {
if (card.hasAttribute('active')) return;
audits.forEach((audit) => {
audit.card?.toggleAttribute('active', false);
});
highlightElement.scrollIntoView();
card.isManualFocus = true;
highlightElement.focus();
card.isManualFocus = false;
};
const selectorTitleContainer = document.createElement('section');
selectorTitleContainer.classList.add('selector-title-container');
const selector = document.createElement('span');
const selectorName = truncate(auditedElement.tagName.toLowerCase(), 8);
selector.classList.add('audit-selector');
selector.innerHTML = escapeHTML(selectorName);
const title = document.createElement('h3');
title.classList.add('audit-title');
title.innerText = rule.title;
selectorTitleContainer.append(selector, title);
card.append(selectorTitleContainer);
const extendedInfo = document.createElement('div');
extendedInfo.classList.add('extended-info');
const selectorButton = document.createElement('button');
selectorButton.className = 'audit-selector reset-button';
selectorButton.innerHTML = `${selectorName} <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M128,136v64a8,8,0,0,1-16,0V155.32L45.66,221.66a8,8,0,0,1-11.32-11.32L100.68,144H56a8,8,0,0,1,0-16h64A8,8,0,0,1,128,136ZM208,32H80A16,16,0,0,0,64,48V96a8,8,0,0,0,16,0V48H208V176H160a8,8,0,0,0,0,16h48a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32Z"></path></svg>`;
selectorButton.addEventListener('click', () => {
highlightElement.scrollIntoView();
highlightElement.focus();
});
extendedInfo.append(title.cloneNode(true));
extendedInfo.append(selectorButton);
extendedInfo.append(document.createElement('hr'));
const message = document.createElement('p');
message.classList.add('audit-message');
message.innerHTML = rule.message;
extendedInfo.appendChild(message);
const description = rule.description;
if (description) {
const descriptionElement = document.createElement('p');
descriptionElement.classList.add('audit-description');
descriptionElement.innerHTML = description;
extendedInfo.appendChild(descriptionElement);
}
card.shadowRoot.appendChild(extendedInfo);
return card;
}

View file

@ -4,12 +4,16 @@ export function createWindowElement(content: string) {
return windowElement;
}
export function closeOnOutsideClick(eventTarget: EventTarget) {
export function closeOnOutsideClick(
eventTarget: EventTarget,
additionalCheck?: (target: Element) => boolean
) {
function onPageClick(event: MouseEvent) {
const target = event.target as Element | null;
if (!target) return;
if (!target.closest) return;
if (target.closest('astro-dev-toolbar')) return;
if (additionalCheck && additionalCheck(target)) return;
eventTarget.dispatchEvent(
new CustomEvent('toggle-app', {
detail: {

View file

@ -160,6 +160,7 @@ export class AstroDevToolbar extends HTMLElement {
opacity: 0;
transition: opacity 0.2s ease-in-out 0s;
pointer-events: none;
user-select: none;
}
#dev-bar .item-tooltip::after{

File diff suppressed because one or more lines are too long

7
pnpm-lock.yaml generated
View file

@ -527,9 +527,6 @@ importers:
'@babel/types':
specifier: ^7.23.3
version: 7.23.6
'@medv/finder':
specifier: ^3.1.0
version: 3.1.0
'@shikijs/core':
specifier: ^1.1.2
version: 1.1.2
@ -7272,10 +7269,6 @@ packages:
- supports-color
dev: false
/@medv/finder@3.1.0:
resolution: {integrity: sha512-ojkXjR3K0Zz3jnCR80tqPL+0yvbZk/lEodb6RIVjLz7W8RVA2wrw8ym/CzCpXO9SYVUIKHFUpc7jvf8UKfIM3w==}
dev: false
/@nanostores/preact@0.5.0(nanostores@0.9.5)(preact@10.19.3):
resolution: {integrity: sha512-Zq5DEAY+kIfwJ1NPd43D1mpsbISuiD6N/SuTHrt/8jUoifLwXaReaZMAnvkvbIGOgcB1Hy++A9jZix2taNNYxQ==}
engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0}