chrome-devtools-frontend
Version:
Chrome DevTools UI
635 lines (600 loc) • 23.1 kB
text/typescript
// Copyright 2021 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 i18n from '../../../../core/i18n/i18n.js';
import * as Buttons from '../../../../ui/components/buttons/buttons.js';
import * as ComponentHelpers from '../../../../ui/components/helpers/helpers.js';
import * as LitHtml from '../../../../ui/lit-html/lit-html.js';
import userAgentClientHintsFormStyles from './userAgentClientHintsForm.css.js';
import type * as Protocol from '../../../../generated/protocol.js';
import * as IconButton from '../../../../ui/components/icon_button/icon_button.js';
import type * as UI from '../../../../ui/legacy/legacy.js';
import * as EmulationUtils from '../utils/utils.js';
const UIStrings = {
/**
* @description Title for user agent client hints form
*/
title: 'User agent client hints',
/**
* @description Heading for brands section.
* Brands here relate to different browser brands/vendors like Google Chrome, Microsoft Edge etc.
*/
brands: 'Brands',
/**
* @description ARIA label for a form with properties for a single brand in a brand list. The form includes a brand name input field, a version
* input field and a delete icon. Brand refer to different browser brands/vendors like Google Chrome, Microsoft Edge etc.
*/
brandProperties: 'Brand properties',
/**
* @description Input field placeholder for brands browser name.
* Brands here relate to different browser brands/vendors like Google Chrome, Microsoft Edge etc.
*/
brandName: 'Brand',
/**
* @description Aria label for brands browser name input field.
* Brands here relate to different browser brands/vendors like Google Chrome, Microsoft Edge etc.
* @example {index} PH1
*/
brandNameAriaLabel: 'Brand {PH1}',
/**
* @description Input field placeholder for brands version.
* Brands here relate to different browser brands/vendors like Google Chrome (v89), Microsoft Edge (v92) etc.
*/
version: 'Version',
/**
* @description Aria label for brands browser version input field.
* Brands here relate to different browser brands/vendors like Google Chrome, Microsoft Edge etc.
* @example {index} PH1
*/
brandVersionAriaLabel: 'Version {PH1}',
/**
* @description Button title for adding another brand in brands section to client hints.
* Brands here relate to different browser brands/vendors like Google Chrome, Microsoft Edge etc.
*/
addBrand: 'Add Brand',
/**
* @description Tooltip for delete icon for deleting browser brand in brands section.
* Brands here relate to different browser brands/vendors like Google Chrome, Microsoft Edge etc.
*/
deleteTooltip: 'Delete',
/**
* @description Aria label for delete icon for deleting browser brand in brands section.
* Brands here relate to different browser brands/vendors like Google Chrome, Microsoft Edge etc.
* @example {index} PH1
*/
brandDeleteAriaLabel: 'Delete {PH1}',
/**
* @description Label for full browser version input field.
*/
fullBrowserVersion: 'Full browser version',
/**
* @description Placeholder for full browser version input field.
*/
fullBrowserVersionPlaceholder: 'Full browser version (e.g. 87.0.4280.88)',
/**
* @description Label for platform heading section, platform relates to OS like Android, Windows etc.
*/
platformLabel: 'Platform',
/**
* @description Platform row, including platform name and platform version input field.
*/
platformProperties: 'Platform properties',
/**
* @description Version for platform input field, platform relates to OS like Android, Windows etc.
*/
platformVersion: 'Platform version',
/**
* @description Placeholder for platform name input field, platform relates to OS like Android, Windows etc.
*/
platformPlaceholder: 'Platform (e.g. Android)',
/**
* @description Label for architecture (Eg: x86, x64, arm) input field.
*/
architecture: 'Architecture',
/**
* @description Placeholder for architecture (Eg: x86, x64, arm) input field.
*/
architecturePlaceholder: 'Architecture (e.g. x86)',
/**
* @description Device model row, including device model input field and mobile checkbox
*/
deviceProperties: 'Device properties',
/**
* @description Label for Device Model input field.
*/
deviceModel: 'Device model',
/**
* @description Label for Mobile phone checkbox.
*/
mobileCheckboxLabel: 'Mobile',
/**
* @description Label for button to submit client hints form in DevTools.
*/
update: 'Update',
/**
*@description Field Error message in the Device settings pane that shows that the entered value has characters that can't be represented in the corresponding User Agent Client Hints
*/
notRepresentable: 'Not representable as structured headers string.',
/**
* @description Aria label for information link in user agent client hints form.
*/
userAgentClientHintsInfo:
'User agent client hints are an alternative to the user agent string that identify the browser and the device in a more structured way with better privacy accounting. Click the button to learn more.',
/**
* @description Success message when brand row is successfully added in client hints form.
* Brands here relate to different browser brands/vendors like Google Chrome, Microsoft Edge etc.
*/
addedBrand: 'Added brand row',
/**
* @description Success message when brand row is successfully deleted in client hints form.
* Brands here relate to different browser brands/vendors like Google Chrome, Microsoft Edge etc.
*/
deletedBrand: 'Deleted brand row',
};
const str_ = i18n.i18n.registerUIStrings('panels/settings/emulation/components/UserAgentClientHintsForm.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export class ClientHintsChangeEvent extends Event {
static readonly eventName = 'clienthintschange';
constructor() {
super(ClientHintsChangeEvent.eventName);
}
}
export class ClientHintsSubmitEvent extends Event {
static readonly eventName = 'clienthintssubmit';
detail: {value: Protocol.Emulation.UserAgentMetadata};
constructor(value: Protocol.Emulation.UserAgentMetadata) {
super(ClientHintsSubmitEvent.eventName);
this.detail = {value};
}
}
export interface UserAgentClientHintsFormData {
metaData?: Protocol.Emulation.UserAgentMetadata;
showMobileCheckbox?: boolean;
showSubmitButton?: boolean;
}
const DEFAULT_METADATA = {
brands: [
{
brand: '',
version: '',
},
],
fullVersion: '',
platform: '',
platformVersion: '',
architecture: '',
model: '',
mobile: false,
};
/**
* Component for user agent client hints form, it is used in device settings panel
* and network conditions panel. It is customizable through showMobileCheckbox and showSubmitButton.
*/
// eslint-disable-next-line rulesdir/custom_element_definitions_location
export class UserAgentClientHintsForm extends HTMLElement {
static readonly litTagName = LitHtml.literal`devtools-user-agent-client-hints-form`;
private readonly shadow = this.attachShadow({mode: 'open'});
private isFormOpened: boolean = false;
private isFormDisabled: boolean = false;
private metaData: Protocol.Emulation.UserAgentMetadata = DEFAULT_METADATA;
private showMobileCheckbox: boolean = false;
private showSubmitButton: boolean = false;
private brandsModifiedAriaMessage: string = '';
connectedCallback(): void {
this.shadow.adoptedStyleSheets = [userAgentClientHintsFormStyles];
}
set value(data: UserAgentClientHintsFormData) {
const {metaData = DEFAULT_METADATA, showMobileCheckbox = false, showSubmitButton = false} = data;
this.metaData = {
...this.metaData,
...metaData,
};
this.showMobileCheckbox = showMobileCheckbox;
this.showSubmitButton = showSubmitButton;
this.render();
}
get value(): UserAgentClientHintsFormData {
return {
metaData: this.metaData,
};
}
set disabled(disableForm: boolean) {
this.isFormDisabled = disableForm;
this.isFormOpened = false;
this.render();
}
get disabled(): boolean {
return this.isFormDisabled;
}
private handleTreeExpand = (event: KeyboardEvent): void => {
if (event.code === 'Space' || event.code === 'Enter' || event.code === 'ArrowLeft' || event.code === 'ArrowRight') {
event.stopPropagation();
this.handleTreeClick(event.code);
}
};
private handleTreeClick = (key: string): void => {
if (this.isFormDisabled) {
return;
}
if ((key === 'ArrowLeft' && !this.isFormOpened) || (key === 'ArrowRight' && this.isFormOpened)) {
return;
}
this.isFormOpened = !this.isFormOpened;
this.render();
};
private handleBrandInputChange = (value: string, index: number, brandInputType: 'brandName'|'brandVersion'): void => {
const updatedBrands = this.metaData.brands?.map((browserBrand, brandIndex) => {
if (brandIndex === index) {
const {brand, version} = browserBrand;
if (brandInputType === 'brandName') {
return {
brand: value,
version,
};
}
return {
brand,
version: value,
};
}
return browserBrand;
});
this.metaData = {
...this.metaData,
brands: updatedBrands,
};
this.dispatchEvent(new ClientHintsChangeEvent());
this.render();
};
private handleBrandDelete = (index: number): void => {
const {brands = []} = this.metaData;
brands.splice(index, 1);
this.metaData = {
...this.metaData,
brands,
};
this.dispatchEvent(new ClientHintsChangeEvent());
this.brandsModifiedAriaMessage = i18nString(UIStrings.deletedBrand);
this.render();
// after deleting a brand row, focus on next Brand input if available,
// otherwise focus on the "Add Brand" button
let nextFocusElement = this.shadowRoot?.getElementById(`brand-${index + 1}-input`);
if (!nextFocusElement) {
nextFocusElement = this.shadowRoot?.getElementById('add-brand-button');
}
(nextFocusElement as HTMLElement)?.focus();
};
private handleAddBrandClick = (): void => {
const {brands} = this.metaData;
this.metaData = {
...this.metaData,
brands: [
...(Array.isArray(brands) ? brands : []),
{
brand: '',
version: '',
},
],
};
this.dispatchEvent(new ClientHintsChangeEvent());
this.brandsModifiedAriaMessage = i18nString(UIStrings.addedBrand);
this.render();
const brandInputElements = this.shadowRoot?.querySelectorAll('.brand-name-input');
if (brandInputElements) {
const lastBrandInputElement = Array.from(brandInputElements).pop();
if (lastBrandInputElement) {
(lastBrandInputElement as HTMLInputElement).focus();
}
}
};
private handleAddBrandKeyPress = (event: KeyboardEvent): void => {
if (event.code === 'Space' || event.code === 'Enter') {
event.preventDefault();
this.handleAddBrandClick();
}
};
private handleInputChange = (stateKey: keyof Protocol.Emulation.UserAgentMetadata, value: string|boolean): void => {
if (stateKey in this.metaData) {
this.metaData = {
...this.metaData,
[stateKey]: value,
};
this.render();
}
this.dispatchEvent(new ClientHintsChangeEvent());
};
private handleLinkPress = (event: KeyboardEvent): void => {
if (event.code === 'Space' || event.code === 'Enter') {
event.preventDefault();
(event.target as HTMLAnchorElement).click();
}
};
private handleSubmit = (event: Event): void => {
event.preventDefault();
if (this.showSubmitButton) {
this.dispatchEvent(new ClientHintsSubmitEvent(this.metaData));
this.render();
}
};
private renderInputWithLabel(
label: string, placeholder: string, value: string,
stateKey: keyof Protocol.Emulation.UserAgentMetadata): LitHtml.TemplateResult {
const handleInputChange = (event: KeyboardEvent): void => {
const value = (event.target as HTMLInputElement).value;
this.handleInputChange(stateKey, value);
};
return LitHtml.html`
<label class="full-row label input-field-label-container">
${label}
<input
class="input-field"
type="text"
="${handleInputChange}"
.value="${value}"
placeholder="${placeholder}"
/>
</label>
`;
}
private renderPlatformSection(): LitHtml.TemplateResult {
const {platform, platformVersion} = this.metaData;
const handlePlatformNameChange = (event: KeyboardEvent): void => {
const value = (event.target as HTMLInputElement).value;
this.handleInputChange('platform', value);
};
const handlePlatformVersionChange = (event: KeyboardEvent): void => {
const value = (event.target as HTMLInputElement).value;
this.handleInputChange('platformVersion', value);
};
return LitHtml.html`
<span class="full-row label">${i18nString(UIStrings.platformLabel)}</span>
<div class="full-row brand-row" aria-label="${i18nString(UIStrings.platformProperties)}" role="group">
<input
class="input-field half-row"
type="text"
="${handlePlatformNameChange}"
.value="${platform}"
placeholder="${i18nString(UIStrings.platformPlaceholder)}"
aria-label="${i18nString(UIStrings.platformLabel)}"
/>
<input
class="input-field half-row"
type="text"
="${handlePlatformVersionChange}"
.value="${platformVersion}"
placeholder="${i18nString(UIStrings.platformVersion)}"
aria-label="${i18nString(UIStrings.platformVersion)}"
/>
</div>
`;
}
private renderDeviceModelSection(): LitHtml.TemplateResult {
const {model, mobile} = this.metaData;
const handleDeviceModelChange = (event: KeyboardEvent): void => {
const value = (event.target as HTMLInputElement).value;
this.handleInputChange('model', value);
};
const handleMobileChange = (event: KeyboardEvent): void => {
const value = (event.target as HTMLInputElement).checked;
this.handleInputChange('mobile', value);
};
const mobileCheckboxInput = this.showMobileCheckbox ? LitHtml.html`
<label class="mobile-checkbox-container">
<input type="checkbox" ="${handleMobileChange}" .checked="${mobile}" />
${i18nString(UIStrings.mobileCheckboxLabel)}
</label>
` :
LitHtml.html``;
return LitHtml.html`
<span class="full-row label">${i18nString(UIStrings.deviceModel)}</span>
<div class="full-row brand-row" aria-label="${i18nString(UIStrings.deviceProperties)}" role="group">
<input
class="input-field ${this.showMobileCheckbox ? 'device-model-input' : 'full-row'}"
type="text"
="${handleDeviceModelChange}"
.value="${model}"
placeholder="${i18nString(UIStrings.deviceModel)}"
/>
${mobileCheckboxInput}
</div>
`;
}
private renderBrands(): LitHtml.TemplateResult {
const {
brands =
[
{
brand: '',
version: '',
},
],
} = this.metaData;
const brandElements = brands.map((brandRow, index) => {
const {brand, version} = brandRow;
const handleDeleteClick = (): void => {
this.handleBrandDelete(index);
};
const handleKeyPress = (event: KeyboardEvent): void => {
if (event.code === 'Space' || event.code === 'Enter') {
event.preventDefault();
handleDeleteClick();
}
};
const handleBrandBrowserChange = (event: KeyboardEvent): void => {
const value = (event.target as HTMLInputElement).value;
this.handleBrandInputChange(value, index, 'brandName');
};
const handleBrandVersionChange = (event: KeyboardEvent): void => {
const value = (event.target as HTMLInputElement).value;
this.handleBrandInputChange(value, index, 'brandVersion');
};
return LitHtml.html`
<div class="full-row brand-row" aria-label="${i18nString(UIStrings.brandProperties)}" role="group">
<input
class="input-field brand-name-input"
type="text"
="${handleBrandBrowserChange}"
.value="${brand}"
id="brand-${index + 1}-input"
placeholder="${i18nString(UIStrings.brandName)}"
aria-label="${i18nString(UIStrings.brandNameAriaLabel, {
PH1: index + 1,
})}"
/>
<input
class="input-field"
type="text"
="${handleBrandVersionChange}"
.value="${version}"
placeholder="${i18nString(UIStrings.version)}"
aria-label="${i18nString(UIStrings.brandVersionAriaLabel, {
PH1: index + 1,
})}"
/>
<${IconButton.Icon.Icon.litTagName}
.data=${
{color: 'var(--client-hints-form-icon-color)', iconName: 'trash_bin_icon', width: '10px', height: '14px'} as
IconButton.Icon.IconData}
title="${i18nString(UIStrings.deleteTooltip)}"
class="delete-icon"
tabindex="0"
role="button"
="${handleDeleteClick}"
="${handleKeyPress}"
aria-label="${i18nString(UIStrings.brandDeleteAriaLabel, {
PH1: index + 1,
})}"
>
</${IconButton.Icon.Icon.litTagName}>
</div>
`;
});
return LitHtml.html`
<span class="full-row label">${i18nString(UIStrings.brands)}</span>
${brandElements}
<div
class="add-container full-row"
role="button"
tabindex="0"
id="add-brand-button"
aria-label="${i18nString(UIStrings.addBrand)}"
="${this.handleAddBrandClick}"
="${this.handleAddBrandKeyPress}"
>
<${IconButton.Icon.Icon.litTagName}
aria-hidden="true"
.data=${
{color: 'var(--client-hints-form-icon-color)', iconName: 'add-icon', width: '10px'} as IconButton.Icon.IconData}
>
</${IconButton.Icon.Icon.litTagName}>
${i18nString(UIStrings.addBrand)}
</div>
`;
}
private render(): void {
const {fullVersion, architecture} = this.metaData;
const brandSection = this.renderBrands();
const fullBrowserInput = this.renderInputWithLabel(
i18nString(UIStrings.fullBrowserVersion), i18nString(UIStrings.fullBrowserVersionPlaceholder),
fullVersion || '', 'fullVersion');
const platformSection = this.renderPlatformSection();
const architectureInput = this.renderInputWithLabel(
i18nString(UIStrings.architecture), i18nString(UIStrings.architecturePlaceholder), architecture,
'architecture');
const deviceModelSection = this.renderDeviceModelSection();
const submitButton = this.showSubmitButton ? LitHtml.html`
<${Buttons.Button.Button.litTagName}
.variant=${Buttons.Button.Variant.SECONDARY}
.type=${'submit'}
>
${i18nString(UIStrings.update)}
</${Buttons.Button.Button.litTagName}>
` :
LitHtml.html``;
const output = LitHtml.html`
<section class="root">
<div
class="tree-title"
role="button"
="${this.handleTreeClick}"
tabindex="0"
="${this.handleTreeExpand}"
aria-expanded="${this.isFormOpened}"
aria-controls="form-container"
="${this.isFormDisabled}"
aria-disabled="${this.isFormDisabled}"
>
<${IconButton.Icon.Icon.litTagName}
class="${this.isFormOpened ? '' : 'rotate-icon'}"
.data=${
{color: 'var(--client-hints-form-icon-color)', iconName: 'chromeSelect', width: '20px'} as
IconButton.Icon.IconData}
>
</${IconButton.Icon.Icon.litTagName}>
${i18nString(UIStrings.title)}
<x-link
tabindex="0"
href="https://web.dev/user-agent-client-hints/"
target="_blank"
class="info-link"
="${this.handleLinkPress}"
aria-label="${i18nString(UIStrings.userAgentClientHintsInfo)}"
>
<${IconButton.Icon.Icon.litTagName}
.data=${
{color: 'var(--client-hints-form-icon-color)', iconName: 'ic_info_black_18dp', width: '14px'} as
IconButton.Icon.IconData}
>
</${IconButton.Icon.Icon.litTagName}>
</x-link>
</div>
<form
id="form-container"
class="form-container ${this.isFormOpened ? '' : 'hide-container'}"
="${this.handleSubmit}"
>
${brandSection}
${fullBrowserInput}
${platformSection}
${architectureInput}
${deviceModelSection}
${submitButton}
</form>
<div aria-live="polite" aria-label="${this.brandsModifiedAriaMessage}"></div>
</section>
`;
// clang-format off
LitHtml.render(output, this.shadow, {host: this});
// clang-format on
}
validate = (): UI.ListWidget.ValidatorResult => {
for (const [metaDataKey, metaDataValue] of Object.entries(this.metaData)) {
if (metaDataKey === 'brands') {
const isBrandValid = this.metaData.brands?.every(({brand, version}) => {
const brandNameResult = EmulationUtils.UserAgentMetadata.validateAsStructuredHeadersString(
brand, i18nString(UIStrings.notRepresentable));
const brandVersionResult = EmulationUtils.UserAgentMetadata.validateAsStructuredHeadersString(
version, i18nString(UIStrings.notRepresentable));
return brandNameResult.valid && brandVersionResult.valid;
});
if (!isBrandValid) {
return {valid: false, errorMessage: i18nString(UIStrings.notRepresentable)};
}
} else {
const metaDataError = EmulationUtils.UserAgentMetadata.validateAsStructuredHeadersString(
metaDataValue, i18nString(UIStrings.notRepresentable));
if (!metaDataError.valid) {
return metaDataError;
}
}
}
return {valid: true};
};
}
ComponentHelpers.CustomElements.defineComponent('devtools-user-agent-client-hints-form', UserAgentClientHintsForm);
declare global {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface HTMLElementTagNameMap {
'devtools-user-agent-client-hints-form': UserAgentClientHintsForm;
}
}