Skip to content

Commit

Permalink
feat: add search component (#522)
Browse files Browse the repository at this point in the history
* feat(search): add experimental search component

* feat(textfield): remove button from tab order

---------

Co-authored-by: lena.rashkovan <[email protected]>
  • Loading branch information
shiba-codes and lena.rashkovan authored Mar 6, 2025
1 parent 7f0f390 commit 3f3dd99
Show file tree
Hide file tree
Showing 6 changed files with 173 additions and 7 deletions.
30 changes: 30 additions & 0 deletions src/components/experimental/Search/Search.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { Search } from './Search';

describe('Experimental: Search', () => {
it('renders correctly', async () => {
const user = userEvent.setup();
const utils = render(<Search placeholder="Test" />);

const searchField = await utils.findByRole('searchbox', {
name: 'Test'
});

await user.type(searchField, 'Text');

const clearButton = utils.queryByRole('button', {
name: 'Clear search'
});

expect(searchField).toHaveValue('Text');
expect(clearButton).toBeVisible();

await user.click(clearButton);

expect(searchField).toHaveValue('');
expect(clearButton).not.toBeInTheDocument();
});
});
87 changes: 87 additions & 0 deletions src/components/experimental/Search/Search.styled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Input as BaseInput, Button as BaseButton, SearchField as BaseSearchField } from 'react-aria-components';
import styled from 'styled-components';

import { getSemanticValue } from '../../../essentials/experimental';
import SearchIcon from '../../../icons/experimental/SearchIcon';
import { get } from '../../../utils/experimental/themeGet';
import { textStyles } from '../Text/Text';

export const Icon = styled(SearchIcon)`
position: absolute;
left: ${get('space.3')};
top: 50%;
transform: translateY(-50%);
pointer-events: none;
`;

export const SearchField = styled(BaseSearchField)`
position: relative;
border-radius: ${get('radii.4')};
background: ${getSemanticValue('surface-variant')};
color: ${getSemanticValue('on-surface-variant')};
&::before {
position: absolute;
pointer-events: none;
inset: 0;
content: '';
border-radius: inherit;
opacity: 0;
transition: opacity ease 200ms;
}
&:has([data-hovered])::before {
opacity: 0.16;
background-color: ${getSemanticValue('on-surface-variant')};
}
&:has([data-focused]) {
background: ${getSemanticValue('surface')};
color: ${getSemanticValue('interactive')};
outline: ${getSemanticValue('interactive')} solid 0.125rem;
outline-offset: -0.125rem;
&::before {
opacity: 0;
}
}
&:has([data-disabled]) {
opacity: 0.38;
}
`;

export const Button = styled(BaseButton)`
appearance: none;
background: none;
display: flex;
margin: 0;
padding: 0;
border: 0;
outline: 0;
cursor: pointer;
position: absolute;
right: ${get('space.3')};
top: 50%;
transform: translateY(-50%);
`;

export const Input = styled(BaseInput)`
background-color: unset;
display: block;
padding: ${get('space.2')} ${get('space.9')};
border: 0;
outline: 0;
caret-color: ${getSemanticValue('interactive')};
color: ${getSemanticValue('on-surface')};
${textStyles.variants.label1}
&[data-placeholder] {
color: ${getSemanticValue('on-surface-variant')};
}
&::-webkit-search-cancel-button {
display: none;
}
`;
25 changes: 25 additions & 0 deletions src/components/experimental/Search/Search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React, { ReactElement } from 'react';
import { SearchFieldProps } from 'react-aria-components';
import XCrossCircleIcon from '../../../icons/actions/XCrossCircleIcon';

import * as Styled from './Search.styled';

interface SearchProps extends SearchFieldProps {
placeholder: string;
}

export const Search = ({ placeholder, ...rest }: SearchProps): ReactElement => (
<Styled.SearchField aria-label={placeholder} {...rest}>
{({ state }) => (
<>
<Styled.Icon size={20} />
<Styled.Input placeholder={placeholder} />
{state.value !== '' && (
<Styled.Button>
<XCrossCircleIcon size={20} />
</Styled.Button>
)}
</>
)}
</Styled.SearchField>
);
26 changes: 26 additions & 0 deletions src/components/experimental/Search/docs/Search.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { StoryObj, Meta } from '@storybook/react';
import { Search } from '../Search';

const meta: Meta = {
title: 'Experimental/Components/Search',
component: Search,
parameters: {
layout: 'centered'
},
args: {
placeholder: 'Search',
isDisabled: false
}
};

export default meta;

type Story = StoryObj<typeof Search>;

export const Default: Story = {};

export const Disabled: Story = {
args: {
isDisabled: true
}
};
11 changes: 4 additions & 7 deletions src/components/experimental/TextField/TextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { FieldError, TextField as BaseTextField, TextFieldProps as BaseTextField
import styled from 'styled-components';
import XCrossCircleIcon from '../../../icons/actions/XCrossCircleIcon';
import { get } from '../../../utils/experimental/themeGet';
import { VisuallyHidden } from '../../VisuallyHidden/VisuallyHidden';
import { Button } from '../Field/Button';
import { Label } from '../Field/Label';
import { TextArea, Input, fieldTextStyles } from '../Field/Field';
Expand All @@ -14,8 +13,7 @@ import { Wrapper } from '../Field/Wrapper';
import { FieldProps } from '../Field/Props';

const defaultAriaStrings = {
clearFieldButton: 'Clear field',
messageFieldIsCleared: 'The field is cleared'
clearFieldButton: 'Clear field'
};

const AutoResizingInnerWrapper = styled(InnerWrapper)`
Expand Down Expand Up @@ -64,7 +62,6 @@ interface TextFieldProps extends FieldProps, BaseTextFieldProps {
*/
ariaStrings?: {
clearFieldButton: string;
messageFieldIsCleared: string;
};
}

Expand Down Expand Up @@ -107,12 +104,12 @@ const TextField = React.forwardRef<HTMLDivElement, TextFieldProps>(
inputRef.current.value = '';
handleChange('');
}}
excludeFromTabOrder
preventFocusOnPress
>
<XCrossCircleIcon />
</Button>
) : (
<VisuallyHidden aria-live="polite">{ariaStrings.messageFieldIsCleared}</VisuallyHidden>
);
) : null;

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

Expand Down
1 change: 1 addition & 0 deletions src/components/experimental/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export { Label } from './Label/Label';
export { ListBox, ListBoxItem } from './ListBox/ListBox';
export { Modal } from './Modal/Modal';
export { Popover } from './Popover/Popover';
export { Search } from './Search/Search';
export { Select } from './Select/Select';
export { Snackbar, SnackbarProps } from './Snackbar/Snackbar';
export { Table, Row, Cell, Skeleton, Column, TableBody, TableHeader } from './Table/Table';
Expand Down

0 comments on commit 3f3dd99

Please sign in to comment.