UNPKG

chrome-devtools-frontend

Version:
690 lines (606 loc) • 24.2 kB
// Copyright 2024 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* eslint-disable @devtools/no-lit-render-outside-of-view */ import * as i18n from '../../../core/i18n/i18n.js'; import * as Platform from '../../../core/platform/platform.js'; import * as CrUXManager from '../../../models/crux-manager/crux-manager.js'; import type * as Trace from '../../../models/trace/trace.js'; import * as Buttons from '../../../ui/components/buttons/buttons.js'; import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js'; import * as UIHelpers from '../../../ui/helpers/helpers.js'; import * as Lit from '../../../ui/lit/lit.js'; import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js'; import metricCardStyles from './metricCard.css.js'; import {type CompareRating, renderCompareText, renderDetailedCompareText} from './MetricCompareStrings.js'; import metricValueStyles from './metricValueStyles.css.js'; import { CLS_THRESHOLDS, determineCompareRating, INP_THRESHOLDS, LCP_THRESHOLDS, type MetricRating, type MetricThresholds, rateMetric, renderMetricValue, } from './Utils.js'; const {html, nothing} = Lit; const UIStrings = { /** * @description Label for a metric value measured in the local environment in the live metrics view of the Performance panel. */ localValue: 'Local', /** * @description Label for the 75th percentile of real user field metrics in the live metrics view of the Performance panel. */ field75thPercentile: 'Field 75th percentile', /** * @description Column header for the 75th percentile field metrics in the live metrics view of the Performance panel. */ fieldP75: 'Field p75', /** * @description Label for metric values classified as good in the live metrics view of the Performance panel. */ good: 'Good', /** * @description Label for metric values classified as needs improvement in the live metrics view of the Performance panel. */ needsImprovement: 'Needs improvement', /** * @description Label for metric values classified as poor in the live metrics view of the Performance panel. */ poor: 'Poor', /** * @description Label for a range of values that are less than or equal to a threshold in the live metrics view of the Performance panel. * @example {500 ms} PH1 */ leqRange: '(≤{PH1})', /** * @description Label for a range of values between two thresholds in the live metrics view of the Performance panel. * @example {500 ms} PH1 * @example {800 ms} PH2 */ betweenRange: '({PH1}-{PH2})', /** * @description Label for a range of values greater than a threshold in the live metrics view of the Performance panel. * @example {500 ms} PH1 */ gtRange: '(>{PH1})', /** * @description Percentage value format string in the live metrics view of the Performance panel. * @example {13} PH1 */ percentage: '{PH1}%', /** * @description Prompt instructing the user to interact with the page to measure INP in the live metrics view of the Performance panel. */ interactToMeasure: 'Interact with the page to measure INP.', /** * @description Tooltip label to expand more details in the metric card of the Performance panel. */ viewCardDetails: 'View card details', /** * @description Header recommending the user inspect their local test environment in the live metrics view of the Performance panel. */ considerTesting: 'Consider your local test conditions', /** * @description Recommendation explaining how network throttling affects LCP page loads in the Performance panel. */ recThrottlingLCP: 'Real users may experience longer page loads due to slower network conditions. Increasing network throttling will simulate slower network conditions.', /** * @description Recommendation explaining how CPU throttling affects INP interaction delays in the Performance panel. */ recThrottlingINP: 'Real users may experience longer interactions due to slower CPU speeds. Increasing CPU throttling will simulate a slower device.', /** * @description Recommendation explaining how viewport size affects the LCP element in the Performance panel. */ recViewportLCP: 'Screen size can influence what the LCP element is. Ensure you are testing common viewport sizes.', /** * @description Recommendation explaining how viewport size affects layout shifts in the Performance panel. */ recViewportCLS: 'Screen size can influence what layout shifts happen. Ensure you are testing common viewport sizes.', /** * @description Recommendation explaining how user interaction journeys affect layout shifts in the Performance panel. */ recJourneyCLS: 'How a user interacts with the page can influence layout shifts. Ensure you are testing common interactions like scrolling the page.', /** * @description Recommendation explaining how user interaction journeys affect interaction delays in the Performance panel. */ recJourneyINP: 'How a user interacts with the page influences interaction delays. Ensure you are testing common interactions.', /** * @description Recommendation explaining how dynamic content affects LCP in the Performance panel. */ recDynamicContentLCP: 'The LCP element can vary between page loads if content is dynamic.', /** * @description Recommendation explaining how dynamic content affects layout shifts in the Performance panel. */ recDynamicContentCLS: 'Dynamic content can influence what layout shifts happen.', /** * @description Table column header for subpart stage names in the live metrics view of the Performance panel. */ subpart: 'Subpart', /** * @description Tooltip text explaining the Largest Contentful Paint (LCP) metric in the live metrics view of the Performance panel. */ lcpHelpTooltip: 'LCP reports the render time of the largest image, text block, or video visible in the viewport. Click here to learn more about LCP.', /** * @description Tooltip text explaining the Cumulative Layout Shift (CLS) metric in the live metrics view of the Performance panel. */ clsHelpTooltip: 'CLS measures the amount of unexpected shifted content. Click here to learn more about CLS.', /** * @description Tooltip text explaining the Interaction to Next Paint (INP) metric in the live metrics view of the Performance panel. */ inpHelpTooltip: 'INP measures the overall responsiveness to all click, tap, and keyboard interactions. Click here to learn more about INP.', } as const; const str_ = i18n.i18n.registerUIStrings('panels/timeline/components/MetricCard.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export type SubpartTable = Array<[string, Trace.Types.Timing.Milli, Trace.Types.Timing.Milli?]>; export interface MetricCardData { metric: 'LCP'|'CLS'|'INP'; localValue?: number; fieldValue?: number|string; histogram?: CrUXManager.MetricResponse['histogram']; tooltipContainer?: HTMLElement; subparts?: SubpartTable; warnings?: string[]; } export class MetricCard extends HTMLElement { readonly #shadow = this.attachShadow({mode: 'open'}); constructor() { super(); this.#render(); } #tooltipEl?: HTMLElement; #data: MetricCardData = { metric: 'LCP', }; set data(data: MetricCardData) { this.#data = data; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } connectedCallback(): void { void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } #hideTooltipOnEsc = (event: KeyboardEvent): void => { if (Platform.KeyboardUtilities.isEscKey(event)) { event.stopPropagation(); this.#hideTooltip(); } }; #hideTooltipOnMouseLeave(event: Event): void { const target = event.target as HTMLElement; if (target?.hasFocus()) { return; } this.#hideTooltip(); } #hideTooltipOnFocusOut(event: FocusEvent): void { const target = event.target as HTMLElement; if (target?.hasFocus()) { return; } const relatedTarget = event.relatedTarget; if (relatedTarget instanceof Node && target.contains(relatedTarget)) { // `focusout` bubbles so we should get another event once focus leaves `relatedTarget` return; } this.#hideTooltip(); } #hideTooltip(): void { const tooltipEl = this.#tooltipEl; if (!tooltipEl) { return; } document.body.removeEventListener('keydown', this.#hideTooltipOnEsc); tooltipEl.style.removeProperty('left'); tooltipEl.style.removeProperty('visibility'); tooltipEl.style.removeProperty('display'); tooltipEl.style.removeProperty('transition-delay'); } #showTooltip(delayMs = 0): void { const tooltipEl = this.#tooltipEl; if (!tooltipEl || tooltipEl.style.visibility || tooltipEl.style.display) { return; } document.body.addEventListener('keydown', this.#hideTooltipOnEsc); tooltipEl.style.display = 'block'; tooltipEl.style.transitionDelay = `${Math.round(delayMs)}ms`; const container = this.#data.tooltipContainer; if (!container) { return; } const containerBox = container.getBoundingClientRect(); tooltipEl.style.setProperty('--tooltip-container-width', `${Math.round(containerBox.width)}px`); requestAnimationFrame(() => { let offset = 0; const tooltipBox = tooltipEl.getBoundingClientRect(); const rightDiff = tooltipBox.right - containerBox.right; const leftDiff = tooltipBox.left - containerBox.left; if (leftDiff < 0) { offset = Math.round(leftDiff); } else if (rightDiff > 0) { offset = Math.round(rightDiff); } tooltipEl.style.left = `calc(50% - ${offset}px)`; tooltipEl.style.visibility = 'visible'; }); } #getTitle(): string { switch (this.#data.metric) { case 'LCP': return i18n.i18n.lockedString('Largest Contentful Paint (LCP)'); case 'CLS': return i18n.i18n.lockedString('Cumulative Layout Shift (CLS)'); case 'INP': return i18n.i18n.lockedString('Interaction to Next Paint (INP)'); } } #getThresholds(): MetricThresholds { switch (this.#data.metric) { case 'LCP': return LCP_THRESHOLDS; case 'CLS': return CLS_THRESHOLDS; case 'INP': return INP_THRESHOLDS; } } #getFormatFn(): (value: number) => string { switch (this.#data.metric) { case 'LCP': return v => { const micro = (v * 1000) as Platform.Timing.MicroSeconds; return i18n.TimeUtilities.formatMicroSecondsAsSeconds(micro); }; case 'CLS': return v => v === 0 ? '0' : v.toFixed(2); case 'INP': return v => i18n.TimeUtilities.preciseMillisToString(v); } } #getHelpLink(): Platform.DevToolsPath.UrlString { switch (this.#data.metric) { case 'LCP': return 'https://web.dev/articles/lcp' as Platform.DevToolsPath.UrlString; case 'CLS': return 'https://web.dev/articles/cls' as Platform.DevToolsPath.UrlString; case 'INP': return 'https://web.dev/articles/inp' as Platform.DevToolsPath.UrlString; } } #getHelpTooltip(): string { switch (this.#data.metric) { case 'LCP': return i18nString(UIStrings.lcpHelpTooltip); case 'CLS': return i18nString(UIStrings.clsHelpTooltip); case 'INP': return i18nString(UIStrings.inpHelpTooltip); } } #getLocalValue(): number|undefined { const {localValue} = this.#data; if (localValue === undefined) { return; } return localValue; } #getFieldValue(): number|undefined { let {fieldValue} = this.#data; if (fieldValue === undefined) { return; } if (typeof fieldValue === 'string') { fieldValue = Number(fieldValue); } if (!Number.isFinite(fieldValue)) { return; } return fieldValue; } /** * Returns if the local value is better/worse/similar compared to field. */ #getCompareRating(): CompareRating|undefined { const localValue = this.#getLocalValue(); const fieldValue = this.#getFieldValue(); if (localValue === undefined || fieldValue === undefined) { return; } return determineCompareRating(this.#data.metric, localValue, fieldValue); } #renderCompareString(): Lit.LitTemplate { const localValue = this.#getLocalValue(); if (localValue === undefined) { if (this.#data.metric === 'INP') { return html` <div class="compare-text">${i18nString(UIStrings.interactToMeasure)}</div> `; } return Lit.nothing; } const compare = this.#getCompareRating(); const rating = rateMetric(localValue, this.#getThresholds()); const valueEl = renderMetricValue( this.#getMetricValueLogContext(true), localValue, this.#getThresholds(), this.#getFormatFn(), {dim: true}); // clang-format off return html` <div class="compare-text"> ${renderCompareText({ metric: i18n.i18n.lockedString(this.#data.metric), rating, compare, localValue: valueEl, })} </div> `; // clang-format on } #renderEnvironmentRecommendations(): Lit.LitTemplate { const compare = this.#getCompareRating(); if (!compare || compare === 'similar') { return Lit.nothing; } const recs: string[] = []; const metric = this.#data.metric; // Recommend using throttling if (metric === 'LCP' && compare === 'better') { recs.push(i18nString(UIStrings.recThrottlingLCP)); } else if (metric === 'INP' && compare === 'better') { recs.push(i18nString(UIStrings.recThrottlingINP)); } // Recommend trying new viewport sizes if (metric === 'LCP') { recs.push(i18nString(UIStrings.recViewportLCP)); } else if (metric === 'CLS') { recs.push(i18nString(UIStrings.recViewportCLS)); } // Recommend trying new user journeys if (metric === 'CLS') { recs.push(i18nString(UIStrings.recJourneyCLS)); } else if (metric === 'INP') { recs.push(i18nString(UIStrings.recJourneyINP)); } // Recommend accounting for dynamic content if (metric === 'LCP') { recs.push(i18nString(UIStrings.recDynamicContentLCP)); } else if (metric === 'CLS') { recs.push(i18nString(UIStrings.recDynamicContentCLS)); } if (!recs.length) { return Lit.nothing; } return html` <details class="environment-recs"> <summary>${i18nString(UIStrings.considerTesting)}</summary> <ul class="environment-recs-list">${recs.map(rec => html`<li>${rec}</li>`)}</ul> </details> `; } #getMetricValueLogContext(isLocal: boolean): string { return `timeline.landing.${isLocal ? 'local' : 'field'}-${this.#data.metric.toLowerCase()}`; } #renderDetailedCompareString(): Lit.LitTemplate { const localValue = this.#getLocalValue(); if (localValue === undefined) { if (this.#data.metric === 'INP') { return html` <div class="detailed-compare-text">${i18nString(UIStrings.interactToMeasure)}</div> `; } return Lit.nothing; } const localRating = rateMetric(localValue, this.#getThresholds()); const fieldValue = this.#getFieldValue(); const fieldRating = fieldValue !== undefined ? rateMetric(fieldValue, this.#getThresholds()) : undefined; const localValueEl = renderMetricValue( this.#getMetricValueLogContext(true), localValue, this.#getThresholds(), this.#getFormatFn(), {dim: true}); const fieldValueEl = renderMetricValue( this.#getMetricValueLogContext(false), fieldValue, this.#getThresholds(), this.#getFormatFn(), {dim: true}); // clang-format off return html` <div class="detailed-compare-text">${renderDetailedCompareText({ metric: i18n.i18n.lockedString(this.#data.metric), localRating, fieldRating, localValue: localValueEl, fieldValue: fieldValueEl, percent: this.#getPercentLabelForRating(localRating), })}</div> `; // clang-format on } #bucketIndexForRating(rating: MetricRating): number { switch (rating) { case 'good': return 0; case 'needs-improvement': return 1; case 'poor': return 2; } } #getBarWidthForRating(rating: MetricRating): string { const histogram = this.#data.histogram; const density = histogram?.[this.#bucketIndexForRating(rating)].density || 0; const percent = Math.round(density * 100); return `${percent}%`; } #getPercentLabelForRating(rating: MetricRating): string { const histogram = this.#data.histogram; if (histogram === undefined) { return '-'; } // A missing density value should be interpreted as 0% const density = histogram[this.#bucketIndexForRating(rating)].density || 0; const percent = Math.round(density * 100); return i18nString(UIStrings.percentage, {PH1: percent}); } #renderFieldHistogram(): Lit.LitTemplate { const fieldEnabled = CrUXManager.CrUXManager.instance().getConfigSetting().get().enabled; const format = this.#getFormatFn(); const thresholds = this.#getThresholds(); // clang-format off const goodLabel = html` <div class="bucket-label"> <span>${i18nString(UIStrings.good)}</span> <span class="bucket-range"> ${i18nString(UIStrings.leqRange, {PH1: format(thresholds[0])})}</span> </div> `; const needsImprovementLabel = html` <div class="bucket-label"> <span>${i18nString(UIStrings.needsImprovement)}</span> <span class="bucket-range"> ${i18nString(UIStrings.betweenRange, {PH1: format(thresholds[0]), PH2: format(thresholds[1])})}</span> </div> `; const poorLabel = html` <div class="bucket-label"> <span>${i18nString(UIStrings.poor)}</span> <span class="bucket-range"> ${i18nString(UIStrings.gtRange, {PH1: format(thresholds[1])})}</span> </div> `; // clang-format on if (!fieldEnabled) { return html` <div class="bucket-summaries"> ${goodLabel} ${needsImprovementLabel} ${poorLabel} </div> `; } // clang-format off return html` <div class="bucket-summaries histogram" jslog=${VisualLogging.canvas('metric-histogram')}> ${goodLabel} <div class="histogram-bar good-bg" style="width: ${this.#getBarWidthForRating('good')}"></div> <div class="histogram-percent">${this.#getPercentLabelForRating('good')}</div> ${needsImprovementLabel} <div class="histogram-bar needs-improvement-bg" style="width: ${this.#getBarWidthForRating('needs-improvement')}"></div> <div class="histogram-percent">${this.#getPercentLabelForRating('needs-improvement')}</div> ${poorLabel} <div class="histogram-bar poor-bg" style="width: ${this.#getBarWidthForRating('poor')}"></div> <div class="histogram-percent">${this.#getPercentLabelForRating('poor')}</div> </div> `; // clang-format on } #renderSubpartTable(subparts: SubpartTable): Lit.LitTemplate { const hasFieldData = subparts.every(subpart => subpart[2] !== undefined); // clang-format off return html` <hr class="divider"> <div class="subpart-table" role="table"> <div class="subpart-table-row subpart-table-header-row" role="row"> <div role="columnheader" style="grid-column: 1">${i18nString(UIStrings.subpart)}</div> <div role="columnheader" class="subpart-table-value" style="grid-column: 2">${i18nString(UIStrings.localValue)}</div> ${hasFieldData ? html` <div role="columnheader" class="subpart-table-value" style="grid-column: 3" title=${i18nString(UIStrings.field75thPercentile)}>${i18nString(UIStrings.fieldP75)}</div> ` : nothing} </div> ${subparts.map(subpart => html` <div class="subpart-table-row" role="row" jslog=${VisualLogging.tableRow('metric-subpart')}> <div role="cell">${subpart[0]}</div> <div role="cell" class="subpart-table-value">${i18n.TimeUtilities.preciseMillisToString(subpart[1])}</div> ${subpart[2] !== undefined ? html` <div role="cell" class="subpart-table-value">${i18n.TimeUtilities.preciseMillisToString(subpart[2])}</div> ` : nothing} </div> `)} </div> `; // clang-format on } #render = (): void => { const fieldEnabled = CrUXManager.CrUXManager.instance().getConfigSetting().get().enabled; const helpLink = this.#getHelpLink(); const localValue = this.#getLocalValue(); const fieldValue = this.#getFieldValue(); const thresholds = this.#getThresholds(); const formatFn = this.#getFormatFn(); const localValueEl = renderMetricValue(this.#getMetricValueLogContext(true), localValue, thresholds, formatFn); const fieldValueEl = renderMetricValue(this.#getMetricValueLogContext(false), fieldValue, thresholds, formatFn); // clang-format off const output = html` <style>${metricCardStyles}</style> <style>${metricValueStyles}</style> <div class="metric-card" jslog=${VisualLogging.section(Platform.StringUtilities.toKebabCase(this.#data.metric))}> <h3 class="title"> ${this.#getTitle()} <devtools-button class="title-help" title=${this.#getHelpTooltip()} .iconName=${'help'} .variant=${Buttons.Button.Variant.ICON} @click=${() => UIHelpers.openInNewTab(helpLink)} ></devtools-button> </h3> <div tabindex="0" class="metric-values-section" @mouseenter=${() => this.#showTooltip(500)} @mouseleave=${this.#hideTooltipOnMouseLeave} @focusin=${this.#showTooltip} @focusout=${this.#hideTooltipOnFocusOut} aria-describedby="tooltip" > <div class="metric-source-block"> <div class="metric-source-value" id="local-value">${localValueEl}</div> ${fieldEnabled ? html`<div class="metric-source-label">${i18nString(UIStrings.localValue)}</div>` : nothing} </div> ${fieldEnabled ? html` <div class="metric-source-block"> <div class="metric-source-value" id="field-value">${fieldValueEl}</div> <div class="metric-source-label">${i18nString(UIStrings.field75thPercentile)}</div> </div> `: nothing} <div id="tooltip" class="tooltip" role="tooltip" aria-label=${i18nString(UIStrings.viewCardDetails)} ${Lit.Directives.ref(el => { if (el instanceof HTMLElement) { this.#tooltipEl = el as HTMLElement; } })} > <div class="tooltip-scroll"> <div class="tooltip-contents"> <div> ${this.#renderDetailedCompareString()} <hr class="divider"> ${this.#renderFieldHistogram()} ${localValue && this.#data.subparts ? this.#renderSubpartTable(this.#data.subparts) : nothing} </div> </div> </div> </div> </div> ${fieldEnabled ? html`<hr class="divider">` : nothing} ${this.#renderCompareString()} ${this.#data.warnings?.map(warning => html` <div class="warning">${warning}</div> `)} ${this.#renderEnvironmentRecommendations()} <slot name="extra-info"></slot> </div> `; Lit.render(output, this.#shadow, {host: this}); }; // clang-format on } customElements.define('devtools-metric-card', MetricCard); declare global { interface HTMLElementTagNameMap { 'devtools-metric-card': MetricCard; } }