Skip to content

Commit 084e813

Browse files
committed
refactor(frontend): refactor tselectlist with reka-ui
Refactor TSelectList from headlessui to reka-ui which makes it more testable and brings a11y improvements and collision detection. Signed-off-by: Edward Sammut Alessi <[email protected]>
1 parent 0aba0fc commit 084e813

File tree

7 files changed

+319
-128
lines changed

7 files changed

+319
-128
lines changed

frontend/e2e/omnictl_fixtures.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const test = base.extend<OmnictlFixtures>({
3131

3232
await page.getByRole('button', { name: 'Download omnictl' }).click()
3333

34-
await page.getByRole('button', { name: 'omnictl:' }).click()
34+
await page.getByRole('combobox', { name: 'omnictl:' }).click()
3535
await page.getByRole('option', { name: `omnictl-${getPlatform()}-${getArch()}` }).click()
3636

3737
const [downloadOmnictl] = await Promise.all([

frontend/src/components/common/Form/EnumRenderer.vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ const values = computed(() => {
2929
<TSelectList
3030
:id="control.id + '-input'"
3131
class="h-6"
32-
menu-align="right"
3332
:default-value="control.data ?? 'unset'"
3433
:disabled="!control.enabled"
3534
:values="values"
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright (c) 2025 Sidero Labs, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the LICENSE file.
5+
import userEvent from '@testing-library/user-event'
6+
import { render, screen, waitFor } from '@testing-library/vue'
7+
import { mount } from '@vue/test-utils'
8+
import { expect, test, vi } from 'vitest'
9+
10+
import TSelectList from './TSelectList.vue'
11+
12+
// Used by reka-ui select, test fails without it
13+
window.HTMLElement.prototype.hasPointerCapture = vi.fn()
14+
15+
test('is accessible with inline label', () => {
16+
render(TSelectList, {
17+
props: {
18+
title: 'My select',
19+
values: ['first option', 'second option'],
20+
},
21+
})
22+
23+
expect(screen.getByLabelText('My select')).toBeInTheDocument()
24+
})
25+
26+
test('is accessible with overhead label', () => {
27+
render(TSelectList, {
28+
props: {
29+
title: 'My select',
30+
values: ['first option', 'second option'],
31+
overheadTitle: true,
32+
},
33+
})
34+
35+
expect(screen.getByLabelText('My select')).toBeInTheDocument()
36+
})
37+
38+
test('accepts a default value', async () => {
39+
const user = userEvent.setup()
40+
const updateFn = vi.fn()
41+
const checkedFn = vi.fn()
42+
43+
render(TSelectList, {
44+
props: {
45+
title: 'My select',
46+
values: ['first option', 'second option'],
47+
defaultValue: 'first option',
48+
'onUpdate:modelValue': updateFn,
49+
onCheckedValue: checkedFn,
50+
},
51+
})
52+
53+
const trigger = screen.getByLabelText('My select')
54+
55+
expect(updateFn).toHaveBeenCalledExactlyOnceWith('first option')
56+
expect(checkedFn).not.toHaveBeenCalled()
57+
58+
expect(trigger).toHaveTextContent('first option')
59+
60+
// Open dropdown
61+
await user.click(trigger)
62+
63+
expect(screen.getByRole('option', { name: 'first option' })).toHaveAttribute(
64+
'aria-selected',
65+
'true',
66+
)
67+
})
68+
69+
test('allows selection', async () => {
70+
const user = userEvent.setup()
71+
const updateFn = vi.fn()
72+
const checkedFn = vi.fn()
73+
74+
render(TSelectList, {
75+
props: {
76+
title: 'My select',
77+
values: ['first option', 'second option'],
78+
'onUpdate:modelValue': updateFn,
79+
onCheckedValue: checkedFn,
80+
},
81+
})
82+
83+
const trigger = screen.getByLabelText('My select')
84+
85+
expect(trigger.textContent).toBe('My select') // Exact match to assert no default
86+
87+
// Open dropdown
88+
await user.click(trigger)
89+
90+
const option = screen.getByRole('option', { name: 'second option' })
91+
92+
// Select option
93+
await user.click(option)
94+
95+
expect(updateFn).toHaveBeenCalledExactlyOnceWith('second option')
96+
expect(checkedFn).toHaveBeenCalledExactlyOnceWith('second option')
97+
98+
expect(trigger).toHaveTextContent('second option')
99+
expect(option).toHaveAttribute('aria-selected', 'true')
100+
})
101+
102+
test('exposes selectItem', async () => {
103+
const updateFn = vi.fn()
104+
const checkedFn = vi.fn()
105+
106+
// Can't test defineExpose with testing-library, using @vue/test-utils instead
107+
const wrapper = mount(TSelectList, {
108+
props: {
109+
title: 'My select',
110+
values: ['first option', 'second option'],
111+
'onUpdate:modelValue': updateFn,
112+
onCheckedValue: checkedFn,
113+
},
114+
})
115+
116+
expect(wrapper.text()).not.toContain('second option')
117+
118+
wrapper.vm.selectItem('second option')
119+
120+
expect(updateFn).toHaveBeenCalledExactlyOnceWith('second option')
121+
expect(checkedFn).toHaveBeenCalledExactlyOnceWith('second option')
122+
123+
await waitFor(() => {
124+
expect(wrapper.text()).toContain('second option')
125+
})
126+
})
127+
128+
test('focuses search on open', async () => {
129+
const user = userEvent.setup()
130+
131+
render(TSelectList, {
132+
props: {
133+
title: 'My select',
134+
values: ['first option', 'second option'],
135+
searcheable: true,
136+
},
137+
})
138+
139+
const trigger = screen.getByLabelText('My select')
140+
141+
// Open dropdown
142+
await user.click(trigger)
143+
144+
expect(screen.getByRole('textbox', { name: 'search' })).toHaveFocus()
145+
})
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) 2025 Sidero Labs, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the LICENSE file.
5+
import { faker } from '@faker-js/faker'
6+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
7+
import { fn } from 'storybook/test'
8+
9+
import TSelectList from './TSelectList.vue'
10+
11+
const values = faker.helpers.uniqueArray(() => faker.animal.cat(), 100).sort()
12+
13+
const meta: Meta<typeof TSelectList> = {
14+
// https://github.com/storybookjs/storybook/issues/24238
15+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
16+
component: TSelectList as any,
17+
parameters: {
18+
layout: 'centered',
19+
},
20+
args: {
21+
title: 'Kitty',
22+
values,
23+
defaultValue: values.at(-Math.round(values.length / 2)),
24+
hideSelectedSmallScreens: false,
25+
searcheable: true,
26+
overheadTitle: false,
27+
onCheckedValue: fn(),
28+
'onUpdate:modelValue': fn(),
29+
},
30+
}
31+
32+
export default meta
33+
type Story = StoryObj<typeof meta>
34+
35+
export const Default: Story = {}

0 commit comments

Comments
 (0)