From 584a9be3a216005e865ef8bbfee39ba9b2d5ad79 Mon Sep 17 00:00:00 2001 From: "lena.rashkovan" Date: Wed, 5 Mar 2025 17:56:03 +0100 Subject: [PATCH 1/2] feat(search): add experimental search component --- .../experimental/Search/Search.spec.tsx | 30 +++++++ .../experimental/Search/Search.styled.ts | 87 +++++++++++++++++++ src/components/experimental/Search/Search.tsx | 25 ++++++ .../Search/docs/Search.stories.tsx | 26 ++++++ src/components/experimental/index.ts | 1 + 5 files changed, 169 insertions(+) create mode 100644 src/components/experimental/Search/Search.spec.tsx create mode 100644 src/components/experimental/Search/Search.styled.ts create mode 100644 src/components/experimental/Search/Search.tsx create mode 100644 src/components/experimental/Search/docs/Search.stories.tsx diff --git a/src/components/experimental/Search/Search.spec.tsx b/src/components/experimental/Search/Search.spec.tsx new file mode 100644 index 00000000..1693e36d --- /dev/null +++ b/src/components/experimental/Search/Search.spec.tsx @@ -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(); + + 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(); + }); +}); diff --git a/src/components/experimental/Search/Search.styled.ts b/src/components/experimental/Search/Search.styled.ts new file mode 100644 index 00000000..00f10549 --- /dev/null +++ b/src/components/experimental/Search/Search.styled.ts @@ -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; + } +`; diff --git a/src/components/experimental/Search/Search.tsx b/src/components/experimental/Search/Search.tsx new file mode 100644 index 00000000..fd66311a --- /dev/null +++ b/src/components/experimental/Search/Search.tsx @@ -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 => ( + + {({ state }) => ( + <> + + + {state.value !== '' && ( + + + + )} + + )} + +); diff --git a/src/components/experimental/Search/docs/Search.stories.tsx b/src/components/experimental/Search/docs/Search.stories.tsx new file mode 100644 index 00000000..08f97987 --- /dev/null +++ b/src/components/experimental/Search/docs/Search.stories.tsx @@ -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; + +export const Default: Story = {}; + +export const Disabled: Story = { + args: { + isDisabled: true + } +}; diff --git a/src/components/experimental/index.ts b/src/components/experimental/index.ts index 3171bf57..9b20791b 100644 --- a/src/components/experimental/index.ts +++ b/src/components/experimental/index.ts @@ -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'; From 20fa86f002f7e00e0d8c4891bee809023cf94dfe Mon Sep 17 00:00:00 2001 From: "lena.rashkovan" Date: Wed, 5 Mar 2025 17:56:53 +0100 Subject: [PATCH 2/2] feat(textfield): remove button from tab order --- src/components/experimental/TextField/TextField.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/components/experimental/TextField/TextField.tsx b/src/components/experimental/TextField/TextField.tsx index 78372663..387f154b 100644 --- a/src/components/experimental/TextField/TextField.tsx +++ b/src/components/experimental/TextField/TextField.tsx @@ -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'; @@ -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)` @@ -64,7 +62,6 @@ interface TextFieldProps extends FieldProps, BaseTextFieldProps { */ ariaStrings?: { clearFieldButton: string; - messageFieldIsCleared: string; }; } @@ -107,12 +104,12 @@ const TextField = React.forwardRef( inputRef.current.value = ''; handleChange(''); }} + excludeFromTabOrder + preventFocusOnPress > - ) : ( - {ariaStrings.messageFieldIsCleared} - ); + ) : null; const flyingLabel = ;