* feat(BOOK-293): Adjusted padding of the buttons to match Figma design * feat(BOOK-293): Updated variants for IconButton * feat(BOOK-113): Updated focus indicators on buttons and added default focus ring color * feat(BOOK-293): Replaced buttons inside booking widget Approved-by: Christel Westerberg
180 lines
5.7 KiB
TypeScript
180 lines
5.7 KiB
TypeScript
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>
|
|
>
|