diff --git a/src/components/experimental/Input/Input.tsx b/src/components/experimental/Input/Input.tsx deleted file mode 100644 index 62489c76..00000000 --- a/src/components/experimental/Input/Input.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import React, { ReactElement } from 'react'; -import styled from 'styled-components'; -import { useGeneratedId } from '../../../utils/hooks'; -import { theme } from '../../../essentials/experimental/theme'; - -const TextInput = styled.input.attrs(() => ({ - type: 'text' -}))` - border: none; - background-color: unset; - outline: none; - - font-size: ${theme.fontSizes[1]}; - font-weight: ${theme.fontWeights.medium}; - line-height: ${theme.lineHeights[1]}; - - padding-top: ${theme.space[6]}; - padding-bottom: ${theme.space[2]}; - - display: block; - width: 100%; -`; - -const Label = styled.label<{ $shouldDisplace: boolean; $shouldLabelAnimate?: boolean }>` - position: absolute; - top: 50%; - left: ${theme.space[4]}; - font-size: ${theme.fontSizes[1]}; - line-height: ${theme.lineHeights[0]}; - - transform: translate3d(0, calc(-${theme.lineHeights[0]} / 2), 0); - transform-origin: 0; - - transition: top 0.2s ease, font-size 0.2s ease, transform 0.2s ease; - - ${props => - props.$shouldDisplace && - ` - top: ${theme.space[1]}; - font-size: ${theme.fontSizes[0]}; - transform: translate3d(1px, 0 ,0); - color: hsla(347, 41%, 50%, 1); // var(--sys-color-interactive, #B44B61); - `} -`; - -const Wrapper = styled.div` - box-sizing: content-box; - - font-family: ${theme.fonts.normal}; - - background-color: hsla(0, 6%, 99%, 1); // var(--sys-color-surface, #FCFCFC); - border-width: 0.0625rem; // 1px - border-style: solid; - border-color: hsla(0, 6%, 82%, 1); // var(--sys-color-divider, #D4CECE); - border-radius: ${theme.radii[4]}; - - padding-left: ${theme.space[4]}; - padding-right: ${theme.space[4]}; - display: flex; - align-items: end; - - position: relative; - overflow: hidden; - - &:hover { - border-color: hsla(0, 6%, 47%, 1); // var(--sys-color-outline, #7F7171); - } - - &:focus-within { - // var(--sys-color-interactive, #B44B61); - outline: hsla(347, 41%, 50%, 1) solid 0.125rem; - outline-offset: -0.125rem; - } -`; - -export interface InputProps { - label: string; - placeholder: string; - id?: string; - onChange?: (value: string) => void; -} - -function Input({ label, placeholder, id: providedId, onChange, ...rest }: InputProps): ReactElement { - const id = useGeneratedId(providedId); - - const [value, setValue] = React.useState(); - const [shouldLabelDisplace, setShouldLabelDisplace] = React.useState(false); - - const handleChange = (e: React.ChangeEvent) => { - onChange(e.target.value); - - setValue(prevState => { - if (!prevState) { - setShouldLabelDisplace(true); - } - return e.target.value; - }); - }; - - const handleFocus = () => { - if (placeholder) { - return; - } - setShouldLabelDisplace(true); - }; - - const handleBlur = () => { - if (!value) { - setShouldLabelDisplace(false); - return; - } - if (placeholder || value) { - return; - } - setShouldLabelDisplace(false); - }; - - return ( - - - - - ); -} - -export { Input }; diff --git a/src/components/experimental/Input/docs/Input.stories.tsx b/src/components/experimental/Input/docs/Input.stories.tsx deleted file mode 100644 index 8913501a..00000000 --- a/src/components/experimental/Input/docs/Input.stories.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { StoryObj, Meta } from '@storybook/react'; -import { Input } from '../Input'; - -const meta: Meta = { - title: 'Experimental/Components/Input', - component: Input, - args: { - label: 'Passenger name' - }, - argTypes: { - label: { - description: 'The label the text field' - } - } -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/src/components/experimental/TextField/ClearButton.tsx b/src/components/experimental/TextField/ClearButton.tsx new file mode 100644 index 00000000..f78dd949 --- /dev/null +++ b/src/components/experimental/TextField/ClearButton.tsx @@ -0,0 +1,25 @@ +import React, { ReactElement } from 'react'; +import styled from 'styled-components'; +import { Button as BaseButton, ButtonProps as BaseButtonProps } from 'react-aria-components'; +import { getSemanticValue } from '../../../essentials/experimental/cssVariables'; +import { XCrossCircleIcon } from '../../../icons'; + +const StyledButton = styled(BaseButton)` + appearance: none; + background: none; + border: none; + display: flex; + margin: 0; + padding: 0; + color: ${getSemanticValue('on-surface-variant')}; +`; + +function ClearButton(props: BaseButtonProps): ReactElement { + return ( + + + + ); +} + +export { ClearButton }; diff --git a/src/components/experimental/TextField/Field.ts b/src/components/experimental/TextField/Field.ts new file mode 100644 index 00000000..4df1f745 --- /dev/null +++ b/src/components/experimental/TextField/Field.ts @@ -0,0 +1,40 @@ +import { Input as BaseInput, TextArea as BaseTextArea } from 'react-aria-components'; +import styled, { css } from 'styled-components'; +import { getSemanticValue } from '../../../essentials/experimental/cssVariables'; +import { textStyles } from '../Text/Text'; + +const fieldStyles = css` + border: none; + background-color: unset; + outline: none; + + display: block; + width: 100%; + padding: 0; + + caret-color: ${getSemanticValue('interactive')}; + color: ${getSemanticValue('on-surface')}; + + ${textStyles.variants.body1} + + &::placeholder { + color: ${getSemanticValue('on-surface-variant')}; + } +`; + +export const TextArea = styled(BaseTextArea).attrs({ rows: 1 })` + ${fieldStyles}; + + resize: none; + min-height: ${textStyles.variants.body1.lineHeight}; +`; + +export const Input = styled(BaseInput)` + ${fieldStyles} + + &[type='search'] { + &::-webkit-search-cancel-button { + display: none; + } + } +`; diff --git a/src/components/experimental/TextField/Label.ts b/src/components/experimental/TextField/Label.ts new file mode 100644 index 00000000..997b7574 --- /dev/null +++ b/src/components/experimental/TextField/Label.ts @@ -0,0 +1,25 @@ +import { Label as BaseLabel } from 'react-aria-components'; +import styled, { css } from 'styled-components'; +import { textStyles } from '../Text/Text'; + +export const flyingLabelStyles = css` + top: 0; + transform: translate3d(0, 0, 0); + + ${textStyles.variants.label2} +`; + +export const Label = styled(BaseLabel)<{ $flying: boolean }>` + position: absolute; + top: 50%; + color: currentColor; + + ${textStyles.variants.body1} + + transform: translate3d(0, calc(-${textStyles.variants.body1.lineHeight} / 2), 0); + transform-origin: 0; + + transition: top 200ms ease, font-size 200ms ease, transform 200ms ease; + + ${props => props.$flying && flyingLabelStyles} +`; diff --git a/src/components/experimental/TextField/TextField.tsx b/src/components/experimental/TextField/TextField.tsx new file mode 100644 index 00000000..616d1469 --- /dev/null +++ b/src/components/experimental/TextField/TextField.tsx @@ -0,0 +1,206 @@ +import React, { ReactElement, RefObject } from 'react'; +import { TextField as BaseTextField, TextFieldProps as BaseTextFieldProps, Text } from 'react-aria-components'; +import styled, { css } from 'styled-components'; +import { get } from '../../../utils/experimental/themeGet'; +import { getSemanticValue } from '../../../essentials/experimental/cssVariables'; +import { textStyles } from '../Text/Text'; +import { VisuallyHidden } from '../../VisuallyHidden/VisuallyHidden'; +import { ClearButton } from './ClearButton'; +import { Label, flyingLabelStyles } from './Label'; +import { TextArea, Input } from './Field'; + +const defaultAriaStrings = { + clearFieldButton: 'Clear field', + messageFieldIsCleared: 'The field is cleared' +}; + +const InnerWrapper = styled.div<{ $autoResize: boolean }>` + width: 100%; + padding-top: ${get('space.4')}; + + position: relative; + overflow: hidden; + + ${props => + props.$autoResize && + css` + display: grid; + + &::after { + /* Styling should be the same */ + ${textStyles.variants.body1} + + /* Note the weird space! Needed to prevent jumpy behavior */ + content: attr(data-replicated-value) ' '; + + /* This is how textarea text behaves */ + white-space: pre-wrap; + + /* Hidden from view, clicks, and screen readers */ + visibility: hidden; + } + + &::after, + ${TextArea} { + overflow: hidden; + + /* Place on top of each other */ + grid-area: 1 / 1 / 2 / 2; + } + `} +`; + +const TopLine = styled.div` + box-sizing: content-box; + + color: ${getSemanticValue('on-surface-variant')}; + background-color: ${getSemanticValue('surface')}; + border-width: 0.0625rem; + border-style: solid; + border-color: ${getSemanticValue('outline-variant')}; + border-radius: ${get('radii.4')}; + + padding: ${get('space.2')} ${get('space.3')} ${get('space.2')} ${get('space.4')}; + display: flex; + align-items: start; + gap: ${get('space.3')}; + + /* stylelint-disable selector-type-case, selector-type-no-unknown */ + & > :not(${InnerWrapper}) { + flex-shrink: 0; + padding-top: ${get('space.2')}; + } + + &:hover { + border-color: ${getSemanticValue('outline')}; + color: ${getSemanticValue('on-surface')}; + } + + &:focus-within { + color: ${getSemanticValue('interactive')}; + outline: ${getSemanticValue('interactive')} solid 0.125rem; + outline-offset: -0.125rem; + + ${Label} { + ${flyingLabelStyles} + } + } +`; + +const BottomLine = styled.footer` + display: grid; + grid-template-areas: '. counter'; + justify-content: space-between; + gap: ${get('space.2')}; + + padding: ${get('space.1')} ${get('space.3')} ${get('space.0')}; + + color: ${getSemanticValue('on-surface-variant')}; + + ${textStyles.variants.label2} + + &:empty { + display: none; + } +`; + +const Counter = styled.span` + grid-area: counter; +`; + +const Wrapper = styled(BaseTextField)` + padding: ${get('space.2')} ${get('space.0')}; + + &[data-disabled] { + opacity: 0.38; + + ${TopLine} { + pointer-events: none; + } + } + + &[data-invalid] { + ${Label}, + ${BottomLine} { + color: ${getSemanticValue('negative')}; + } + + ${TopLine} { + border-color: ${getSemanticValue('negative')}; + } + } +`; + +export interface TextFieldProps extends BaseTextFieldProps { + label: string; + leadingIcon?: React.ReactNode; + actionIcon?: React.ReactNode; + placeholder?: string; + description?: string; + errorMessage?: string; + multiline?: boolean; + /** + * If you project supports multiple languages, it is recommended to pass translated labels to these properties + */ + ariaStrings?: { + clearFieldButton: string; + messageFieldIsCleared: string; + }; +} + +function TextField({ + label, + description, + errorMessage, + placeholder, + leadingIcon, + actionIcon, + multiline = false, + ariaStrings = defaultAriaStrings, + ...props +}: TextFieldProps): ReactElement { + const [text, setText] = React.useState(props.defaultValue || props.value || ''); + const inputRef = React.useRef(null); + + const handleChange = (value: string) => { + setText(value); + props.onChange?.(value); + }; + + return ( + + + {leadingIcon} + + + {multiline ? ( +