UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

998 lines 55.9 kB
import { createInjector } from '@furystack/inject'; import { createComponent, flushUpdates, initializeShadeRoot } from '@furystack/shades'; import { usingAsync } from '@furystack/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Select } from './select.js'; describe('Select', () => { beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; }); afterEach(() => { document.body.innerHTML = ''; vi.restoreAllMocks(); }); const defaultOptions = [ { value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }, { value: 'c', label: 'Gamma' }, ]; const renderSelect = async (props = { options: defaultOptions }) => { const injector = createInjector(); const root = document.getElementById('root'); initializeShadeRoot({ injector, rootElement: root, jsxElement: createComponent(Select, { ...props }), }); await flushUpdates(); return { injector, select: document.querySelector('shade-select'), [Symbol.asyncDispose]: () => injector[Symbol.asyncDispose](), }; }; describe('types', () => { describe('SelectOption', () => { it('Should accept a basic option', () => { const option = { value: 'a', label: 'Alpha' }; expect(option.value).toBe('a'); expect(option.label).toBe('Alpha'); expect(option.disabled).toBeUndefined(); }); it('Should accept a disabled option', () => { const option = { value: 'a', label: 'Alpha', disabled: true }; expect(option.disabled).toBe(true); }); }); describe('SelectOptionGroup', () => { it('Should accept a group with label and options', () => { const group = { label: 'Fruits', options: [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, ], }; expect(group).toHaveProperty('label', 'Fruits'); expect(group).toHaveProperty('options'); expect(group.options).toHaveLength(2); }); it('Should accept an empty group', () => { const group = { label: 'Empty', options: [], }; expect(group).toHaveProperty('options'); expect(group.options).toHaveLength(0); }); }); describe('SelectState', () => { it('Should have all required state fields for single mode', () => { const state = { value: 'test', isOpen: false, highlightedIndex: -1, searchText: '', }; expect(state.value).toBe('test'); expect(state.isOpen).toBe(false); expect(state.highlightedIndex).toBe(-1); expect(state.searchText).toBe(''); }); it('Should accept string[] value for multiple mode', () => { const state = { value: ['a', 'b', 'c'], isOpen: true, highlightedIndex: 0, searchText: 'search', }; expect(state.value).toEqual(['a', 'b', 'c']); expect(state.searchText).toBe('search'); }); }); describe('SelectProps', () => { it('Should accept minimal props', () => { const props = { options: [{ value: 'a', label: 'Alpha' }], }; expect(props.options).toHaveLength(1); }); it('Should accept full single-select props', () => { const props = { options: [ { value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta', disabled: true }, ], value: 'a', placeholder: 'Choose...', disabled: false, required: true, labelTitle: 'My Select', variant: 'outlined', defaultColor: 'primary', name: 'mySelect', showSearch: false, onValueChange: () => { }, getValidationResult: () => ({ isValid: true }), getHelperText: () => 'Pick one', }; expect(props.options).toHaveLength(2); expect(props.value).toBe('a'); expect(props.variant).toBe('outlined'); expect(props.required).toBe(true); }); it('Should accept multiple mode props', () => { const onMultiValueChange = vi.fn(); const props = { options: [ { value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }, { value: 'c', label: 'Gamma' }, ], mode: 'multiple', value: ['a', 'c'], placeholder: 'Select multiple...', onMultiValueChange, }; expect(props.mode).toBe('multiple'); expect(props.value).toEqual(['a', 'c']); }); it('Should accept searchable props', () => { const filterOption = vi.fn().mockReturnValue(true); const props = { options: [{ value: 'a', label: 'Alpha' }], showSearch: true, filterOption, }; expect(props.showSearch).toBe(true); filterOption('test', { value: 'a', label: 'Alpha' }); expect(filterOption).toHaveBeenCalledWith('test', { value: 'a', label: 'Alpha' }); expect(filterOption).toHaveReturnedWith(true); }); it('Should accept optionGroups', () => { const props = { optionGroups: [ { label: 'Fruits', options: [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, ], }, { label: 'Vegetables', options: [ { value: 'carrot', label: 'Carrot' }, { value: 'potato', label: 'Potato' }, ], }, ], }; expect(props.optionGroups).toHaveLength(2); expect(props.optionGroups?.[0]).toHaveProperty('label', 'Fruits'); expect(props.optionGroups?.[0].options).toHaveLength(2); }); it('Should accept combined options and optionGroups', () => { const props = { options: [{ value: 'none', label: 'None' }], optionGroups: [ { label: 'Group A', options: [{ value: 'a1', label: 'A1' }], }, ], }; expect(props.options).toHaveLength(1); expect(props.optionGroups).toHaveLength(1); }); it('Should accept all enhancements together', () => { const props = { optionGroups: [ { label: 'Group 1', options: [{ value: 'g1a', label: 'G1-A' }], }, ], mode: 'multiple', showSearch: true, value: ['g1a'], filterOption: (text, opt) => opt.label.startsWith(text), onMultiValueChange: () => { }, onValueChange: () => { }, }; expect(props.mode).toBe('multiple'); expect(props.showSearch).toBe(true); expect(props.optionGroups).toHaveLength(1); }); }); }); describe('rendering', () => { it('should render the select element', async () => { await usingAsync(await renderSelect(), async ({ select }) => { expect(select).not.toBeNull(); expect(select.tagName.toLowerCase()).toBe('shade-select'); }); }); it('should render a hidden input for form integration', async () => { await usingAsync(await renderSelect({ options: defaultOptions, name: 'myField' }), async ({ select }) => { const input = select.querySelector('input[type="hidden"]'); expect(input).not.toBeNull(); expect(input.name).toBe('myField'); }); }); it('should render a label title', async () => { await usingAsync(await renderSelect({ options: defaultOptions, labelTitle: 'Choose one' }), async ({ select }) => { expect(select.textContent).toContain('Choose one'); }); }); it('should show placeholder when no value is selected', async () => { await usingAsync(await renderSelect({ options: defaultOptions, placeholder: 'Pick something' }), async ({ select }) => { const valueEl = select.querySelector('.select-value'); expect(valueEl?.textContent).toContain('Pick something'); expect(valueEl?.hasAttribute('data-placeholder')).toBe(true); }); }); it('should show the selected value label', async () => { await usingAsync(await renderSelect({ options: defaultOptions, value: 'b' }), async ({ select }) => { const valueEl = select.querySelector('.select-value'); expect(valueEl?.textContent).toContain('Beta'); expect(valueEl?.hasAttribute('data-placeholder')).toBe(false); }); }); it('should render the arrow indicator', async () => { await usingAsync(await renderSelect(), async ({ select }) => { const arrow = select.querySelector('.select-arrow'); expect(arrow).not.toBeNull(); }); }); }); describe('variant and data attributes', () => { it('should set data-variant="outlined" when variant is outlined', async () => { await usingAsync(await renderSelect({ options: defaultOptions, variant: 'outlined' }), async ({ select }) => { expect(select.getAttribute('data-variant')).toBe('outlined'); }); }); it('should set data-variant="contained" when variant is contained', async () => { await usingAsync(await renderSelect({ options: defaultOptions, variant: 'contained' }), async ({ select }) => { expect(select.getAttribute('data-variant')).toBe('contained'); }); }); it('should not set data-variant when no variant is provided', async () => { await usingAsync(await renderSelect({ options: defaultOptions }), async ({ select }) => { expect(select.hasAttribute('data-variant')).toBe(false); }); }); }); describe('disabled state', () => { it('should set data-disabled when disabled', async () => { await usingAsync(await renderSelect({ options: defaultOptions, disabled: true }), async ({ select }) => { expect(select.hasAttribute('data-disabled')).toBe(true); }); }); it('should not set data-disabled when not disabled', async () => { await usingAsync(await renderSelect({ options: defaultOptions, disabled: false }), async ({ select }) => { expect(select.hasAttribute('data-disabled')).toBe(false); }); }); it('should not open dropdown when disabled and trigger is clicked', async () => { await usingAsync(await renderSelect({ options: defaultOptions, disabled: true }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); expect(select.hasAttribute('data-open')).toBe(false); }); }); }); describe('opening and closing dropdown', () => { it('should open dropdown when trigger is clicked', async () => { await usingAsync(await renderSelect(), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); expect(select.hasAttribute('data-open')).toBe(true); const dropdown = select.querySelector('.dropdown'); expect(dropdown).not.toBeNull(); }); }); it('should show options when open', async () => { await usingAsync(await renderSelect(), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const items = select.querySelectorAll('.dropdown-item'); expect(items.length).toBe(3); }); }); it('should close dropdown when backdrop is clicked', async () => { await usingAsync(await renderSelect(), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); expect(select.hasAttribute('data-open')).toBe(true); const backdrop = select.querySelector('.dropdown-backdrop'); backdrop.click(); await flushUpdates(); expect(select.hasAttribute('data-open')).toBe(false); }); }); it('should close on trigger click when open in single mode', async () => { await usingAsync(await renderSelect(), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); expect(select.hasAttribute('data-open')).toBe(true); // Re-query after re-render const trigger2 = select.querySelector('.select-trigger'); trigger2.click(); await flushUpdates(); expect(select.hasAttribute('data-open')).toBe(false); }); }); }); describe('single selection', () => { it('should call onValueChange when an option is clicked', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSelect({ options: defaultOptions, onValueChange }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const items = select.querySelectorAll('.dropdown-item'); items[1].click(); await flushUpdates(); expect(onValueChange).toHaveBeenCalledWith('b'); }); }); it('should close dropdown after single selection', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSelect({ options: defaultOptions, onValueChange }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const items = select.querySelectorAll('.dropdown-item'); items[0].click(); await flushUpdates(); expect(select.hasAttribute('data-open')).toBe(false); }); }); it('should not select disabled options', async () => { const options = [ { value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta', disabled: true }, ]; const onValueChange = vi.fn(); await usingAsync(await renderSelect({ options, onValueChange }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const items = select.querySelectorAll('.dropdown-item'); items[1].click(); await flushUpdates(); expect(onValueChange).not.toHaveBeenCalled(); }); }); it('should mark selected option with data-selected', async () => { await usingAsync(await renderSelect({ options: defaultOptions, value: 'b' }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const selected = select.querySelector('.dropdown-item[data-selected]'); expect(selected).not.toBeNull(); expect(selected?.textContent).toContain('Beta'); }); }); }); describe('multiple selection', () => { it('should render chips for selected values', async () => { await usingAsync(await renderSelect({ options: defaultOptions, mode: 'multiple', value: ['a', 'c'] }), async ({ select }) => { const chips = select.querySelectorAll('.select-chip'); expect(chips.length).toBe(2); expect(chips[0].textContent).toContain('Alpha'); expect(chips[1].textContent).toContain('Gamma'); }); }); it('should show placeholder when no values are selected in multi mode', async () => { await usingAsync(await renderSelect({ options: defaultOptions, mode: 'multiple', value: [], placeholder: 'Pick many' }), async ({ select }) => { const valueEl = select.querySelector('.select-value'); expect(valueEl?.textContent).toContain('Pick many'); }); }); it('should set data-multiple attribute', async () => { await usingAsync(await renderSelect({ options: defaultOptions, mode: 'multiple' }), async ({ select }) => { expect(select.hasAttribute('data-multiple')).toBe(true); }); }); it('should toggle selection in multiple mode', async () => { const onMultiValueChange = vi.fn(); await usingAsync(await renderSelect({ options: defaultOptions, mode: 'multiple', value: ['a'], onMultiValueChange, }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const items = select.querySelectorAll('.dropdown-item'); items[1].click(); await flushUpdates(); expect(onMultiValueChange).toHaveBeenCalledWith(['a', 'b']); }); }); it('should deselect an already selected value in multiple mode', async () => { const onMultiValueChange = vi.fn(); await usingAsync(await renderSelect({ options: defaultOptions, mode: 'multiple', value: ['a', 'b'], onMultiValueChange, }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const items = select.querySelectorAll('.dropdown-item'); items[0].click(); await flushUpdates(); expect(onMultiValueChange).toHaveBeenCalledWith(['b']); }); }); it('should remove chip when remove button is clicked', async () => { const onMultiValueChange = vi.fn(); await usingAsync(await renderSelect({ options: defaultOptions, mode: 'multiple', value: ['a', 'b'], onMultiValueChange, }), async ({ select }) => { const chipRemoves = select.querySelectorAll('.select-chip-remove'); expect(chipRemoves.length).toBe(2); chipRemoves[0].click(); await flushUpdates(); expect(onMultiValueChange).toHaveBeenCalledWith(['b']); }); }); it('should not show chip remove buttons when disabled', async () => { await usingAsync(await renderSelect({ options: defaultOptions, mode: 'multiple', value: ['a'], disabled: true }), async ({ select }) => { const chipRemoves = select.querySelectorAll('.select-chip-remove'); expect(chipRemoves.length).toBe(0); }); }); }); describe('keyboard navigation', () => { it('should open dropdown on Enter key', async () => { await usingAsync(await renderSelect(), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await flushUpdates(); expect(select.hasAttribute('data-open')).toBe(true); }); }); it('should open dropdown on Space key', async () => { await usingAsync(await renderSelect(), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); await flushUpdates(); expect(select.hasAttribute('data-open')).toBe(true); }); }); it('should open dropdown on ArrowDown key', async () => { await usingAsync(await renderSelect(), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await flushUpdates(); expect(select.hasAttribute('data-open')).toBe(true); }); }); it('should open dropdown on ArrowUp key', async () => { await usingAsync(await renderSelect(), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); await flushUpdates(); expect(select.hasAttribute('data-open')).toBe(true); }); }); it('should close dropdown on Escape key', async () => { await usingAsync(await renderSelect(), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); expect(select.hasAttribute('data-open')).toBe(true); trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await flushUpdates(); expect(select.hasAttribute('data-open')).toBe(false); }); }); it('should select highlighted option on Enter when open', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSelect({ options: defaultOptions, onValueChange }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); // Re-query trigger after re-render, then navigate and select const trigger2 = select.querySelector('.select-trigger'); trigger2.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await flushUpdates(); const trigger3 = select.querySelector('.select-trigger'); trigger3.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await flushUpdates(); expect(onValueChange).toHaveBeenCalled(); }); }); it('should navigate to Home and End', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSelect({ options: defaultOptions, onValueChange }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); // Re-query trigger after re-render const trigger2 = select.querySelector('.select-trigger'); trigger2.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })); await flushUpdates(); const trigger3 = select.querySelector('.select-trigger'); trigger3.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await flushUpdates(); expect(onValueChange).toHaveBeenCalledWith('c'); }); }); it('should not respond to keyboard when disabled', async () => { await usingAsync(await renderSelect({ options: defaultOptions, disabled: true }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await flushUpdates(); expect(select.hasAttribute('data-open')).toBe(false); }); }); }); describe('search / filter', () => { it('should show search input when showSearch is true', async () => { await usingAsync(await renderSelect({ options: defaultOptions, showSearch: true }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const searchInput = select.querySelector('.dropdown-search'); expect(searchInput).not.toBeNull(); }); }); it('should not show search input when showSearch is false', async () => { await usingAsync(await renderSelect({ options: defaultOptions, showSearch: false }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const searchInput = select.querySelector('.dropdown-search'); expect(searchInput).toBeNull(); }); }); it('should filter options based on search text', async () => { await usingAsync(await renderSelect({ options: defaultOptions, showSearch: true }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const searchInput = select.querySelector('.dropdown-search'); searchInput.value = 'alp'; searchInput.dispatchEvent(new Event('input', { bubbles: true })); await flushUpdates(); const items = select.querySelectorAll('.dropdown-item'); expect(items.length).toBe(1); expect(items[0].textContent).toContain('Alpha'); }); }); it('should show no results when nothing matches', async () => { await usingAsync(await renderSelect({ options: defaultOptions, showSearch: true }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const searchInput = select.querySelector('.dropdown-search'); searchInput.value = 'zzz'; searchInput.dispatchEvent(new Event('input', { bubbles: true })); await flushUpdates(); const noResults = select.querySelector('.dropdown-no-results'); expect(noResults).not.toBeNull(); expect(noResults?.textContent).toContain('No results found'); }); }); it('should use custom filter function', async () => { const filterOption = (_searchText, option) => option.value.startsWith('a'); await usingAsync(await renderSelect({ options: defaultOptions, showSearch: true, filterOption }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const searchInput = select.querySelector('.dropdown-search'); searchInput.value = 'anything'; searchInput.dispatchEvent(new Event('input', { bubbles: true })); await flushUpdates(); const items = select.querySelectorAll('.dropdown-item'); expect(items.length).toBe(1); expect(items[0].textContent).toContain('Alpha'); }); }); }); describe('option groups', () => { it('should render grouped options with group labels', async () => { const optionGroups = [ { label: 'Fruits', options: [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, ], }, { label: 'Vegetables', options: [{ value: 'carrot', label: 'Carrot' }], }, ]; await usingAsync(await renderSelect({ optionGroups }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const groupLabels = select.querySelectorAll('.dropdown-group-label'); expect(groupLabels.length).toBe(2); expect(groupLabels[0].textContent).toContain('Fruits'); expect(groupLabels[1].textContent).toContain('Vegetables'); const items = select.querySelectorAll('.dropdown-item'); expect(items.length).toBe(3); }); }); it('should filter grouped options when searching', async () => { const optionGroups = [ { label: 'Fruits', options: [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, ], }, { label: 'Vegetables', options: [{ value: 'carrot', label: 'Carrot' }], }, ]; await usingAsync(await renderSelect({ optionGroups, showSearch: true }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const searchInput = select.querySelector('.dropdown-search'); searchInput.value = 'apple'; searchInput.dispatchEvent(new Event('input', { bubbles: true })); await flushUpdates(); const items = select.querySelectorAll('.dropdown-item'); expect(items.length).toBe(1); expect(items[0].textContent).toContain('Apple'); }); }); }); describe('validation', () => { it('should set data-invalid when getValidationResult returns invalid', async () => { await usingAsync(await renderSelect({ options: defaultOptions, required: true, getValidationResult: () => ({ isValid: false, message: 'Required' }), }), async ({ select }) => { expect(select.hasAttribute('data-invalid')).toBe(true); }); }); it('should not set data-invalid when validation is valid', async () => { await usingAsync(await renderSelect({ options: defaultOptions, value: 'a', getValidationResult: () => ({ isValid: true }), }), async ({ select }) => { expect(select.hasAttribute('data-invalid')).toBe(false); }); }); it('should display helper text from getHelperText', async () => { await usingAsync(await renderSelect({ options: defaultOptions, getHelperText: () => 'Select an option', }), async ({ select }) => { const helperText = select.querySelector('.helperText'); expect(helperText?.textContent).toContain('Select an option'); }); }); it('should display validation message as helper text when invalid', async () => { await usingAsync(await renderSelect({ options: defaultOptions, getValidationResult: () => ({ isValid: false, message: 'This field is required' }), }), async ({ select }) => { const helperText = select.querySelector('.helperText'); expect(helperText?.textContent).toContain('This field is required'); }); }); }); describe('focus management', () => { it('should set data-focused when trigger receives focus', async () => { await usingAsync(await renderSelect(), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.dispatchEvent(new FocusEvent('focus', { bubbles: true })); await flushUpdates(); expect(select.hasAttribute('data-focused')).toBe(true); }); }); it('should remove data-focused on blur when not open', async () => { await usingAsync(await renderSelect(), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.dispatchEvent(new FocusEvent('focus', { bubbles: true })); await flushUpdates(); expect(select.hasAttribute('data-focused')).toBe(true); trigger.dispatchEvent(new FocusEvent('blur', { bubbles: true })); await flushUpdates(); expect(select.hasAttribute('data-focused')).toBe(false); }); }); }); describe('value normalization', () => { it('should normalize string value to array in multiple mode', async () => { await usingAsync(await renderSelect({ options: defaultOptions, mode: 'multiple', value: 'a' }), async ({ select }) => { const chips = select.querySelectorAll('.select-chip'); expect(chips.length).toBe(1); expect(chips[0].textContent).toContain('Alpha'); }); }); it('should normalize array value to string in single mode', async () => { await usingAsync(await renderSelect({ options: defaultOptions, mode: 'single', value: ['a'] }), async ({ select }) => { const valueEl = select.querySelector('.select-value'); expect(valueEl?.textContent).toContain('Alpha'); }); }); it('should handle undefined value in single mode', async () => { await usingAsync(await renderSelect({ options: defaultOptions }), async ({ select }) => { expect(select.querySelector('.select-value')).not.toBeNull(); }); }); }); describe('hidden input value', () => { it('should set hidden input value to selected option value in single mode', async () => { await usingAsync(await renderSelect({ options: defaultOptions, name: 'myField', value: 'b' }), async ({ select }) => { const input = select.querySelector('input[type="hidden"]'); expect(input.value).toBe('b'); }); }); it('should set hidden input value to comma-separated values in multiple mode', async () => { await usingAsync(await renderSelect({ options: defaultOptions, name: 'myField', mode: 'multiple', value: ['a', 'c'] }), async ({ select }) => { const input = select.querySelector('input[type="hidden"]'); expect(input.value).toBe('a,c'); }); }); it('should set required attribute on hidden input when required', async () => { await usingAsync(await renderSelect({ options: defaultOptions, name: 'myField', required: true }), async ({ select }) => { const input = select.querySelector('input[type="hidden"]'); expect(input.required).toBe(true); }); }); }); describe('ARIA attributes', () => { it('should have combobox role on trigger', async () => { await usingAsync(await renderSelect(), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); expect(trigger?.getAttribute('role')).toBe('combobox'); }); }); it('should set aria-expanded to false when closed', async () => { await usingAsync(await renderSelect(), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); expect(trigger?.getAttribute('aria-expanded')).toBe('false'); }); }); it('should set aria-expanded to true when open', async () => { await usingAsync(await renderSelect(), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); // Re-query after re-render const trigger2 = select.querySelector('.select-trigger'); expect(trigger2.getAttribute('aria-expanded')).toBe('true'); }); }); it('should have listbox role on dropdown', async () => { await usingAsync(await renderSelect(), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const dropdown = select.querySelector('.dropdown'); expect(dropdown?.getAttribute('role')).toBe('listbox'); }); }); it('should have option role on items', async () => { await usingAsync(await renderSelect(), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const items = select.querySelectorAll('.dropdown-item'); items.forEach((item) => { expect(item.getAttribute('role')).toBe('option'); }); }); }); it('should set aria-multiselectable on listbox in multiple mode', async () => { await usingAsync(await renderSelect({ options: defaultOptions, mode: 'multiple' }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const dropdown = select.querySelector('.dropdown'); expect(dropdown?.getAttribute('aria-multiselectable')).toBe('true'); }); }); }); describe('Backspace in multi mode with search', () => { it('should remove last chip on Backspace when search is empty', async () => { const onMultiValueChange = vi.fn(); await usingAsync(await renderSelect({ options: defaultOptions, mode: 'multiple', value: ['a', 'b'], showSearch: true, onMultiValueChange, }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const searchInput = select.querySelector('.dropdown-search'); searchInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true })); await flushUpdates(); expect(onMultiValueChange).toHaveBeenCalledWith(['a']); }); }); }); describe('ArrowUp keyboard navigation', () => { it('should navigate up through options with ArrowUp', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSelect({ options: defaultOptions, onValueChange }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); // Navigate to End first const trigger2 = select.querySelector('.select-trigger'); trigger2.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })); await flushUpdates(); // Now ArrowUp const trigger3 = select.querySelector('.select-trigger'); trigger3.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); await flushUpdates(); // Then select with Enter const trigger4 = select.querySelector('.select-trigger'); trigger4.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await flushUpdates(); expect(onValueChange).toHaveBeenCalledWith('b'); }); }); }); describe('Space key selection', () => { it('should select highlighted option on Space when open and no search', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSelect({ options: defaultOptions, onValueChange }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const trigger2 = select.querySelector('.select-trigger'); trigger2.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await flushUpdates(); const trigger3 = select.querySelector('.select-trigger'); trigger3.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); await flushUpdates(); expect(onValueChange).toHaveBeenCalled(); }); }); it('should not select on Space when showSearch is enabled (allows typing spaces)', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSelect({ options: defaultOptions, showSearch: true, onValueChange }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const searchInput = select.querySelector('.dropdown-search'); searchInput.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); await flushUpdates(); // Space should be ignored as a selection trigger when search is active expect(onValueChange).not.toHaveBeenCalled(); }); }); }); describe('Home key navigation', () => { it('should navigate to first option with Home', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSelect({ options: defaultOptions, onValueChange }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const trigger2 = select.querySelector('.select-trigger'); trigger2.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })); await flushUpdates(); const trigger3 = select.querySelector('.select-trigger'); trigger3.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await flushUpdates(); expect(onValueChange).toHaveBeenCalledWith('a'); }); }); }); describe('value normalization edge cases', () => { it('should handle empty string value in multiple mode', async () => { await usingAsync(await renderSelect({ options: defaultOptions, mode: 'multiple', value: '' }), async ({ select }) => { const chips = select.querySelectorAll('.select-chip'); expect(chips.length).toBe(0); }); }); it('should handle empty array in single mode', async () => { await usingAsync(await renderSelect({ options: defaultOptions, mode: 'single', value: [] }), async ({ select }) => { const valueEl = select.querySelector('.select-value'); expect(valueEl).not.toBeNull(); }); }); }); describe('multiple mode with onValueChange', () => { it('should call onValueChange with comma-separated values', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSelect({ options: defaultOptions, mode: 'multiple', value: ['a'], onValueChange, }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const items = select.querySelectorAll('.dropdown-item'); items[1].click(); await flushUpdates(); expect(onValueChange).toHaveBeenCalledWith('a,b'); }); }); }); describe('chip removal with onValueChange', () => { it('should also call onValueChange when removing a chip', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSelect({ options: defaultOptions, mode: 'multiple', value: ['a', 'b'], onValueChange, }), async ({ select }) => { const chipRemoves = select.querySelectorAll('.select-chip-remove'); chipRemoves[0].click(); await flushUpdates(); expect(onValueChange).toHaveBeenCalledWith('b'); }); }); it('should not remove chip when disabled', async () => { const onValueChange = vi.fn(); await usingAsync(await renderSelect({ options: defaultOptions, mode: 'multiple', value: ['a', 'b'], disabled: true, onValueChange, }), async ({ select }) => { // Disabled mode should have no chip remove buttons const chipRemoves = select.querySelectorAll('.select-chip-remove'); expect(chipRemoves.length).toBe(0); }); }); }); describe('grouped options with search no results', () => { it('should show no results when all groups are filtered out', async () => { const optionGroups = [ { label: 'Fruits', options: [{ value: 'apple', label: 'Apple' }], }, ]; await usingAsync(await renderSelect({ optionGroups, showSearch: true }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); const searchInput = select.querySelector('.dropdown-search'); searchInput.value = 'zzz'; searchInput.dispatchEvent(new Event('input', { bubbles: true })); await flushUpdates(); const noResults = select.querySelector('.dropdown-no-results'); expect(noResults).not.toBeNull(); expect(noResults?.textContent).toContain('No results found'); }); }); }); describe('dropdown not staying open in multi mode on trigger click', () => { it('should keep dropdown open when trigger is clicked in multiple mode', async () => { await usingAsync(await renderSelect({ options: defaultOptions, mode: 'multiple' }), async ({ select }) => { const trigger = select.querySelector('.select-trigger'); trigger.click(); await flushUpdates(); expect(select.hasAttribute('data-open')).toBe(true); // In multi mode, clicking trigger again should NOT close (only backdrop closes) const trigger2 = select.query