Files
web/packages/design-system/lib/components/InputNew/Input.tsx
Erik Tiekstra 4ec1e85d84 Feat/BOOK-293 button adjustments
* 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
2025-12-15 07:05:31 +00:00

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>
>