-
(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
= {}
- if (type === "number") {
- numberAttributes.onWheel = function (evt: WheelEvent) {
- evt.currentTarget.blur()
- }
- }
-
- return (
- (
-
-
- {helpText && !fieldState.error ? (
-
-
-
- {helpText}
-
-
- ) : null}
- {fieldState.error && !hideError ? (
-
- ) : null}
-
- )}
- />
- )
-})
-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 {
- 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 {
+interface PasswordInputProps extends React.InputHTMLAttributes {
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 {
helpText?: string
@@ -34,7 +21,6 @@ interface InputProps extends React.InputHTMLAttributes {
const BookingFlowInput = forwardRef(
function Input(
{
- "aria-label": ariaLabel,
autoComplete,
className = "",
disabled = false,
@@ -48,67 +34,38 @@ const BookingFlowInput = forwardRef(
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 (
- (
-
-
- {helpText && !fieldState.error ? (
-
-
-
- {helpText}
-
-
- ) : null}
- {fieldState.error && !hideError ? (
-
- ) : null}
-
- )}
+ 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 = {
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
-export const LabelFloating: Story = {
+export const Default: Story = {
render: (args) => (
- ),
- args: {
- onSubmit: fn(),
- },
-}
-
-export const LabelOnTop: Story = {
- render: (args) => (
-
),
args: {
onSubmit: fn(),
+ labelPosition: 'floating',
},
}
@@ -348,18 +345,6 @@ export const WithErrors: SignupStory = {
},
}
-export const WithErrorsLabelOnTop: SignupStory = {
- render: (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(
autoComplete,
className = '',
description = '',
+ descriptionIcon = 'info' as MaterialIconProps['icon'],
disabled = false,
errorFormatter,
hideError,
@@ -102,7 +103,7 @@ export const FormInput = forwardRef(
/>
{showDescription ? (
-
+
{description}
) : 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,
- VariantProps {}
+ extends ComponentProps, VariantProps {}
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 = {
title: 'Core Components/Input',
// @ts-expect-error Input does not support this, but wrapping does
- component: ({ isInvalid, ...props }) => (
-
-
+ component: ({ isInvalid, validationState, ...props }) => (
+
+
),
- 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 (
+
+
+ ) : undefined
+ }
+ rightIcon={
+ showRightIcon && rightIconName ? (
+
+ ) : undefined
+ }
+ />
+
+ )
+ },
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 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
+ {
+ 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
) {
- const ref = useRef(null)
+ // Create an internal ref that we can access
+ const internalRef = useRef(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 (
+ <>
+
+ {label}
+
+
+ {leftIcon && (
+
{leftIcon}
+ )}
+
+
+ {showClearContentIcon && hasValue && (
+
+
+
+
+
+ )}
+ {rightIcon && !(showClearContentIcon && hasValue) && (
+
{rightIcon}
+ )}
+
+ >
+ )
+ }
+
+ // Floating label (default behavior) - label inside container
return (
-
-
-
-
- {label}
-
+
+ {leftIcon &&
{leftIcon}
}
+
+
+
+
+ {label}
+
+ {showClearContentIcon && hasValue && (
+
+
+
+
+
+ )}
+ {rightIcon && !(showClearContentIcon && hasValue) && (
+
{rightIcon}
+ )}
+
)
})
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 {
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 {children}
+ return (
+
+ {children}
+ {required && {' *'}}
+
+ )
}
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 = {
- title: 'Core Components/Input (New)',
- // @ts-expect-error Input does not support this, but wrapping does
- component: ({ isInvalid, validationState, ...props }) => (
-
-
-
- ),
- 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
-
-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: ,
- rightIcon: ,
- 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: ,
- 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: ,
- rightIcon: ,
- 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 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 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 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 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 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 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 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 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
-) {
- // Create an internal ref that we can access
- const internalRef = useRef(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 (
- <>
-
- {label}
-
-
- {leftIcon && (
-
{leftIcon}
- )}
-
-
- {showClearContentIcon && hasValue && (
-
-
-
-
-
- )}
- {rightIcon && !(showClearContentIcon && hasValue) && (
-
{rightIcon}
- )}
-
- >
- )
- }
-
- // Floating label (default behavior) - label inside container
- return (
-
- {leftIcon &&
{leftIcon}
}
-
-
-
-
- {label}
-
- {showClearContentIcon && hasValue && (
-
-
-
-
-
- )}
- {rightIcon && !(showClearContentIcon && hasValue) && (
-
{rightIcon}
- )}
-
- )
-})
-
-export const Input = InputComponent as React.ForwardRefExoticComponent<
- InputProps & React.RefAttributes
->
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 {
- 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 000000000..6e9034c5c
Binary files /dev/null and b/packages/design-system/public/img/scandic-logotype.png differ