blob: 18b74bee1f626dfdbdf0aa9319b54825062c696f [file]
// 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');
});
});