Skip to content

Commit 0fc543f

Browse files
authored
feat(SearchField): empty suggestion and improvements (#1473)
1 parent a3fa78a commit 0fc543f

12 files changed

Lines changed: 278 additions & 43 deletions
4.07 MB
Binary file not shown.
Binary file not shown.
10.5 MB
Binary file not shown.
964 Bytes
Loading
23.2 KB
Loading

src/__screenshot_tests__/input-fields-screenshot-test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,21 @@ test('SearchField with suggestions', async () => {
418418
expect(screenshot).toMatchImageSnapshot();
419419
});
420420

421+
test('SearchField without suggestions', async () => {
422+
await openStoryPage({
423+
id: 'components-input-fields-searchfield--controlled',
424+
device: 'MOBILE_IOS',
425+
args: {suggestions: true, withSuggestionsEmptyCase: true},
426+
});
427+
428+
const field = await screen.findByLabelText('Label');
429+
await field.type('merry xmas');
430+
431+
const screenshot = await page.screenshot({fullPage: true});
432+
433+
expect(screenshot).toMatchImageSnapshot();
434+
});
435+
421436
test('DateField', async () => {
422437
await openStoryPage({
423438
id: 'components-input-fields-datefield--uncontrolled',

src/__stories__/search-field-story.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ const defaultBaseArgs: SearchFieldBaseArgs = {
5252
interface SearchFieldControlledArgs extends SearchFieldBaseArgs {
5353
initialValue: string;
5454
suggestions: boolean;
55+
withSuggestionsEmptyCase: boolean | string;
56+
shouldShowSuggestions: 'focus' | number;
5557
}
5658

5759
export const Controlled: StoryComponent<SearchFieldControlledArgs> = ({
@@ -98,9 +100,21 @@ export const Controlled: StoryComponent<SearchFieldControlledArgs> = ({
98100

99101
Controlled.storyName = 'controlled';
100102
Controlled.args = {
101-
initialValue: '',
102103
...defaultBaseArgs,
104+
initialValue: '',
103105
suggestions: false,
106+
withSuggestionsEmptyCase: false,
107+
shouldShowSuggestions: 'focus',
108+
};
109+
Controlled.argTypes = {
110+
shouldShowSuggestions: {
111+
options: ['focus', 1, 2, 3, 4, 5],
112+
control: {type: 'select'},
113+
},
114+
withSuggestionsEmptyCase: {
115+
options: [false, true, 'Custom no suggestions text'],
116+
control: {type: 'select'},
117+
},
104118
};
105119

106120
interface SearchFieldUncontrolledArgs extends SearchFieldBaseArgs {
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import * as React from 'react';
2+
import {act, render, screen} from '@testing-library/react';
3+
import SearchField from '../search-field';
4+
import ThemeContextProvider from '../theme-context-provider';
5+
import {makeTheme} from './test-utils';
6+
import userEvent from '@testing-library/user-event';
7+
8+
const getSuggestions = (value: string) => {
9+
const allSuggestions = ['Apple', 'Banana', 'Orange'];
10+
return allSuggestions.filter((item) => item.toLowerCase().includes(value.toLowerCase()));
11+
};
12+
13+
test('Should show suggestions on focus when shouldShowSuggestions is "focus"', async () => {
14+
await act(async () =>
15+
render(
16+
<ThemeContextProvider theme={makeTheme()}>
17+
<SearchField
18+
getSuggestions={getSuggestions}
19+
shouldShowSuggestions="focus"
20+
label="Search"
21+
name="search"
22+
/>
23+
</ThemeContextProvider>
24+
)
25+
);
26+
27+
expect(screen.queryByRole('menuitem', {name: 'Apple'})).not.toBeInTheDocument();
28+
29+
await userEvent.click(await screen.findByLabelText('Search'));
30+
31+
expect(await screen.findByRole('menuitem', {name: 'Apple'})).toBeInTheDocument();
32+
});
33+
34+
test('Should show suggestions on type when shouldShowSuggestions is 2', async () => {
35+
await act(async () =>
36+
render(
37+
<ThemeContextProvider theme={makeTheme()}>
38+
<SearchField
39+
getSuggestions={getSuggestions}
40+
shouldShowSuggestions={2}
41+
label="Search"
42+
name="search"
43+
/>
44+
</ThemeContextProvider>
45+
)
46+
);
47+
48+
await userEvent.click(await screen.findByLabelText('Search'));
49+
expect(screen.queryByRole('menuitem', {name: 'Apple'})).not.toBeInTheDocument();
50+
51+
await userEvent.type(await screen.findByLabelText('Search'), 'A');
52+
expect(screen.queryByRole('menuitem', {name: 'Apple'})).not.toBeInTheDocument();
53+
54+
await userEvent.type(await screen.findByLabelText('Search'), 'p');
55+
expect(await screen.findByRole('menuitem', {name: 'Apple'})).toBeInTheDocument();
56+
});
57+
58+
test('Should reload suggestions when clearing the field', async () => {
59+
await act(async () =>
60+
render(
61+
<ThemeContextProvider theme={makeTheme()}>
62+
<SearchField
63+
getSuggestions={getSuggestions}
64+
shouldShowSuggestions="focus"
65+
label="Search"
66+
name="search"
67+
/>
68+
</ThemeContextProvider>
69+
)
70+
);
71+
72+
await userEvent.type(await screen.findByLabelText('Search'), 'invent');
73+
74+
expect(screen.queryByRole('menuitem', {name: 'Apple'})).not.toBeInTheDocument();
75+
76+
await userEvent.click(await screen.findByRole('button', {name: 'Borrar búsqueda'}));
77+
78+
expect(screen.getByRole('menuitem', {name: 'Apple'})).toBeInTheDocument();
79+
});
80+
81+
test('Should not show suggestions empty case if enabled', async () => {
82+
await act(async () =>
83+
render(
84+
<ThemeContextProvider theme={makeTheme()}>
85+
<SearchField
86+
getSuggestions={getSuggestions}
87+
shouldShowSuggestions="focus"
88+
withSuggestionsEmptyCase
89+
label="Search"
90+
name="search"
91+
/>
92+
</ThemeContextProvider>
93+
)
94+
);
95+
96+
await userEvent.type(await screen.findByLabelText('Search'), 'invent');
97+
98+
expect(screen.getByText('Sin sugerencias')).toBeInTheDocument();
99+
});
100+
101+
test('Should not show suggestions empty case by default', async () => {
102+
await act(async () =>
103+
render(
104+
<ThemeContextProvider theme={makeTheme()}>
105+
<SearchField
106+
getSuggestions={getSuggestions}
107+
shouldShowSuggestions="focus"
108+
label="Search"
109+
name="search"
110+
/>
111+
</ThemeContextProvider>
112+
)
113+
);
114+
115+
await userEvent.type(await screen.findByLabelText('Search'), 'invent');
116+
117+
expect(screen.queryByText('Sin sugerencias')).not.toBeInTheDocument();
118+
});

src/search-field.tsx

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,33 @@
11
'use client';
22
import * as React from 'react';
33
import {useFieldProps} from './form-context';
4-
import {FieldEndIcon, TextFieldBaseAutosuggest} from './text-field-base';
5-
import IconSearchRegular from './generated/mistica-icons/icon-search-regular';
64
import IconCloseRegular from './generated/mistica-icons/icon-close-regular';
5+
import IconSearchRegular from './generated/mistica-icons/icon-search-regular';
76
import {useTheme} from './hooks';
8-
import {createChangeEvent} from './utils/dom';
9-
import {combineRefs} from './utils/common';
107
import {iconSize} from './icon-button.css';
8+
import {FieldEndIcon, TextFieldBaseAutosuggest} from './text-field-base';
119
import * as tokens from './text-tokens';
10+
import {combineRefs} from './utils/common';
11+
import {createChangeEvent} from './utils/dom';
12+
import {flushSync} from 'react-dom';
1213

1314
import type {CommonFormFieldProps} from './text-field-base';
1415

1516
export interface SearchFieldProps extends CommonFormFieldProps {
1617
onChangeValue?: (value: string, rawValue: string) => void;
1718
getSuggestions?: (value: string) => ReadonlyArray<string>;
19+
/**
20+
* Content to show when there are no suggestions. By default it is not shown.
21+
* - true: Show default "no suggestions" text.
22+
* - string: Show custom text.
23+
*/
24+
withSuggestionsEmptyCase?: boolean | string;
25+
/**
26+
* Indicates when suggestions should be shown.
27+
* - 'focus': Show suggestions when the input is focused.
28+
* - number: Show suggestions after a certain number of characters have been typed.
29+
*/
30+
shouldShowSuggestions?: 'focus' | number;
1831
inputMode?: React.HTMLAttributes<HTMLInputElement>['inputMode'];
1932
withStartIcon?: boolean;
2033
}
@@ -60,11 +73,15 @@ const SearchField = React.forwardRef<any, SearchFieldProps>(
6073
);
6174

6275
const clearInput = React.useCallback(() => {
63-
handleChangeValue('', '');
64-
if (inputRef.current) {
65-
onChange?.(createChangeEvent(inputRef.current, ''));
66-
inputRef.current.focus();
67-
}
76+
flushSync(() => {
77+
handleChangeValue('', '');
78+
if (inputRef.current) {
79+
onChange?.(createChangeEvent(inputRef.current, ''));
80+
}
81+
});
82+
// When clearing the field, we need to blur and focus again the input so suggestions are reloaded
83+
inputRef?.current?.blur();
84+
inputRef?.current?.focus();
6885
}, [handleChangeValue, onChange]);
6986

7087
const fieldProps = useFieldProps({

src/text-field-base.css.ts

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import {globalStyle, style} from '@vanilla-extract/css';
2-
import {sprinkles} from './sprinkles.css';
3-
import {vars} from './skins/skin-contract.css';
2+
import {iconContainerSize} from './icon-button.css';
43
import * as mq from './media-queries.css';
4+
import {vars as skinVars, vars} from './skins/skin-contract.css';
5+
import {sprinkles} from './sprinkles.css';
56
import {pxToRem} from './utils/css';
6-
import {iconContainerSize} from './icon-button.css';
77

88
const borderSize = 1;
99

@@ -328,36 +328,52 @@ export const prefix = style([
328328
},
329329
]);
330330

331-
export const menuItem = style([
331+
export const menuItemBase = style([
332332
sprinkles({
333333
display: 'flex',
334334
alignItems: 'center',
335+
}),
336+
{
337+
minHeight: pxToRem(48),
338+
padding: '6px 8px',
339+
userSelect: 'none',
340+
},
341+
]);
342+
343+
export const menuItem = style([
344+
menuItemBase,
345+
sprinkles({
335346
cursor: 'pointer',
336347
}),
337348
{
338-
height: pxToRem(48),
339-
padding: '6px 16px',
340-
transition: 'background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms',
341-
selectors: {
342-
'&:hover': {
343-
background: 'rgba(0, 0, 0, 0.08)',
344-
},
345-
},
349+
transition: 'background-color 0.1s ease-in-out',
346350
},
347351
]);
348352

349-
export const menuItemSelected = sprinkles({
350-
background: vars.colors.backgroundAlternative,
353+
export const menuItemSelected = style({
354+
backgroundColor: skinVars.colors.backgroundContainerHover,
355+
':active': {
356+
backgroundColor: skinVars.colors.backgroundContainerPressed,
357+
},
358+
'@media': {
359+
[mq.touchableOnly]: {
360+
backgroundColor: 'transparent',
361+
transition: 'none',
362+
},
363+
},
351364
});
352365

353366
export const suggestionsContainer = style([
354367
sprinkles({
355368
position: 'absolute',
356369
}),
357370
{
358-
boxShadow:
359-
'0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,0.12)',
360-
background: 'white',
371+
marginTop: 8,
372+
boxSizing: 'border-box',
373+
boxShadow: '0px 2px 4px 0px #00000033',
374+
padding: 8,
375+
background: skinVars.colors.backgroundContainer,
376+
borderRadius: skinVars.borderRadii.popup,
361377

362378
// one more than TextField label
363379
zIndex: 2,

0 commit comments

Comments
 (0)