diff --git a/src/components/NcSelect/NcSelect.vue b/src/components/NcSelect/NcSelect.vue index b212773289..b82397027a 100644 --- a/src/components/NcSelect/NcSelect.vue +++ b/src/components/NcSelect/NcSelect.vue @@ -150,6 +150,150 @@ export default { ``` +### Pre-selected and clearable + +```vue + + + + + + + + + + + + + + +``` + +### Disabled state + +```vue + + + + + + + + + + + + + + +``` + +### Label outside + +```vue + + + + External label (single) + + + + External label (multi) + + + + + + + + +``` + ### Native form validation example ```vue @@ -323,6 +467,7 @@ export default { - + @@ -339,13 +484,25 @@ export default { - + + @update:modelValue="events.input({ target: { value: $event } })" /> + - + :size="20" /> @@ -401,6 +557,7 @@ import ChevronDown from 'vue-material-design-icons/ChevronDown.vue' import Close from 'vue-material-design-icons/Close.vue' import NcEllipsisedOption from '../NcEllipsisedOption/NcEllipsisedOption.vue' import NcLoadingIcon from '../NcLoadingIcon/NcLoadingIcon.vue' +import NcTextField from '../NcTextField/NcTextField.vue' import { t } from '../../l10n.ts' import { createElementId } from '../../utils/createElementId.ts' import { isLegacy } from '../../utils/legacy.ts' @@ -412,6 +569,7 @@ export default { ChevronDown, NcEllipsisedOption, NcLoadingIcon, + NcTextField, VueSelect, }, @@ -895,6 +1053,21 @@ export default { methods: { t, + + /** + * Filter out the `input` event from vue-select events. + * Input is handled via @update:modelValue to avoid double-firing. + * All other events (keydown, blur, focus, compositionstart, compositionend) + * must reach the native for keyboard nav, dropdown close, and IME. + * + * @param {object} events vue-select event handlers + * @return {object} events without `input` + */ + filterEvents(events) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { input, ...rest } = events + return rest + }, }, } @@ -902,12 +1075,8 @@ export default { diff --git a/tests/component/components/NcSelect/NcSelect.spec.ts b/tests/component/components/NcSelect/NcSelect.spec.ts new file mode 100644 index 0000000000..6beee846b5 --- /dev/null +++ b/tests/component/components/NcSelect/NcSelect.spec.ts @@ -0,0 +1,286 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/experimental-ct-vue' +import NcSelectStory from './NcSelect.story.vue' + +test.describe('NcSelect - single mode', () => { + test('has a visible combobox', async ({ mount }) => { + const component = await mount(NcSelectStory) + await expect(component.getByRole('combobox')).toBeVisible() + }) + + test('shows label as placeholder when empty', async ({ mount }) => { + const component = await mount(NcSelectStory, { + props: { inputLabel: 'Pick a fruit' }, + }) + await expect(component.getByText('Pick a fruit')).toBeVisible() + }) + + test('opens dropdown on click', async ({ mount, page }) => { + const component = await mount(NcSelectStory) + await component.getByRole('combobox').click() + await expect(page.getByRole('option', { name: 'foo' })).toBeVisible() + }) + + test('opens dropdown on Enter', async ({ mount, page }) => { + const component = await mount(NcSelectStory) + await component.getByRole('combobox').press('Enter') + await expect(page.getByRole('option', { name: 'foo' })).toBeVisible() + }) + + test('closes on Escape', async ({ mount, page }) => { + const component = await mount(NcSelectStory) + await component.getByRole('combobox').click() + await expect(page.getByRole('option', { name: 'foo' })).toBeVisible() + + await component.getByRole('combobox').press('Escape') + await expect(page.getByRole('option', { name: 'foo' })).not.toBeVisible() + }) + + test('arrow keys navigate options', async ({ mount, page }) => { + const component = await mount(NcSelectStory) + await component.getByRole('combobox').click() + await component.getByRole('combobox').press('ArrowDown') + + // First option should be highlighted + const options = page.getByRole('option') + await expect(options.first()).toBeVisible() + }) + + test('selects option with Enter', async ({ mount, page }) => { + const emitted: unknown[] = [] + const component = await mount(NcSelectStory, { + on: { selected: (data) => emitted.push(data) }, + }) + + await component.getByRole('combobox').click() + await page.getByRole('option', { name: 'bar' }).click() + + expect(emitted).toHaveLength(1) + expect(emitted[0]).toBe('bar') + }) + + test('typing filters options', async ({ mount, page }) => { + const component = await mount(NcSelectStory) + await component.getByRole('combobox').click() + await component.getByRole('combobox').fill('ba') + + await expect(page.getByRole('option', { name: 'bar' })).toBeVisible() + await expect(page.getByRole('option', { name: 'baz' })).toBeVisible() + expect(await page.getByRole('option').all()).toHaveLength(2) + }) + + test('shows selected value', async ({ mount }) => { + const component = await mount(NcSelectStory, { + props: { preselected: true }, + }) + + await expect(component.getByText('foo')).toBeVisible() + }) + + test('disabled state prevents interaction', async ({ mount }) => { + const component = await mount(NcSelectStory, { + props: { disabled: true }, + }) + + await component.getByRole('combobox').click({ force: true }) + await expect(component.locator('.vs__dropdown-menu')).not.toBeVisible() + }) +}) + +test.describe('NcSelect - multi mode', () => { + test('shows label when empty', async ({ mount }) => { + const component = await mount(NcSelectStory, { + props: { multiple: true, inputLabel: 'Pick items' }, + }) + await expect(component.getByText('Pick items')).toBeVisible() + }) + + test('shows tags when values selected', async ({ mount }) => { + const component = await mount(NcSelectStory, { + props: { multiple: true, preselected: true }, + }) + + await expect(component.getByText('foo')).toBeVisible() + await expect(component.getByText('bar')).toBeVisible() + }) + + test('can select multiple options', async ({ mount, page }) => { + const emitted: unknown[] = [] + const component = await mount(NcSelectStory, { + props: { multiple: true }, + on: { selected: (data) => emitted.push(data) }, + }) + + await component.getByRole('combobox').click() + await page.getByRole('option', { name: 'foo' }).click() + + await component.getByRole('combobox').click() + await page.getByRole('option', { name: 'bar' }).click() + + expect(emitted).toHaveLength(2) + expect(emitted[0]).toEqual(['foo']) + expect(emitted[1]).toEqual(['foo', 'bar']) + }) + + test('can remove tag with deselect button', async ({ mount }) => { + const emitted: unknown[] = [] + const component = await mount(NcSelectStory, { + props: { multiple: true, preselected: true }, + on: { selected: (data) => emitted.push(data) }, + }) + + // Click the deselect (×) button on first tag + const deselectButtons = component.locator('.vs__deselect') + await deselectButtons.first().click() + + expect(emitted).toHaveLength(1) + expect(emitted[0]).toEqual(['bar']) + }) +}) + +test.describe('NcSelect - e2e flows', () => { + test('search, select, and clear single value', async ({ mount, page }) => { + const emitted: unknown[] = [] + const component = await mount(NcSelectStory, { + props: { inputLabel: 'Pick one' }, + on: { selected: (data) => emitted.push(data) }, + }) + + // Type to search + await component.getByRole('combobox').click() + await component.getByRole('combobox').fill('qu') + expect(await page.getByRole('option').all()).toHaveLength(2) + + // Select 'qux' + await page.getByRole('option', { name: 'qux' }).click() + expect(emitted[0]).toBe('qux') + + // Value is shown + await expect(component.getByText('qux')).toBeVisible() + }) + + test('keyboard-only selection flow', async ({ mount, page }) => { + const emitted: unknown[] = [] + const component = await mount(NcSelectStory, { + on: { selected: (data) => emitted.push(data) }, + }) + + const combobox = component.getByRole('combobox') + + // Open with Enter + await combobox.press('Enter') + await expect(page.getByRole('option').first()).toBeVisible() + + // Navigate down twice and select with Enter + await combobox.press('ArrowDown') + await combobox.press('ArrowDown') + await combobox.press('Enter') + + expect(emitted).toHaveLength(1) + }) + + test('click outside closes dropdown', async ({ mount, page }) => { + const component = await mount(NcSelectStory) + + await component.getByRole('combobox').click() + await expect(page.getByRole('option').first()).toBeVisible() + + // Click outside the select + await page.locator('body').click({ position: { x: 10, y: 10 } }) + await expect(page.getByRole('option').first()).not.toBeVisible() + }) + + test('multi: search filters then select adds tag', async ({ mount, page }) => { + const emitted: unknown[] = [] + const component = await mount(NcSelectStory, { + props: { multiple: true }, + on: { selected: (data) => emitted.push(data) }, + }) + + // Type to filter + await component.getByRole('combobox').click() + await component.getByRole('combobox').fill('ba') + expect(await page.getByRole('option').all()).toHaveLength(2) + + // Select 'baz' + await page.getByRole('option', { name: 'baz' }).click() + expect(emitted[0]).toEqual(['baz']) + + // Tag visible + await expect(component.getByText('baz')).toBeVisible() + + // Select another + await component.getByRole('combobox').click() + await page.getByRole('option', { name: 'foo' }).click() + expect(emitted[1]).toEqual(['baz', 'foo']) + }) + + test('multi: Backspace removes last tag', async ({ mount }) => { + const emitted: unknown[] = [] + const component = await mount(NcSelectStory, { + props: { multiple: true, preselected: true }, + on: { selected: (data) => emitted.push(data) }, + }) + + // foo and bar are preselected + await expect(component.getByText('foo')).toBeVisible() + await expect(component.getByText('bar')).toBeVisible() + + // Focus and press Backspace to remove last tag + await component.getByRole('combobox').click() + await component.getByRole('combobox').press('Backspace') + + expect(emitted).toHaveLength(1) + expect(emitted[0]).toEqual(['foo']) + }) + + test('selecting same value in single mode replaces it', async ({ mount, page }) => { + const emitted: unknown[] = [] + const component = await mount(NcSelectStory, { + props: { preselected: true }, + on: { selected: (data) => emitted.push(data) }, + }) + + // foo is preselected, select bar instead + await component.getByRole('combobox').click() + await page.getByRole('option', { name: 'bar' }).click() + + expect(emitted[0]).toBe('bar') + await expect(component.getByText('bar')).toBeVisible() + }) +}) + +test.describe('NcSelect - NcTextField parity', () => { + test('renders an input element', async ({ mount }) => { + const component = await mount(NcSelectStory) + await expect(component.locator('input')).toBeVisible() + }) + + test('label floats when input is focused', async ({ mount }) => { + const component = await mount(NcSelectStory, { + props: { inputLabel: 'My label' }, + }) + + const label = component.locator('label') + await component.getByRole('combobox').click() + + // Label should still be visible (floated above border) + await expect(label).toBeVisible() + await expect(label).toContainText('My label') + }) + + test('label floats when value is selected', async ({ mount }) => { + const component = await mount(NcSelectStory, { + props: { inputLabel: 'My label', preselected: true }, + }) + + // Label should be floated (not centered as placeholder) + const label = component.locator('label') + await expect(label).toBeVisible() + await expect(label).toContainText('My label') + }) +}) diff --git a/tests/component/components/NcSelect/NcSelect.story.vue b/tests/component/components/NcSelect/NcSelect.story.vue new file mode 100644 index 0000000000..5858a36bf1 --- /dev/null +++ b/tests/component/components/NcSelect/NcSelect.story.vue @@ -0,0 +1,53 @@ + + + + + + + + + + + diff --git a/tests/unit/components/NcSelect/NcSelect.spec.ts b/tests/unit/components/NcSelect/NcSelect.spec.ts new file mode 100644 index 0000000000..5e04969952 --- /dev/null +++ b/tests/unit/components/NcSelect/NcSelect.spec.ts @@ -0,0 +1,191 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import NcSelect from '../../../../src/components/NcSelect/NcSelect.vue' + +const options = ['foo', 'bar', 'baz'] + +describe('NcSelect', () => { + it('renders NcTextField in search slot', () => { + const wrapper = mount(NcSelect, { + props: { inputLabel: 'Label', options }, + }) + + expect(wrapper.findComponent({ name: 'NcTextField' }).exists()).toBe(true) + }) + + it('has the label set', () => { + const wrapper = mount(NcSelect, { + props: { inputLabel: 'The label', options }, + }) + + expect(wrapper.find('label').text()).toBe('The label') + }) + + it('passes placeholder prop to NcTextField', () => { + const wrapper = mount(NcSelect, { + props: { inputLabel: 'Label', placeholder: 'Pick one', options }, + }) + + // vue-select includes placeholder in attributes, NcTextField receives it + const textField = wrapper.findComponent({ name: 'NcInputField' }) + expect(textField.exists()).toBe(true) + }) + + it('has the disabled attribute', async () => { + const wrapper = mount(NcSelect, { + props: { inputLabel: 'Label', options }, + }) + + expect(wrapper.find('input').attributes('disabled')).toBeUndefined() + + await wrapper.setProps({ disabled: true }) + expect(wrapper.find('input').attributes('disabled')).toBe('') + }) + + it('has the inputClass set on NcTextField', () => { + const wrapper = mount(NcSelect, { + props: { inputLabel: 'Label', inputClass: 'custom-class', options }, + }) + + expect(wrapper.findComponent({ name: 'NcTextField' }).classes('custom-class')).toBe(true) + }) + + it('emits update:modelValue on selection', async () => { + const wrapper = mount(NcSelect, { + props: { inputLabel: 'Label', options }, + }) + + // Open dropdown + await wrapper.find('.vs__dropdown-toggle').trigger('mousedown') + await wrapper.vm.$nextTick() + + // Select first option + const option = wrapper.find('.vs__dropdown-option') + if (option.exists()) { + await option.trigger('mousedown') + await wrapper.vm.$nextTick() + expect(wrapper.emitted('update:modelValue')).toBeTruthy() + } + }) + + it('sets required conditionally based on value', async () => { + const wrapper = mount(NcSelect, { + props: { inputLabel: 'Label', required: true, modelValue: null, options }, + }) + + expect(wrapper.find('input').attributes('required')).toBe('') + + await wrapper.setProps({ modelValue: 'foo' }) + expect(wrapper.find('input').attributes('required')).toBeUndefined() + }) + + describe('single mode', () => { + it('uses NcTextField label (not header slot)', () => { + const wrapper = mount(NcSelect, { + props: { inputLabel: 'Single label', options }, + }) + + // Label is on NcTextField, not a separate .select__label element + const textField = wrapper.findComponent({ name: 'NcTextField' }) + expect(textField.props('label')).toBe('Single label') + expect(wrapper.find('.select__label').exists()).toBe(false) + }) + }) + + describe('multi mode', () => { + it('renders label via header slot', () => { + const wrapper = mount(NcSelect, { + props: { inputLabel: 'Multi label', multiple: true, options }, + }) + + expect(wrapper.find('.select__label').exists()).toBe(true) + expect(wrapper.find('.select__label').text()).toBe('Multi label') + }) + + it('passes labelOutside to NcTextField', () => { + const wrapper = mount(NcSelect, { + props: { inputLabel: 'Multi label', multiple: true, options }, + }) + + const textField = wrapper.findComponent({ name: 'NcTextField' }) + expect(textField.props('labelOutside')).toBe(true) + }) + }) + + describe('labelOutside', () => { + it('does not render floating label when labelOutside is true', () => { + const wrapper = mount(NcSelect, { + props: { inputLabel: 'Label', labelOutside: true, options }, + }) + + // Single: NcTextField label should be empty + const textField = wrapper.findComponent({ name: 'NcTextField' }) + expect(textField.props('label')).toBe('') + }) + + it('does not render header label in multi mode when labelOutside', () => { + const wrapper = mount(NcSelect, { + props: { inputLabel: 'Label', labelOutside: true, multiple: true, options }, + }) + + expect(wrapper.find('.select__label').exists()).toBe(false) + }) + }) + + describe('accessibility', () => { + it('warns when neither inputLabel nor ariaLabelCombobox is set', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mount(NcSelect, { props: { options } }) + expect(warnSpy).toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it('does not warn when inputLabel is set', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mount(NcSelect, { props: { inputLabel: 'Label', options } }) + expect(warnSpy).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it('does not warn about NcSelect label when ariaLabelCombobox is set', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mount(NcSelect, { props: { ariaLabelCombobox: 'Search', options } }) + // NcSelect itself should not warn, though NcInputField may warn separately + const ncSelectWarns = warnSpy.mock.calls.filter((args) => typeof args[0] === 'string' && args[0].includes('[NcSelect]')) + expect(ncSelectWarns).toHaveLength(0) + warnSpy.mockRestore() + }) + }) + + describe('event forwarding', () => { + it('forwards keydown events to the inner input', async () => { + const wrapper = mount(NcSelect, { + props: { inputLabel: 'Label', options }, + }) + + const input = wrapper.find('input') + await input.trigger('keydown', { key: 'ArrowDown' }) + // If keydown is forwarded, vue-select processes it — no error thrown + expect(wrapper.find('.v-select').exists()).toBe(true) + }) + + it('filters input event from forwarded events', () => { + const wrapper = mount(NcSelect, { + props: { inputLabel: 'Label', options }, + }) + + const vm = wrapper.vm as any + const events = { input: vi.fn(), keydown: vi.fn(), blur: vi.fn() } + const filtered = vm.filterEvents(events) + + expect(filtered).not.toHaveProperty('input') + expect(filtered).toHaveProperty('keydown') + expect(filtered).toHaveProperty('blur') + }) + }) +})