| // Copyright 2025 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, @devtools/enforce-custom-element-definitions-location */ |
| |
| import * as i18n from '../../../core/i18n/i18n.js'; |
| import {Directives, html, nothing, render, type TemplateResult} from '../../lit/lit.js'; |
| import * as Buttons from '../buttons/buttons.js'; |
| |
| import listStyles from './list.css.js'; |
| |
| const UIStrings = { |
| /** |
| * @description Title of the edit button for the list items. |
| */ |
| edit: 'Edit', |
| /** |
| * @description Title of the remove button for the list items. |
| */ |
| remove: 'Remove', |
| } as const; |
| |
| const str_ = i18n.i18n.registerUIStrings('ui/components/list/List.ts', UIStrings); |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); |
| |
| export interface ListItemEventDetail { |
| index: number; |
| } |
| |
| export class ItemEditEvent extends CustomEvent<ListItemEventDetail> { |
| constructor(detail: ListItemEventDetail) { |
| super('edit', { |
| bubbles: true, |
| composed: true, |
| detail, |
| }); |
| } |
| } |
| |
| export class ItemRemoveEvent extends CustomEvent<ListItemEventDetail> { |
| constructor(detail: ListItemEventDetail) { |
| super('delete', { |
| bubbles: true, |
| composed: true, |
| detail, |
| }); |
| } |
| } |
| |
| export class List extends HTMLElement { |
| static observedAttributes: string[] = ['editable', 'deletable', 'disable-li-focus']; |
| #observer: MutationObserver; |
| #editable = false; |
| #deletable = false; |
| #disableListItemFocus?: boolean; |
| |
| constructor() { |
| super(); |
| this.attachShadow({mode: 'open'}); |
| this.#observer = new MutationObserver(this.#render.bind(this)); |
| } |
| |
| set editable(isEditable: boolean) { |
| if (this.#editable === isEditable) { |
| return; |
| } |
| this.#editable = isEditable; |
| this.#render(); |
| } |
| |
| set deletable(isDeletable: boolean) { |
| if (this.#deletable === isDeletable) { |
| return; |
| } |
| this.#deletable = isDeletable; |
| this.#render(); |
| } |
| |
| set disableListItemFocus(disableFocus: boolean) { |
| if (this.#disableListItemFocus === disableFocus) { |
| return; |
| } |
| this.#disableListItemFocus = disableFocus; |
| this.#render(); |
| } |
| |
| attributeChangedCallback(name: string, oldValue: string|null, newValue: string|null): void { |
| const isSet = newValue !== null; |
| if (name === 'editable') { |
| this.editable = isSet; |
| } else if (name === 'deletable') { |
| this.deletable = isSet; |
| } else if (name === 'disable-li-focus') { |
| this.disableListItemFocus = isSet; |
| } |
| } |
| |
| connectedCallback(): void { |
| this.#observer.observe(this, {childList: true}); |
| this.#render(); |
| } |
| |
| disconnectedCallback(): void { |
| this.#observer.disconnect(); |
| } |
| |
| createSlottedListItem(index: number): TemplateResult { |
| return html` |
| <li role='listitem' tabindex=${this.#disableListItemFocus ? '-1' : '0'}> |
| <slot name='slot-${index}'></slot> |
| <div class='controls-container'> |
| <div class='controls-gradient'></div> |
| <div class='controls-buttons'> |
| ${ |
| this.#editable ? html` |
| <devtools-button |
| title=${i18nString(UIStrings.edit)} |
| aria-label=${i18nString(UIStrings.edit)} |
| .iconName=${'edit'} |
| .jslogContext=${'edit-item'} |
| .variant=${Buttons.Button.Variant.ICON} |
| @click=${this.#dispatchEdit.bind(this, index)} |
| ></devtools-button> |
| ` : |
| nothing} |
| ${ |
| this.#deletable ? html` |
| <devtools-button |
| title=${i18nString(UIStrings.remove)} |
| aria-label=${i18nString(UIStrings.remove)} |
| .iconName=${'bin'} |
| .jslogContext=${'remove-item'} |
| .variant=${Buttons.Button.Variant.ICON} |
| @click=${this.#dispatchRemove.bind(this, index)} |
| ></devtools-button> |
| ` : |
| nothing} |
| </div> |
| </div> |
| </li>`; |
| } |
| |
| #render(): void { |
| if (this.shadowRoot) { |
| const items = [...this.children] as HTMLElement[]; |
| const listData = items.map((item, index) => { |
| const slotName = `slot-${index}`; |
| if (item.getAttribute('slot') !== slotName) { |
| item.setAttribute('slot', slotName); |
| } |
| return {index, item}; |
| }); |
| render( |
| html` |
| <style>${listStyles}</style> |
| <ul role='list'> |
| ${Directives.repeat(listData, data => data.item, data => this.createSlottedListItem(data.index))} |
| </ul> |
| `, |
| this.shadowRoot); |
| } |
| } |
| |
| #dispatchRemove(index: number): void { |
| this.dispatchEvent(new ItemRemoveEvent({index})); |
| } |
| |
| #dispatchEdit(index: number): void { |
| this.dispatchEvent(new ItemEditEvent({index})); |
| } |
| } |
| |
| customElements.define('devtools-list', List); |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'devtools-list': List; |
| } |
| } |
| |
| declare global { |
| interface HTMLElementEventMap extends Record<'delete', ItemRemoveEvent>, Record<'edit', ItemEditEvent> {} |
| } |