Skip to content

Commit 3f3dd99

Browse files
Elena Rashkovanlena.rashkovan
andauthored
feat: add search component (#522)
* feat(search): add experimental search component * feat(textfield): remove button from tab order --------- Co-authored-by: lena.rashkovan <lena.rashkovan@free-now.com>
1 parent 7f0f390 commit 3f3dd99

File tree

6 files changed

+173
-7
lines changed

6 files changed

+173
-7
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import React from 'react';
2+
import { render } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
5+
import { Search } from './Search';
6+
7+
describe('Experimental: Search', () => {
8+
it('renders correctly', async () => {
9+
const user = userEvent.setup();
10+
const utils = render(<Search placeholder="Test" />);
11+
12+
const searchField = await utils.findByRole('searchbox', {
13+
name: 'Test'
14+
});
15+
16+
await user.type(searchField, 'Text');
17+
18+
const clearButton = utils.queryByRole('button', {
19+
name: 'Clear search'
20+
});
21+
22+
expect(searchField).toHaveValue('Text');
23+
expect(clearButton).toBeVisible();
24+
25+
await user.click(clearButton);
26+
27+
expect(searchField).toHaveValue('');
28+
expect(clearButton).not.toBeInTheDocument();
29+
});
30+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { Input as BaseInput, Button as BaseButton, SearchField as BaseSearchField } from 'react-aria-components';
2+
import styled from 'styled-components';
3+
4+
import { getSemanticValue } from '../../../essentials/experimental';
5+
import SearchIcon from '../../../icons/experimental/SearchIcon';
6+
import { get } from '../../../utils/experimental/themeGet';
7+
import { textStyles } from '../Text/Text';
8+
9+
export const Icon = styled(SearchIcon)`
10+
position: absolute;
11+
left: ${get('space.3')};
12+
top: 50%;
13+
transform: translateY(-50%);
14+
pointer-events: none;
15+
`;
16+
17+
export const SearchField = styled(BaseSearchField)`
18+
position: relative;
19+
border-radius: ${get('radii.4')};
20+
background: ${getSemanticValue('surface-variant')};
21+
color: ${getSemanticValue('on-surface-variant')};
22+
23+
&::before {
24+
position: absolute;
25+
pointer-events: none;
26+
inset: 0;
27+
content: '';
28+
border-radius: inherit;
29+
opacity: 0;
30+
transition: opacity ease 200ms;
31+
}
32+
33+
&:has([data-hovered])::before {
34+
opacity: 0.16;
35+
background-color: ${getSemanticValue('on-surface-variant')};
36+
}
37+
38+
&:has([data-focused]) {
39+
background: ${getSemanticValue('surface')};
40+
color: ${getSemanticValue('interactive')};
41+
outline: ${getSemanticValue('interactive')} solid 0.125rem;
42+
outline-offset: -0.125rem;
43+
44+
&::before {
45+
opacity: 0;
46+
}
47+
}
48+
49+
&:has([data-disabled]) {
50+
opacity: 0.38;
51+
}
52+
`;
53+
54+
export const Button = styled(BaseButton)`
55+
appearance: none;
56+
background: none;
57+
display: flex;
58+
margin: 0;
59+
padding: 0;
60+
border: 0;
61+
outline: 0;
62+
cursor: pointer;
63+
position: absolute;
64+
right: ${get('space.3')};
65+
top: 50%;
66+
transform: translateY(-50%);
67+
`;
68+
69+
export const Input = styled(BaseInput)`
70+
background-color: unset;
71+
display: block;
72+
padding: ${get('space.2')} ${get('space.9')};
73+
border: 0;
74+
outline: 0;
75+
caret-color: ${getSemanticValue('interactive')};
76+
color: ${getSemanticValue('on-surface')};
77+
78+
${textStyles.variants.label1}
79+
80+
&[data-placeholder] {
81+
color: ${getSemanticValue('on-surface-variant')};
82+
}
83+
84+
&::-webkit-search-cancel-button {
85+
display: none;
86+
}
87+
`;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React, { ReactElement } from 'react';
2+
import { SearchFieldProps } from 'react-aria-components';
3+
import XCrossCircleIcon from '../../../icons/actions/XCrossCircleIcon';
4+
5+
import * as Styled from './Search.styled';
6+
7+
interface SearchProps extends SearchFieldProps {
8+
placeholder: string;
9+
}
10+
11+
export const Search = ({ placeholder, ...rest }: SearchProps): ReactElement => (
12+
<Styled.SearchField aria-label={placeholder} {...rest}>
13+
{({ state }) => (
14+
<>
15+
<Styled.Icon size={20} />
16+
<Styled.Input placeholder={placeholder} />
17+
{state.value !== '' && (
18+
<Styled.Button>
19+
<XCrossCircleIcon size={20} />
20+
</Styled.Button>
21+
)}
22+
</>
23+
)}
24+
</Styled.SearchField>
25+
);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { StoryObj, Meta } from '@storybook/react';
2+
import { Search } from '../Search';
3+
4+
const meta: Meta = {
5+
title: 'Experimental/Components/Search',
6+
component: Search,
7+
parameters: {
8+
layout: 'centered'
9+
},
10+
args: {
11+
placeholder: 'Search',
12+
isDisabled: false
13+
}
14+
};
15+
16+
export default meta;
17+
18+
type Story = StoryObj<typeof Search>;
19+
20+
export const Default: Story = {};
21+
22+
export const Disabled: Story = {
23+
args: {
24+
isDisabled: true
25+
}
26+
};

src/components/experimental/TextField/TextField.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { FieldError, TextField as BaseTextField, TextFieldProps as BaseTextField
33
import styled from 'styled-components';
44
import XCrossCircleIcon from '../../../icons/actions/XCrossCircleIcon';
55
import { get } from '../../../utils/experimental/themeGet';
6-
import { VisuallyHidden } from '../../VisuallyHidden/VisuallyHidden';
76
import { Button } from '../Field/Button';
87
import { Label } from '../Field/Label';
98
import { TextArea, Input, fieldTextStyles } from '../Field/Field';
@@ -14,8 +13,7 @@ import { Wrapper } from '../Field/Wrapper';
1413
import { FieldProps } from '../Field/Props';
1514

1615
const defaultAriaStrings = {
17-
clearFieldButton: 'Clear field',
18-
messageFieldIsCleared: 'The field is cleared'
16+
clearFieldButton: 'Clear field'
1917
};
2018

2119
const AutoResizingInnerWrapper = styled(InnerWrapper)`
@@ -64,7 +62,6 @@ interface TextFieldProps extends FieldProps, BaseTextFieldProps {
6462
*/
6563
ariaStrings?: {
6664
clearFieldButton: string;
67-
messageFieldIsCleared: string;
6865
};
6966
}
7067

@@ -107,12 +104,12 @@ const TextField = React.forwardRef<HTMLDivElement, TextFieldProps>(
107104
inputRef.current.value = '';
108105
handleChange('');
109106
}}
107+
excludeFromTabOrder
108+
preventFocusOnPress
110109
>
111110
<XCrossCircleIcon />
112111
</Button>
113-
) : (
114-
<VisuallyHidden aria-live="polite">{ariaStrings.messageFieldIsCleared}</VisuallyHidden>
115-
);
112+
) : null;
116113

117114
const flyingLabel = <Label $flying={Boolean(placeholder || text.length > 0)}>{label}</Label>;
118115

src/components/experimental/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export { Label } from './Label/Label';
1313
export { ListBox, ListBoxItem } from './ListBox/ListBox';
1414
export { Modal } from './Modal/Modal';
1515
export { Popover } from './Popover/Popover';
16+
export { Search } from './Search/Search';
1617
export { Select } from './Select/Select';
1718
export { Snackbar, SnackbarProps } from './Snackbar/Snackbar';
1819
export { Table, Row, Cell, Skeleton, Column, TableBody, TableHeader } from './Table/Table';

0 commit comments

Comments
 (0)