| // 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. | |
| import {assert} from 'chai'; | |
| import {describe, it} from 'mocha'; | |
| import { | |
| dispatchClickEvent, | |
| getEventPromise, | |
| renderElementIntoDOM, | |
| } from '../../../testing/DOMHelpers.js'; | |
| import {setupLocaleHooks} from '../../../testing/LocaleHelpers.js'; | |
| import * as Lit from '../../lit/lit.js'; | |
| import * as RenderCoordinator from '../render_coordinator/render_coordinator.js'; | |
| import * as Lists from './lists.js'; | |
| const {html, render} = Lit; | |
| async function renderListComponent(items: Lit.TemplateResult, editable?: boolean, | |
| deletable?: boolean): Promise<Lists.List.List> { | |
| const component = new Lists.List.List(); | |
| if (editable) { | |
| component.editable = true; | |
| } | |
| if (deletable) { | |
| component.deletable = true; | |
| } | |
| render(items, component); | |
| renderElementIntoDOM(component); | |
| await RenderCoordinator.done(); | |
| return component; | |
| } | |
| describe('List', () => { | |
| setupLocaleHooks(); | |
| it('renders items from light DOM and assigns slot attributes', async () => { | |
| const component = await renderListComponent(html` | |
| <p>Item 1</p> | |
| <div>Item 2</div> | |
| `); | |
| assert.isNotNull(component.shadowRoot); | |
| const listItems = component.shadowRoot.querySelectorAll('li[role="listitem"]'); | |
| assert.lengthOf(listItems, 2, 'Should render two list items'); | |
| const item1Child = component.querySelector('p'); | |
| const item2Child = component.querySelector('div'); | |
| assert.strictEqual(item1Child?.getAttribute('slot'), 'slot-0', 'First child must have slot="slot-0"'); | |
| assert.strictEqual(item2Child?.getAttribute('slot'), 'slot-1', 'Second child must have slot="slot-1"'); | |
| assert.strictEqual(listItems[0].querySelector('slot')?.assignedNodes()[0].textContent, 'Item 1'); | |
| assert.strictEqual(listItems[1].querySelector('slot')?.assignedNodes()[0].textContent, 'Item 2'); | |
| }); | |
| it('shows no control buttons per default', async () => { | |
| const component = await renderListComponent(html` | |
| <p>Item 1</p> | |
| `); | |
| assert.isNotNull(component.shadowRoot); | |
| const buttons = component.shadowRoot.querySelectorAll('devtools-button'); | |
| assert.lengthOf(buttons, 0, 'No buttons should be rendered by default'); | |
| }); | |
| it('renders li items with tabindex=0 per default for keyboard navigation', async () => { | |
| const component = await renderListComponent(html`<p>Item 1</p>`); | |
| assert.isNotNull(component.shadowRoot); | |
| const listItem = component.shadowRoot.querySelector('li[role="listitem"]'); | |
| assert.isNotNull(listItem, 'List item wrapper should exist'); | |
| assert.strictEqual(listItem.getAttribute('tabindex'), '0', 'List item should be focusable by default'); | |
| }); | |
| it('shows only the edit button when editable is set', async () => { | |
| const component = await renderListComponent(html` | |
| <p>Item 1</p> | |
| `, | |
| true); | |
| assert.isNotNull(component.shadowRoot); | |
| const buttons = component.shadowRoot.querySelectorAll('devtools-button'); | |
| assert.lengthOf(buttons, 1, 'Exactly one button should be rendered'); | |
| const editButton = component.shadowRoot.querySelector('devtools-button[title="Edit"]'); | |
| assert.isNotNull(editButton, 'Edit button should be present'); | |
| }); | |
| it('shows only the remove button when deletable is set', async () => { | |
| const component = await renderListComponent(html` | |
| <p>Item 1</p> | |
| `, | |
| undefined, true); | |
| assert.isNotNull(component.shadowRoot); | |
| const buttons = component.shadowRoot.querySelectorAll('devtools-button'); | |
| assert.lengthOf(buttons, 1, 'Exactly one button should be rendered'); | |
| const removeButton = component.shadowRoot.querySelector('devtools-button[title="Remove"]'); | |
| assert.isNotNull(removeButton, 'Remove button should be present'); | |
| }); | |
| it('omits tabindex when list item focus is disabled via attribute', async () => { | |
| const component = await renderListComponent(html`<p>Item 1</p>`); | |
| assert.isNotNull(component.shadowRoot); | |
| let listItem = component.shadowRoot.querySelector('li[role="listitem"]'); | |
| assert.strictEqual(listItem?.getAttribute('tabindex'), '0', 'Initial state must be focusable'); | |
| component.setAttribute('disable-li-focus', 'true'); | |
| await RenderCoordinator.done(); | |
| listItem = component.shadowRoot.querySelector('li[role="listitem"]'); | |
| assert.strictEqual(listItem?.getAttribute('tabindex'), '-1', 'Tabindex must be -1 when attribute is set'); | |
| component.removeAttribute('disable-li-focus'); | |
| await RenderCoordinator.done(); | |
| listItem = component.shadowRoot.querySelector('li[role="listitem"]'); | |
| assert.strictEqual(listItem?.getAttribute('tabindex'), '0', 'Tabindex must return when attribute is removed'); | |
| }); | |
| it('shows both edit and remove buttons when both flags are set', async () => { | |
| const component = await renderListComponent(html`<p>Item 1</p>`, true, true); | |
| assert.isNotNull(component.shadowRoot); | |
| const buttons = component.shadowRoot.querySelectorAll('devtools-button'); | |
| assert.lengthOf(buttons, 2, 'Exactly two buttons should be rendered'); | |
| assert.isNotNull(component.shadowRoot.querySelector('devtools-button[title="Edit"]')); | |
| assert.isNotNull(component.shadowRoot.querySelector('devtools-button[title="Remove"]')); | |
| }); | |
| it('supports per-item editable and deletable attributes on child elements', async () => { | |
| const component = await renderListComponent(html` | |
| <p>Read-only item</p> | |
| <p editable deletable>Custom item</p> | |
| `); | |
| assert.isNotNull(component.shadowRoot); | |
| const listItems = component.shadowRoot.querySelectorAll('li[role="listitem"]'); | |
| assert.lengthOf(listItems, 2); | |
| assert.isNull(listItems[0].querySelector('.controls-container')); | |
| assert.isNotNull(listItems[1].querySelector('devtools-button[title="Edit"]')); | |
| assert.isNotNull(listItems[1].querySelector('devtools-button[title="Remove"]')); | |
| let deletedIndex = -1; | |
| component.addEventListener('delete', event => { | |
| deletedIndex = event.detail.index; | |
| }); | |
| listItems[0].dispatchEvent( | |
| new KeyboardEvent('keydown', {bubbles: true, cancelable: true, composed: true, key: 'Delete'})); | |
| assert.strictEqual(deletedIndex, -1); | |
| listItems[1].dispatchEvent( | |
| new KeyboardEvent('keydown', {bubbles: true, cancelable: true, composed: true, key: 'Delete'})); | |
| assert.strictEqual(deletedIndex, 1); | |
| const customChild = component.querySelectorAll('p')[1]; | |
| customChild.removeAttribute('editable'); | |
| customChild.removeAttribute('deletable'); | |
| await RenderCoordinator.done(); | |
| assert.isNull(component.shadowRoot.querySelectorAll('li[role="listitem"]')[1].querySelector('.controls-container')); | |
| }); | |
| it('dispatches an "edit" event when the edit button is clicked', async () => { | |
| const component = await renderListComponent(html` | |
| <p>Item 1</p> | |
| <p>Item 2</p> | |
| `, | |
| true, false); | |
| assert.isNotNull(component.shadowRoot); | |
| const listItems = component.shadowRoot.querySelectorAll('li'); | |
| const secondItem = listItems[1]; | |
| const editButton = secondItem.querySelector<HTMLElement>('devtools-button[title="Edit"]'); | |
| assert.instanceOf(editButton, HTMLElement); | |
| const eventPromise = getEventPromise<Lists.List.ItemEditEvent>(component, 'edit'); | |
| dispatchClickEvent(editButton); | |
| const event = await eventPromise; | |
| assert.deepEqual(event.detail, {index: 1}); | |
| }); | |
| it('dispatches a "delete" event when the remove button is clicked', async () => { | |
| const component = await renderListComponent(html` | |
| <p>Item 1</p> | |
| <p>Item 2</p> | |
| `, | |
| false, true); | |
| assert.isNotNull(component.shadowRoot); | |
| const listItems = component.shadowRoot.querySelectorAll('li'); | |
| const firstItem = listItems[0]; | |
| const removeButton = firstItem.querySelector<HTMLElement>('devtools-button[title="Remove"]'); | |
| assert.instanceOf(removeButton, HTMLElement); | |
| const eventPromise = getEventPromise<Lists.List.ItemRemoveEvent>(component, 'delete'); | |
| dispatchClickEvent(removeButton); | |
| const event = await eventPromise; | |
| assert.deepEqual(event.detail, {index: 0}); | |
| }); | |
| it('dispatches a "delete" event when Delete is pressed on a list item', async () => { | |
| const component = await renderListComponent(html` | |
| <p>Item 1</p> | |
| <p>Item 2</p> | |
| `, | |
| false, true); | |
| assert.isNotNull(component.shadowRoot); | |
| const secondItem = component.shadowRoot.querySelectorAll('li')[1]; | |
| const eventPromise = getEventPromise<Lists.List.ItemRemoveEvent>(component, 'delete'); | |
| const deleteEvent = new KeyboardEvent('keydown', {bubbles: true, cancelable: true, composed: true, key: 'Delete'}); | |
| secondItem.dispatchEvent(deleteEvent); | |
| const event = await eventPromise; | |
| assert.deepEqual(event.detail, {index: 1}); | |
| assert.isTrue(deleteEvent.defaultPrevented); | |
| const backspaceEventPromise = getEventPromise<Lists.List.ItemRemoveEvent>(component, 'delete'); | |
| const backspaceEvent = | |
| new KeyboardEvent('keydown', {bubbles: true, cancelable: true, composed: true, key: 'Backspace'}); | |
| secondItem.dispatchEvent(backspaceEvent); | |
| const backspaceEventDetail = await backspaceEventPromise; | |
| assert.deepEqual(backspaceEventDetail.detail, {index: 1}); | |
| assert.isTrue(backspaceEvent.defaultPrevented); | |
| }); | |
| it('adds an item when a new child is appended', async () => { | |
| const component = await renderListComponent(html` | |
| <p>Item 1</p> | |
| `); | |
| assert.isNotNull(component.shadowRoot); | |
| let listItems = component.shadowRoot.querySelectorAll('li'); | |
| assert.lengthOf(listItems, 1); | |
| const newItem = document.createElement('p'); | |
| newItem.textContent = 'Item 2'; | |
| component.appendChild(newItem); | |
| await RenderCoordinator.done(); | |
| listItems = component.shadowRoot.querySelectorAll('li'); | |
| assert.lengthOf(listItems, 2, 'Should have two items after adding one'); | |
| assert.strictEqual(listItems[1].querySelector('slot')?.assignedNodes()[0].textContent, 'Item 2'); | |
| }); | |
| it('removes an item when a child is removed', async () => { | |
| const component = await renderListComponent(html` | |
| <p>Item 1</p> | |
| <p>Item 2</p> | |
| `); | |
| assert.isNotNull(component.shadowRoot); | |
| let listItems = component.shadowRoot.querySelectorAll('li'); | |
| assert.lengthOf(listItems, 2); | |
| const itemToRemove = component.querySelector('p:last-child'); | |
| assert.instanceOf(itemToRemove, HTMLParagraphElement); | |
| itemToRemove.remove(); | |
| await RenderCoordinator.done(); | |
| listItems = component.shadowRoot.querySelectorAll('li'); | |
| assert.lengthOf(listItems, 1, 'Should have one item after removing one'); | |
| assert.strictEqual(listItems[0].querySelector('slot')?.assignedNodes()[0].textContent, 'Item 1'); | |
| }); | |
| }); |