Skip to content

Commit fbcea5a

Browse files
committed
chore: Improve select component and implement tests
1 parent b973594 commit fbcea5a

File tree

3 files changed

+85
-50
lines changed

3 files changed

+85
-50
lines changed

packages/solid/src/components/control/select/select.component.test.tsx

+68-34
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,29 @@ import { SelectOption } from './select.types';
44
import { render } from '@solidjs/testing-library';
55

66
describe('Select', () => {
7-
const fruits: SelectOption[] = [
8-
{ value: 'apple', label: 'Apple' },
9-
{ value: 'banana', label: 'Banana' },
10-
{ value: 'cherry', label: 'Cherry' },
11-
];
12-
13-
interface Car {
14-
type: string;
15-
color: string;
7+
interface Fruit {
8+
name: string;
9+
description: string;
1610
}
1711

18-
const cars: SelectOption<Car>[] = [
19-
{
20-
value: { type: 'limousine', color: 'black' },
21-
label: 'Black Limousine',
22-
},
12+
const fruits: SelectOption<Fruit>[] = [
2313
{
24-
value: { type: 'sedan', color: 'red' },
25-
label: 'Red Sedan',
14+
key: 'apple',
15+
value: {
16+
name: 'Apple',
17+
description: 'A red apple.',
18+
},
19+
label: 'Apple',
2620
},
2721
{
28-
value: { type: 'coupe', color: 'blue' },
29-
label: 'Blue Coupe',
22+
key: 'banana',
23+
value: {
24+
name: 'Banana',
25+
description: 'A tasty banana.',
26+
},
27+
label: 'Banana',
3028
},
29+
{ key: 'cherry', value: { name: 'Cherry', description: 'A sweet cherry.' }, label: 'Cherry' },
3130
];
3231

3332
it('should render with default values', () => {
@@ -43,20 +42,21 @@ describe('Select', () => {
4342
const label = selectContainer?.querySelector('label');
4443
expect(label).toBeInTheDocument();
4544
expect(label).toHaveAttribute('for', select!.id);
45+
const icon = label!.querySelector('iconify-icon');
46+
expect(icon).not.toBeInTheDocument();
4647
const options = selectContainer?.querySelectorAll('option');
4748
expect(options).toHaveLength(3);
48-
expect(options![0]).toHaveAttribute('value', 'apple');
49-
expect(options![0]).toHaveAttribute('label', 'Apple');
50-
expect(options![1]).toHaveAttribute('value', 'banana');
51-
expect(options![1]).toHaveAttribute('label', 'Banana');
52-
expect(options![2]).toHaveAttribute('value', 'cherry');
53-
expect(options![2]).toHaveAttribute('label', 'Cherry');
49+
fruits.forEach((fruit, index) => {
50+
expect(options![index]).toHaveAttribute('value', fruit.key);
51+
expect(options![index]).toHaveAttribute('label', fruit.label);
52+
});
5453
});
5554

5655
it('should render with custom values', () => {
5756
const { container } = render(() => (
5857
<Select
5958
label="Fruits"
59+
icon="mdi:fruit-cherries"
6060
options={fruits}
6161
variant="outlined"
6262
size="small"
@@ -71,9 +71,13 @@ describe('Select', () => {
7171
expect(selectContainer).toHaveAttribute('spx-size', 'small');
7272
expect(selectContainer).toHaveClass('spx', 'spx-select', 'my-class');
7373
expect(selectContainer).toHaveStyle({ color: 'rgb(255, 0, 0)' });
74-
const select = selectContainer?.querySelector('select');
74+
const select = selectContainer!.querySelector('select');
7575
expect(select).toBeInTheDocument();
7676
expect(select).toHaveAttribute('disabled');
77+
const label = selectContainer!.querySelector('label');
78+
expect(label).toBeInTheDocument();
79+
const icon = label!.querySelector('iconify-icon');
80+
expect(icon).toBeInTheDocument();
7781
});
7882

7983
it('should call the onChange callback', () => {
@@ -86,17 +90,47 @@ describe('Select', () => {
8690
select!.value = 'banana';
8791
select!.dispatchEvent(new Event('change'));
8892
expect(onChange).toHaveBeenCalledTimes(1);
89-
expect(onChange).toHaveBeenCalledWith(fruits[1].value, expect.any(Event));
93+
expect(onChange).toHaveBeenCalledWith(fruits[1], expect.any(Event));
9094
});
9195

92-
it('should be able to handle complex values', () => {
93-
const onChange = vi.fn();
94-
const { container } = render(() => <Select label="Cars" options={cars} onChange={onChange} />);
96+
it('it should generate random UUIDs if no option keys are provided', () => {
97+
const fruitsWithoutKeys = fruits.map((fruit) => ({ ...fruit, key: undefined }));
98+
const { container } = render(() => <Select label="Fruits" options={fruitsWithoutKeys} />);
99+
const selectContainer = container.querySelector('.spx-select');
100+
const options = selectContainer?.querySelectorAll('option');
101+
expect(options).toHaveLength(3);
102+
fruits.forEach((_fruit, index) => {
103+
expect(options![index]).toHaveAttribute('value', expect.stringContaining('-'));
104+
});
105+
});
106+
107+
it('should set a default option by value', () => {
108+
const { container } = render(() => (
109+
<Select label="Fruits" options={fruits} default={fruits[1]} />
110+
));
95111
const select = container.querySelector('select');
96-
expect(select).toBeInTheDocument();
97-
select!.value = '1';
98-
select!.dispatchEvent(new Event('change'));
99-
expect(onChange).toHaveBeenCalledTimes(1);
100-
expect(onChange).toHaveBeenCalledWith(cars[1].value, expect.any(Event));
112+
expect(select).toHaveValue(fruits[1].key);
113+
});
114+
115+
it('should set a default option by key', () => {
116+
const { container } = render(() => (
117+
<Select label="Fruits" options={fruits} default={fruits[1].key} />
118+
));
119+
const select = container.querySelector('select');
120+
expect(select).toHaveValue(fruits[1].key);
121+
});
122+
123+
it('should set a default option by index', () => {
124+
const { container } = render(() => <Select label="Fruits" options={fruits} default={1} />);
125+
const select = container.querySelector('select');
126+
expect(select).toHaveValue(fruits[1].key);
127+
});
128+
129+
it('should use the provided id instead of generating a random one', () => {
130+
const { container } = render(() => (
131+
<Select label="Fruits" options={fruits} attrs={{ id: 'my-select' }} />
132+
));
133+
const select = container.querySelector('select');
134+
expect(select).toHaveAttribute('id', 'my-select');
101135
});
102136
});

packages/solid/src/components/control/select/select.component.tsx

+13-15
Original file line numberDiff line numberDiff line change
@@ -8,37 +8,35 @@ import { Icon } from '@iconify-icon/solid';
88
* @returns The select component.
99
*/
1010
export const Select: Component<SelectProps> = (props) => {
11-
const { variant = 'contained', size } = props;
11+
const { variant = 'contained', size, options } = props;
1212
const id = props.attrs?.id ?? crypto.randomUUID();
1313

1414
// Ensure that each option has a unique key
15-
const _options = [...props.options];
16-
for (const option of _options) {
17-
if (typeof option.value === 'object') {
18-
if (!option.key) {
19-
option.key = crypto.randomUUID();
20-
}
15+
for (const option of props.options) {
16+
if (!option.key) {
17+
option.key = crypto.randomUUID();
2118
}
2219
}
23-
const [options, setOptions] = createSignal<SelectOption[]>(_options);
2420

2521
const findOptionByKey = (key: string): SelectOption => {
26-
const option = options().find((option) => option.key === key);
27-
if (!option) throw new Error(`Option with key ${key} not found.`);
28-
return option;
22+
const option = options.find((option) => option.key === key);
23+
return option!;
2924
};
3025

3126
const handleChange: JSX.EventHandler<HTMLSelectElement, Event> = (event) => {
3227
const select = event.currentTarget;
33-
if (props.onChange) props.onChange(findOptionByKey(select.value), event);
28+
const selectedOption = findOptionByKey(select.value);
29+
if (props.onChange) props.onChange(selectedOption, event);
3430
};
3531

3632
const getSelected = (option: SelectOption): true | undefined => {
3733
if (props.default) {
3834
if (typeof props.default === 'number') {
39-
return props.default === options().indexOf(option) ? true : undefined;
35+
return props.default === options.indexOf(option) ? true : undefined;
36+
} else if (typeof props.default === 'string') {
37+
return props.default === option.key ? true : undefined;
4038
} else {
41-
return props.default.value === option ? true : undefined;
39+
return props.default === option ? true : undefined;
4240
}
4341
}
4442
};
@@ -56,7 +54,7 @@ export const Select: Component<SelectProps> = (props) => {
5654
disabled={props.disabled || undefined}
5755
onChange={handleChange}
5856
>
59-
<For each={options()}>
57+
<For each={options}>
6058
{(option) => (
6159
<option value={option.key} label={option.label} selected={getSelected(option)} />
6260
)}

packages/solid/src/components/control/select/select.types.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { DOMElement, JSX } from 'solid-js/jsx-runtime';
55
/**
66
* An option for the Select component.
77
*/
8+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
89
export interface SelectOption<T = any> {
910
/**
1011
* The value of the option.
@@ -17,6 +18,7 @@ export interface SelectOption<T = any> {
1718
/**
1819
* The key of the option. Must be a unique string. If none is provided, a random
1920
* key will be generated. The key will be used to map the option to the select element.
21+
* ⚠️ Note: If a key is generated, the original object will be mutated.
2022
* @default crypto.randomUUID()
2123
*/
2224
key?: string;
@@ -25,6 +27,7 @@ export interface SelectOption<T = any> {
2527
/**
2628
* The Select component properties.
2729
*/
30+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2831
export interface SelectProps<T = any>
2932
extends ComponentProps<JSX.SelectHTMLAttributes<HTMLSelectElement>> {
3033
/**
@@ -41,7 +44,7 @@ export interface SelectProps<T = any>
4144
* will be selected.
4245
* @default undefined
4346
*/
44-
default?: SelectOption<T> | number;
47+
default?: SelectOption<T> | number | string;
4548
/**
4649
* An optional icon to render as part of the label.
4750
* @default undefined

0 commit comments

Comments
 (0)