Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add search component #522

Merged
merged 2 commits into from
Mar 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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