blob: e4ac5a544ef91efc5bb4ca28fb703ebbaffcba7c [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Common from '../../core/common/common.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as Root from '../../core/root/root.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as TextUtils from '../../models/text_utils/text_utils.js';
import * as DataGrid from '../../ui/legacy/components/data_grid/data_grid.js';
import * as Components from '../../ui/legacy/components/utils/utils.js';
import * as UI from '../../ui/legacy/legacy.js';
import type * as Protocol from '../../generated/protocol.js';
import cssOverviewCompletedViewStyles from './cssOverviewCompletedView.css.js';
import type {
OverviewController, PopulateNodesEvent, PopulateNodesEventNodes, PopulateNodesEventNodeTypes} from
'./CSSOverviewController.js';
import {Events as CSSOverViewControllerEvents} from './CSSOverviewController.js';
import {CSSOverviewSidebarPanel, SidebarEvents} from './CSSOverviewSidebarPanel.js';
import type {UnusedDeclaration} from './CSSOverviewUnusedDeclarations.js';
const UIStrings = {
/**
*@description Label for the summary in the CSS Overview report
*/
overviewSummary: 'Overview summary',
/**
*@description Title of colors subsection in the CSS Overview Panel
*/
colors: 'Colors',
/**
*@description Title of font info subsection in the CSS Overview Panel
*/
fontInfo: 'Font info',
/**
*@description Label to denote unused declarations in the target page
*/
unusedDeclarations: 'Unused declarations',
/**
*@description Label for the number of media queries in the CSS Overview report
*/
mediaQueries: 'Media queries',
/**
*@description Title of the Elements Panel
*/
elements: 'Elements',
/**
*@description Label for the number of External stylesheets in the CSS Overview report
*/
externalStylesheets: 'External stylesheets',
/**
*@description Label for the number of inline style elements in the CSS Overview report
*/
inlineStyleElements: 'Inline style elements',
/**
*@description Label for the number of style rules in CSS Overview report
*/
styleRules: 'Style rules',
/**
*@description Label for the number of type selectors in the CSS Overview report
*/
typeSelectors: 'Type selectors',
/**
*@description Label for the number of ID selectors in the CSS Overview report
*/
idSelectors: 'ID selectors',
/**
*@description Label for the number of class selectors in the CSS Overview report
*/
classSelectors: 'Class selectors',
/**
*@description Label for the number of universal selectors in the CSS Overview report
*/
universalSelectors: 'Universal selectors',
/**
*@description Label for the number of Attribute selectors in the CSS Overview report
*/
attributeSelectors: 'Attribute selectors',
/**
*@description Label for the number of non-simple selectors in the CSS Overview report
*/
nonsimpleSelectors: 'Non-simple selectors',
/**
*@description Label for unique background colors in the CSS Overview Panel
*@example {32} PH1
*/
backgroundColorsS: 'Background colors: {PH1}',
/**
*@description Label for unique text colors in the CSS Overview Panel
*@example {32} PH1
*/
textColorsS: 'Text colors: {PH1}',
/**
*@description Label for unique fill colors in the CSS Overview Panel
*@example {32} PH1
*/
fillColorsS: 'Fill colors: {PH1}',
/**
*@description Label for unique border colors in the CSS Overview Panel
*@example {32} PH1
*/
borderColorsS: 'Border colors: {PH1}',
/**
*@description Label to indicate that there are no fonts in use
*/
thereAreNoFonts: 'There are no fonts.',
/**
*@description Message to show when no unused declarations in the target page
*/
thereAreNoUnusedDeclarations: 'There are no unused declarations.',
/**
*@description Message to show when no media queries are found in the target page
*/
thereAreNoMediaQueries: 'There are no media queries.',
/**
*@description Title of the Drawer for contrast issues in the CSS Overview Panel
*/
contrastIssues: 'Contrast issues',
/**
* @description Text to indicate how many times this CSS rule showed up.
*/
nOccurrences: '{n, plural, =1 {# occurrence} other {# occurrences}}',
/**
*@description Section header for contrast issues in the CSS Overview Panel
*@example {1} PH1
*/
contrastIssuesS: 'Contrast issues: {PH1}',
/**
*@description Title of the button for a contrast issue in the CSS Overview Panel
*@example {#333333} PH1
*@example {#333333} PH2
*@example {2} PH3
*/
textColorSOverSBackgroundResults: 'Text color {PH1} over {PH2} background results in low contrast for {PH3} elements',
/**
*@description Label aa text content in Contrast Details of the Color Picker
*/
aa: 'AA',
/**
*@description Label aaa text content in Contrast Details of the Color Picker
*/
aaa: 'AAA',
/**
*@description Label for the APCA contrast in Color Picker
*/
apca: 'APCA',
/**
*@description Label for the column in the element list in the CSS Overview report
*/
element: 'Element',
/**
*@description Column header title denoting which declaration is unused
*/
declaration: 'Declaration',
/**
*@description Text for the source of something
*/
source: 'Source',
/**
*@description Text of a DOM element in Contrast Details of the Color Picker
*/
contrastRatio: 'Contrast ratio',
/**
*@description Accessible title of a table in the CSS Overview Elements.
*/
cssOverviewElements: 'CSS Overview Elements',
/**
*@description Title of the button to show the element in the CSS Overview panel
*/
showElement: 'Show element',
};
const str_ = i18n.i18n.registerUIStrings('panels/css_overview/CSSOverviewCompletedView.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export type NodeStyleStats = Map<string, Set<number>>;
export interface ContrastIssue {
nodeId: Protocol.DOM.BackendNodeId;
contrastRatio: number;
textColor: Common.Color.Color;
backgroundColor: Common.Color.Color;
thresholdsViolated: {
aa: boolean,
aaa: boolean,
apca: boolean,
};
}
export interface OverviewData {
backgroundColors: Map<string, Set<Protocol.DOM.BackendNodeId>>;
textColors: Map<string, Set<Protocol.DOM.BackendNodeId>>;
textColorContrastIssues: Map<string, ContrastIssue[]>;
fillColors: Map<string, Set<Protocol.DOM.BackendNodeId>>;
borderColors: Map<string, Set<Protocol.DOM.BackendNodeId>>;
globalStyleStats: {
styleRules: number,
inlineStyles: number,
externalSheets: number,
stats: {type: number, class: number, id: number, universal: number, attribute: number, nonSimple: number},
};
fontInfo: Map<string, Map<string, Map<string, Protocol.DOM.BackendNodeId[]>>>;
elementCount: number;
mediaQueries: Map<string, Protocol.CSS.CSSMedia[]>;
unusedDeclarations: Map<string, UnusedDeclaration[]>;
}
export type FontInfo = Map<string, Map<string, Map<string, number[]>>>;
function getBorderString(color: Common.Color.Color): string {
let [h, s, l] = color.hsla();
h = Math.round(h * 360);
s = Math.round(s * 100);
l = Math.round(l * 100);
// Reduce the lightness of the border to make sure that there's always a visible outline.
l = Math.max(0, l - 15);
return `1px solid hsl(${h}deg ${s}% ${l}%)`;
}
export class CSSOverviewCompletedView extends UI.Panel.PanelWithSidebar {
#controller: OverviewController;
#formatter: Intl.NumberFormat;
readonly #mainContainer: UI.SplitWidget.SplitWidget;
readonly #resultsContainer: UI.Widget.VBox;
readonly #elementContainer: DetailsView;
readonly #sideBar: CSSOverviewSidebarPanel;
#cssModel?: SDK.CSSModel.CSSModel;
#domModel?: SDK.DOMModel.DOMModel;
#linkifier: Components.Linkifier.Linkifier;
#viewMap: Map<string, ElementDetailsView>;
#data: OverviewData|null;
#fragment?: UI.Fragment.Fragment;
constructor(controller: OverviewController) {
super('css_overview_completed_view');
this.#controller = controller;
this.#formatter = new Intl.NumberFormat('en-US');
this.#mainContainer = new UI.SplitWidget.SplitWidget(true, true);
this.#resultsContainer = new UI.Widget.VBox();
this.#elementContainer = new DetailsView();
// If closing the last tab, collapse the sidebar.
this.#elementContainer.addEventListener(Events.TabClosed, evt => {
if (evt.data === 0) {
this.#mainContainer.setSidebarMinimized(true);
}
});
// Dupe the styles into the main container because of the shadow root will prevent outer styles.
this.#mainContainer.setMainWidget(this.#resultsContainer);
this.#mainContainer.setSidebarWidget(this.#elementContainer);
this.#mainContainer.setVertical(false);
this.#mainContainer.setSecondIsSidebar(true);
this.#mainContainer.setSidebarMinimized(true);
this.#sideBar = new CSSOverviewSidebarPanel();
this.#sideBar.setMinimumSize(100, 25);
this.splitWidget().setSidebarWidget(this.#sideBar);
this.splitWidget().setMainWidget(this.#mainContainer);
this.#linkifier = new Components.Linkifier.Linkifier(/* maxLinkLength */ 20, /* useLinkDecorator */ true);
this.#viewMap = new Map();
this.#sideBar.addItem(i18nString(UIStrings.overviewSummary), 'summary');
this.#sideBar.addItem(i18nString(UIStrings.colors), 'colors');
this.#sideBar.addItem(i18nString(UIStrings.fontInfo), 'font-info');
this.#sideBar.addItem(i18nString(UIStrings.unusedDeclarations), 'unused-declarations');
this.#sideBar.addItem(i18nString(UIStrings.mediaQueries), 'media-queries');
this.#sideBar.select('summary');
this.#sideBar.addEventListener(SidebarEvents.ItemSelected, this.#sideBarItemSelected, this);
this.#sideBar.addEventListener(SidebarEvents.Reset, this.#sideBarReset, this);
this.#controller.addEventListener(CSSOverViewControllerEvents.Reset, this.#reset, this);
this.#controller.addEventListener(CSSOverViewControllerEvents.PopulateNodes, this.#createElementsView, this);
this.#resultsContainer.element.addEventListener('click', this.#onClick.bind(this));
this.#data = null;
}
wasShown(): void {
super.wasShown();
this.#mainContainer.registerCSSFiles([cssOverviewCompletedViewStyles]);
this.registerCSSFiles([cssOverviewCompletedViewStyles]);
// TODO(paullewis): update the links in the panels in case source has been .
}
initializeModels(target: SDK.Target.Target): void {
const cssModel = target.model(SDK.CSSModel.CSSModel);
const domModel = target.model(SDK.DOMModel.DOMModel);
if (!cssModel || !domModel) {
throw new Error('Target must provide CSS and DOM models');
}
this.#cssModel = cssModel;
this.#domModel = domModel;
}
#sideBarItemSelected(event: Common.EventTarget.EventTargetEvent<string>): void {
const {data} = event;
const section = (this.#fragment as UI.Fragment.Fragment).$(data);
if (!section) {
return;
}
section.scrollIntoView();
}
#sideBarReset(): void {
this.#controller.dispatchEventToListeners(CSSOverViewControllerEvents.Reset);
}
#reset(): void {
this.#resultsContainer.element.removeChildren();
this.#mainContainer.setSidebarMinimized(true);
this.#elementContainer.closeTabs();
this.#viewMap = new Map();
CSSOverviewCompletedView.pushedNodes.clear();
this.#sideBar.select('summary');
}
#onClick(evt: Event): void {
if (!evt.target) {
return;
}
const target = (evt.target as HTMLElement);
const dataset = target.dataset;
const type = dataset.type;
if (!type || !this.#data) {
return;
}
let payload: PopulateNodesEvent;
switch (type) {
case 'contrast': {
const section = dataset.section;
const key = dataset.key;
if (!key) {
return;
}
// Remap the Set to an object that is the same shape as the unused declarations.
const nodes = this.#data.textColorContrastIssues.get(key) || [];
payload = {type, key, nodes, section};
break;
}
case 'color': {
const color = dataset.color;
const section = dataset.section;
if (!color) {
return;
}
let nodes;
switch (section) {
case 'text':
nodes = this.#data.textColors.get(color);
break;
case 'background':
nodes = this.#data.backgroundColors.get(color);
break;
case 'fill':
nodes = this.#data.fillColors.get(color);
break;
case 'border':
nodes = this.#data.borderColors.get(color);
break;
}
if (!nodes) {
return;
}
// Remap the Set to an object that is the same shape as the unused declarations.
nodes = Array.from(nodes).map(nodeId => ({nodeId}));
payload = {type, color, nodes, section};
break;
}
case 'unused-declarations': {
const declaration = dataset.declaration;
if (!declaration) {
return;
}
const nodes = this.#data.unusedDeclarations.get(declaration);
if (!nodes) {
return;
}
payload = {type, declaration, nodes};
break;
}
case 'media-queries': {
const text = dataset.text;
if (!text) {
return;
}
const nodes = this.#data.mediaQueries.get(text);
if (!nodes) {
return;
}
payload = {type, text, nodes};
break;
}
case 'font-info': {
const value = dataset.value;
if (!dataset.path) {
return;
}
const [fontFamily, fontMetric] = dataset.path.split('/');
if (!value) {
return;
}
const fontFamilyInfo = this.#data.fontInfo.get(fontFamily);
if (!fontFamilyInfo) {
return;
}
const fontMetricInfo = fontFamilyInfo.get(fontMetric);
if (!fontMetricInfo) {
return;
}
const nodesIds = fontMetricInfo.get(value);
if (!nodesIds) {
return;
}
const nodes = nodesIds.map(nodeId => ({nodeId}));
const name = `${value} (${fontFamily}, ${fontMetric})`;
payload = {type, name, nodes};
break;
}
default:
return;
}
evt.consume();
this.#controller.dispatchEventToListeners(CSSOverViewControllerEvents.PopulateNodes, {payload});
this.#mainContainer.setSidebarMinimized(false);
}
async #render(data: OverviewData): Promise<void> {
if (!data || !('backgroundColors' in data) || !('textColors' in data)) {
return;
}
this.#data = data;
const {
elementCount,
backgroundColors,
textColors,
textColorContrastIssues,
fillColors,
borderColors,
globalStyleStats,
mediaQueries,
unusedDeclarations,
fontInfo,
} = this.#data;
// Convert rgb values from the computed styles to either undefined or HEX(A) strings.
const sortedBackgroundColors = this.#sortColorsByLuminance(backgroundColors);
const sortedTextColors = this.#sortColorsByLuminance(textColors);
const sortedFillColors = this.#sortColorsByLuminance(fillColors);
const sortedBorderColors = this.#sortColorsByLuminance(borderColors);
this.#fragment = UI.Fragment.Fragment.build`
<div class="vbox overview-completed-view">
<div $="summary" class="results-section horizontally-padded summary">
<h1>${i18nString(UIStrings.overviewSummary)}</h1>
<ul>
<li>
<div class="label">${i18nString(UIStrings.elements)}</div>
<div class="value">${this.#formatter.format(elementCount)}</div>
</li>
<li>
<div class="label">${i18nString(UIStrings.externalStylesheets)}</div>
<div class="value">${this.#formatter.format(globalStyleStats.externalSheets)}</div>
</li>
<li>
<div class="label">${i18nString(UIStrings.inlineStyleElements)}</div>
<div class="value">${this.#formatter.format(globalStyleStats.inlineStyles)}</div>
</li>
<li>
<div class="label">${i18nString(UIStrings.styleRules)}</div>
<div class="value">${this.#formatter.format(globalStyleStats.styleRules)}</div>
</li>
<li>
<div class="label">${i18nString(UIStrings.mediaQueries)}</div>
<div class="value">${this.#formatter.format(mediaQueries.size)}</div>
</li>
<li>
<div class="label">${i18nString(UIStrings.typeSelectors)}</div>
<div class="value">${this.#formatter.format(globalStyleStats.stats.type)}</div>
</li>
<li>
<div class="label">${i18nString(UIStrings.idSelectors)}</div>
<div class="value">${this.#formatter.format(globalStyleStats.stats.id)}</div>
</li>
<li>
<div class="label">${i18nString(UIStrings.classSelectors)}</div>
<div class="value">${this.#formatter.format(globalStyleStats.stats.class)}</div>
</li>
<li>
<div class="label">${i18nString(UIStrings.universalSelectors)}</div>
<div class="value">${this.#formatter.format(globalStyleStats.stats.universal)}</div>
</li>
<li>
<div class="label">${i18nString(UIStrings.attributeSelectors)}</div>
<div class="value">${this.#formatter.format(globalStyleStats.stats.attribute)}</div>
</li>
<li>
<div class="label">${i18nString(UIStrings.nonsimpleSelectors)}</div>
<div class="value">${this.#formatter.format(globalStyleStats.stats.nonSimple)}</div>
</li>
</ul>
</div>
<div $="colors" class="results-section horizontally-padded colors">
<h1>${i18nString(UIStrings.colors)}</h1>
<h2>${i18nString(UIStrings.backgroundColorsS, {
PH1: sortedBackgroundColors.length,
})}</h2>
<ul>
${sortedBackgroundColors.map(this.#colorsToFragment.bind(this, 'background'))}
</ul>
<h2>${i18nString(UIStrings.textColorsS, {
PH1: sortedTextColors.length,
})}</h2>
<ul>
${sortedTextColors.map(this.#colorsToFragment.bind(this, 'text'))}
</ul>
${textColorContrastIssues.size > 0 ? this.#contrastIssuesToFragment(textColorContrastIssues) : ''}
<h2>${i18nString(UIStrings.fillColorsS, {
PH1: sortedFillColors.length,
})}</h2>
<ul>
${sortedFillColors.map(this.#colorsToFragment.bind(this, 'fill'))}
</ul>
<h2>${i18nString(UIStrings.borderColorsS, {
PH1: sortedBorderColors.length,
})}</h2>
<ul>
${sortedBorderColors.map(this.#colorsToFragment.bind(this, 'border'))}
</ul>
</div>
<div $="font-info" class="results-section font-info">
<h1>${i18nString(UIStrings.fontInfo)}</h1>
${
fontInfo.size > 0 ? this.#fontInfoToFragment(fontInfo) :
UI.Fragment.Fragment.build`<div>${i18nString(UIStrings.thereAreNoFonts)}</div>`}
</div>
<div $="unused-declarations" class="results-section unused-declarations">
<h1>${i18nString(UIStrings.unusedDeclarations)}</h1>
${
unusedDeclarations.size > 0 ? this.#groupToFragment(unusedDeclarations, 'unused-declarations', 'declaration') :
UI.Fragment.Fragment.build`<div class="horizontally-padded">${
i18nString(UIStrings.thereAreNoUnusedDeclarations)}</div>`}
</div>
<div $="media-queries" class="results-section media-queries">
<h1>${i18nString(UIStrings.mediaQueries)}</h1>
${
mediaQueries.size > 0 ? this.#groupToFragment(mediaQueries, 'media-queries', 'text') :
UI.Fragment.Fragment.build`<div class="horizontally-padded">${
i18nString(UIStrings.thereAreNoMediaQueries)}</div>`}
</div>
</div>`;
this.#resultsContainer.element.appendChild(this.#fragment.element());
}
#createElementsView(evt: Common.EventTarget.EventTargetEvent<{payload: PopulateNodesEvent}>): void {
const {payload} = evt.data;
let id = '';
let tabTitle = '';
switch (payload.type) {
case 'contrast': {
const {section, key} = payload;
id = `${section}-${key}`;
tabTitle = i18nString(UIStrings.contrastIssues);
break;
}
case 'color': {
const {section, color} = payload;
id = `${section}-${color}`;
tabTitle = `${color.toUpperCase()} (${section})`;
break;
}
case 'unused-declarations': {
const {declaration} = payload;
id = `${declaration}`;
tabTitle = `${declaration}`;
break;
}
case 'media-queries': {
const {text} = payload;
id = `${text}`;
tabTitle = `${text}`;
break;
}
case 'font-info': {
const {name} = payload;
id = `${name}`;
tabTitle = `${name}`;
break;
}
}
let view = this.#viewMap.get(id);
if (!view) {
if (!this.#domModel || !this.#cssModel) {
throw new Error('Unable to initialize CSS Overview, missing models');
}
view = new ElementDetailsView(this.#controller, this.#domModel, this.#cssModel, this.#linkifier);
void view.populateNodes(payload.nodes);
this.#viewMap.set(id, view);
}
this.#elementContainer.appendTab(id, tabTitle, view, true);
}
#fontInfoToFragment(fontInfo: Map<string, Map<string, Map<string, number[]>>>): UI.Fragment.Fragment {
const fonts = Array.from(fontInfo.entries());
return UI.Fragment.Fragment.build`
${fonts.map(([font, fontMetrics]) => {
return UI.Fragment.Fragment.build`<section class="font-family"><h2>${font}</h2> ${
this.#fontMetricsToFragment(font, fontMetrics)}</section>`;
})}
`;
}
#fontMetricsToFragment(font: string, fontMetrics: Map<string, Map<string, number[]>>): UI.Fragment.Fragment {
const fontMetricInfo = Array.from(fontMetrics.entries());
return UI.Fragment.Fragment.build`
<div class="font-metric">
${fontMetricInfo.map(([label, values]) => {
const sanitizedPath = `${font}/${label}`;
return UI.Fragment.Fragment.build`
<div>
<h3>${label}</h3>
${this.#groupToFragment(values, 'font-info', 'value', sanitizedPath)}
</div>`;
})}
</div>`;
}
#groupToFragment(
items: Map<string, (number | UnusedDeclaration | Protocol.CSS.CSSMedia)[]>, type: string, dataLabel: string,
path: string = ''): UI.Fragment.Fragment {
// Sort by number of items descending.
const values = Array.from(items.entries()).sort((d1, d2) => {
const v1Nodes = d1[1];
const v2Nodes = d2[1];
return v2Nodes.length - v1Nodes.length;
});
const total = values.reduce((prev, curr) => prev + curr[1].length, 0);
return UI.Fragment.Fragment.build`<ul>
${values.map(([title, nodes]) => {
const width = 100 * nodes.length / total;
const itemLabel = i18nString(UIStrings.nOccurrences, {n: nodes.length});
return UI.Fragment.Fragment.build`<li>
<div class="title">${title}</div>
<button data-type="${type}" data-path="${path}" data-${dataLabel}="${title}">
<div class="details">${itemLabel}</div>
<div class="bar-container">
<div class="bar" style="width: ${width}%;"></div>
</div>
</button>
</li>`;
})}
</ul>`;
}
#contrastIssuesToFragment(issues: Map<string, ContrastIssue[]>): UI.Fragment.Fragment {
return UI.Fragment.Fragment.build`
<h2>${i18nString(UIStrings.contrastIssuesS, {
PH1: issues.size,
})}</h2>
<ul>
${[...issues.entries()].map(([key, value]) => this.#contrastIssueToFragment(key, value))}
</ul>
`;
}
#contrastIssueToFragment(key: string, issues: ContrastIssue[]): UI.Fragment.Fragment {
console.assert(issues.length > 0);
let minContrastIssue: ContrastIssue = issues[0];
for (const issue of issues) {
// APCA contrast can be a negative value that is to be displayed. But the
// absolute value is used to compare against the threshold. Therefore, the min
// absolute value is the worst contrast.
if (Math.abs(issue.contrastRatio) < Math.abs(minContrastIssue.contrastRatio)) {
minContrastIssue = issue;
}
}
const color = (minContrastIssue.textColor.asString(Common.Color.Format.HEXA) as string);
const backgroundColor = (minContrastIssue.backgroundColor.asString(Common.Color.Format.HEXA) as string);
const showAPCA = Root.Runtime.experiments.isEnabled('APCA');
const blockFragment = UI.Fragment.Fragment.build`<li>
<button
title="${i18nString(UIStrings.textColorSOverSBackgroundResults, {
PH1: color,
PH2: backgroundColor,
PH3: issues.length,
})}"
data-type="contrast" data-key="${key}" data-section="contrast" class="block" $="color">
Text
</button>
<div class="block-title">
<div class="contrast-warning hidden" $="aa"><span class="threshold-label">${
i18nString(UIStrings.aa)}</span></div>
<div class="contrast-warning hidden" $="aaa"><span class="threshold-label">${
i18nString(UIStrings.aaa)}</span></div>
<div class="contrast-warning hidden" $="apca"><span class="threshold-label">${
i18nString(UIStrings.apca)}</span></div>
</div>
</li>`;
if (showAPCA) {
const apca = (blockFragment.$('apca') as HTMLElement);
if (minContrastIssue.thresholdsViolated.apca) {
apca.appendChild(UI.Icon.Icon.create('smallicon-no'));
} else {
apca.appendChild(UI.Icon.Icon.create('smallicon-checkmark-square'));
}
apca.classList.remove('hidden');
} else {
const aa = (blockFragment.$('aa') as HTMLElement);
if (minContrastIssue.thresholdsViolated.aa) {
aa.appendChild(UI.Icon.Icon.create('smallicon-no'));
} else {
aa.appendChild(UI.Icon.Icon.create('smallicon-checkmark-square'));
}
const aaa = (blockFragment.$('aaa') as HTMLElement);
if (minContrastIssue.thresholdsViolated.aaa) {
aaa.appendChild(UI.Icon.Icon.create('smallicon-no'));
} else {
aaa.appendChild(UI.Icon.Icon.create('smallicon-checkmark-square'));
}
aa.classList.remove('hidden');
aaa.classList.remove('hidden');
}
const block = (blockFragment.$('color') as HTMLElement);
block.style.backgroundColor = backgroundColor;
block.style.color = color;
block.style.border = getBorderString(minContrastIssue.backgroundColor);
return blockFragment;
}
#colorsToFragment(section: string, color: string): UI.Fragment.Fragment|undefined {
const blockFragment = UI.Fragment.Fragment.build`<li>
<button data-type="color" data-color="${color}" data-section="${section}" class="block" $="color"></button>
<div class="block-title color-text">${color}</div>
</li>`;
const block = (blockFragment.$('color') as HTMLElement);
block.style.backgroundColor = color;
const borderColor = Common.Color.Color.parse(color);
if (!borderColor) {
return;
}
block.style.border = getBorderString(borderColor);
return blockFragment;
}
#sortColorsByLuminance(srcColors: Map<string, Set<number>>): string[] {
return Array.from(srcColors.keys()).sort((colA, colB) => {
const colorA = Common.Color.Color.parse(colA);
const colorB = Common.Color.Color.parse(colB);
if (!colorA || !colorB) {
return 0;
}
return Common.ColorUtils.luminance(colorB.rgba()) - Common.ColorUtils.luminance(colorA.rgba());
});
}
setOverviewData(data: OverviewData): void {
void this.#render(data);
}
// eslint-disable-next-line @typescript-eslint/naming-convention
static readonly pushedNodes = new Set<Protocol.DOM.BackendNodeId>();
}
export class DetailsView extends Common.ObjectWrapper.eventMixin<EventTypes, typeof UI.Widget.VBox>(UI.Widget.VBox) {
#tabbedPane: UI.TabbedPane.TabbedPane;
constructor() {
super();
this.#tabbedPane = new UI.TabbedPane.TabbedPane();
this.#tabbedPane.show(this.element);
this.#tabbedPane.addEventListener(UI.TabbedPane.Events.TabClosed, () => {
this.dispatchEventToListeners(Events.TabClosed, this.#tabbedPane.tabIds().length);
});
}
appendTab(id: string, tabTitle: string, view: UI.Widget.Widget, isCloseable?: boolean): void {
if (!this.#tabbedPane.hasTab(id)) {
this.#tabbedPane.appendTab(id, tabTitle, view, undefined, undefined, isCloseable);
}
this.#tabbedPane.selectTab(id);
}
closeTabs(): void {
this.#tabbedPane.closeTabs(this.#tabbedPane.tabIds());
}
}
export const enum Events {
TabClosed = 'TabClosed',
}
export type EventTypes = {
[Events.TabClosed]: number,
};
export class ElementDetailsView extends UI.Widget.Widget {
readonly #controller: OverviewController;
#domModel: SDK.DOMModel.DOMModel;
readonly #cssModel: SDK.CSSModel.CSSModel;
readonly #linkifier: Components.Linkifier.Linkifier;
readonly #elementGridColumns: DataGrid.DataGrid.ColumnDescriptor[];
#elementGrid: DataGrid.SortableDataGrid.SortableDataGrid<unknown>;
constructor(
controller: OverviewController, domModel: SDK.DOMModel.DOMModel, cssModel: SDK.CSSModel.CSSModel,
linkifier: Components.Linkifier.Linkifier) {
super();
this.#controller = controller;
this.#domModel = domModel;
this.#cssModel = cssModel;
this.#linkifier = linkifier;
this.#elementGridColumns = [
{
id: 'nodeId',
title: i18nString(UIStrings.element),
sortable: true,
weight: 50,
titleDOMFragment: undefined,
sort: undefined,
align: undefined,
width: undefined,
fixedWidth: undefined,
editable: undefined,
nonSelectable: undefined,
longText: undefined,
disclosure: undefined,
allowInSortByEvenWhenHidden: undefined,
dataType: undefined,
defaultWeight: undefined,
},
{
id: 'declaration',
title: i18nString(UIStrings.declaration),
sortable: true,
weight: 50,
titleDOMFragment: undefined,
sort: undefined,
align: undefined,
width: undefined,
fixedWidth: undefined,
editable: undefined,
nonSelectable: undefined,
longText: undefined,
disclosure: undefined,
allowInSortByEvenWhenHidden: undefined,
dataType: undefined,
defaultWeight: undefined,
},
{
id: 'sourceURL',
title: i18nString(UIStrings.source),
sortable: false,
weight: 100,
titleDOMFragment: undefined,
sort: undefined,
align: undefined,
width: undefined,
fixedWidth: undefined,
editable: undefined,
nonSelectable: undefined,
longText: undefined,
disclosure: undefined,
allowInSortByEvenWhenHidden: undefined,
dataType: undefined,
defaultWeight: undefined,
},
{
id: 'contrastRatio',
title: i18nString(UIStrings.contrastRatio),
sortable: true,
weight: 25,
titleDOMFragment: undefined,
sort: undefined,
align: undefined,
width: '150px',
fixedWidth: true,
editable: undefined,
nonSelectable: undefined,
longText: undefined,
disclosure: undefined,
allowInSortByEvenWhenHidden: undefined,
dataType: undefined,
defaultWeight: undefined,
},
];
this.#elementGrid = new DataGrid.SortableDataGrid.SortableDataGrid({
displayName: i18nString(UIStrings.cssOverviewElements),
columns: this.#elementGridColumns,
editCallback: undefined,
deleteCallback: undefined,
refreshCallback: undefined,
});
this.#elementGrid.element.classList.add('element-grid');
this.#elementGrid.element.addEventListener('mouseover', this.#onMouseOver.bind(this));
this.#elementGrid.setStriped(true);
this.#elementGrid.addEventListener(
DataGrid.DataGrid.Events.SortingChanged, this.#sortMediaQueryDataGrid.bind(this));
this.#elementGrid.asWidget().show(this.element);
}
#sortMediaQueryDataGrid(): void {
const sortColumnId = this.#elementGrid.sortColumnId();
if (!sortColumnId) {
return;
}
const comparator = DataGrid.SortableDataGrid.SortableDataGrid.StringComparator.bind(null, sortColumnId);
this.#elementGrid.sortNodes(comparator, !this.#elementGrid.isSortOrderAscending());
}
#onMouseOver(evt: Event): void {
// Traverse the event path on the grid to find the nearest element with a backend node ID attached. Use
// that for the highlighting.
const node = (evt.composedPath() as HTMLElement[]).find(el => el.dataset && el.dataset.backendNodeId);
if (!node) {
return;
}
const backendNodeId = Number(node.dataset.backendNodeId);
this.#controller.dispatchEventToListeners(CSSOverViewControllerEvents.RequestNodeHighlight, backendNodeId);
}
async populateNodes(data: PopulateNodesEventNodes): Promise<void> {
this.#elementGrid.rootNode().removeChildren();
if (!data.length) {
return;
}
const [firstItem] = data;
const visibility = new Set<string>();
'nodeId' in firstItem && firstItem.nodeId && visibility.add('nodeId');
'declaration' in firstItem && firstItem.declaration && visibility.add('declaration');
'sourceURL' in firstItem && firstItem.sourceURL && visibility.add('sourceURL');
'contrastRatio' in firstItem && firstItem.contrastRatio && visibility.add('contrastRatio');
let relatedNodesMap: Map<Protocol.DOM.BackendNodeId, SDK.DOMModel.DOMNode|null>|null|undefined;
if ('nodeId' in firstItem && visibility.has('nodeId')) {
// Grab the nodes from the frontend, but only those that have not been
// retrieved already.
const nodeIds = (data as {nodeId: Protocol.DOM.BackendNodeId}[]).reduce((prev, curr) => {
const nodeId = curr.nodeId;
if (CSSOverviewCompletedView.pushedNodes.has(nodeId)) {
return prev;
}
CSSOverviewCompletedView.pushedNodes.add(nodeId);
return prev.add(nodeId);
}, new Set<Protocol.DOM.BackendNodeId>());
relatedNodesMap = await this.#domModel.pushNodesByBackendIdsToFrontend(nodeIds);
}
for (const item of data) {
let frontendNode;
if ('nodeId' in item && visibility.has('nodeId')) {
if (!relatedNodesMap) {
continue;
}
frontendNode = relatedNodesMap.get(item.nodeId);
if (!frontendNode) {
continue;
}
}
const node = new ElementNode(item, frontendNode, this.#linkifier, this.#cssModel);
node.selectable = false;
this.#elementGrid.insertChild(node);
}
this.#elementGrid.setColumnsVisiblity(visibility);
this.#elementGrid.renderInline();
this.#elementGrid.wasShown();
}
}
export class ElementNode extends DataGrid.SortableDataGrid.SortableDataGridNode<ElementNode> {
readonly #linkifier: Components.Linkifier.Linkifier;
readonly #cssModel: SDK.CSSModel.CSSModel;
readonly #frontendNode: SDK.DOMModel.DOMNode|null|undefined;
constructor(
data: PopulateNodesEventNodeTypes, frontendNode: SDK.DOMModel.DOMNode|null|undefined,
linkifier: Components.Linkifier.Linkifier, cssModel: SDK.CSSModel.CSSModel) {
super(data);
this.#frontendNode = frontendNode;
this.#linkifier = linkifier;
this.#cssModel = cssModel;
}
createCell(columnId: string): HTMLElement {
// Nodes.
const frontendNode = this.#frontendNode;
if (columnId === 'nodeId') {
const cell = this.createTD(columnId);
cell.textContent = '...';
if (!frontendNode) {
throw new Error('Node entry is missing a related frontend node.');
}
void Common.Linkifier.Linkifier.linkify(frontendNode).then(link => {
cell.textContent = '';
(link as HTMLElement).dataset.backendNodeId = frontendNode.backendNodeId().toString();
cell.appendChild(link);
const button = document.createElement('button');
button.classList.add('show-element');
UI.Tooltip.Tooltip.install(button, i18nString(UIStrings.showElement));
button.tabIndex = 0;
button.onclick = (): void => this.data.node.scrollIntoView();
cell.appendChild(button);
});
return cell;
}
// Links to CSS.
if (columnId === 'sourceURL') {
const cell = this.createTD(columnId);
if (this.data.range) {
const link = this.#linkifyRuleLocation(
this.#cssModel, this.#linkifier, this.data.styleSheetId,
TextUtils.TextRange.TextRange.fromObject(this.data.range));
if (!link || link.textContent === '') {
cell.textContent = '(unable to link)';
} else {
cell.appendChild(link);
}
} else {
cell.textContent = '(unable to link to inlined styles)';
}
return cell;
}
if (columnId === 'contrastRatio') {
const cell = this.createTD(columnId);
const showAPCA = Root.Runtime.experiments.isEnabled('APCA');
const contrastRatio = Platform.NumberUtilities.floor(this.data.contrastRatio, 2);
const contrastRatioString = showAPCA ? contrastRatio + '%' : contrastRatio;
const border = getBorderString(this.data.backgroundColor);
const color = this.data.textColor.asString();
const backgroundColor = this.data.backgroundColor.asString();
const contrastFragment = UI.Fragment.Fragment.build`
<div class="contrast-container-in-grid" $="container">
<span class="contrast-preview" style="border: ${border};
color: ${color};
background-color: ${backgroundColor};">Aa</span>
<span>${contrastRatioString}</span>
</div>
`;
const container = contrastFragment.$('container');
if (showAPCA) {
container.append(UI.Fragment.Fragment.build`<span>${i18nString(UIStrings.apca)}</span>`.element());
if (this.data.thresholdsViolated.apca) {
container.appendChild(UI.Icon.Icon.create('smallicon-no'));
} else {
container.appendChild(UI.Icon.Icon.create('smallicon-checkmark-square'));
}
} else {
container.append(UI.Fragment.Fragment.build`<span>${i18nString(UIStrings.aa)}</span>`.element());
if (this.data.thresholdsViolated.aa) {
container.appendChild(UI.Icon.Icon.create('smallicon-no'));
} else {
container.appendChild(UI.Icon.Icon.create('smallicon-checkmark-square'));
}
container.append(UI.Fragment.Fragment.build`<span>${i18nString(UIStrings.aaa)}</span>`.element());
if (this.data.thresholdsViolated.aaa) {
container.appendChild(UI.Icon.Icon.create('smallicon-no'));
} else {
container.appendChild(UI.Icon.Icon.create('smallicon-checkmark-square'));
}
}
cell.appendChild(contrastFragment.element());
return cell;
}
return super.createCell(columnId);
}
#linkifyRuleLocation(
cssModel: SDK.CSSModel.CSSModel, linkifier: Components.Linkifier.Linkifier,
styleSheetId: Protocol.CSS.StyleSheetId, ruleLocation: TextUtils.TextRange.TextRange): Element|undefined {
const styleSheetHeader = cssModel.styleSheetHeaderForId(styleSheetId);
if (!styleSheetHeader) {
return;
}
const lineNumber = styleSheetHeader.lineNumberInSource(ruleLocation.startLine);
const columnNumber = styleSheetHeader.columnNumberInSource(ruleLocation.startLine, ruleLocation.startColumn);
const matchingSelectorLocation = new SDK.CSSModel.CSSLocation(styleSheetHeader, lineNumber, columnNumber);
return linkifier.linkifyCSSLocation(matchingSelectorLocation);
}
}