// eslint-disable-next-line @typescript-eslint/triple-slash-reference
///
/* eslint-disable no-undef */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ZuiOptionElement, ZuiSelectDropdownElement } from '@zywave/zui-select';
import { ZuiDialogElement } from '@zywave/zui-dialog';
import { assert } from '@esm-bundle/chai';
import { awaitEvent, randNumber, randString, sleep } from '../../../../test/src/util/helpers.js';
import { executeServerCommand, sendKeys } from '@web/test-runner-commands';
async function open(element: ZuiSelectDropdownElement) {
await element.updateComplete;
element.showPicker();
await sleep(10);
await element.updateComplete;
}
async function close(element: ZuiSelectDropdownElement) {
element.closest('body')?.click();
await sleep(10);
await element.updateComplete;
}
async function selectOption(element: ZuiSelectDropdownElement, index: number) {
await open(element);
const option = element.item(index);
assert.exists(option, `No option found with index ${index}.`);
option.selected = true;
await close(element);
return option.value;
}
async function toggleSelectAllOption(element: ZuiSelectDropdownElement) {
// forgive me, as I hack around the inability to simply "click" on DOM element
await open(element);
(element as any)._highlightedIndex = -1;
element.requestUpdate();
await element.updateComplete;
(element.shadowRoot?.querySelector('input') as HTMLElement).dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter' })
);
await close(element);
}
function generateOptions(element: ZuiSelectDropdownElement, optionCount: number) {
const result: ZuiOptionElement[] = [];
for (let i = 0; i < optionCount; i++) {
const option: ZuiOptionElement = document.createElement('zui-option');
option.value = randString();
option.innerText = randString();
element.append(option);
result.push(option);
}
return result;
}
suite('zui-select-dropdown', () => {
let element: ZuiSelectDropdownElement;
setup(() => {
element = document.createElement('zui-select-dropdown');
document.body.append(element);
});
teardown(() => {
element.remove();
});
test('initializes as a ZuiSelectDropdownElement', () => {
assert.instanceOf(element, ZuiSelectDropdownElement);
});
test('item method exposes matching option', async () => {
const options = generateOptions(element, 10);
await open(element);
for (let i = 0; i < 10; i++) {
const option = element.item(i);
assert.exists(option);
assert.equal(option.value, options[i].value);
assert.equal(option.label, options[i].innerHTML);
}
});
test('item method returns null when no options', async () => {
await open(element);
const option = element.item(0);
assert.isNull(option);
});
test('item method returns null when index out of bounds', async () => {
generateOptions(element, 10);
await open(element);
const option = element.item(11);
assert.isNull(option);
});
test('queryHandler is called with user input', async () => {
element.searchable = true;
let resultQuery: string | undefined;
element.queryHandler = (query, options) => {
resultQuery = query;
return options;
};
await element.updateComplete;
const text = randString();
await executeServerCommand('fill-text', { selector: `zui-select-dropdown input`, text });
await element.updateComplete;
assert.equal(resultQuery, text);
});
test('user input triggers query event', async () => {
element.searchable = true;
element.debounce = 1_000;
await element.updateComplete;
const text = randString();
const userInputPromise = executeServerCommand('fill-text', { selector: `zui-select-dropdown input`, text }).then(
() => element.updateComplete
);
let queryEvent: CustomEvent;
const queryPromise = awaitEvent(element, 'query').then((e) => (queryEvent = e as CustomEvent));
await Promise.all([userInputPromise, queryPromise]);
assert.exists(queryEvent);
assert.equal(queryEvent.detail, text);
});
test('empty groups are not rendered with hide-empty-groups attribute', async () => {
element.hideEmptyGroups = true;
const group1 = document.createElement('zui-option-group');
group1.label = 'Group 1';
const option1 = document.createElement('zui-option');
option1.value = 'option1';
option1.innerText = 'Test Option 1';
group1.append(option1);
element.append(group1);
const group2 = document.createElement('zui-option-group');
group2.label = 'Group 2';
element.append(group2);
element.requestUpdate();
await element.updateComplete;
await open(element);
const renderedOptionGroups = element.shadowRoot?.querySelectorAll('.group-label');
assert.exists(renderedOptionGroups);
assert.lengthOf(renderedOptionGroups, 1);
assert.equal(renderedOptionGroups?.[0].textContent?.trim(), 'Group 1');
});
suite('single select', () => {
const optionCount = 10;
setup(() => {
element.multiple = false;
generateOptions(element, optionCount);
});
suite('searchable behavior', () => {
setup(() => {
element.searchable = true;
});
// disabled test which violates encapsulation
test.skip('modifying input without clearing it does not modify selected property of option', async () => {
// // Choose an option that is NOT the first
// const optionIndex = 1 + randNumber(optionCount - 1);
// const option = element._zuiOptions[optionIndex];
// assert.exists(option, `No option found at index ${optionIndex}`);
// option.selected = true;
// element.requestUpdate();
// await element.updateComplete;
// const text = randString();
// element.shadowRoot?.querySelector('input')?.focus();
// sendKeys({
// type: text,
// });
// await awaitEvent(element.shadowRoot?.querySelector('input') as HTMLInputElement, 'input', 1000);
// await element.updateComplete;
// assert.isTrue(element._zuiOptions[optionIndex].selected);
});
test('modifying input value which can be parsed as falsy does not modify selected property of option', async () => {
const option: ZuiOptionElement = document.createElement('zui-option');
const almostFalsyValue = 'fals';
option.value = almostFalsyValue;
option.innerText = almostFalsyValue;
option.selected = true;
element.append(option);
element.requestUpdate();
await element.updateComplete;
element.shadowRoot?.querySelector('input')?.focus();
sendKeys({
type: 'e',
});
await awaitEvent(element.shadowRoot?.querySelector('input') as HTMLInputElement, 'input');
await element.updateComplete;
assert.isTrue(option.selected);
});
});
suite('form association', () => {
let form: HTMLFormElement;
const name = randString();
setup(() => {
form = document.createElement('form');
document.body.append(form);
form.append(element);
element.setAttribute('name', name);
});
teardown(() => {
form.remove();
});
test('selected option value included in form submission', async () => {
const value = await selectOption(element, randNumber(optionCount));
const formData = new FormData(form);
const formValues = formData.getAll(name);
assert.isNotEmpty(formValues);
assert.equal(formValues.length, 1);
assert.include(formValues, value);
});
test('only one selected option value included in form submission', async () => {
let value;
for (let i = 0; i < 3; i++) {
value = await selectOption(element, i);
}
const formData = new FormData(form);
const formValues = formData.getAll(name);
assert.isNotEmpty(formValues);
assert.equal(formValues.length, 1);
assert.include(formValues, value);
});
});
});
suite('multiselect', () => {
const optionCount = 10;
setup(() => {
element.multiple = true;
generateOptions(element, optionCount);
});
suite('truncation', () => {
test('maximumResultsDisplayCount defaults to 5', () => {
assert.equal(element.maximumResultsDisplayCount, 5);
});
test('truncatedResultMessageFormat defaults to null', () => {
assert.isNull(element.truncatedResultMessageFormat);
});
test('truncation does not occur when truncatedResultMessageFormat is null', async () => {
element.truncatedResultMessageFormat = null;
let selection = 6;
while (selection > 0) {
await selectOption(element, --selection);
}
assert.equal(element.shadowRoot?.querySelectorAll('.selection').length, 6);
assert.notExists(element.shadowRoot?.querySelector('.selection.truncated'));
});
test('truncation is applied when more than maximumResultsDisplayCount selected', async () => {
element.truncatedResultMessageFormat = '{0}';
element.maximumResultsDisplayCount = 2;
let selection = 3;
while (selection > 0) {
await selectOption(element, --selection);
}
assert.equal(element.shadowRoot?.querySelectorAll('.selection:not(.truncated)').length, 2);
assert.exists(element.shadowRoot?.querySelector('.selection.truncated'));
});
});
suite('enable-select-all validation', () => {
test('enable-select-all is false when multiple is not set', () => {
element.multiple = false;
element.selectAllOptionLabel = 'Select all';
element.enableSelectAll = true;
element.enableSelectAllOverride = false;
assert.isFalse(element.enableSelectAll);
});
test('enable-select-all is false when selectAllOptionLabel is not set', () => {
element.multiple = false;
element.selectAllOptionLabel = 'Select all';
element.enableSelectAll = true;
element.enableSelectAllOverride = false;
assert.isFalse(element.enableSelectAll);
});
suite('enable-select-all-override is true', () => {
setup(() => {
element.enableSelectAllOverride = true;
element.selectAllOptionLabel = 'Select all';
element.enableSelectAll = true;
element.selectAllOptionValue = '';
element.selectAllResultLabel = 'All selected';
});
test('enable-select-all is false when selectAllOptionValue is not set', () => {
element.selectAllOptionValue = null;
assert.isFalse(element.enableSelectAll);
});
test('enable-select-all is false when selectAllResultLabel is not set', () => {
element.selectAllResultLabel = null;
assert.isFalse(element.enableSelectAll);
});
test('enable-select-all is true', () => {
assert.isTrue(element.enableSelectAll);
});
});
suite('enable-select-all-override is false', () => {
setup(() => {
element.enableSelectAllOverride = false;
element.selectAllOptionLabel = 'Select all';
element.enableSelectAll = true;
});
test('enable-select-all is true', () => {
assert.isTrue(element.enableSelectAll);
});
});
});
suite('select all behavior', () => {
setup(() => {
element.enableSelectAllOverride = false;
element.selectAllOptionLabel = 'Select all';
element.enableSelectAll = true;
});
suite('enable-select-all-override is false', () => {
test('when user selects all other options manually, select all option is checked', async () => {
let selection = optionCount;
while (selection > 0) {
await selectOption(element, --selection);
}
assert.equal(element.shadowRoot?.querySelectorAll('.selection').length, optionCount);
assert.isTrue(element.allSelected);
await open(element);
assert.exists(element.shadowRoot?.querySelector('.option.select-all.selected'));
});
test('when user chooses select all option, all options selected in result container', async () => {
await toggleSelectAllOption(element);
assert.equal(element.shadowRoot?.querySelectorAll('.selection').length, optionCount);
assert.isTrue(element.allSelected);
await open(element);
assert.exists(element.shadowRoot?.querySelector('.option.select-all.selected'));
});
});
suite('enable-select-all-override is true', () => {
setup(() => {
element.enableSelectAllOverride = true;
element.selectAllOptionValue = '';
element.selectAllResultLabel = 'All selected';
});
test('when user selects all other options manually, selectAllResultLabel is not rendered instead', async () => {
let selection = optionCount;
while (selection > 0) {
await selectOption(element, --selection);
}
assert.equal(element.shadowRoot?.querySelectorAll('.selection').length, optionCount);
assert.notExists(element.shadowRoot?.querySelector('.selection.all-selected'));
assert.isFalse(element.allSelected);
});
test('when user chooses select all option, selectAllResultLabel is rendered instead', async () => {
await toggleSelectAllOption(element);
assert.equal(element.shadowRoot?.querySelectorAll('.selection').length, 1);
assert.exists(element.shadowRoot?.querySelector('.selection.all-selected'));
assert.isTrue(element.allSelected);
});
test('when user chooses select all option, selectAllOptionValue is only value in form submission', async () => {
const form = document.createElement('form');
element.setAttribute('name', 'test');
form.append(element);
await toggleSelectAllOption(element);
const formData = new FormData(form);
const values = formData.getAll('test');
assert.equal(values.length, 1);
assert.include(values, element.selectAllOptionValue);
form.remove();
});
});
});
suite('form association', () => {
let form: HTMLFormElement;
const name = randString();
setup(() => {
form = document.createElement('form');
document.body.append(form);
form.append(element);
element.setAttribute('name', name);
});
teardown(() => {
form.remove();
});
test('multiple selected option values included in form submission', async () => {
const values: string[] = [];
for (let i = 0; i < 3; i++) {
const value = await selectOption(element, i);
values.push(value);
}
const formData = new FormData(form);
const formValues = formData.getAll(name);
assert.isNotEmpty(formValues);
assert.equal(formValues.length, values.length);
assert.includeMembers(formValues, values);
});
});
test('clear() properly clears all selected options', async () => {
const values: string[] = [];
for (let i = 0; i < 3; i++) {
const value = await selectOption(element, i);
values.push(value);
}
assert.equal((element as any)._formValue?.length, values.length);
element.clear();
await element.updateComplete;
assert.isNull((element as any)._formValue);
});
});
suite('zui-select-dropdown in dialogs', () => {
let dialog: ZuiDialogElement;
setup(() => {
dialog = document.createElement('zui-dialog') as ZuiDialogElement;
document.body.append(dialog);
});
teardown(() => {
dialog.remove();
});
test('zui-select-dropdown is in a scrolling dialog', async () => {
const longContentDiv = document.createElement('div');
longContentDiv.setAttribute('slot', 'content');
longContentDiv.style.height = '10000px';
longContentDiv.append(element);
dialog.append(longContentDiv);
dialog.open();
await dialog.updateComplete;
assert.isTrue(dialog.opened, 'zui-dialog is opened');
const dialogIsScrolling = dialog.shadowRoot?.querySelector('dialog')?.classList.contains('scrolling');
assert.exists(dialogIsScrolling, 'zui-dialog is scrolling');
assert.exists(dialog.querySelector('zui-select-dropdown'), 'zui-select-dropdown is, in fact, in zui-dialog');
await open(element);
assert.exists(
element.shadowRoot?.querySelector('.in-scrolling-dialog'),
'zui-select-dropdown is in a dialog that scrolls, therefore the wrapper has the class "in-scrolling-dialog"'
);
});
test('zui-select-dropdown is in a dialog that is not scrolling', async () => {
const dialogIsScrolling = dialog.shadowRoot?.querySelector('dialog')?.classList.contains('scrolling');
dialog.append(element);
dialog.open();
await open(element);
assert.isTrue(dialog.opened, 'zui-dialog is opened');
assert.notExists(dialogIsScrolling, 'zui-dialog is not scrolling');
assert.exists(dialog.querySelector('zui-select-dropdown'), 'zui-select-dropdown is, in fact, in zui-dialog');
assert.notExists(
dialog.shadowRoot?.querySelector('.in-scrolling-dialog'),
'zui-select-dropdown is in a dialog that does not scroll'
);
});
});
suite('initial state detection', () => {
let element: ZuiSelectDropdownElement;
const optionCount = 10;
setup(() => {
element = document.createElement('zui-select-dropdown');
element.multiple = true;
element.enableSelectAll = true;
element.selectAllOptionLabel = 'Select all';
document.body.append(element);
});
teardown(() => {
element.remove();
});
test('correctly detects when all options are initially selected', async () => {
// Generate options and set them all as selected
const options = generateOptions(element, optionCount);
options.forEach((option) => {
option.selected = true;
});
// Wait for the element to initialize
await element.updateComplete;
// Verify the initial state
assert.isTrue(element.allSelected);
await open(element);
assert.exists(element.shadowRoot?.querySelector('.option.select-all.selected'));
await toggleSelectAllOption(element);
assert.isFalse(element.allSelected);
// check if each option is selected
options.forEach((option) => {
assert.isFalse(option.selected);
});
});
});
suite('async loading with queryHandler', () => {
let element: ZuiSelectDropdownElement;
let resolveQuery: (value: any) => void;
setup(() => {
element = document.createElement('zui-select-dropdown');
element.searchable = true;
document.body.append(element);
});
teardown(() => {
element.remove();
});
test('does not show "no results found" message while async loading', async () => {
// Set up a queryHandler that returns a promise we can control
element.queryHandler = () => {
return new Promise((resolve) => {
resolveQuery = resolve;
});
};
await element.updateComplete;
// Open the dropdown to trigger the queryHandler
await open(element);
// At this point, the queryHandler has been called but not resolved
// The "no results found" message should NOT be visible
const noResultsMessage = element.shadowRoot?.querySelector('.option.readonly');
assert.notExists(noResultsMessage, 'No results message should not be shown while loading');
// Verify the spinner is shown instead
const spinner = element.shadowRoot?.querySelector('zui-spinner[active]');
assert.exists(spinner, 'Spinner should be shown while loading');
// Now resolve the query with no results
resolveQuery([]);
await element.updateComplete;
await sleep(10);
// After async loading completes with no results, the message SHOULD be shown
const noResultsMessageAfter = element.shadowRoot?.querySelector('.option.readonly');
assert.exists(
noResultsMessageAfter,
'No results message should be shown after loading completes with no results'
);
assert.equal(noResultsMessageAfter?.textContent, 'No results');
});
test('shows options after async loading completes with results', async () => {
// Set up a queryHandler that returns a promise we can control
element.queryHandler = () => {
return new Promise((resolve) => {
resolveQuery = resolve;
});
};
await element.updateComplete;
// Open the dropdown to trigger the queryHandler
await open(element);
// The "no results found" message should NOT be visible while loading
const noResultsMessageWhileLoading = element.shadowRoot?.querySelector('.option.readonly');
assert.notExists(noResultsMessageWhileLoading, 'No results message should not be shown while loading');
// Resolve the query with some results
resolveQuery([
{ label: 'Option 1', value: 'opt1' },
{ label: 'Option 2', value: 'opt2' },
]);
await element.updateComplete;
await sleep(10);
// After async loading completes with results, options should be shown
const options = element.shadowRoot?.querySelectorAll('.option:not(.readonly)');
assert.exists(options);
assert.isAtLeast(options.length, 2, 'Options should be shown after loading completes');
// No results message should NOT be shown
const noResultsMessageAfter = element.shadowRoot?.querySelector('.option.readonly');
assert.notExists(noResultsMessageAfter, 'No results message should not be shown when options are available');
});
});
});