chrome-devtools-frontend
Version:
Chrome DevTools UI
690 lines (606 loc) • 24.2 kB
text/typescript
// 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;
}
}