diff --git a/app/components/Input/Base.vue b/app/components/Input/Base.vue index 49b971b66..cd826664d 100644 --- a/app/components/Input/Base.vue +++ b/app/components/Input/Base.vue @@ -35,11 +35,11 @@ defineExpose({ v-bind="props.noCorrect ? noCorrect : undefined" @focus="emit('focus', $event)" @blur="emit('blur', $event)" - class="bg-bg-subtle border border-border font-mono text-fg placeholder:text-fg-subtle transition-[border-color,outline-color] duration-300 hover:border-fg-subtle outline-2 outline-transparent outline-offset-2 focus:border-accent focus-visible:outline-accent/70 disabled:(opacity-50 cursor-not-allowed)" + class="appearance-none bg-bg-subtle border border-border font-mono text-fg placeholder:text-fg-subtle transition-[border-color,outline-color] duration-300 hover:border-fg-subtle outline-2 outline-transparent outline-offset-2 focus:border-accent focus-visible:outline-accent/70 disabled:(opacity-50 cursor-not-allowed)" :class="{ 'text-xs leading-[1.2] px-2 py-2 rounded-md': size === 'small', 'text-sm leading-none px-3 py-2.5 rounded-lg': size === 'medium', - 'text-base leading-none px-6 py-3.5 h-14 rounded-xl': size === 'large', + 'text-base leading-[1.4] px-6 py-4 rounded-xl': size === 'large', }" :disabled=" /** Catching Vue render-bug of invalid `disabled=false` attribute in the final HTML */ diff --git a/app/components/Org/MembersPanel.vue b/app/components/Org/MembersPanel.vue index 2da3ff55f..757030ee4 100644 --- a/app/components/Org/MembersPanel.vue +++ b/app/components/Org/MembersPanel.vue @@ -35,7 +35,7 @@ const isLoadingTeams = shallowRef(false) // Search/filter const searchQuery = shallowRef('') const filterRole = shallowRef('all') -const filterTeam = shallowRef(null) +const filterTeam = shallowRef('') const sortBy = shallowRef<'name' | 'role'>('name') const sortOrder = shallowRef<'asc' | 'desc'>('asc') @@ -362,18 +362,19 @@ watch(lastExecutionTime, () => {
- - + block + size="sm" + :items="[ + { label: $t('org.members.all_teams'), value: '' }, + ...teamNames.map(team => ({ label: team, value: team })), + ]" + />
{ - + block + size="sm" + :items="[ + { label: getRoleLabel('developer'), value: 'developer' }, + { label: getRoleLabel('admin'), value: 'admin' }, + { label: getRoleLabel('owner'), value: 'owner' }, + ]" + :value="member.role" + @update:modelValue="value => handleChangeRole(member.name, value as MemberRole)" + /> diff --git a/app/components/PaginationControls.vue b/app/components/PaginationControls.vue index a42c5952c..8c35dba22 100644 --- a/app/components/PaginationControls.vue +++ b/app/components/PaginationControls.vue @@ -12,6 +12,8 @@ const mode = defineModel('mode', { required: true }) const pageSize = defineModel('pageSize', { required: true }) const currentPage = defineModel('currentPage', { required: true }) +const pageSizeSelectValue = computed(() => String(pageSize.value)) + // Whether we should show pagination controls (table view always uses pagination) const shouldShowControls = computed(() => props.viewMode === 'table' || mode.value === 'paginated') @@ -149,21 +151,22 @@ function handlePageSizeChange(event: Event) {
- - + :items=" + PAGE_SIZE_OPTIONS.map(size => ({ + label: + size === 'all' + ? $t('filters.pagination.all_yolo') + : $t('filters.pagination.per_page', { count: size }), + value: String(size), + })) + " + /> diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 2a53e48df..af4f3dd2d 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -176,6 +176,8 @@ import { ReadmeTocDropdown, SearchProviderToggle, SearchSuggestionCard, + SelectBase, + SelectField, SettingsAccentColorPicker, SettingsBgThemePicker, SettingsToggle, @@ -2232,6 +2234,96 @@ describe('component accessibility audits', () => { }) }) + describe('SelectBase', () => { + it('should have no accessibility violations with options and aria-label', async () => { + const component = await mountSuspended(SelectBase, { + attrs: { 'aria-label': 'Choose option' }, + slots: { + default: + '', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations when disabled', async () => { + const component = await mountSuspended(SelectBase, { + props: { disabled: true }, + attrs: { 'aria-label': 'Disabled select' }, + slots: { default: '' }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with size small', async () => { + const component = await mountSuspended(SelectBase, { + props: { size: 'sm' }, + attrs: { 'aria-label': 'Small select' }, + slots: { default: '' }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('SelectField', () => { + it('should have no accessibility violations with label and items', async () => { + const component = await mountSuspended(SelectField, { + props: { + id: 'a11y-select-1', + label: 'Choose one', + items: [ + { label: 'Option 1', value: 'option1' }, + { label: 'Option 2', value: 'option2' }, + ], + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with hiddenLabel', async () => { + const component = await mountSuspended(SelectField, { + props: { + id: 'a11y-select-2', + label: 'Hidden', + hiddenLabel: true, + items: [{ label: 'Option 1', value: 'option1' }], + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations when disabled', async () => { + const component = await mountSuspended(SelectField, { + props: { + id: 'a11y-select-3', + selectAttrs: { 'aria-label': 'Disabled select' }, + items: [{ label: 'Option 1', value: 'option1' }], + disabled: true, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with size small', async () => { + const component = await mountSuspended(SelectField, { + props: { + id: 'a11y-select-4', + selectAttrs: { 'aria-label': 'Disabled select' }, + items: [{ label: 'Option 1', value: 'option1' }], + size: 'sm', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + describe('SearchSuggestionCard', () => { it('should have no accessibility violations for user suggestion', async () => { const component = await mountSuspended(SearchSuggestionCard, { diff --git a/test/nuxt/components/Select/Base.spec.ts b/test/nuxt/components/Select/Base.spec.ts new file mode 100644 index 000000000..be5d679b3 --- /dev/null +++ b/test/nuxt/components/Select/Base.spec.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { mountSuspended } from '@nuxt/test-utils/runtime' +import SelectBase from '~/components/Select/Base.vue' + +describe('SelectBase', () => { + describe('rendering', () => { + it('renders native select with slot options', async () => { + const component = await mountSuspended(SelectBase, { + slots: { + default: + '', + }, + }) + const select = component.find('select') + expect(select.exists()).toBe(true) + expect(component.findAll('option')).toHaveLength(2) + }) + + it('renders with initial modelValue', async () => { + const component = await mountSuspended(SelectBase, { + props: { modelValue: 'option2' }, + slots: { + default: + '', + }, + }) + const select = component.find('select').element + expect(select.value).toBe('option2') + }) + + it('renders without disabled attribute when disabled is false', async () => { + const component = await mountSuspended(SelectBase, { + props: { disabled: false }, + slots: { default: '' }, + }) + const select = component.find('select') + expect(select.attributes('disabled')).toBeUndefined() + }) + + it('renders disabled when disabled is true', async () => { + const component = await mountSuspended(SelectBase, { + props: { disabled: true }, + slots: { default: '' }, + }) + const select = component.find('select').element + expect(select.disabled).toBe(true) + }) + }) + + describe('v-model', () => { + it('emits update:modelValue when selection changes', async () => { + const component = await mountSuspended(SelectBase, { + props: { modelValue: 'option1' }, + slots: { + default: + '', + }, + }) + const select = component.find('select') + await select.setValue('option2') + expect(component.emitted('update:modelValue')).toBeTruthy() + expect(component.emitted('update:modelValue')?.at(-1)).toEqual(['option2']) + }) + + it('reflects modelValue prop changes', async () => { + const component = await mountSuspended(SelectBase, { + props: { modelValue: 'option1' }, + slots: { + default: + '', + }, + }) + await component.setProps({ modelValue: 'option2' }) + const select = component.find('select').element + expect(select.value).toBe('option2') + }) + }) +}) diff --git a/test/nuxt/components/Select/Field.spec.ts b/test/nuxt/components/Select/Field.spec.ts new file mode 100644 index 000000000..eda167cd6 --- /dev/null +++ b/test/nuxt/components/Select/Field.spec.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { mountSuspended } from '@nuxt/test-utils/runtime' +import SelectField from '~/components/Select/Field.vue' + +const defaultItems = [ + { label: 'Option 1', value: 'option1' }, + { label: 'Option 2', value: 'option2' }, +] + +describe('SelectField', () => { + describe('rendering', () => { + it('renders options from items prop', async () => { + const component = await mountSuspended(SelectField, { + props: { id: 'select', items: defaultItems }, + }) + const options = component.findAll('option') + expect(options).toHaveLength(2) + expect(options[0]?.text()).toBe('Option 1') + expect(options[1]?.text()).toBe('Option 2') + expect(options[0]?.attributes('value')).toBe('option1') + expect(options[1]?.attributes('value')).toBe('option2') + }) + + it('renders label when provided', async () => { + const component = await mountSuspended(SelectField, { + props: { id: 'select', items: defaultItems, label: 'Choose one' }, + }) + const label = component.find('label') + expect(label.exists()).toBe(true) + expect(label.text()).toBe('Choose one') + expect(label.attributes('for')).toBe('select') + }) + + it('renders disabled option when item.disabled is true', async () => { + const component = await mountSuspended(SelectField, { + props: { + id: 'select', + items: [ + { label: 'Enabled', value: 'on' }, + { label: 'Disabled', value: 'off', disabled: true }, + ], + }, + }) + const options = component.findAll('option') + expect(options[1]?.element?.disabled).toBe(true) + }) + + it('applies block class when block is true', async () => { + const component = await mountSuspended(SelectField, { + props: { id: 'select', items: defaultItems, block: true }, + }) + const wrapper = component.find('.relative') + expect(wrapper.classes()).toContain('w-full') + const select = component.find('select') + expect(select.classes()).toContain('w-full') + }) + }) + + describe('v-model', () => { + it('emits update:modelValue when option is selected', async () => { + const component = await mountSuspended(SelectField, { + props: { id: 'select', items: defaultItems, modelValue: 'option1' }, + }) + const select = component.find('select') + await select.setValue('option2') + expect(component.emitted('update:modelValue')).toBeTruthy() + expect(component.emitted('update:modelValue')?.at(-1)).toEqual(['option2']) + }) + + it('reflects modelValue prop changes', async () => { + const component = await mountSuspended(SelectField, { + props: { id: 'select', items: defaultItems, modelValue: 'option1' }, + }) + await component.setProps({ modelValue: 'option2' }) + const select = component.find('select').element + expect(select.value).toBe('option2') + }) + }) + + describe('disabled', () => { + it('passes disabled to SelectBase', async () => { + const component = await mountSuspended(SelectField, { + props: { id: 'select', items: defaultItems, disabled: true }, + }) + const select = component.find('select').element + expect(select.disabled).toBe(true) + }) + }) + + describe('accessibility', () => { + it('chevron has aria-hidden', async () => { + const component = await mountSuspended(SelectField, { + props: { id: 'select', items: defaultItems }, + }) + const chevron = component.find('span[aria-hidden="true"]') + expect(chevron.exists()).toBe(true) + }) + + it('render sr-only label when hiddenLabel is true', async () => { + const component = await mountSuspended(SelectField, { + props: { + id: 'select', + items: defaultItems, + label: 'Hidden', + hiddenLabel: true, + }, + }) + const label = component.find('label') + expect(label.exists()).toBe(true) + expect(label.classes()).toContain('sr-only') + expect(label.text()).toBe('Hidden') + }) + + it('associates select with id', async () => { + const component = await mountSuspended(SelectField, { + props: { id: 'my-select', items: defaultItems, label: 'My Select' }, + }) + const select = component.find('select') + expect(select.attributes('id')).toBe('my-select') + const label = component.find('label') + expect(label.attributes('for')).toBe('my-select') + }) + }) +}) diff --git a/test/nuxt/components/Tooltip.spec.ts b/test/nuxt/components/Tooltip.spec.ts new file mode 100644 index 000000000..51c5a737c --- /dev/null +++ b/test/nuxt/components/Tooltip.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { mountSuspended } from '@nuxt/test-utils/runtime' +import TooltipBase from '~/components/Tooltip/Base.vue' + +describe('TooltipBase to prop', () => { + it('teleports to body by default', async () => { + await mountSuspended(TooltipBase, { + props: { + text: 'Tooltip text', + isVisible: true, + tooltipAttr: { 'aria-label': 'tooltip' }, + }, + slots: { + default: '', + }, + }) + + const tooltip = document.querySelector('[aria-label="tooltip"]') + expect(tooltip).not.toBeNull() + expect(tooltip?.textContent).toContain('Tooltip text') + + const currentContainer = tooltip?.parentElement?.parentElement + expect(currentContainer).toBe(document.body) + }) + + it('teleports into provided container when using selector string', async () => { + const container = document.createElement('div') + container.id = 'tooltip-container' + document.body.appendChild(container) + + try { + await mountSuspended(TooltipBase, { + props: { + text: 'Tooltip text', + isVisible: true, + to: '#tooltip-container', + tooltipAttr: { 'aria-label': 'tooltip' }, + }, + slots: { + default: '', + }, + }) + + const tooltip = container.querySelector('[aria-label="tooltip"]') + expect(tooltip).not.toBeNull() + expect(tooltip?.textContent).toContain('Tooltip text') + + const currentContainer = tooltip?.parentElement?.parentElement + expect(currentContainer).toBe(container) + } finally { + container.remove() + } + }) +})