| // Copyright 2021 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 '../../../../ui/kit/kit.js'; |
| import '../../../../ui/legacy/legacy.js'; |
| |
| import * as i18n from '../../../../core/i18n/i18n.js'; |
| import * as Platform from '../../../../core/platform/platform.js'; |
| import type * as Protocol from '../../../../generated/protocol.js'; |
| import * as Buttons from '../../../../ui/components/buttons/buttons.js'; |
| import * as Input from '../../../../ui/components/input/input.js'; |
| import type * as UI from '../../../../ui/legacy/legacy.js'; |
| import * as Lit from '../../../../ui/lit/lit.js'; |
| import * as VisualLogging from '../../../../ui/visual_logging/visual_logging.js'; |
| import * as EmulationUtils from '../utils/utils.js'; |
| |
| import userAgentClientHintsFormStyles from './userAgentClientHintsForm.css.js'; |
| |
| const {html} = Lit; |
| |
| const UIStrings = { |
| /** |
| * @description Title for user agent client hints form |
| */ |
| title: 'User agent client hints', |
| /** |
| * @description Heading for user agent section. |
| * Brands here relate to different browser brands/vendors like Google Chrome, Microsoft Edge etc. |
| */ |
| useragent: 'User agent (Sec-CH-UA)', |
| /** |
| * @description Heading for full-version-list section. |
| */ |
| fullVersionList: 'Full version list (Sec-CH-UA-Full-Version-List)', |
| /** |
| * @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: 'User agent 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 significant brand version. |
| * Brands here relate to different browser brands/vendors like Google Chrome (v89), Microsoft Edge (v92) etc. |
| */ |
| significantBrandVersionPlaceholder: 'Significant version (e.g. 87)', |
| /** |
| * @description Input field placeholder for brand version. |
| * Brands here relate to different browser brands/vendors like Google Chrome (v89), Microsoft Edge (v92) etc. |
| */ |
| brandVersionPlaceholder: 'Version (e.g. 87.0.4280.88)', |
| /** |
| * @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 and aria label for delete icon for deleting browser brand from brands user agent section. |
| * Brands here relate to different browser brands/vendors like Google Chrome, Microsoft Edge etc. |
| */ |
| brandUserAgentDelete: 'Delete brand from user agent section', |
| /** |
| * @description Tooltip and aria label for delete icon for deleting user agent from brands full version list. |
| * Brands here relate to different browser brands/vendors like Google Chrome, Microsoft Edge etc. |
| */ |
| brandFullVersionListDelete: 'Delete brand from full version list', |
| |
| /** |
| * @description Heading for the form factors section. |
| */ |
| formFactorsTitle: 'Form Factors (Sec-CH-UA-Form-Factors)', |
| /** |
| * @description ARIA label for the group of form factor checkboxes. |
| */ |
| formFactorsGroupAriaLabel: 'Available Form Factors', |
| /** |
| * @description Form factor option: Desktop. |
| */ |
| formFactorDesktop: 'Desktop', |
| /** |
| * @description Form factor option: Automotive. |
| */ |
| formFactorAutomotive: 'Automotive', |
| /** |
| * @description Form factor option: Mobile. |
| */ |
| formFactorMobile: 'Mobile', |
| /** |
| * @description Form factor option: Tablet. |
| */ |
| formFactorTablet: 'Tablet', |
| /** |
| * @description Form factor option: XR. |
| */ |
| formFactorXR: 'XR', |
| /** |
| * @description Form factor option: EInk. |
| */ |
| formFactorEInk: 'EInk', |
| /** |
| * @description Form factor option: Watch. |
| */ |
| formFactorWatch: 'Watch', |
| |
| /** |
| * @description Label for full browser version input field. |
| */ |
| fullBrowserVersion: 'Full browser version (Sec-CH-UA-Full-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 (Sec-CH-UA-Platform / Sec-CH-UA-Platform-Version)', |
| /** |
| * @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 (Sec-CH-UA-Arch)', |
| /** |
| * @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 (Sec-CH-UA-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 Hover text for info icon which explains user agent client hints. |
| */ |
| 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.', |
| /** |
| * @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', |
| /** |
| * @description Text that is usually a hyperlink to more documentation |
| */ |
| learnMore: 'Learn more', |
| } as const; |
| 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: '', |
| }, |
| ], |
| fullVersionList: [ |
| { |
| brand: '', |
| version: '', |
| }, |
| ], |
| fullVersion: '', |
| platform: '', |
| platformVersion: '', |
| architecture: '', |
| model: '', |
| mobile: false, |
| formFactors: [] |
| }; |
| |
| export const ALL_PROTOCOL_FORM_FACTORS: string[] = [ |
| UIStrings.formFactorDesktop, |
| UIStrings.formFactorAutomotive, |
| UIStrings.formFactorMobile, |
| UIStrings.formFactorTablet, |
| UIStrings.formFactorXR, |
| UIStrings.formFactorEInk, |
| UIStrings.formFactorWatch, |
| ] as const; |
| |
| /** |
| * 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. |
| */ |
| export class UserAgentClientHintsForm extends HTMLElement { |
| readonly #shadow = this.attachShadow({mode: 'open'}); |
| |
| #isFormOpened = false; |
| #isFormDisabled = false; |
| #metaData: Protocol.Emulation.UserAgentMetadata = DEFAULT_METADATA; |
| #showMobileCheckbox = false; |
| #showSubmitButton = false; |
| #useragentModifiedAriaMessage = ''; |
| |
| 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; |
| } |
| |
| #handleTreeExpand = (event: KeyboardEvent): void => { |
| if (event.code === 'Space' || event.code === 'Enter' || event.code === 'ArrowLeft' || event.code === 'ArrowRight') { |
| event.consume(true); |
| this.#handleTreeClick(event.code); |
| } |
| }; |
| |
| #handleTreeClick = (key: string): void => { |
| if (this.#isFormDisabled) { |
| return; |
| } |
| if ((key === 'ArrowLeft' && !this.#isFormOpened) || (key === 'ArrowRight' && this.#isFormOpened)) { |
| return; |
| } |
| this.#isFormOpened = !this.#isFormOpened; |
| this.#render(); |
| }; |
| |
| #handleUseragentInputChange = (value: string, index: number, brandInputType: 'brandName'|'brandVersion'): void => { |
| const updatedUseragent = 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: updatedUseragent, |
| }; |
| this.dispatchEvent(new ClientHintsChangeEvent()); |
| this.#render(); |
| }; |
| |
| #handleFullVersionListInputChange = |
| (value: string, index: number, brandInputType: 'brandName'|'brandVersion'): void => { |
| const fullVersionList = this.#metaData.fullVersionList?.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, |
| fullVersionList, |
| }; |
| this.dispatchEvent(new ClientHintsChangeEvent()); |
| this.#render(); |
| }; |
| |
| #handleUseragentDelete = (index: number): void => { |
| const {brands = []} = this.#metaData; |
| brands.splice(index, 1); |
| this.#metaData = { |
| ...this.#metaData, |
| brands, |
| }; |
| this.dispatchEvent(new ClientHintsChangeEvent()); |
| this.#useragentModifiedAriaMessage = 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(`ua-brand-${index + 1}-input`); |
| if (!nextFocusElement) { |
| nextFocusElement = this.shadowRoot?.getElementById('add-brand-button'); |
| } |
| (nextFocusElement as HTMLElement)?.focus(); |
| }; |
| |
| #handleFullVersionListDelete = (index: number): void => { |
| const {fullVersionList = []} = this.#metaData; |
| fullVersionList.splice(index, 1); |
| this.#metaData = { |
| ...this.#metaData, |
| fullVersionList, |
| }; |
| this.dispatchEvent(new ClientHintsChangeEvent()); |
| this.#useragentModifiedAriaMessage = 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(`fvl-brand-${index + 1}-input`); |
| if (!nextFocusElement) { |
| nextFocusElement = this.shadowRoot?.getElementById('add-fvl-brand-button'); |
| } |
| (nextFocusElement as HTMLElement)?.focus(); |
| }; |
| |
| #handleAddUseragentBrandClick = (): void => { |
| const {brands} = this.#metaData; |
| this.#metaData = { |
| ...this.#metaData, |
| brands: [ |
| ...(Array.isArray(brands) ? brands : []), |
| { |
| brand: '', |
| version: '', |
| }, |
| ], |
| }; |
| this.dispatchEvent(new ClientHintsChangeEvent()); |
| this.#useragentModifiedAriaMessage = i18nString(UIStrings.addedBrand); |
| this.#render(); |
| const brandInputElements = this.shadowRoot?.querySelectorAll('.ua-brand-name-input'); |
| if (brandInputElements) { |
| const lastBrandInputElement = Array.from(brandInputElements).pop(); |
| if (lastBrandInputElement) { |
| (lastBrandInputElement as HTMLInputElement).focus(); |
| } |
| } |
| }; |
| |
| #handleAddUseragentBrandKeyPress = (event: KeyboardEvent): void => { |
| if (event.code === 'Space' || event.code === 'Enter') { |
| event.preventDefault(); |
| this.#handleAddUseragentBrandClick(); |
| } |
| }; |
| |
| #handleAddFullVersionListBrandClick = (): void => { |
| const {fullVersionList} = this.#metaData; |
| this.#metaData = { |
| ...this.#metaData, |
| fullVersionList: [ |
| ...(Array.isArray(fullVersionList) ? fullVersionList : []), |
| { |
| brand: '', |
| version: '', |
| }, |
| ], |
| }; |
| this.dispatchEvent(new ClientHintsChangeEvent()); |
| this.#useragentModifiedAriaMessage = i18nString(UIStrings.addedBrand); |
| this.#render(); |
| const brandInputElements = this.shadowRoot?.querySelectorAll('.fvl-brand-name-input'); |
| if (brandInputElements) { |
| const lastBrandInputElement = Array.from(brandInputElements).pop(); |
| if (lastBrandInputElement) { |
| (lastBrandInputElement as HTMLInputElement).focus(); |
| } |
| } |
| }; |
| |
| #handleAddFullVersionListBrandKeyPress = (event: KeyboardEvent): void => { |
| if (event.code === 'Space' || event.code === 'Enter') { |
| event.preventDefault(); |
| this.#handleAddFullVersionListBrandClick(); |
| } |
| }; |
| |
| #handleFormFactorCheckboxChange = (formFactorValue: string, isChecked: boolean): void => { |
| let currentFormFactors = [...(this.#metaData.formFactors || [])]; |
| if (isChecked) { |
| if (!currentFormFactors.includes(formFactorValue)) { |
| currentFormFactors.push(formFactorValue); |
| } |
| } else { |
| currentFormFactors = currentFormFactors.filter(ff => ff !== formFactorValue); |
| } |
| this.#metaData = { |
| ...this.#metaData, |
| formFactors: currentFormFactors, |
| }; |
| this.dispatchEvent(new ClientHintsChangeEvent()); |
| this.#render(); |
| }; |
| |
| #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()); |
| }; |
| |
| #handleSubmit = (event: Event): void => { |
| event.preventDefault(); |
| if (this.#showSubmitButton) { |
| this.dispatchEvent(new ClientHintsSubmitEvent(this.#metaData)); |
| this.#render(); |
| } |
| }; |
| |
| #renderInputWithLabel( |
| label: string, placeholder: string, value: string, |
| stateKey: keyof Protocol.Emulation.UserAgentMetadata): Lit.TemplateResult { |
| const handleInputChange = (event: KeyboardEvent): void => { |
| const value = (event.target as HTMLInputElement).value; |
| this.#handleInputChange(stateKey, value); |
| }; |
| return html` |
| <label class="full-row label input-field-label-container"> |
| ${label} |
| <input |
| class="input-field" |
| type="text" |
| @input=${handleInputChange} |
| .value=${value} |
| placeholder=${placeholder} |
| jslog=${ |
| VisualLogging.textField().track({change: true}).context(Platform.StringUtilities.toKebabCase(stateKey))} |
| /> |
| </label> |
| `; |
| } |
| |
| #renderPlatformSection(): Lit.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 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" |
| @input=${handlePlatformNameChange} |
| .value=${platform} |
| placeholder=${i18nString(UIStrings.platformPlaceholder)} |
| aria-label=${i18nString(UIStrings.platformLabel)} |
| jslog=${VisualLogging.textField('platform').track({ |
| change: true, |
| })} |
| /> |
| <input |
| class="input-field half-row" |
| type="text" |
| @input=${handlePlatformVersionChange} |
| .value=${platformVersion} |
| placeholder=${i18nString(UIStrings.platformVersion)} |
| aria-label=${i18nString(UIStrings.platformVersion)} |
| jslog=${VisualLogging.textField('platform-version').track({ |
| change: true, |
| })} |
| /> |
| </div> |
| `; |
| } |
| |
| #renderDeviceModelSection(): Lit.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 ? html` |
| <label class="mobile-checkbox-container"> |
| <input type="checkbox" @input=${handleMobileChange} .checked=${mobile} |
| jslog=${VisualLogging.toggle('mobile').track({ |
| click: true, |
| })} |
| /> |
| ${i18nString(UIStrings.mobileCheckboxLabel)} |
| </label> |
| ` : |
| Lit.nothing; |
| return 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" |
| @input=${handleDeviceModelChange} |
| .value=${model} |
| placeholder=${i18nString(UIStrings.deviceModel)} |
| jslog=${VisualLogging.textField('model').track({ |
| change: true, |
| })} |
| /> |
| ${mobileCheckboxInput} |
| </div> |
| `; |
| } |
| |
| #renderUseragent(): Lit.TemplateResult { |
| const { |
| brands = |
| [ |
| { |
| brand: '', |
| version: '', |
| }, |
| ], |
| } = this.#metaData; |
| const brandElements = brands.map((brandRow, index) => { |
| const {brand, version} = brandRow; |
| const handleDeleteClick = (): void => { |
| this.#handleUseragentDelete(index); |
| }; |
| const handleKeyPress = (event: KeyboardEvent): void => { |
| if (event.code === 'Space' || event.code === 'Enter') { |
| event.preventDefault(); |
| handleDeleteClick(); |
| } |
| }; |
| const handleBrandChange = (event: KeyboardEvent): void => { |
| const value = (event.target as HTMLInputElement).value; |
| this.#handleUseragentInputChange(value, index, 'brandName'); |
| }; |
| const handleVersionChange = (event: KeyboardEvent): void => { |
| const value = (event.target as HTMLInputElement).value; |
| this.#handleUseragentInputChange(value, index, 'brandVersion'); |
| }; |
| return html` |
| <div class="full-row brand-row" aria-label=${i18nString(UIStrings.brandProperties)} role="group"> |
| <input |
| class="input-field ua-brand-name-input" |
| type="text" |
| @input=${handleBrandChange} |
| .value=${brand} |
| id="ua-brand-${index + 1}-input" |
| placeholder=${i18nString(UIStrings.brandName)} |
| aria-label=${i18nString(UIStrings.brandNameAriaLabel, { |
| PH1: index + 1, |
| })} |
| jslog=${VisualLogging.textField('brand-name').track({ |
| change: true, |
| })} |
| /> |
| <input |
| class="input-field" |
| type="text" |
| @input=${handleVersionChange} |
| .value=${version} |
| placeholder=${i18nString(UIStrings.significantBrandVersionPlaceholder)} |
| aria-label=${i18nString(UIStrings.brandVersionAriaLabel, { |
| PH1: index + 1, |
| })} |
| jslog=${VisualLogging.textField('brand-version').track({ |
| change: true, |
| })} |
| /> |
| <devtools-icon name="bin" |
| title=${i18nString(UIStrings.brandUserAgentDelete)} |
| class="medium delete-icon" |
| tabindex="0" |
| role="button" |
| @click=${handleDeleteClick} |
| @keypress=${handleKeyPress} |
| aria-label=${i18nString(UIStrings.brandUserAgentDelete)} |
| > |
| </devtools-icon> |
| </div> |
| `; |
| }); |
| return html` |
| <span class="full-row label">${i18nString(UIStrings.useragent)}</span> |
| ${brandElements} |
| <div |
| class="add-container full-row" |
| role="button" |
| tabindex="0" |
| id="add-brand-button" |
| aria-label=${i18nString(UIStrings.addBrand)} |
| @click=${this.#handleAddUseragentBrandClick} |
| @keypress=${this.#handleAddUseragentBrandKeyPress} |
| > |
| <devtools-icon |
| aria-hidden="true" name="plus" class="medium"> |
| </devtools-icon> |
| ${i18nString(UIStrings.addBrand)} |
| </div> |
| `; |
| } |
| |
| #renderFullVersionList(): Lit.TemplateResult { |
| const { |
| fullVersionList = |
| [ |
| { |
| brand: '', |
| version: '', |
| }, |
| ], |
| } = this.#metaData; |
| const elements = fullVersionList.map((brandRow, index) => { |
| const {brand, version} = brandRow; |
| const handleDeleteClick = (): void => { |
| this.#handleFullVersionListDelete(index); |
| }; |
| const handleKeyPress = (event: KeyboardEvent): void => { |
| if (event.code === 'Space' || event.code === 'Enter') { |
| event.preventDefault(); |
| handleDeleteClick(); |
| } |
| }; |
| const handleBrandChange = (event: KeyboardEvent): void => { |
| const value = (event.target as HTMLInputElement).value; |
| this.#handleFullVersionListInputChange(value, index, 'brandName'); |
| }; |
| const handleVersionChange = (event: KeyboardEvent): void => { |
| const value = (event.target as HTMLInputElement).value; |
| this.#handleFullVersionListInputChange(value, index, 'brandVersion'); |
| }; |
| return html` |
| <div |
| class="full-row brand-row" |
| aria-label=${i18nString(UIStrings.brandProperties)} |
| jslog=${VisualLogging.section('full-version')} |
| role="group"> |
| <input |
| class="input-field fvl-brand-name-input" |
| type="text" |
| @input=${handleBrandChange} |
| .value=${brand} |
| id="fvl-brand-${index + 1}-input" |
| placeholder=${i18nString(UIStrings.brandName)} |
| aria-label=${i18nString(UIStrings.brandNameAriaLabel, { |
| PH1: index + 1, |
| })} |
| jslog=${VisualLogging.textField('brand-name').track({ |
| change: true, |
| })} |
| /> |
| <input |
| class="input-field" |
| type="text" |
| @input=${handleVersionChange} |
| .value=${version} |
| placeholder=${i18nString(UIStrings.brandVersionPlaceholder)} |
| aria-label=${i18nString(UIStrings.brandVersionAriaLabel, { |
| PH1: index + 1, |
| })} |
| jslog=${VisualLogging.textField('brand-version').track({ |
| change: true, |
| })} |
| /> |
| <devtools-icon name="bin" |
| title=${i18nString(UIStrings.brandFullVersionListDelete)} |
| class="medium delete-icon" |
| tabindex="0" |
| role="button" |
| @click=${handleDeleteClick} |
| @keypress=${handleKeyPress} |
| aria-label=${i18nString(UIStrings.brandFullVersionListDelete)} |
| > |
| </devtools-icon> |
| </div> |
| `; |
| }); |
| return html` |
| <span class="full-row label">${i18nString(UIStrings.fullVersionList)}</span> |
| ${elements} |
| <div |
| class="add-container full-row" |
| role="button" |
| tabindex="0" |
| id="add-fvl-brand-button" |
| aria-label=${i18nString(UIStrings.addBrand)} |
| @click=${this.#handleAddFullVersionListBrandClick} |
| @keypress=${this.#handleAddFullVersionListBrandKeyPress} |
| > |
| <devtools-icon name="plus" class="medium" |
| aria-hidden="true"> |
| </devtools-icon> |
| ${i18nString(UIStrings.addBrand)} |
| </div> |
| `; |
| } |
| |
| #renderFormFactorsSection(): Lit.TemplateResult { |
| const checkboxElements = ALL_PROTOCOL_FORM_FACTORS.map(ffValue => { |
| const isChecked = this.#metaData.formFactors?.includes(ffValue) || false; |
| const labelStringId = `formFactor${ffValue}` as keyof typeof UIStrings; |
| const label = i18nString(UIStrings[labelStringId]); |
| |
| return html` |
| <label class="form-factor-checkbox-label"> |
| <input |
| type="checkbox" |
| .checked=${isChecked} |
| value=${ffValue} |
| jslog=${VisualLogging.toggle(Platform.StringUtilities.toKebabCase(ffValue)).track({ |
| click: true |
| })} |
| @change=${ |
| (e: Event) => this.#handleFormFactorCheckboxChange(ffValue, (e.target as HTMLInputElement).checked)} |
| /> |
| ${label} |
| </label> |
| `; |
| }); |
| |
| return html` |
| <span class="full-row label" jslog=${VisualLogging.sectionHeader('form-factors')}> |
| ${i18nString(UIStrings.formFactorsTitle)} |
| </span> |
| <div class="full-row form-factors-checkbox-group" role="group" aria-label=${ |
| i18nString(UIStrings.formFactorsGroupAriaLabel)}> |
| ${checkboxElements} |
| </div> |
| `; |
| } |
| |
| #render(): void { |
| const {fullVersion, architecture} = this.#metaData; |
| const useragentSection = this.#renderUseragent(); |
| const fullVersionListSection = this.#renderFullVersionList(); |
| const fullBrowserInput = this.#renderInputWithLabel( |
| i18nString(UIStrings.fullBrowserVersion), i18nString(UIStrings.fullBrowserVersionPlaceholder), |
| fullVersion || '', 'fullVersion'); |
| const formFactorsSection = this.#renderFormFactorsSection(); |
| const platformSection = this.#renderPlatformSection(); |
| const architectureInput = this.#renderInputWithLabel( |
| i18nString(UIStrings.architecture), i18nString(UIStrings.architecturePlaceholder), architecture, |
| 'architecture'); |
| const deviceModelSection = this.#renderDeviceModelSection(); |
| // clang-format off |
| const submitButton = this.#showSubmitButton ? html` |
| <devtools-button |
| .variant=${Buttons.Button.Variant.OUTLINED} |
| .type=${'submit'} |
| > |
| ${i18nString(UIStrings.update)} |
| </devtools-button> |
| ` : Lit.nothing; |
| // clang-format on |
| |
| // clang-format off |
| const output = html` |
| <style>${Input.checkboxStyles}</style> |
| <style>${userAgentClientHintsFormStyles}</style> |
| <section class="root"> |
| <div class="tree-title"> |
| <div |
| role=button |
| @click=${this.#handleTreeClick} |
| tabindex=${this.#isFormDisabled ? '-1' : '0'} |
| @keydown=${this.#handleTreeExpand} |
| aria-expanded=${this.#isFormOpened} |
| aria-controls=form-container |
| aria-disabled=${this.#isFormDisabled} |
| aria-label=${i18nString(UIStrings.title)} |
| jslog=${VisualLogging.toggleSubpane().track({click: true})}> |
| <devtools-icon name=triangle-right></devtools-icon> |
| <devtools-icon name=triangle-down></devtools-icon> |
| ${i18nString(UIStrings.title)} |
| </div> |
| <devtools-icon tabindex=${this.#isFormDisabled ? '-1' : '0'} class=info-icon name=info aria-label=${i18nString(UIStrings.userAgentClientHintsInfo)} title=${i18nString(UIStrings.userAgentClientHintsInfo)}></devtools-icon> |
| <devtools-link |
| tabindex=${this.#isFormDisabled ? '-1' : '0'} |
| href="https://web.dev/user-agent-client-hints/" |
| class="link" |
| aria-label=${i18nString(UIStrings.learnMore)} |
| jslogcontext="learn-more" |
| > |
| ${i18nString(UIStrings.learnMore)} |
| </devtools-link> |
| </div> |
| <form |
| id="form-container" |
| class="form-container ${this.#isFormOpened ? '' : 'hide-container'}" |
| @submit=${this.#handleSubmit} |
| > |
| ${useragentSection} |
| <hr class="section-separator"> |
| ${fullVersionListSection} |
| <hr class="section-separator"> |
| ${fullBrowserInput} |
| <hr class="section-separator"> |
| ${formFactorsSection} |
| <hr class="section-separator"> |
| ${platformSection} |
| <hr class="section-separator"> |
| ${architectureInput} |
| <hr class="section-separator"> |
| ${deviceModelSection} |
| ${submitButton} |
| </form> |
| <div aria-live="polite" aria-label=${this.#useragentModifiedAriaMessage}></div> |
| </section> |
| `; |
| // clang-format on |
| Lit.render(output, this.#shadow, {host: this}); |
| } |
| |
| validate = (): UI.ListWidget.ValidatorResult => { |
| for (const [metaDataKey, metaDataValue] of Object.entries(this.#metaData)) { |
| if (metaDataKey === 'brands' || metaDataKey === 'fullVersionList') { |
| // for structured fields, check each individual brand/version |
| 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 if (metaDataKey === 'formFactors') { |
| const formFactors = metaDataValue as string[] | undefined; |
| if (formFactors) { |
| for (const ff of formFactors) { |
| if (!ALL_PROTOCOL_FORM_FACTORS.includes(ff)) { |
| return { |
| valid: false, |
| errorMessage: i18nString(UIStrings.notRepresentable) + ` (Invalid form factor: ${ff})`, |
| }; |
| } |
| const ffError = EmulationUtils.UserAgentMetadata.validateAsStructuredHeadersString( |
| ff, i18nString(UIStrings.notRepresentable)); |
| if (!ffError.valid) { |
| return ffError; |
| } |
| } |
| } |
| } else { |
| // otherwise, validate the value as a string |
| const metaDataError = EmulationUtils.UserAgentMetadata.validateAsStructuredHeadersString( |
| metaDataValue, i18nString(UIStrings.notRepresentable)); |
| if (!metaDataError.valid) { |
| return metaDataError; |
| } |
| } |
| } |
| return {valid: true}; |
| }; |
| } |
| |
| customElements.define('devtools-user-agent-client-hints-form', UserAgentClientHintsForm); |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'devtools-user-agent-client-hints-form': UserAgentClientHintsForm; |
| } |
| } |