From b9a62b528052e46ea5606ed6525850031b04549a Mon Sep 17 00:00:00 2001 From: Rasmus Langvad Date: Thu, 18 Dec 2025 15:42:09 +0000 Subject: [PATCH] Merged in feat/use-new-input-component (pull request #3324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../TransferPointsFormClient.tsx | 5 +- .../Forms/Edit/Profile/FormContent/index.tsx | 16 +- .../components/Forms/Signup/index.tsx | 19 +- .../FindMyBooking/AdditionalInfoForm.tsx | 9 +- .../HotelReservation/FindMyBooking/index.tsx | 15 +- .../MyStay/ModifyContact/index.tsx | 8 +- .../TempDesignSystem/Form/Input/index.tsx | 102 ----- .../Form/Input/input.module.css | 17 - .../TempDesignSystem/Form/Input/input.ts | 10 - .../Form/PasswordInput/index.tsx | 3 +- .../PasswordInput/passwordInput.module.css | 2 +- apps/scandic-web/utils/getErrorMessage.ts | 11 + .../lib/components/BookingFlowInput/index.tsx | 99 ++--- .../design-system/.storybook/scandic-theme.ts | 2 +- .../Form/Compositions/ExampleForm.stories.tsx | 45 +- .../lib/components/Form/FormInput/index.tsx | 7 +- .../lib/components/Form/FormInput/input.ts | 5 +- .../lib/components/Form/Phone/index.tsx | 2 +- .../lib/components/IconButton/IconButton.tsx | 3 +- .../lib/components/Input/Input.stories.tsx | 234 +++++++--- .../{InputNew => Input}/Input.test.tsx | 0 .../lib/components/Input/Input.tsx | 173 +++++++- .../lib/components/Input/input.module.css | 83 +++- .../lib/components/Input/types.ts | 5 + .../{InputNew => Input}/utils.test.ts | 0 .../components/{InputNew => Input}/utils.ts | 0 .../lib/components/InputLabel/InputLabel.tsx | 8 +- .../InputLabel/inputLabel.module.css | 4 - .../lib/components/InputNew/Input.stories.tsx | 416 ------------------ .../lib/components/InputNew/Input.tsx | 179 -------- .../lib/components/InputNew/index.tsx | 1 - .../lib/components/InputNew/input.module.css | 139 ------ .../lib/components/InputNew/types.ts | 11 - .../public/img/scandic-logotype.png | Bin 0 -> 2094 bytes 34 files changed, 520 insertions(+), 1113 deletions(-) delete mode 100644 apps/scandic-web/components/TempDesignSystem/Form/Input/index.tsx delete mode 100644 apps/scandic-web/components/TempDesignSystem/Form/Input/input.module.css delete mode 100644 apps/scandic-web/components/TempDesignSystem/Form/Input/input.ts rename packages/design-system/lib/components/{InputNew => Input}/Input.test.tsx (100%) rename packages/design-system/lib/components/{InputNew => Input}/utils.test.ts (100%) rename packages/design-system/lib/components/{InputNew => Input}/utils.ts (100%) delete mode 100644 packages/design-system/lib/components/InputNew/Input.stories.tsx delete mode 100644 packages/design-system/lib/components/InputNew/Input.tsx delete mode 100644 packages/design-system/lib/components/InputNew/index.tsx delete mode 100644 packages/design-system/lib/components/InputNew/input.module.css delete mode 100644 packages/design-system/lib/components/InputNew/types.ts create mode 100644 packages/design-system/public/img/scandic-logotype.png diff --git a/apps/scandic-web/components/Blocks/DynamicContent/SAS/TransferPoints/TransferPointsFormClient.tsx b/apps/scandic-web/components/Blocks/DynamicContent/SAS/TransferPoints/TransferPointsFormClient.tsx index e820771df..cffaf1e2a 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/SAS/TransferPoints/TransferPointsFormClient.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/SAS/TransferPoints/TransferPointsFormClient.tsx @@ -15,9 +15,9 @@ import { import { FormProvider, useForm } from "react-hook-form" import { useIntl } from "react-intl" +import { FormInput } from "@scandic-hotels/design-system/Form/FormInput" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import Image from "@scandic-hotels/design-system/Image" -import { Input } from "@scandic-hotels/design-system/Input" import Modal from "@scandic-hotels/design-system/Modal" import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton" import { Typography } from "@scandic-hotels/design-system/Typography" @@ -87,7 +87,8 @@ export function TransferPointsFormClient({
- - -
- @@ -105,7 +109,7 @@ export default function FormContent({ errors }: { errors: FieldErrors }) { registerOptions={{ required: true }} />
-
- -
- - <div className={styles.inputs}> - <Input + <FormInput label={intl.formatMessage({ id: "common.firstName", defaultMessage: "First name", })} name="firstName" registerOptions={{ required: true }} + errorFormatter={formatFormErrorMessage} /> - <Input + <FormInput label={intl.formatMessage({ id: "common.email", defaultMessage: "Email", @@ -69,6 +71,7 @@ export default function AdditionalInfoForm({ name="email" type="email" registerOptions={{ required: true }} + errorFormatter={formatFormErrorMessage} /> </div> <div className={styles.buttons}> diff --git a/apps/scandic-web/components/HotelReservation/FindMyBooking/index.tsx b/apps/scandic-web/components/HotelReservation/FindMyBooking/index.tsx index d18486696..27b80b7d2 100644 --- a/apps/scandic-web/components/HotelReservation/FindMyBooking/index.tsx +++ b/apps/scandic-web/components/HotelReservation/FindMyBooking/index.tsx @@ -11,13 +11,14 @@ import { myStay } from "@scandic-hotels/common/constants/routes/myStay" import { logger } from "@scandic-hotels/common/logger" import { Alert } from "@scandic-hotels/design-system/Alert" import { Button } from "@scandic-hotels/design-system/Button" +import { FormInput } from "@scandic-hotels/design-system/Form/FormInput" import { TextLink } from "@scandic-hotels/design-system/TextLink" import { toast } from "@scandic-hotels/design-system/Toast" import { Typography } from "@scandic-hotels/design-system/Typography" import { trpc } from "@scandic-hotels/trpc/client" -import Input from "@/components/TempDesignSystem/Form/Input" import useLang from "@/hooks/useLang" +import { formatFormErrorMessage } from "@/utils/getErrorMessage" import { type FindMyBookingFormSchema, findMyBookingFormSchema } from "./schema" import { Title } from "./Title" @@ -95,31 +96,34 @@ export default function FindMyBooking({ /> ) : null} <div className={[styles.inputs, styles.grid].join(" ")}> - <Input + <FormInput label={intl.formatMessage({ id: "common.bookingNumber", defaultMessage: "Booking number", })} name="confirmationNumber" registerOptions={{ required: true }} + errorFormatter={formatFormErrorMessage} /> - <Input + <FormInput label={intl.formatMessage({ id: "common.firstName", defaultMessage: "First name", })} name="firstName" registerOptions={{ required: true }} + errorFormatter={formatFormErrorMessage} /> - <Input + <FormInput label={intl.formatMessage({ id: "common.lastName", defaultMessage: "Last name", })} name="lastName" registerOptions={{ required: true }} + errorFormatter={formatFormErrorMessage} /> - <Input + <FormInput label={intl.formatMessage({ id: "common.email", defaultMessage: "Email", @@ -127,6 +131,7 @@ export default function FindMyBooking({ name="email" type="email" registerOptions={{ required: true }} + errorFormatter={formatFormErrorMessage} /> </div> <div className={styles.buttons}> diff --git a/apps/scandic-web/components/HotelReservation/MyStay/ModifyContact/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/ModifyContact/index.tsx index 8fc42c5c1..74e1cf183 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/ModifyContact/index.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/ModifyContact/index.tsx @@ -7,10 +7,10 @@ import { getDefaultCountryFromLang, } from "@scandic-hotels/common/utils/phone" import CountrySelect from "@scandic-hotels/design-system/Form/Country" +import { FormInput } from "@scandic-hotels/design-system/Form/FormInput" import Phone from "@scandic-hotels/design-system/Form/Phone" import { Typography } from "@scandic-hotels/design-system/Typography" -import Input from "@/components/TempDesignSystem/Form/Input" import useLang from "@/hooks/useLang" import { getFormattedCountryList } from "@/utils/countries" import { getErrorMessage } from "@/utils/getErrorMessage" @@ -45,7 +45,7 @@ export default function ModifyContact({ {isFirstStep ? ( <div className={styles.container}> <div className={`${styles.row} ${styles.gridEqual}`}> - <Input + <FormInput label={intl.formatMessage({ id: "common.firstName", defaultMessage: "First name", @@ -54,7 +54,7 @@ export default function ModifyContact({ name="firstName" disabled={!!guest.firstName} /> - <Input + <FormInput label={intl.formatMessage({ id: "common.lastName", defaultMessage: "Last name", @@ -80,7 +80,7 @@ export default function ModifyContact({ /> </div> <div className={styles.row}> - <Input + <FormInput label={intl.formatMessage({ id: "common.email", defaultMessage: "Email", diff --git a/apps/scandic-web/components/TempDesignSystem/Form/Input/index.tsx b/apps/scandic-web/components/TempDesignSystem/Form/Input/index.tsx deleted file mode 100644 index 0680b0358..000000000 --- a/apps/scandic-web/components/TempDesignSystem/Form/Input/index.tsx +++ /dev/null @@ -1,102 +0,0 @@ -"use client" - -import { forwardRef, type HTMLAttributes, type WheelEvent } from "react" -import { Text, TextField } from "react-aria-components" -import { Controller, useFormContext } from "react-hook-form" -import { useIntl } from "react-intl" - -import Caption from "@scandic-hotels/design-system/Caption" -import { ErrorMessage } from "@scandic-hotels/design-system/Form/ErrorMessage" -import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" -import { Input as InputWithLabel } from "@scandic-hotels/design-system/Input" - -import { getErrorMessage } from "@/utils/getErrorMessage" - -import styles from "./input.module.css" - -import type { InputProps } from "./input" - -const Input = forwardRef<HTMLInputElement, InputProps>(function Input( - { - "aria-label": ariaLabel, - autoComplete, - className = "", - disabled = false, - helpText = "", - label, - maxLength, - name, - placeholder, - readOnly = false, - registerOptions = {}, - type = "text", - hideError, - inputMode, - }, - ref -) { - const intl = useIntl() - const { control } = useFormContext() - const numberAttributes: HTMLAttributes<HTMLInputElement> = {} - if (type === "number") { - numberAttributes.onWheel = function (evt: WheelEvent<HTMLInputElement>) { - evt.currentTarget.blur() - } - } - - return ( - <Controller - disabled={disabled} - control={control} - name={name} - rules={registerOptions} - render={({ field, fieldState, formState }) => ( - <TextField - aria-label={ariaLabel} - className={className} - isDisabled={field.disabled} - isReadOnly={readOnly} - isInvalid={fieldState.invalid} - isRequired={!!registerOptions.required} - name={field.name} - onBlur={field.onBlur} - onChange={field.onChange} - validationBehavior="aria" - value={field.value} - > - <InputWithLabel - {...field} - ref={ref} - aria-labelledby={field.name} - autoComplete={autoComplete} - id={field.name} - label={label} - maxLength={maxLength} - placeholder={placeholder} - readOnly={readOnly} - disabled={disabled} - required={!!registerOptions.required} - type={type} - inputMode={inputMode} - /> - {helpText && !fieldState.error ? ( - <Caption asChild color="black"> - <Text className={styles.helpText} slot="description"> - <MaterialIcon icon="check" size={20} /> - {helpText} - </Text> - </Caption> - ) : null} - {fieldState.error && !hideError ? ( - <ErrorMessage - errors={formState.errors} - name={name} - messageLabel={getErrorMessage(intl, fieldState.error.message)} - /> - ) : null} - </TextField> - )} - /> - ) -}) -export default Input diff --git a/apps/scandic-web/components/TempDesignSystem/Form/Input/input.module.css b/apps/scandic-web/components/TempDesignSystem/Form/Input/input.module.css deleted file mode 100644 index 1917df96f..000000000 --- a/apps/scandic-web/components/TempDesignSystem/Form/Input/input.module.css +++ /dev/null @@ -1,17 +0,0 @@ -.helpText { - align-items: flex-start; - display: flex; - gap: var(--Space-x05); -} - -.error { - align-items: center; - color: var(--Text-Interactive-Error); - display: flex; - gap: var(--Space-x05); - margin: var(--Space-x1) 0 0; -} - -.error svg { - min-width: 20px; -} diff --git a/apps/scandic-web/components/TempDesignSystem/Form/Input/input.ts b/apps/scandic-web/components/TempDesignSystem/Form/Input/input.ts deleted file mode 100644 index a8e8cb60f..000000000 --- a/apps/scandic-web/components/TempDesignSystem/Form/Input/input.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { RegisterOptions } from "react-hook-form" - -export interface InputProps - extends React.InputHTMLAttributes<HTMLInputElement> { - helpText?: string - label: string - name: string - registerOptions?: RegisterOptions - hideError?: boolean -} diff --git a/apps/scandic-web/components/TempDesignSystem/Form/PasswordInput/index.tsx b/apps/scandic-web/components/TempDesignSystem/Form/PasswordInput/index.tsx index d99ebde63..1181bda32 100644 --- a/apps/scandic-web/components/TempDesignSystem/Form/PasswordInput/index.tsx +++ b/apps/scandic-web/components/TempDesignSystem/Form/PasswordInput/index.tsx @@ -20,8 +20,7 @@ import { NewPasswordValidation } from "./NewPasswordValidation" import styles from "./passwordInput.module.css" -interface PasswordInputProps - extends React.InputHTMLAttributes<HTMLInputElement> { +interface PasswordInputProps extends React.InputHTMLAttributes<HTMLInputElement> { label?: string registerOptions?: RegisterOptions visibilityToggleable?: boolean diff --git a/apps/scandic-web/components/TempDesignSystem/Form/PasswordInput/passwordInput.module.css b/apps/scandic-web/components/TempDesignSystem/Form/PasswordInput/passwordInput.module.css index 626c38efa..89488e0f6 100644 --- a/apps/scandic-web/components/TempDesignSystem/Form/PasswordInput/passwordInput.module.css +++ b/apps/scandic-web/components/TempDesignSystem/Form/PasswordInput/passwordInput.module.css @@ -23,7 +23,7 @@ position: relative; } -.toggleButton { +.inputWrapper .toggleButton { position: absolute; right: var(--Space-x2); top: 50%; diff --git a/apps/scandic-web/utils/getErrorMessage.ts b/apps/scandic-web/utils/getErrorMessage.ts index f2aab01ff..1c9b43f45 100644 --- a/apps/scandic-web/utils/getErrorMessage.ts +++ b/apps/scandic-web/utils/getErrorMessage.ts @@ -196,3 +196,14 @@ export function getErrorMessage(intl: IntlShape, errorCode?: string) { return errorCode } } + +/** + * Wrapper for getErrorMessage that ensures a string is always returned. + * Can be used directly as errorFormatter prop for FormInput components. + */ +export function formatFormErrorMessage( + intl: IntlShape, + errorMessage?: string +): string { + return getErrorMessage(intl, errorMessage) ?? "" +} diff --git a/packages/booking-flow/lib/components/BookingFlowInput/index.tsx b/packages/booking-flow/lib/components/BookingFlowInput/index.tsx index 919378f34..ee17f3e33 100644 --- a/packages/booking-flow/lib/components/BookingFlowInput/index.tsx +++ b/packages/booking-flow/lib/components/BookingFlowInput/index.tsx @@ -1,27 +1,14 @@ "use client" -// This is almost a copy of the Input in TempDesignSystem, but since it's tightly coupled -// to the error messages we need to duplicate it for now. In the future we should -// rewrite it to be more reusable. +import { forwardRef, useMemo } from "react" -import { forwardRef } from "react" -import { Text, TextField } from "react-aria-components" -import { - Controller, - type RegisterOptions, - useFormContext, -} from "react-hook-form" -import { useIntl } from "react-intl" - -import Caption from "@scandic-hotels/design-system/Caption" -import { ErrorMessage } from "@scandic-hotels/design-system/Form/ErrorMessage" -import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" -import { Input as InputWithLabel } from "@scandic-hotels/design-system/Input" +import { FormInput } from "@scandic-hotels/design-system/Form/FormInput" import { useBookingFlowConfig } from "../../bookingFlowConfig/bookingFlowConfigContext" import { getErrorMessage } from "./errors" -import styles from "./input.module.css" +import type { RegisterOptions } from "react-hook-form" +import type { IntlShape } from "react-intl" interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { helpText?: string @@ -34,7 +21,6 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { const BookingFlowInput = forwardRef<HTMLInputElement, InputProps>( function Input( { - "aria-label": ariaLabel, autoComplete, className = "", disabled = false, @@ -48,67 +34,38 @@ const BookingFlowInput = forwardRef<HTMLInputElement, InputProps>( type = "text", hideError, inputMode, + ...props }, ref ) { - const intl = useIntl() - const { control, formState } = useFormContext() const config = useBookingFlowConfig() + // Create error formatter wrapper that uses getErrorMessage with the variant + const errorFormatter = useMemo( + () => (intl: IntlShape, errorMessage?: string) => + getErrorMessage(intl, config.variant, errorMessage) ?? "", + [config.variant] + ) + return ( - <Controller + <FormInput + {...props} + ref={ref} + autoComplete={autoComplete} + className={className} + description={helpText} + descriptionIcon="check" disabled={disabled} - control={control} + errorFormatter={errorFormatter} + hideError={hideError} + inputMode={inputMode} + label={label} + maxLength={maxLength} name={name} - rules={registerOptions} - render={({ field, fieldState }) => ( - <TextField - aria-label={ariaLabel} - className={className} - isDisabled={field.disabled} - isInvalid={fieldState.invalid} - isRequired={!!registerOptions.required} - name={field.name} - onBlur={field.onBlur} - onChange={field.onChange} - validationBehavior="aria" - value={field.value} - > - <InputWithLabel - {...field} - ref={ref} - aria-labelledby={field.name} - autoComplete={autoComplete} - id={field.name} - label={label} - maxLength={maxLength} - placeholder={placeholder} - readOnly={readOnly} - required={!!registerOptions.required} - type={type} - inputMode={inputMode} - /> - {helpText && !fieldState.error ? ( - <Caption asChild color="black"> - <Text className={styles.helpText} slot="description"> - <MaterialIcon icon="check" size={20} /> - {helpText} - </Text> - </Caption> - ) : null} - {fieldState.error && !hideError ? ( - <ErrorMessage - errors={formState.errors} - name={name} - messageLabel={getErrorMessage( - intl, - config.variant, - fieldState.error.message - )} - /> - ) : null} - </TextField> - )} + placeholder={placeholder} + readOnly={readOnly} + registerOptions={registerOptions} + type={type} /> ) } diff --git a/packages/design-system/.storybook/scandic-theme.ts b/packages/design-system/.storybook/scandic-theme.ts index 070db4ba8..5985ecdb6 100644 --- a/packages/design-system/.storybook/scandic-theme.ts +++ b/packages/design-system/.storybook/scandic-theme.ts @@ -4,5 +4,5 @@ export default create({ base: 'dark', brandTitle: 'Scandic Design System', brandUrl: 'https://www.scandichotels.com/', - brandImage: 'http://scandichotels.com/_static/img/scandic-logotype.png', + brandImage: '/img/scandic-logotype.png', }) diff --git a/packages/design-system/lib/components/Form/Compositions/ExampleForm.stories.tsx b/packages/design-system/lib/components/Form/Compositions/ExampleForm.stories.tsx index b38961dee..1c98b02bc 100644 --- a/packages/design-system/lib/components/Form/Compositions/ExampleForm.stories.tsx +++ b/packages/design-system/lib/components/Form/Compositions/ExampleForm.stories.tsx @@ -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 // ============================================================================ diff --git a/packages/design-system/lib/components/Form/FormInput/index.tsx b/packages/design-system/lib/components/Form/FormInput/index.tsx index 263ae3458..e9698e804 100644 --- a/packages/design-system/lib/components/Form/FormInput/index.tsx +++ b/packages/design-system/lib/components/Form/FormInput/index.tsx @@ -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} diff --git a/packages/design-system/lib/components/Form/FormInput/input.ts b/packages/design-system/lib/components/Form/FormInput/input.ts index fd5620b6d..6dfc36716 100644 --- a/packages/design-system/lib/components/Form/FormInput/input.ts +++ b/packages/design-system/lib/components/Form/FormInput/input.ts @@ -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 */ diff --git a/packages/design-system/lib/components/Form/Phone/index.tsx b/packages/design-system/lib/components/Form/Phone/index.tsx index afac2eb9e..42cc89573 100644 --- a/packages/design-system/lib/components/Form/Phone/index.tsx +++ b/packages/design-system/lib/components/Form/Phone/index.tsx @@ -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' diff --git a/packages/design-system/lib/components/IconButton/IconButton.tsx b/packages/design-system/lib/components/IconButton/IconButton.tsx index 555d0ca26..71156aada 100644 --- a/packages/design-system/lib/components/IconButton/IconButton.tsx +++ b/packages/design-system/lib/components/IconButton/IconButton.tsx @@ -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, diff --git a/packages/design-system/lib/components/Input/Input.stories.tsx b/packages/design-system/lib/components/Input/Input.stories.tsx index 2be91ac8b..bcc3254eb 100644 --- a/packages/design-system/lib/components/Input/Input.stories.tsx +++ b/packages/design-system/lib/components/Input/Input.stories.tsx @@ -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') - }, -} diff --git a/packages/design-system/lib/components/InputNew/Input.test.tsx b/packages/design-system/lib/components/Input/Input.test.tsx similarity index 100% rename from packages/design-system/lib/components/InputNew/Input.test.tsx rename to packages/design-system/lib/components/Input/Input.test.tsx diff --git a/packages/design-system/lib/components/Input/Input.tsx b/packages/design-system/lib/components/Input/Input.tsx index 8a7c17b71..328d575e4 100644 --- a/packages/design-system/lib/components/Input/Input.tsx +++ b/packages/design-system/lib/components/Input/Input.tsx @@ -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> ) }) diff --git a/packages/design-system/lib/components/Input/input.module.css b/packages/design-system/lib/components/Input/input.module.css index 9c31f3afa..7159eba0a 100644 --- a/packages/design-system/lib/components/Input/input.module.css +++ b/packages/design-system/lib/components/Input/input.module.css @@ -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; diff --git a/packages/design-system/lib/components/Input/types.ts b/packages/design-system/lib/components/Input/types.ts index c664747ad..dbd0ce62b 100644 --- a/packages/design-system/lib/components/Input/types.ts +++ b/packages/design-system/lib/components/Input/types.ts @@ -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 } diff --git a/packages/design-system/lib/components/InputNew/utils.test.ts b/packages/design-system/lib/components/Input/utils.test.ts similarity index 100% rename from packages/design-system/lib/components/InputNew/utils.test.ts rename to packages/design-system/lib/components/Input/utils.test.ts diff --git a/packages/design-system/lib/components/InputNew/utils.ts b/packages/design-system/lib/components/Input/utils.ts similarity index 100% rename from packages/design-system/lib/components/InputNew/utils.ts rename to packages/design-system/lib/components/Input/utils.ts diff --git a/packages/design-system/lib/components/InputLabel/InputLabel.tsx b/packages/design-system/lib/components/InputLabel/InputLabel.tsx index dd3493ec6..33eaa8041 100644 --- a/packages/design-system/lib/components/InputLabel/InputLabel.tsx +++ b/packages/design-system/lib/components/InputLabel/InputLabel.tsx @@ -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> + ) } diff --git a/packages/design-system/lib/components/InputLabel/inputLabel.module.css b/packages/design-system/lib/components/InputLabel/inputLabel.module.css index 311af10e8..bcad08308 100644 --- a/packages/design-system/lib/components/InputLabel/inputLabel.module.css +++ b/packages/design-system/lib/components/InputLabel/inputLabel.module.css @@ -39,10 +39,6 @@ order: unset; } -.required:after { - content: ' *'; -} - input:focus ~ .inputLabel, input:placeholder-shown ~ .inputLabel, input[value]:not([value='']) ~ .inputLabel, diff --git a/packages/design-system/lib/components/InputNew/Input.stories.tsx b/packages/design-system/lib/components/InputNew/Input.stories.tsx deleted file mode 100644 index 4e00dab14..000000000 --- a/packages/design-system/lib/components/InputNew/Input.stories.tsx +++ /dev/null @@ -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() - }, -} diff --git a/packages/design-system/lib/components/InputNew/Input.tsx b/packages/design-system/lib/components/InputNew/Input.tsx deleted file mode 100644 index 328d575e4..000000000 --- a/packages/design-system/lib/components/InputNew/Input.tsx +++ /dev/null @@ -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> -> diff --git a/packages/design-system/lib/components/InputNew/index.tsx b/packages/design-system/lib/components/InputNew/index.tsx deleted file mode 100644 index 3188ccc6a..000000000 --- a/packages/design-system/lib/components/InputNew/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { Input } from './Input' diff --git a/packages/design-system/lib/components/InputNew/input.module.css b/packages/design-system/lib/components/InputNew/input.module.css deleted file mode 100644 index e8831c788..000000000 --- a/packages/design-system/lib/components/InputNew/input.module.css +++ /dev/null @@ -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; - } -} diff --git a/packages/design-system/lib/components/InputNew/types.ts b/packages/design-system/lib/components/InputNew/types.ts deleted file mode 100644 index dbd0ce62b..000000000 --- a/packages/design-system/lib/components/InputNew/types.ts +++ /dev/null @@ -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 -} diff --git a/packages/design-system/public/img/scandic-logotype.png b/packages/design-system/public/img/scandic-logotype.png new file mode 100644 index 0000000000000000000000000000000000000000..6e9034c5c3cf5d15aa0dac6e4795da188855484c GIT binary patch literal 2094 zcmaJ?dsGv57ETLdi@1O$)gVX=C~yu*2qA<7vTVo@o<Wv}AP^ys0TRfAWFP?-YHd*n zDCMyNzS_00XbS-?f(Y9v5Vp!9qJp3eQdC4%!H5dcvhIY6?H|j|IWxb<x!-rcd+&G7 z6h}q`t+%wZL?96BLxO=QcnybN+m9^Zukv8%hwy@i0^^}*X)2`T%0UD}AmxFW5HU9m zi~_lW+^j309|B?KDvXJT;@ROezEq6onqu%Ou?$8d5PtqD8JC{{LKq&HCX~>zbDdf& zMkt_T<H&3ewu}j;3xo6IV02zY3_mY}PZePO|A_HZ(O?2G2yrngai&B;Q_-=@yfk=k zx+Y*T%OOw(9s8@Ocy=U)DV2j5GM<d%dw3Et6e^xbCQ~S$?if!Gq8Gt~NFb7ML@Le8 zi{{~hdG}!9Y;r*=Eec@0%LSk4*mMYz(Fg>kQi)fR@KSjifk>rN2_BvVPfr{ifm7s4 zAg&50Q8=wI0HA^|7s?=^RDv-va(U8hh>nGo{#t@q_MTRvc-JPlVFVRdMj+xnOeL)Z zvf2MXR4jfUt$?Dy-}L@ZVns}@3?xK>3Td{S4-YQY$&^Y)W6D7;B$dZVrI{;Tj7*n8 zQboE{hG8<7yM}RPbNNDv>C$Z(#b(n&BnpTt;e#On9Sif|g+c+13<Lt?Knj&gVo`|1 zKrcps7XYwG04spSVo^K;SFnJTpDhL@&<a-Y8<w#u)?^5=49*OIa$ycAV9BLo%yQv0 z;p$o_tMa|W3Rc(RvnrMViy@fC`@3<kT)`ePZQq+0KD@U-D1kjMhs_-}x7P)MFh3pw zFk)19ZdD7@lVXwoycd42?odKg!p-d>hrf!FqQk_kv!99d)d`8;?Z1G*pH4Pks=M*6 z4a!Y@z8+tfTxHRkZC0LMq7H2x0fG~RsFE{boB%ej0{wPvf%PA%DPbG64=$B;b#*Ls z?H#1w9e{34ee)+d?^KQR0o}gEIOHjVOIvkk@5iThHh$8nlV=?`jUp5u7NPoPjXp!} zcDnGa(naPkt_8MRdLP}tp3<(So^l=M655MOHqSpyw`vP+v<*6f-aG2!6%SO^SQ(VS zFV3%c*R`*LYSE2*x^^6%;53f{n~&P>YtKH!<S*T~N{#CIVJm8|GVkK6+bNrT`1wwf zu&klt-o0*AJBKxWM~9S#yXIN0eD&q_1)Jx>ihq5lEKhl@)wDF^MP2>cC(_=2+H36N zht7j157eY}G*fmMx8K-<J-K!pvfui)ny*7zRrKg_V+HP8Q^PrGhI>$f^VS~K)WAZK z(LLu}&5XCm;x2xqq}nV$P5nkEoRoRnk^IX)7+JXBWBrdQ@mwcSS=8@_JZRIs+i>hL zrO?0ga(}OqCq|7Mk6*9`|8~p3AdOO;^<K4jyB|-)ovQ`GzMxY2S3lkC>&*D*Y%OZU zZ@=x}hO~Lf5#LL(ld~HXX*IXUQvD&L$j)&V*~B~J6Drt$WkG(l`0O}1)X+W5BWas- z8wboHK5^mJxg=qKFfX&~yIJA9RCO~KZEIuDZ&9_iBt9-ZkG2k(%AXiHvCZ;7(A;a| zn|-7shjKYdGcRhc<$V8YS5|}SNN9XdU#X7gh|iGx^!X<D9LG%EzE3-qCuTdEdW**v zG?Tte`*7vNdT~UYi}2@wc4LuDDx)r{Q*he2XBX?VU3zqe!w|`4)-82k?3JCppCdmz z^Tn`p3ro6Hb3A4M*tKSz`@?IB;7QW7QLPz8hJBGUJ*Ks=AX9aZ(|7I;({$gdNUBDz z?bkM&_l1g+lwZo6EE%K*vSahHLyS1{VTrPE4JT-EK;xmQd5%98<r+ZMqg&FfaKV>R z2gXITd_po~9ZE=@v*vNhvC~D*z8+R(miH+S`Wg#W`glPZNg_ecm#E|;Oa4hBS({~H zqJCZw(fz2f1Dx{a3o7TX_)~gr5c2>mT|!`gQ{&h<vTYKvr62u7l~w68B*qi3y~Lw; z7&w>SY{;#!tl4oMZ~e0VLE)3PDJ|<YcM@znh>Q8jsD*(Kl~?O#+P-X#Zt^V)ZLeg> z3H5C!<wq4AGiy%9McJovr`u0QE=4HUJ?%s2KQ*g=O^;|Tzv{E=@!t&`21BI#z=Fd} zWY4x*yx~<h-$N#w|7?Vw_+hMt=K}ql3ntz^t(uP`&Opzew=GuXJV1Z@bVHYSqng@& z*ykZ9VfeU>s^euvne2XiOn0THTP|gH(59t{5-)xBi#>b1Q)#04==AH)UvQlw{_I%) zI<bp@J2SYQ0uq7JhFXf4U%qa-v4L9VFqMebIAlzZZ<5VDd35K&Ja}%zooe{L%2&J* z7k-WN?~5HnncMzyZ1a|RNt`T<*z8x$bbChc{84N83SowzkJ`lE_B&f)`h$lAMgW?C Hl>Pq&-W_AD literal 0 HcmV?d00001