Merged in feat/use-new-input-component (pull request #3324)
feat(SW-3659): Use new input component * Use new input component * Update error formatter * Merged master into feat/use-new-input-component * Merged master into feat/use-new-input-component * Merge branch 'master' into feat/use-new-input-component * Merged master into feat/use-new-input-component * Update Input stories * Merge branch 'feat/use-new-input-component' of bitbucket.org:scandic-swap/web into feat/use-new-input-component * Update Storybook logo * Add some new demo icon input story * Fix the clear content button position * Fix broken password input icon * Merged master into feat/use-new-input-component * Merged master into feat/use-new-input-component * Add aria-hidden to required asterisk * Merge branch 'feat/use-new-input-component' of bitbucket.org:scandic-swap/web into feat/use-new-input-component * Merge branch 'master' into feat/use-new-input-component Approved-by: Bianca Widstam Approved-by: Matilda Landström
This commit is contained in:
@@ -137,40 +137,37 @@ function ExampleFormComponent({
|
||||
|
||||
const meta: Meta<typeof ExampleFormComponent> = {
|
||||
title: 'Compositions/Form/ExampleForm',
|
||||
component: ExampleFormComponent,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
argTypes: {
|
||||
labelPosition: {
|
||||
control: 'select',
|
||||
options: ['floating', 'top'],
|
||||
description: 'Position of labels for all input fields in the form',
|
||||
table: {
|
||||
type: { summary: "'floating' | 'top'" },
|
||||
defaultValue: { summary: "'floating'" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof ExampleFormComponent>
|
||||
|
||||
export const LabelFloating: Story = {
|
||||
export const Default: Story = {
|
||||
render: (args) => (
|
||||
<ExampleFormComponent
|
||||
key="label-on-top"
|
||||
key={`label-${args.labelPosition || 'floating'}`}
|
||||
{...args}
|
||||
labelPosition="floating"
|
||||
/>
|
||||
),
|
||||
args: {
|
||||
onSubmit: fn(),
|
||||
},
|
||||
}
|
||||
|
||||
export const LabelOnTop: Story = {
|
||||
render: (args) => (
|
||||
<ExampleFormComponent
|
||||
key="label-on-top"
|
||||
{...args}
|
||||
labelPosition="top"
|
||||
fieldPrefix="top"
|
||||
fieldPrefix="example"
|
||||
/>
|
||||
),
|
||||
args: {
|
||||
onSubmit: fn(),
|
||||
labelPosition: 'floating',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -348,18 +345,6 @@ export const WithErrors: SignupStory = {
|
||||
},
|
||||
}
|
||||
|
||||
export const WithErrorsLabelOnTop: SignupStory = {
|
||||
render: (args) => <SignupFormComponent {...args} />,
|
||||
args: {
|
||||
onSubmit: fn(),
|
||||
labelPosition: 'top',
|
||||
showErrors: true,
|
||||
},
|
||||
parameters: {
|
||||
...signupMeta.parameters,
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Input Variations Showcase
|
||||
// ============================================================================
|
||||
|
||||
@@ -8,8 +8,8 @@ import { cx } from 'class-variance-authority'
|
||||
|
||||
import { Error } from '../ErrorMessage/Error'
|
||||
import { mergeRefs } from '../utils/mergeRefs'
|
||||
import { MaterialIcon } from '../../Icons/MaterialIcon'
|
||||
import { Input } from '../../InputNew'
|
||||
import { MaterialIcon, MaterialIconProps } from '../../Icons/MaterialIcon'
|
||||
import { Input } from '../../Input'
|
||||
|
||||
import styles from './input.module.css'
|
||||
|
||||
@@ -26,6 +26,7 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
|
||||
autoComplete,
|
||||
className = '',
|
||||
description = '',
|
||||
descriptionIcon = 'info' as MaterialIconProps['icon'],
|
||||
disabled = false,
|
||||
errorFormatter,
|
||||
hideError,
|
||||
@@ -102,7 +103,7 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
|
||||
/>
|
||||
{showDescription ? (
|
||||
<Text className={styles.description} slot="description">
|
||||
<MaterialIcon icon="info" size={20} />
|
||||
<MaterialIcon icon={descriptionIcon} size={20} />
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { RegisterOptions } from 'react-hook-form'
|
||||
import type { IntlShape } from 'react-intl'
|
||||
|
||||
import type { InputProps } from '../../InputNew/types'
|
||||
import type { MaterialIconProps } from '../../Icons/MaterialIcon'
|
||||
import type { InputProps } from '../../Input/types'
|
||||
|
||||
export interface FormInputProps extends InputProps {
|
||||
/** Helper text displayed below the input (hidden when there's an error) */
|
||||
description?: string
|
||||
/** Icon to display with the description text. Defaults to 'info' */
|
||||
descriptionIcon?: MaterialIconProps['icon']
|
||||
/** Field name for react-hook-form registration */
|
||||
name: string
|
||||
/** react-hook-form validation rules */
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
|
||||
import { ErrorMessage } from '../ErrorMessage'
|
||||
import { MaterialIcon } from '../../Icons/MaterialIcon'
|
||||
import { Input } from '../../InputNew'
|
||||
import { Input } from '../../Input'
|
||||
import { InputLabel } from '../../InputLabel'
|
||||
|
||||
import styles from './phone.module.css'
|
||||
|
||||
@@ -5,8 +5,7 @@ import { ComponentProps } from 'react'
|
||||
import { variants } from './variants'
|
||||
|
||||
interface IconButtonProps
|
||||
extends ComponentProps<typeof ButtonRAC>,
|
||||
VariantProps<typeof variants> {}
|
||||
extends ComponentProps<typeof ButtonRAC>, VariantProps<typeof variants> {}
|
||||
|
||||
export function IconButton({
|
||||
variant,
|
||||
|
||||
@@ -4,16 +4,133 @@ import { expect } from 'storybook/test'
|
||||
|
||||
import { Input } from './Input'
|
||||
import { TextField } from 'react-aria-components'
|
||||
import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||
import type { SymbolCodepoints } from '../Icons/MaterialIcon/MaterialSymbol/types'
|
||||
|
||||
const meta: Meta<typeof Input> = {
|
||||
title: 'Core Components/Input',
|
||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
||||
component: ({ isInvalid, ...props }) => (
|
||||
<TextField isInvalid={isInvalid}>
|
||||
<Input {...props} />
|
||||
component: ({ isInvalid, validationState, ...props }) => (
|
||||
<TextField isInvalid={isInvalid} data-validation-state={validationState}>
|
||||
<Input {...props} data-validation-state={validationState} />
|
||||
</TextField>
|
||||
),
|
||||
argTypes: {},
|
||||
argTypes: {
|
||||
label: {
|
||||
control: 'text',
|
||||
description: 'The label text displayed for the input field',
|
||||
table: {
|
||||
type: { summary: 'string' },
|
||||
},
|
||||
},
|
||||
labelPosition: {
|
||||
control: 'select',
|
||||
options: ['floating', 'top'],
|
||||
description: 'Position of the label relative to the input',
|
||||
table: {
|
||||
type: { summary: "'floating' | 'top'" },
|
||||
defaultValue: { summary: "'floating'" },
|
||||
},
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text shown when input is empty',
|
||||
table: {
|
||||
type: { summary: 'string' },
|
||||
defaultValue: { summary: 'undefined' },
|
||||
},
|
||||
},
|
||||
required: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the input is required',
|
||||
table: {
|
||||
type: { summary: 'boolean' },
|
||||
defaultValue: { summary: 'false' },
|
||||
},
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the input is disabled',
|
||||
table: {
|
||||
type: { summary: 'boolean' },
|
||||
defaultValue: { summary: 'false' },
|
||||
},
|
||||
},
|
||||
showClearContentIcon: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the clear content icon is shown',
|
||||
table: {
|
||||
type: { summary: 'boolean' },
|
||||
defaultValue: { summary: 'false' },
|
||||
},
|
||||
},
|
||||
showLeftIcon: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to show a left icon',
|
||||
table: {
|
||||
type: { summary: 'boolean' },
|
||||
defaultValue: { summary: 'false' },
|
||||
},
|
||||
},
|
||||
showRightIcon: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to show a right icon',
|
||||
table: {
|
||||
type: { summary: 'boolean' },
|
||||
defaultValue: { summary: 'false' },
|
||||
},
|
||||
},
|
||||
leftIconName: {
|
||||
control: 'select',
|
||||
options: [
|
||||
'calendar_month',
|
||||
'credit_card',
|
||||
'email',
|
||||
'info_circle',
|
||||
'location_on',
|
||||
'lock',
|
||||
'phone',
|
||||
'search',
|
||||
'sell',
|
||||
'visibility',
|
||||
'visibility_off',
|
||||
],
|
||||
description: 'Icon name for the left icon',
|
||||
table: {
|
||||
type: { summary: 'string' },
|
||||
defaultValue: { summary: "'person'" },
|
||||
},
|
||||
},
|
||||
rightIconName: {
|
||||
control: 'select',
|
||||
options: [
|
||||
'calendar_month',
|
||||
'credit_card',
|
||||
'email',
|
||||
'info_circle',
|
||||
'location_on',
|
||||
'lock',
|
||||
'phone',
|
||||
'search',
|
||||
'sell',
|
||||
'visibility',
|
||||
'visibility_off',
|
||||
],
|
||||
description: 'Icon name for the right icon',
|
||||
table: {
|
||||
type: { summary: 'string' },
|
||||
defaultValue: { summary: "'lock'" },
|
||||
},
|
||||
},
|
||||
showWarning: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to show warning validation state',
|
||||
table: {
|
||||
type: { summary: 'boolean' },
|
||||
defaultValue: { summary: 'false' },
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
}
|
||||
|
||||
export default meta
|
||||
@@ -25,8 +142,50 @@ export const Default: Story = {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
required: false,
|
||||
},
|
||||
showLeftIcon: false,
|
||||
showRightIcon: false,
|
||||
leftIconName: 'person',
|
||||
rightIconName: 'lock',
|
||||
showWarning: false,
|
||||
} as any,
|
||||
render: (args) => {
|
||||
// Extract custom Storybook args
|
||||
const {
|
||||
showLeftIcon,
|
||||
showRightIcon,
|
||||
leftIconName,
|
||||
rightIconName,
|
||||
showWarning,
|
||||
...inputProps
|
||||
} = args as typeof args & {
|
||||
showLeftIcon?: boolean
|
||||
showRightIcon?: boolean
|
||||
leftIconName?: string
|
||||
rightIconName?: string
|
||||
showWarning?: boolean
|
||||
}
|
||||
|
||||
const validationState = showWarning ? 'warning' : undefined
|
||||
|
||||
return (
|
||||
<TextField data-validation-state={validationState}>
|
||||
<Input
|
||||
{...inputProps}
|
||||
data-validation-state={validationState}
|
||||
leftIcon={
|
||||
showLeftIcon && leftIconName ? (
|
||||
<MaterialIcon icon={leftIconName as SymbolCodepoints} />
|
||||
) : undefined
|
||||
}
|
||||
rightIcon={
|
||||
showRightIcon && rightIconName ? (
|
||||
<MaterialIcon icon={rightIconName as SymbolCodepoints} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</TextField>
|
||||
)
|
||||
},
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).not.toBeDisabled()
|
||||
@@ -40,68 +199,3 @@ export const Default: Story = {
|
||||
expect(textbox).toHaveValue('')
|
||||
},
|
||||
}
|
||||
|
||||
export const Filled: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
value: 'Value',
|
||||
},
|
||||
|
||||
play: async ({ canvas }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).toHaveValue('Value')
|
||||
|
||||
expect(textbox).not.toBeDisabled()
|
||||
},
|
||||
}
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
||||
isInvalid: true,
|
||||
},
|
||||
|
||||
play: async ({ canvas }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(textbox).not.toBeDisabled()
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
disabled: true,
|
||||
},
|
||||
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).toHaveValue('')
|
||||
expect(textbox).toBeDisabled()
|
||||
|
||||
await userEvent.type(textbox, 'Hello World')
|
||||
expect(textbox).toHaveValue('')
|
||||
},
|
||||
}
|
||||
|
||||
export const DisabledFilled: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
disabled: true,
|
||||
value: 'Value',
|
||||
},
|
||||
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).toHaveValue('Value')
|
||||
expect(textbox).toBeDisabled()
|
||||
|
||||
await userEvent.type(textbox, 'Hello World')
|
||||
expect(textbox).toHaveValue('Value')
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,36 +12,165 @@ import { InputLabel } from '../InputLabel'
|
||||
|
||||
import styles from './input.module.css'
|
||||
|
||||
import type { InputProps } from './types'
|
||||
import { IconButton } from '../IconButton'
|
||||
import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||
import { Typography } from '../Typography'
|
||||
import type { InputProps } from './types'
|
||||
import { clearInput, useInputHasValue } from './utils'
|
||||
|
||||
const InputComponent = forwardRef(function AriaInputWithLabelComponent(
|
||||
{ label, ...props }: InputProps,
|
||||
forwardedRef: ForwardedRef<HTMLInputElement>
|
||||
{
|
||||
label,
|
||||
labelPosition = 'floating',
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onRightIconClick,
|
||||
showClearContentIcon,
|
||||
placeholder,
|
||||
id,
|
||||
required,
|
||||
'data-validation-state': validationState,
|
||||
...props
|
||||
}: InputProps & { 'data-validation-state'?: string },
|
||||
ref: ForwardedRef<HTMLInputElement>
|
||||
) {
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
// Create an internal ref that we can access
|
||||
const internalRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Unique id is required for multiple inputs of same name appearing multiple times
|
||||
// on same page. This will inherited by parent label element.
|
||||
// Shouldn't really be needed if we don't set id though.
|
||||
const uniqueId = useId()
|
||||
const inputId = `${uniqueId}-${props.name}`
|
||||
// Generate a unique ID for the input
|
||||
// This is used to ensure the input is properly associated with the label
|
||||
// when the label is positioned above the input
|
||||
const generatedId = useId()
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ref.current as HTMLInputElement)
|
||||
// Forward the ref properly
|
||||
useImperativeHandle(ref, () => internalRef.current!, [])
|
||||
|
||||
// Track whether input has a value (for showing/hiding clear button)
|
||||
const hasValue = useInputHasValue(props.value, internalRef)
|
||||
|
||||
const onClearContent = () => {
|
||||
clearInput({
|
||||
inputRef: internalRef,
|
||||
onChange: props.onChange,
|
||||
value: props.value,
|
||||
})
|
||||
}
|
||||
// When labelPosition is 'top', restructure to have label outside container
|
||||
// We need an ID for proper label-input association
|
||||
if (labelPosition === 'top') {
|
||||
const inputId = id || generatedId
|
||||
|
||||
return (
|
||||
<>
|
||||
<InputLabel
|
||||
required={required}
|
||||
className={cx(
|
||||
styles.labelAbove,
|
||||
leftIcon && styles.labelAboveWithLeftIcon
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</InputLabel>
|
||||
<div className={styles.inputContainer}>
|
||||
{leftIcon && (
|
||||
<div className={styles.leftIconContainer}>{leftIcon}</div>
|
||||
)}
|
||||
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className={cx(
|
||||
styles.container,
|
||||
leftIcon && styles.containerWithLeftIcon,
|
||||
rightIcon && styles.containerWithRightIcon
|
||||
)}
|
||||
data-validation-state={validationState}
|
||||
>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<AriaInput
|
||||
{...props}
|
||||
id={inputId}
|
||||
required={required}
|
||||
// Avoid duplicating label text in placeholder when label is positioned above
|
||||
// Screen readers would announce the label twice (once as label, once as placeholder)
|
||||
// Only use placeholder if explicitly provided, otherwise use empty string
|
||||
placeholder={placeholder ?? ''}
|
||||
className={cx(
|
||||
styles.input,
|
||||
styles.inputTopLabel,
|
||||
props.className
|
||||
)}
|
||||
ref={internalRef}
|
||||
/>
|
||||
</Typography>
|
||||
</label>
|
||||
{showClearContentIcon && hasValue && (
|
||||
<div className={styles.rightIconContainer}>
|
||||
<IconButton
|
||||
className={styles.rightIconButton}
|
||||
variant="Muted"
|
||||
emphasis
|
||||
onPress={onClearContent}
|
||||
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||
aria-label="Clear content"
|
||||
>
|
||||
<MaterialIcon icon="cancel" />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
{rightIcon && !(showClearContentIcon && hasValue) && (
|
||||
<div className={styles.rightIconContainer}>{rightIcon}</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Floating label (default behavior) - label inside container
|
||||
return (
|
||||
<AriaLabel className={styles.container}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<AriaInput
|
||||
{...props}
|
||||
placeholder={props.placeholder}
|
||||
className={cx(styles.input, props.className)}
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
/>
|
||||
</Typography>
|
||||
<InputLabel required={props.required}>{label}</InputLabel>
|
||||
</AriaLabel>
|
||||
<div className={styles.inputContainer}>
|
||||
{leftIcon && <div className={styles.leftIconContainer}>{leftIcon}</div>}
|
||||
<AriaLabel
|
||||
className={cx(
|
||||
styles.container,
|
||||
leftIcon && styles.containerWithLeftIcon,
|
||||
rightIcon && styles.containerWithRightIcon
|
||||
)}
|
||||
data-validation-state={validationState}
|
||||
>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<AriaInput
|
||||
{...props}
|
||||
id={id}
|
||||
required={required}
|
||||
// For floating labels, only set placeholder if explicitly provided
|
||||
// The label itself acts as the placeholder, so we don't want to duplicate it
|
||||
// This ensures the label only floats when focused or has value
|
||||
placeholder={placeholder}
|
||||
className={cx(styles.input, props.className)}
|
||||
ref={internalRef}
|
||||
/>
|
||||
</Typography>
|
||||
<InputLabel required={required}>{label}</InputLabel>
|
||||
</AriaLabel>
|
||||
{showClearContentIcon && hasValue && (
|
||||
<div className={styles.rightIconContainer}>
|
||||
<IconButton
|
||||
className={styles.rightIconButton}
|
||||
variant="Muted"
|
||||
emphasis
|
||||
onPress={onClearContent}
|
||||
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||
aria-label="Clear content"
|
||||
>
|
||||
<MaterialIcon icon="cancel" />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
{rightIcon && !(showClearContentIcon && hasValue) && (
|
||||
<div className={styles.rightIconContainer}>{rightIcon}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
/* Label positioned above input (outside container) */
|
||||
.labelAbove {
|
||||
color: var(--Text-Default);
|
||||
font-family: var(--Label-Font-family), var(--Label-Font-fallback);
|
||||
font-size: var(--Body-Supporting-text-Size);
|
||||
font-weight: var(--Body-Supporting-text-Font-weight-2);
|
||||
letter-spacing: var(--Body-Supporting-text-Letter-spacing);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.container {
|
||||
align-content: center;
|
||||
background-color: var(--Surface-Primary-Default);
|
||||
@@ -10,8 +20,13 @@
|
||||
box-sizing: border-box;
|
||||
cursor: text;
|
||||
|
||||
.container:has(.input:focus):not(:has(.input:disabled)),
|
||||
.container:has(.input:focus):not(:has(.input:read-only)) {
|
||||
&:has(.input:focus):not(:has(.input:disabled)):not(
|
||||
:has(.input:read-only)
|
||||
):not(:has(.input[data-invalid='true'])):not(
|
||||
:has(.input[aria-invalid='true'])
|
||||
):not(:has(.input[data-warning='true'])):not(
|
||||
:has(.input[data-validation-state='warning'])
|
||||
):not([data-validation-state='warning']) {
|
||||
outline-offset: -2px;
|
||||
outline: 2px solid var(--Border-Interactive-Focus);
|
||||
}
|
||||
@@ -26,11 +41,35 @@
|
||||
&:has(.input[data-invalid='true'], .input[aria-invalid='true']) {
|
||||
border-color: var(--Border-Interactive-Error);
|
||||
|
||||
&:focus-within {
|
||||
&:focus-within,
|
||||
&:has(.input:focus) {
|
||||
outline-offset: -2px;
|
||||
outline: 2px solid var(--Border-Interactive-Error);
|
||||
border-color: var(--Border-Interactive-Error);
|
||||
}
|
||||
}
|
||||
|
||||
&:has(.input[data-warning='true']),
|
||||
&:has(.input[data-validation-state='warning']),
|
||||
&[data-validation-state='warning'] {
|
||||
background-color: var(--Surface-Feedback-Warning-light);
|
||||
border-color: var(--Border-Interactive-Focus);
|
||||
|
||||
&:focus-within,
|
||||
&:has(.input:focus) {
|
||||
outline-offset: -2px;
|
||||
outline: 2px solid var(--Border-Interactive-Focus);
|
||||
border-color: var(--Border-Interactive-Focus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.containerWithLeftIcon {
|
||||
padding-left: calc(var(--Space-x5) + 4px);
|
||||
}
|
||||
|
||||
.containerWithRightIcon {
|
||||
padding-right: calc(var(--Space-x5));
|
||||
}
|
||||
|
||||
.input {
|
||||
@@ -41,6 +80,7 @@
|
||||
order: 2;
|
||||
padding: 0;
|
||||
transition: height 150ms ease;
|
||||
width: 100%;
|
||||
|
||||
&:focus,
|
||||
&:placeholder-shown,
|
||||
@@ -56,6 +96,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Input with label on top - always has proper height */
|
||||
.inputTopLabel {
|
||||
height: 24px;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.leftIconContainer {
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto 0 auto var(--Space-x15);
|
||||
}
|
||||
|
||||
.rightIconContainer {
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
margin: auto var(--Space-x15) auto 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rightIconButton {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.input:active:not(:disabled) {
|
||||
height: 24px;
|
||||
|
||||
@@ -3,4 +3,9 @@ import { Input } from 'react-aria-components'
|
||||
|
||||
export interface InputProps extends ComponentProps<typeof Input> {
|
||||
label: string
|
||||
labelPosition?: 'floating' | 'top'
|
||||
leftIcon?: React.ReactNode
|
||||
rightIcon?: React.ReactNode
|
||||
onRightIconClick?: () => void
|
||||
showClearContentIcon?: boolean
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export function InputLabel({
|
||||
required,
|
||||
disabled,
|
||||
size,
|
||||
...rest
|
||||
}: InputLabelProps) {
|
||||
const classNames = inputLabelVariants({
|
||||
size,
|
||||
@@ -18,5 +19,10 @@ export function InputLabel({
|
||||
className,
|
||||
})
|
||||
|
||||
return <span className={classNames}>{children}</span>
|
||||
return (
|
||||
<span className={classNames} {...rest}>
|
||||
{children}
|
||||
{required && <span aria-hidden="true">{' *'}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,10 +39,6 @@
|
||||
order: unset;
|
||||
}
|
||||
|
||||
.required:after {
|
||||
content: ' *';
|
||||
}
|
||||
|
||||
input:focus ~ .inputLabel,
|
||||
input:placeholder-shown ~ .inputLabel,
|
||||
input[value]:not([value='']) ~ .inputLabel,
|
||||
|
||||
@@ -1,416 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
|
||||
import { expect } from 'storybook/test'
|
||||
|
||||
import { Input } from './Input'
|
||||
import { TextField } from 'react-aria-components'
|
||||
import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||
|
||||
const meta: Meta<typeof Input> = {
|
||||
title: 'Core Components/Input (New)',
|
||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
||||
component: ({ isInvalid, validationState, ...props }) => (
|
||||
<TextField isInvalid={isInvalid} data-validation-state={validationState}>
|
||||
<Input {...props} data-validation-state={validationState} />
|
||||
</TextField>
|
||||
),
|
||||
argTypes: {
|
||||
label: {
|
||||
control: 'text',
|
||||
description: 'The label text displayed for the input field',
|
||||
table: {
|
||||
type: { summary: 'string' },
|
||||
},
|
||||
},
|
||||
labelPosition: {
|
||||
control: 'select',
|
||||
options: ['floating', 'top'],
|
||||
description: 'Position of the label relative to the input',
|
||||
table: {
|
||||
type: { summary: "'floating' | 'top'" },
|
||||
defaultValue: { summary: "'floating'" },
|
||||
},
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text shown when input is empty',
|
||||
table: {
|
||||
type: { summary: 'string' },
|
||||
defaultValue: { summary: 'undefined' },
|
||||
},
|
||||
},
|
||||
required: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the input is required',
|
||||
table: {
|
||||
type: { summary: 'boolean' },
|
||||
defaultValue: { summary: 'false' },
|
||||
},
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the input is disabled',
|
||||
table: {
|
||||
type: { summary: 'boolean' },
|
||||
defaultValue: { summary: 'false' },
|
||||
},
|
||||
},
|
||||
showClearContentIcon: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the clear content icon is shown',
|
||||
table: {
|
||||
type: { summary: 'boolean' },
|
||||
defaultValue: { summary: 'false' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof Input>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
required: false,
|
||||
},
|
||||
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).not.toBeDisabled()
|
||||
|
||||
expect(textbox).toHaveValue('')
|
||||
|
||||
await userEvent.type(textbox, 'Hello World')
|
||||
expect(textbox).toHaveValue('Hello World')
|
||||
|
||||
await userEvent.clear(textbox)
|
||||
expect(textbox).toHaveValue('')
|
||||
},
|
||||
}
|
||||
|
||||
export const WithIconsFloatingLabel: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
value: 'Value',
|
||||
leftIcon: <MaterialIcon icon="sell" />,
|
||||
rightIcon: <MaterialIcon icon="lock" />,
|
||||
showClearContentIcon: true,
|
||||
},
|
||||
|
||||
play: async ({ canvas }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).toHaveValue('Value')
|
||||
|
||||
expect(textbox).not.toBeDisabled()
|
||||
},
|
||||
}
|
||||
|
||||
export const WithIconsTopLabel: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
value: 'Value',
|
||||
labelPosition: 'top',
|
||||
leftIcon: <MaterialIcon icon="email" />,
|
||||
showClearContentIcon: true,
|
||||
},
|
||||
|
||||
play: async ({ canvas }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).toHaveValue('Value')
|
||||
|
||||
expect(textbox).not.toBeDisabled()
|
||||
},
|
||||
}
|
||||
|
||||
export const WithIconsAndClearIconTopLabel: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
value: 'Value',
|
||||
labelPosition: 'top',
|
||||
leftIcon: <MaterialIcon icon="person" />,
|
||||
rightIcon: <MaterialIcon icon="email" />,
|
||||
showClearContentIcon: true,
|
||||
},
|
||||
|
||||
play: async ({ canvas }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).toHaveValue('Value')
|
||||
|
||||
expect(textbox).not.toBeDisabled()
|
||||
},
|
||||
}
|
||||
|
||||
export const Filled: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
value: 'Value',
|
||||
},
|
||||
|
||||
play: async ({ canvas }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).toHaveValue('Value')
|
||||
|
||||
expect(textbox).not.toBeDisabled()
|
||||
},
|
||||
}
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
||||
isInvalid: true,
|
||||
},
|
||||
|
||||
play: async ({ canvas }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(textbox).not.toBeDisabled()
|
||||
},
|
||||
}
|
||||
|
||||
export const ErrorFilled: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
||||
isInvalid: true,
|
||||
value: 'Value',
|
||||
},
|
||||
|
||||
play: async ({ canvas }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).toHaveValue('Value')
|
||||
expect(textbox).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(textbox).not.toBeDisabled()
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
disabled: true,
|
||||
},
|
||||
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).toHaveValue('')
|
||||
expect(textbox).toBeDisabled()
|
||||
|
||||
await userEvent.type(textbox, 'Hello World')
|
||||
expect(textbox).toHaveValue('')
|
||||
},
|
||||
}
|
||||
|
||||
export const DisabledFilled: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
disabled: true,
|
||||
value: 'Value',
|
||||
},
|
||||
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).toHaveValue('Value')
|
||||
expect(textbox).toBeDisabled()
|
||||
|
||||
await userEvent.type(textbox, 'Hello World')
|
||||
expect(textbox).toHaveValue('Value')
|
||||
},
|
||||
}
|
||||
|
||||
export const WarningDefault: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
||||
validationState: 'warning',
|
||||
},
|
||||
|
||||
play: async ({ canvas }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
// data-validation-state is on the parent label element, not the input
|
||||
const container = textbox.closest('[data-validation-state]')
|
||||
expect(container?.getAttribute('data-validation-state')).toBe('warning')
|
||||
expect(textbox).not.toBeDisabled()
|
||||
},
|
||||
}
|
||||
|
||||
export const WarningFilled: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
||||
validationState: 'warning',
|
||||
value: 'Value',
|
||||
},
|
||||
|
||||
play: async ({ canvas }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).toHaveValue('Value')
|
||||
// data-validation-state is on the parent label element, not the input
|
||||
const container = textbox.closest('[data-validation-state]')
|
||||
expect(container?.getAttribute('data-validation-state')).toBe('warning')
|
||||
expect(textbox).not.toBeDisabled()
|
||||
},
|
||||
}
|
||||
|
||||
export const DefaultTop: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
placeholder: 'Label',
|
||||
name: 'foo',
|
||||
required: false,
|
||||
labelPosition: 'top',
|
||||
},
|
||||
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).not.toBeDisabled()
|
||||
|
||||
expect(textbox).toHaveValue('')
|
||||
|
||||
await userEvent.type(textbox, 'Hello World')
|
||||
expect(textbox).toHaveValue('Hello World')
|
||||
|
||||
await userEvent.clear(textbox)
|
||||
expect(textbox).toHaveValue('')
|
||||
},
|
||||
}
|
||||
|
||||
export const FilledTop: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
value: 'Value',
|
||||
labelPosition: 'top',
|
||||
},
|
||||
|
||||
play: async ({ canvas }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).toHaveValue('Value')
|
||||
|
||||
expect(textbox).not.toBeDisabled()
|
||||
},
|
||||
}
|
||||
|
||||
export const ErrorTop: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
||||
isInvalid: true,
|
||||
placeholder: 'Label',
|
||||
labelPosition: 'top',
|
||||
},
|
||||
|
||||
play: async ({ canvas }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(textbox).not.toBeDisabled()
|
||||
},
|
||||
}
|
||||
|
||||
export const ErrorFilledTop: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
||||
isInvalid: true,
|
||||
value: 'Value',
|
||||
labelPosition: 'top',
|
||||
},
|
||||
|
||||
play: async ({ canvas }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).toHaveValue('Value')
|
||||
expect(textbox).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(textbox).not.toBeDisabled()
|
||||
},
|
||||
}
|
||||
|
||||
export const DisabledTop: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
disabled: true,
|
||||
labelPosition: 'top',
|
||||
placeholder: 'Label',
|
||||
},
|
||||
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).toHaveValue('')
|
||||
expect(textbox).toBeDisabled()
|
||||
|
||||
await userEvent.type(textbox, 'Hello World')
|
||||
expect(textbox).toHaveValue('')
|
||||
},
|
||||
}
|
||||
|
||||
export const DisabledFilledTop: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
disabled: true,
|
||||
value: 'Value',
|
||||
labelPosition: 'top',
|
||||
},
|
||||
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).toHaveValue('Value')
|
||||
expect(textbox).toBeDisabled()
|
||||
|
||||
await userEvent.type(textbox, 'Hello World')
|
||||
expect(textbox).toHaveValue('Value')
|
||||
},
|
||||
}
|
||||
|
||||
export const WarningDefaultTop: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
||||
validationState: 'warning',
|
||||
labelPosition: 'top',
|
||||
placeholder: 'Label',
|
||||
},
|
||||
|
||||
play: async ({ canvas }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
// data-validation-state is on the parent label element, not the input
|
||||
const container = textbox.closest('[data-validation-state]')
|
||||
expect(container?.getAttribute('data-validation-state')).toBe('warning')
|
||||
expect(textbox).not.toBeDisabled()
|
||||
},
|
||||
}
|
||||
|
||||
export const WarningFilledTop: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
name: 'foo',
|
||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
||||
validationState: 'warning',
|
||||
value: 'Value',
|
||||
labelPosition: 'top',
|
||||
},
|
||||
|
||||
play: async ({ canvas }) => {
|
||||
const textbox = canvas.getByRole('textbox')
|
||||
expect(textbox).toHaveValue('Value')
|
||||
// data-validation-state is on the parent label element, not the input
|
||||
const container = textbox.closest('[data-validation-state]')
|
||||
expect(container?.getAttribute('data-validation-state')).toBe('warning')
|
||||
expect(textbox).not.toBeDisabled()
|
||||
},
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { cx } from 'class-variance-authority'
|
||||
import {
|
||||
type ForwardedRef,
|
||||
forwardRef,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { Input as AriaInput, Label as AriaLabel } from 'react-aria-components'
|
||||
|
||||
import { InputLabel } from '../InputLabel'
|
||||
|
||||
import styles from './input.module.css'
|
||||
|
||||
import { IconButton } from '../IconButton'
|
||||
import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||
import { Typography } from '../Typography'
|
||||
import type { InputProps } from './types'
|
||||
import { clearInput, useInputHasValue } from './utils'
|
||||
|
||||
const InputComponent = forwardRef(function AriaInputWithLabelComponent(
|
||||
{
|
||||
label,
|
||||
labelPosition = 'floating',
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onRightIconClick,
|
||||
showClearContentIcon,
|
||||
placeholder,
|
||||
id,
|
||||
required,
|
||||
'data-validation-state': validationState,
|
||||
...props
|
||||
}: InputProps & { 'data-validation-state'?: string },
|
||||
ref: ForwardedRef<HTMLInputElement>
|
||||
) {
|
||||
// Create an internal ref that we can access
|
||||
const internalRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Generate a unique ID for the input
|
||||
// This is used to ensure the input is properly associated with the label
|
||||
// when the label is positioned above the input
|
||||
const generatedId = useId()
|
||||
|
||||
// Forward the ref properly
|
||||
useImperativeHandle(ref, () => internalRef.current!, [])
|
||||
|
||||
// Track whether input has a value (for showing/hiding clear button)
|
||||
const hasValue = useInputHasValue(props.value, internalRef)
|
||||
|
||||
const onClearContent = () => {
|
||||
clearInput({
|
||||
inputRef: internalRef,
|
||||
onChange: props.onChange,
|
||||
value: props.value,
|
||||
})
|
||||
}
|
||||
// When labelPosition is 'top', restructure to have label outside container
|
||||
// We need an ID for proper label-input association
|
||||
if (labelPosition === 'top') {
|
||||
const inputId = id || generatedId
|
||||
|
||||
return (
|
||||
<>
|
||||
<InputLabel
|
||||
required={required}
|
||||
className={cx(
|
||||
styles.labelAbove,
|
||||
leftIcon && styles.labelAboveWithLeftIcon
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</InputLabel>
|
||||
<div className={styles.inputContainer}>
|
||||
{leftIcon && (
|
||||
<div className={styles.leftIconContainer}>{leftIcon}</div>
|
||||
)}
|
||||
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className={cx(
|
||||
styles.container,
|
||||
leftIcon && styles.containerWithLeftIcon,
|
||||
rightIcon && styles.containerWithRightIcon
|
||||
)}
|
||||
data-validation-state={validationState}
|
||||
>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<AriaInput
|
||||
{...props}
|
||||
id={inputId}
|
||||
required={required}
|
||||
// Avoid duplicating label text in placeholder when label is positioned above
|
||||
// Screen readers would announce the label twice (once as label, once as placeholder)
|
||||
// Only use placeholder if explicitly provided, otherwise use empty string
|
||||
placeholder={placeholder ?? ''}
|
||||
className={cx(
|
||||
styles.input,
|
||||
styles.inputTopLabel,
|
||||
props.className
|
||||
)}
|
||||
ref={internalRef}
|
||||
/>
|
||||
</Typography>
|
||||
</label>
|
||||
{showClearContentIcon && hasValue && (
|
||||
<div className={styles.rightIconContainer}>
|
||||
<IconButton
|
||||
className={styles.rightIconButton}
|
||||
variant="Muted"
|
||||
emphasis
|
||||
onPress={onClearContent}
|
||||
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||
aria-label="Clear content"
|
||||
>
|
||||
<MaterialIcon icon="cancel" />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
{rightIcon && !(showClearContentIcon && hasValue) && (
|
||||
<div className={styles.rightIconContainer}>{rightIcon}</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Floating label (default behavior) - label inside container
|
||||
return (
|
||||
<div className={styles.inputContainer}>
|
||||
{leftIcon && <div className={styles.leftIconContainer}>{leftIcon}</div>}
|
||||
<AriaLabel
|
||||
className={cx(
|
||||
styles.container,
|
||||
leftIcon && styles.containerWithLeftIcon,
|
||||
rightIcon && styles.containerWithRightIcon
|
||||
)}
|
||||
data-validation-state={validationState}
|
||||
>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<AriaInput
|
||||
{...props}
|
||||
id={id}
|
||||
required={required}
|
||||
// For floating labels, only set placeholder if explicitly provided
|
||||
// The label itself acts as the placeholder, so we don't want to duplicate it
|
||||
// This ensures the label only floats when focused or has value
|
||||
placeholder={placeholder}
|
||||
className={cx(styles.input, props.className)}
|
||||
ref={internalRef}
|
||||
/>
|
||||
</Typography>
|
||||
<InputLabel required={required}>{label}</InputLabel>
|
||||
</AriaLabel>
|
||||
{showClearContentIcon && hasValue && (
|
||||
<div className={styles.rightIconContainer}>
|
||||
<IconButton
|
||||
className={styles.rightIconButton}
|
||||
variant="Muted"
|
||||
emphasis
|
||||
onPress={onClearContent}
|
||||
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||
aria-label="Clear content"
|
||||
>
|
||||
<MaterialIcon icon="cancel" />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
{rightIcon && !(showClearContentIcon && hasValue) && (
|
||||
<div className={styles.rightIconContainer}>{rightIcon}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const Input = InputComponent as React.ForwardRefExoticComponent<
|
||||
InputProps & React.RefAttributes<HTMLInputElement>
|
||||
>
|
||||
@@ -1 +0,0 @@
|
||||
export { Input } from './Input'
|
||||
@@ -1,139 +0,0 @@
|
||||
/* Label positioned above input (outside container) */
|
||||
.labelAbove {
|
||||
color: var(--Text-Default);
|
||||
font-family: var(--Label-Font-family), var(--Label-Font-fallback);
|
||||
font-size: var(--Body-Supporting-text-Size);
|
||||
font-weight: var(--Body-Supporting-text-Font-weight-2);
|
||||
letter-spacing: var(--Body-Supporting-text-Letter-spacing);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.container {
|
||||
align-content: center;
|
||||
background-color: var(--Surface-Primary-Default);
|
||||
border: 1px solid var(--Border-Interactive-Default);
|
||||
border-radius: var(--Corner-radius-md);
|
||||
display: grid;
|
||||
min-width: 0; /* allow shrinkage */
|
||||
height: 56px;
|
||||
padding: 0 var(--Space-x15);
|
||||
box-sizing: border-box;
|
||||
cursor: text;
|
||||
margin-top: var(--Space-x1);
|
||||
|
||||
&:has(.input:focus):not(:has(.input:disabled)):not(
|
||||
:has(.input:read-only)
|
||||
):not(:has(.input[data-invalid='true'])):not(
|
||||
:has(.input[aria-invalid='true'])
|
||||
):not(:has(.input[data-warning='true'])):not(
|
||||
:has(.input[data-validation-state='warning'])
|
||||
):not([data-validation-state='warning']) {
|
||||
outline-offset: -2px;
|
||||
outline: 2px solid var(--Border-Interactive-Focus);
|
||||
}
|
||||
|
||||
&:has(.input:disabled),
|
||||
&:has(.input:read-only) {
|
||||
background-color: var(--Surface-Primary-Disabled);
|
||||
border: transparent;
|
||||
cursor: unset;
|
||||
}
|
||||
|
||||
&:has(.input[data-invalid='true'], .input[aria-invalid='true']) {
|
||||
border-color: var(--Border-Interactive-Error);
|
||||
|
||||
&:focus-within,
|
||||
&:has(.input:focus) {
|
||||
outline-offset: -2px;
|
||||
outline: 2px solid var(--Border-Interactive-Error);
|
||||
border-color: var(--Border-Interactive-Error);
|
||||
}
|
||||
}
|
||||
|
||||
&:has(.input[data-warning='true']),
|
||||
&:has(.input[data-validation-state='warning']),
|
||||
&[data-validation-state='warning'] {
|
||||
background-color: var(--Surface-Feedback-Warning-light);
|
||||
border-color: var(--Border-Interactive-Focus);
|
||||
|
||||
&:focus-within,
|
||||
&:has(.input:focus) {
|
||||
outline-offset: -2px;
|
||||
outline: 2px solid var(--Border-Interactive-Focus);
|
||||
border-color: var(--Border-Interactive-Focus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.containerWithLeftIcon {
|
||||
padding-left: calc(var(--Space-x5) + 4px);
|
||||
}
|
||||
|
||||
.containerWithRightIcon {
|
||||
padding-right: calc(var(--Space-x5));
|
||||
}
|
||||
|
||||
.input {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--Text-Default);
|
||||
height: 1px;
|
||||
order: 2;
|
||||
padding: 0;
|
||||
transition: height 150ms ease;
|
||||
width: 100%;
|
||||
|
||||
&:focus,
|
||||
&:placeholder-shown,
|
||||
&[value]:not([value='']) {
|
||||
height: 24px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&:read-only {
|
||||
color: var(--Text-Interactive-Disabled);
|
||||
cursor: unset;
|
||||
}
|
||||
}
|
||||
|
||||
/* Input with label on top - always has proper height */
|
||||
.inputTopLabel {
|
||||
height: 24px;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.leftIconContainer {
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto 0 auto var(--Space-x15);
|
||||
}
|
||||
|
||||
.rightIconContainer {
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
margin: auto var(--Space-x15) auto 0;
|
||||
}
|
||||
|
||||
.rightIconButton {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.input:active:not(:disabled) {
|
||||
height: 24px;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ComponentProps } from 'react'
|
||||
import { Input } from 'react-aria-components'
|
||||
|
||||
export interface InputProps extends ComponentProps<typeof Input> {
|
||||
label: string
|
||||
labelPosition?: 'floating' | 'top'
|
||||
leftIcon?: React.ReactNode
|
||||
rightIcon?: React.ReactNode
|
||||
onRightIconClick?: () => void
|
||||
showClearContentIcon?: boolean
|
||||
}
|
||||
Reference in New Issue
Block a user