From 02d7c78aff3fb81fb0136f515a8fd5821c07f044 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 11 Jun 2026 17:47:32 -0300 Subject: [PATCH 01/13] feat(forms): add FieldLabel and FieldError primitives FieldLabel wires a label to its control (+ required indicator); FieldError renders an inline per-field validation message (the small-text counterpart to the ErrorMessage banner). With Storybook stories. Co-Authored-By: Claude Opus 4.8 --- .../components/FieldError.stories.tsx | 29 +++++++++++++++++ .../components/FieldLabel.stories.tsx | 25 +++++++++++++++ .../web/components/base/forms/FieldError.tsx | 24 ++++++++++++++ .../web/components/base/forms/FieldLabel.tsx | 32 +++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 frontend/documentation/components/FieldError.stories.tsx create mode 100644 frontend/documentation/components/FieldLabel.stories.tsx create mode 100644 frontend/web/components/base/forms/FieldError.tsx create mode 100644 frontend/web/components/base/forms/FieldLabel.tsx diff --git a/frontend/documentation/components/FieldError.stories.tsx b/frontend/documentation/components/FieldError.stories.tsx new file mode 100644 index 000000000000..bb22af48a9a1 --- /dev/null +++ b/frontend/documentation/components/FieldError.stories.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import FieldError from 'components/base/forms/FieldError' + +const meta: Meta = { + component: FieldError, + parameters: { layout: 'padded' }, + title: 'Components/Forms/FieldError', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => , +} + +export const RichMessage: Story = { + render: () => ( + + Must be a valid email address. + + } + /> + ), +} diff --git a/frontend/documentation/components/FieldLabel.stories.tsx b/frontend/documentation/components/FieldLabel.stories.tsx new file mode 100644 index 000000000000..befc6ccbcff0 --- /dev/null +++ b/frontend/documentation/components/FieldLabel.stories.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import FieldLabel from 'components/base/forms/FieldLabel' + +const meta: Meta = { + component: FieldLabel, + parameters: { layout: 'padded' }, + title: 'Components/Forms/FieldLabel', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => Email, +} + +export const Required: Story = { + render: () => ( + + Email + + ), +} diff --git a/frontend/web/components/base/forms/FieldError.tsx b/frontend/web/components/base/forms/FieldError.tsx new file mode 100644 index 000000000000..34f51358338d --- /dev/null +++ b/frontend/web/components/base/forms/FieldError.tsx @@ -0,0 +1,24 @@ +import React, { FC, ReactNode } from 'react' +import cn from 'classnames' + +interface FieldErrorProps { + // The field-level error to show; renders nothing when falsy. + error?: ReactNode + className?: string +} + +// Inline, per-field validation message shown beneath a control. This is the +// small-text counterpart to ErrorMessage (which is a banner alert for +// API/form-level errors) — use FieldError for a single field's validation. +const FieldError: FC = ({ className, error }) => { + if (!error) { + return null + } + return ( + + {error} + + ) +} + +export default FieldError diff --git a/frontend/web/components/base/forms/FieldLabel.tsx b/frontend/web/components/base/forms/FieldLabel.tsx new file mode 100644 index 000000000000..d5ec6ba1806a --- /dev/null +++ b/frontend/web/components/base/forms/FieldLabel.tsx @@ -0,0 +1,32 @@ +import React, { FC, ReactNode } from 'react' +import cn from 'classnames' + +interface FieldLabelProps { + // Associates the label with its control; required for accessibility. + htmlFor?: string + children: ReactNode + // Shows a danger asterisk after the label. + required?: boolean + className?: string +} + +// The label for a form field — wires `htmlFor` to the control and renders an +// optional required indicator. Pair with FieldError + a control inside +// InputField, or use standalone. +const FieldLabel: FC = ({ + children, + className, + htmlFor, + required, +}) => ( + +) + +export default FieldLabel From 84721d08cc6971f95ff9064bdf6a0aee88d4691c Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 11 Jun 2026 17:47:34 -0300 Subject: [PATCH 02/13] feat(forms): add InputField Composes FieldLabel + Input + FieldError into one labelled, validated field. Controlled and library-agnostic (takes `error`); the typed successor to InputGroup. With Storybook stories. Co-Authored-By: Claude Opus 4.8 --- .../components/InputField.stories.tsx | 59 +++++++++++++++++++ .../web/components/base/forms/InputField.tsx | 57 ++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 frontend/documentation/components/InputField.stories.tsx create mode 100644 frontend/web/components/base/forms/InputField.tsx diff --git a/frontend/documentation/components/InputField.stories.tsx b/frontend/documentation/components/InputField.stories.tsx new file mode 100644 index 000000000000..8084c8b28d39 --- /dev/null +++ b/frontend/documentation/components/InputField.stories.tsx @@ -0,0 +1,59 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' + +import InputField from 'components/base/forms/InputField' + +const meta: Meta = { + component: InputField, + parameters: { layout: 'padded' }, + title: 'Components/Forms/InputField', +} +export default meta + +type Story = StoryObj + +const Interactive = () => { + const [value, setValue] = useState('') + return ( + setValue(e.target.value)} + /> + ) +} + +export const Default: Story = { + render: () => , +} + +export const Required: Story = { + render: () => ( + + ), +} + +export const WithError: Story = { + render: () => ( + + ), +} + +export const Disabled: Story = { + render: () => , +} + +export const Sizes: Story = { + render: () => ( +
+ + + +
+ ), +} diff --git a/frontend/web/components/base/forms/InputField.tsx b/frontend/web/components/base/forms/InputField.tsx new file mode 100644 index 000000000000..d7c5aedc8751 --- /dev/null +++ b/frontend/web/components/base/forms/InputField.tsx @@ -0,0 +1,57 @@ +import React, { FC, ReactNode, Ref, useRef } from 'react' +import cn from 'classnames' +import Input, { InputMethods, InputProps } from './Input' +import Utils from 'common/utils/utils' +import FieldLabel from './FieldLabel' +import FieldError from './FieldError' + +interface InputFieldProps extends Omit { + // Field label; wired to the input via htmlFor/id. + label?: ReactNode + // Field-level error. When set, the input shows its invalid state and the + // message renders beneath it. Controlled — the consumer decides when an error + // is present, so this works with manual state or any form library. + error?: ReactNode + required?: boolean + // Styles the label/input/error wrapper; `inputClassName` styles the input. + wrapperClassName?: string + ref?: Ref +} + +// Composes FieldLabel + Input + FieldError into one labelled, validated field — +// the typed successor to InputGroup. Presentational and library-agnostic: pass +// `error` and it surfaces both the invalid styling and the message. +const InputField: FC = ({ + error, + id, + label, + ref, + required, + wrapperClassName, + ...inputProps +}) => { + const generatedId = useRef(Utils.GUID()).current + const fieldId = id ?? generatedId + return ( +
+ {label && ( + + {label} + + )} + + +
+ ) +} + +export default InputField From 68cb81ad17780befffb2b4f85a28e6fba3abf9a1 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 11 Jun 2026 17:47:35 -0300 Subject: [PATCH 03/13] feat(forms): add PasswordInput and SearchInput Composed inputs that will own the password reveal / search adornments. Phase 1 delegates to Input; the adornment logic moves here when Input is slimmed. With Storybook stories. Co-Authored-By: Claude Opus 4.8 --- .../components/PasswordInput.stories.tsx | 32 ++++++++++++++++ .../components/SearchInput.stories.tsx | 38 +++++++++++++++++++ .../components/base/forms/PasswordInput.tsx | 16 ++++++++ .../web/components/base/forms/SearchInput.tsx | 16 ++++++++ 4 files changed, 102 insertions(+) create mode 100644 frontend/documentation/components/PasswordInput.stories.tsx create mode 100644 frontend/documentation/components/SearchInput.stories.tsx create mode 100644 frontend/web/components/base/forms/PasswordInput.tsx create mode 100644 frontend/web/components/base/forms/SearchInput.tsx diff --git a/frontend/documentation/components/PasswordInput.stories.tsx b/frontend/documentation/components/PasswordInput.stories.tsx new file mode 100644 index 000000000000..110323bba4b8 --- /dev/null +++ b/frontend/documentation/components/PasswordInput.stories.tsx @@ -0,0 +1,32 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' + +import PasswordInput from 'components/base/forms/PasswordInput' + +const meta: Meta = { + component: PasswordInput, + parameters: { layout: 'padded' }, + title: 'Components/Forms/PasswordInput', +} +export default meta + +type Story = StoryObj + +const Interactive = () => { + const [value, setValue] = useState('hunter2') + return ( + setValue(e.target.value)} + /> + ) +} + +export const Default: Story = { + render: () => , +} + +export const Disabled: Story = { + render: () => , +} diff --git a/frontend/documentation/components/SearchInput.stories.tsx b/frontend/documentation/components/SearchInput.stories.tsx new file mode 100644 index 000000000000..09575220442b --- /dev/null +++ b/frontend/documentation/components/SearchInput.stories.tsx @@ -0,0 +1,38 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' + +import SearchInput from 'components/base/forms/SearchInput' + +const meta: Meta = { + component: SearchInput, + parameters: { layout: 'padded' }, + title: 'Components/Forms/SearchInput', +} +export default meta + +type Story = StoryObj + +const Interactive = () => { + const [value, setValue] = useState('') + return ( + setValue(e.target.value)} + /> + ) +} + +export const Default: Story = { + render: () => , +} + +export const Sizes: Story = { + render: () => ( +
+ + + +
+ ), +} diff --git a/frontend/web/components/base/forms/PasswordInput.tsx b/frontend/web/components/base/forms/PasswordInput.tsx new file mode 100644 index 000000000000..19e9bca28518 --- /dev/null +++ b/frontend/web/components/base/forms/PasswordInput.tsx @@ -0,0 +1,16 @@ +import React, { FC } from 'react' +import Input, { InputProps } from './Input' + +// `type` and `search` are fixed by this component. +type PasswordInputProps = Omit + +// Password field with a reveal (eye) toggle. +// +// Phase 1: composes Input, which still owns the toggle. This establishes the +// component consumers migrate to; the toggle logic moves here when Input is +// slimmed to a plain input (the wrapper-less redesign). +const PasswordInput: FC = (props) => ( + +) + +export default PasswordInput diff --git a/frontend/web/components/base/forms/SearchInput.tsx b/frontend/web/components/base/forms/SearchInput.tsx new file mode 100644 index 000000000000..3ba44f1f9045 --- /dev/null +++ b/frontend/web/components/base/forms/SearchInput.tsx @@ -0,0 +1,16 @@ +import React, { FC } from 'react' +import Input, { InputProps } from './Input' + +// `type` and `search` are fixed by this component. +type SearchInputProps = Omit + +// Text input with a search (magnifying-glass) icon. +// +// Phase 1: composes Input, which still owns the icon. This establishes the +// component consumers migrate to; the adornment moves here when Input is +// slimmed to a plain input (the wrapper-less redesign). +const SearchInput: FC = (props) => ( + +) + +export default SearchInput From 2c4f9afd53df6466b66c34882286c0eb0e7ea34c Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 11 Jun 2026 17:52:41 -0300 Subject: [PATCH 04/13] docs(forms): add missing Storybook variants for field components PasswordInput sizes, SearchInput disabled, and InputField read-only + no-label states. Dark mode is covered automatically by Chromatic's global light/dark modes. Co-Authored-By: Claude Opus 4.8 --- .../documentation/components/InputField.stories.tsx | 10 ++++++++++ .../documentation/components/PasswordInput.stories.tsx | 10 ++++++++++ .../documentation/components/SearchInput.stories.tsx | 6 ++++++ 3 files changed, 26 insertions(+) diff --git a/frontend/documentation/components/InputField.stories.tsx b/frontend/documentation/components/InputField.stories.tsx index 8084c8b28d39..1547d89f8dd9 100644 --- a/frontend/documentation/components/InputField.stories.tsx +++ b/frontend/documentation/components/InputField.stories.tsx @@ -57,3 +57,13 @@ export const Sizes: Story = { ), } + +export const ReadOnly: Story = { + render: () => ( + + ), +} + +export const WithoutLabel: Story = { + render: () => , +} diff --git a/frontend/documentation/components/PasswordInput.stories.tsx b/frontend/documentation/components/PasswordInput.stories.tsx index 110323bba4b8..2eb65cc3e9c0 100644 --- a/frontend/documentation/components/PasswordInput.stories.tsx +++ b/frontend/documentation/components/PasswordInput.stories.tsx @@ -30,3 +30,13 @@ export const Default: Story = { export const Disabled: Story = { render: () => , } + +export const Sizes: Story = { + render: () => ( +
+ + + +
+ ), +} diff --git a/frontend/documentation/components/SearchInput.stories.tsx b/frontend/documentation/components/SearchInput.stories.tsx index 09575220442b..5618be8ee7ac 100644 --- a/frontend/documentation/components/SearchInput.stories.tsx +++ b/frontend/documentation/components/SearchInput.stories.tsx @@ -36,3 +36,9 @@ export const Sizes: Story = { ), } + +export const Disabled: Story = { + render: () => ( + + ), +} From 2ac87b3fb04f97ea1f4e2b09cff2ae82a945161d Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 11 Jun 2026 17:56:32 -0300 Subject: [PATCH 05/13] feat(forms): add label tooltip to FieldLabel and InputField FieldLabel shows an info icon with a hover tooltip when `tooltip` is passed (the recurring InputGroup pattern); InputField forwards it. With Storybook stories. Co-Authored-By: Claude Opus 4.8 --- .../components/FieldLabel.stories.tsx | 8 +++++++ .../components/InputField.stories.tsx | 10 +++++++++ .../web/components/base/forms/FieldLabel.tsx | 21 ++++++++++++++++--- .../web/components/base/forms/InputField.tsx | 5 ++++- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/frontend/documentation/components/FieldLabel.stories.tsx b/frontend/documentation/components/FieldLabel.stories.tsx index befc6ccbcff0..acfacaa19bd2 100644 --- a/frontend/documentation/components/FieldLabel.stories.tsx +++ b/frontend/documentation/components/FieldLabel.stories.tsx @@ -23,3 +23,11 @@ export const Required: Story = { ), } + +export const WithTooltip: Story = { + render: () => ( + + Email + + ), +} diff --git a/frontend/documentation/components/InputField.stories.tsx b/frontend/documentation/components/InputField.stories.tsx index 1547d89f8dd9..63d49be3a7d3 100644 --- a/frontend/documentation/components/InputField.stories.tsx +++ b/frontend/documentation/components/InputField.stories.tsx @@ -67,3 +67,13 @@ export const ReadOnly: Story = { export const WithoutLabel: Story = { render: () => , } + +export const WithTooltip: Story = { + render: () => ( + + ), +} diff --git a/frontend/web/components/base/forms/FieldLabel.tsx b/frontend/web/components/base/forms/FieldLabel.tsx index d5ec6ba1806a..86340ccad752 100644 --- a/frontend/web/components/base/forms/FieldLabel.tsx +++ b/frontend/web/components/base/forms/FieldLabel.tsx @@ -1,5 +1,6 @@ import React, { FC, ReactNode } from 'react' import cn from 'classnames' +import Icon from 'components/icons/Icon' interface FieldLabelProps { // Associates the label with its control; required for accessibility. @@ -7,17 +8,20 @@ interface FieldLabelProps { children: ReactNode // Shows a danger asterisk after the label. required?: boolean + // When set, an info icon follows the label and reveals this content on hover. + tooltip?: ReactNode className?: string } -// The label for a form field — wires `htmlFor` to the control and renders an -// optional required indicator. Pair with FieldError + a control inside -// InputField, or use standalone. +// The label for a form field — wires `htmlFor` to the control, with an optional +// required indicator and an info-icon tooltip. Pair with FieldError + a control +// inside InputField, or use standalone. const FieldLabel: FC = ({ children, className, htmlFor, required, + tooltip, }) => ( ) diff --git a/frontend/web/components/base/forms/InputField.tsx b/frontend/web/components/base/forms/InputField.tsx index d7c5aedc8751..951b7d8e42e4 100644 --- a/frontend/web/components/base/forms/InputField.tsx +++ b/frontend/web/components/base/forms/InputField.tsx @@ -13,6 +13,8 @@ interface InputFieldProps extends Omit { // is present, so this works with manual state or any form library. error?: ReactNode required?: boolean + // Info-icon tooltip shown next to the label. + tooltip?: ReactNode // Styles the label/input/error wrapper; `inputClassName` styles the input. wrapperClassName?: string ref?: Ref @@ -27,6 +29,7 @@ const InputField: FC = ({ label, ref, required, + tooltip, wrapperClassName, ...inputProps }) => { @@ -35,7 +38,7 @@ const InputField: FC = ({ return (
{label && ( - + {label} )} From c084756ee45e9bd1f10b2ee8f57ecc4fff6f0d04 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 11 Jun 2026 17:57:27 -0300 Subject: [PATCH 06/13] fix(forms): type FieldLabel/InputField tooltip as string Tooltip's children is a string; match it (and LabelWithTooltip). Co-Authored-By: Claude Opus 4.8 --- frontend/web/components/base/forms/FieldLabel.tsx | 4 ++-- frontend/web/components/base/forms/InputField.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/web/components/base/forms/FieldLabel.tsx b/frontend/web/components/base/forms/FieldLabel.tsx index 86340ccad752..cb0118dc23c9 100644 --- a/frontend/web/components/base/forms/FieldLabel.tsx +++ b/frontend/web/components/base/forms/FieldLabel.tsx @@ -8,8 +8,8 @@ interface FieldLabelProps { children: ReactNode // Shows a danger asterisk after the label. required?: boolean - // When set, an info icon follows the label and reveals this content on hover. - tooltip?: ReactNode + // When set, an info icon follows the label and reveals this text on hover. + tooltip?: string className?: string } diff --git a/frontend/web/components/base/forms/InputField.tsx b/frontend/web/components/base/forms/InputField.tsx index 951b7d8e42e4..aed2c155529f 100644 --- a/frontend/web/components/base/forms/InputField.tsx +++ b/frontend/web/components/base/forms/InputField.tsx @@ -13,8 +13,8 @@ interface InputFieldProps extends Omit { // is present, so this works with manual state or any form library. error?: ReactNode required?: boolean - // Info-icon tooltip shown next to the label. - tooltip?: ReactNode + // Info-icon tooltip text shown next to the label. + tooltip?: string // Styles the label/input/error wrapper; `inputClassName` styles the input. wrapperClassName?: string ref?: Ref From 99bd9ec180998b0281d5e81683874fa96e7b0860 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 11 Jun 2026 18:00:50 -0300 Subject: [PATCH 07/13] feat(forms): add generic Field wrapper, refactor InputField onto it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field composes FieldLabel (+ tooltip/required) + any control + FieldError — the control-agnostic foundation that replaces InputGroup's component slot. InputField is now Field specialised to Input. Co-Authored-By: Claude Opus 4.8 --- .../components/Field.stories.tsx | 58 +++++++++++++++++++ frontend/web/components/base/forms/Field.tsx | 44 ++++++++++++++ .../web/components/base/forms/InputField.tsx | 27 +++++---- 3 files changed, 115 insertions(+), 14 deletions(-) create mode 100644 frontend/documentation/components/Field.stories.tsx create mode 100644 frontend/web/components/base/forms/Field.tsx diff --git a/frontend/documentation/components/Field.stories.tsx b/frontend/documentation/components/Field.stories.tsx new file mode 100644 index 000000000000..3e9f39e1482d --- /dev/null +++ b/frontend/documentation/components/Field.stories.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import Field from 'components/base/forms/Field' +import Input from 'components/base/forms/Input' + +const meta: Meta = { + component: Field, + parameters: { layout: 'padded' }, + title: 'Components/Forms/Field', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + + + ), +} + +export const WithError: Story = { + render: () => ( + + + + ), +} + +export const RequiredWithTooltip: Story = { + render: () => ( + + + + ), +} + +// Field wraps any control, not just Input (this replaces InputGroup's +// `component=`). +export const CustomControl: Story = { + render: () => ( + +