From 06f21cddace5bf2451199101ba56136ad0330eac Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 7 Apr 2026 22:41:09 +0000 Subject: [PATCH] :sparkles: Add InputField and Input components to @penpot/ui --- frontend/packages/ui/src/index.ts | 12 ++ .../ui/src/lib/controls/Input.module.scss | 16 +++ .../ui/src/lib/controls/Input.spec.tsx | 84 +++++++++++++ .../ui/src/lib/controls/Input.stories.tsx | 46 ++++++++ .../packages/ui/src/lib/controls/Input.tsx | 99 ++++++++++++++++ .../controls/utilities/InputField.module.scss | 111 ++++++++++++++++++ .../controls/utilities/InputField.spec.tsx | 84 +++++++++++++ .../controls/utilities/InputField.stories.tsx | 42 +++++++ .../src/lib/controls/utilities/InputField.tsx | 94 +++++++++++++++ 9 files changed, 588 insertions(+) create mode 100644 frontend/packages/ui/src/lib/controls/Input.module.scss create mode 100644 frontend/packages/ui/src/lib/controls/Input.spec.tsx create mode 100644 frontend/packages/ui/src/lib/controls/Input.stories.tsx create mode 100644 frontend/packages/ui/src/lib/controls/Input.tsx create mode 100644 frontend/packages/ui/src/lib/controls/utilities/InputField.module.scss create mode 100644 frontend/packages/ui/src/lib/controls/utilities/InputField.spec.tsx create mode 100644 frontend/packages/ui/src/lib/controls/utilities/InputField.stories.tsx create mode 100644 frontend/packages/ui/src/lib/controls/utilities/InputField.tsx diff --git a/frontend/packages/ui/src/index.ts b/frontend/packages/ui/src/index.ts index bdbd1982cc..529fd41386 100644 --- a/frontend/packages/ui/src/index.ts +++ b/frontend/packages/ui/src/index.ts @@ -51,3 +51,15 @@ export { Switch } from "./lib/controls/Switch"; export type { SwitchProps } from "./lib/controls/Switch"; export { Checkbox } from "./lib/controls/Checkbox"; export type { CheckboxProps } from "./lib/controls/Checkbox"; +export { InputField } from "./lib/controls/utilities/InputField"; +export type { + InputFieldProps, + InputFieldVariant, + InputFieldHintType, +} from "./lib/controls/utilities/InputField"; +export { Input } from "./lib/controls/Input"; +export type { + InputProps, + InputVariant, + InputHintType, +} from "./lib/controls/Input"; diff --git a/frontend/packages/ui/src/lib/controls/Input.module.scss b/frontend/packages/ui/src/lib/controls/Input.module.scss new file mode 100644 index 0000000000..f89ff7209e --- /dev/null +++ b/frontend/packages/ui/src/lib/controls/Input.module.scss @@ -0,0 +1,16 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +.input-wrapper { + display: flex; + flex-direction: column; + gap: var(--sp-xs); + inline-size: 100%; +} + +.hint-formatted { + white-space: pre; +} diff --git a/frontend/packages/ui/src/lib/controls/Input.spec.tsx b/frontend/packages/ui/src/lib/controls/Input.spec.tsx new file mode 100644 index 0000000000..86b39ad90d --- /dev/null +++ b/frontend/packages/ui/src/lib/controls/Input.spec.tsx @@ -0,0 +1,84 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +import { render } from "@testing-library/react"; +import { Input } from "./Input"; + +describe("Input", () => { + it("should render successfully", () => { + const { baseElement } = render(); + expect(baseElement).toBeTruthy(); + }); + + it("should render an element", () => { + const { container } = render(); + expect(container.querySelector("input")).toBeTruthy(); + }); + + it("should render a label when label prop is provided", () => { + const { container } = render(); + const label = container.querySelector("label"); + expect(label).toBeTruthy(); + expect(label?.textContent).toContain("Name"); + }); + + it("should not render a label when label is not provided", () => { + const { container } = render(); + expect(container.querySelector("label")).toBeNull(); + }); + + it("should associate label with input via id", () => { + const { container } = render(); + const label = container.querySelector("label"); + const input = container.querySelector("input"); + expect(label?.getAttribute("for")).toBe("my-input"); + expect(input?.getAttribute("id")).toBe("my-input"); + }); + + it("should auto-generate id when not provided", () => { + const { container } = render(); + const label = container.querySelector("label"); + const input = container.querySelector("input"); + const forAttr = label?.getAttribute("for"); + expect(forAttr).toBeTruthy(); + expect(input?.getAttribute("id")).toBe(forAttr); + }); + + it("should render hint message when hintMessage is provided", () => { + const { container } = render(); + const span = container.querySelector("span"); + expect(span?.textContent).toBe("A hint"); + }); + + it("should not render hint when hintMessage is not provided", () => { + const { container } = render(); + // The only children should be the InputField wrapper div + expect(container.querySelector("span")).toBeNull(); + }); + + it("should render Optional suffix when isOptional is true", () => { + const { container } = render(); + expect(container.textContent).toContain("(Optional)"); + }); + + it("should merge className on the outer wrapper", () => { + const { container } = render(); + const wrapper = container.firstElementChild; + expect(wrapper?.getAttribute("class")).toContain("extra"); + }); + + it("should pass through additional HTML attributes to the input", () => { + const { container } = render(); + const input = container.querySelector("input"); + expect(input?.getAttribute("data-testid")).toBe("my-input"); + }); + + it("should pass type prop to the input", () => { + const { container } = render(); + const input = container.querySelector("input"); + expect(input?.getAttribute("type")).toBe("email"); + }); +}); diff --git a/frontend/packages/ui/src/lib/controls/Input.stories.tsx b/frontend/packages/ui/src/lib/controls/Input.stories.tsx new file mode 100644 index 0000000000..256f1307f5 --- /dev/null +++ b/frontend/packages/ui/src/lib/controls/Input.stories.tsx @@ -0,0 +1,46 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Input } from "./Input"; + +const meta = { + title: "Controls/Input", + component: Input, + args: { + label: "Field label", + placeholder: "Type something…", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Optional: Story = { + args: { isOptional: true }, +}; + +export const WithHint: Story = { + args: { hintMessage: "This is a hint." }, +}; + +export const WithError: Story = { + args: { hintMessage: "This field is required.", hintType: "error" }, +}; + +export const WithWarning: Story = { + args: { hintMessage: "Value is unusual.", hintType: "warning" }, +}; + +export const Comfortable: Story = { + args: { variant: "comfortable" }, +}; + +export const NoLabel: Story = { + args: { label: undefined }, +}; diff --git a/frontend/packages/ui/src/lib/controls/Input.tsx b/frontend/packages/ui/src/lib/controls/Input.tsx new file mode 100644 index 0000000000..5ed4edfcbd --- /dev/null +++ b/frontend/packages/ui/src/lib/controls/Input.tsx @@ -0,0 +1,99 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +import { forwardRef, memo, useId } from "react"; +import clsx from "clsx"; +import { HintMessage } from "./utilities/HintMessage"; +import type { HintMessageType } from "./utilities/HintMessage"; +import { InputField } from "./utilities/InputField"; +import type { + InputFieldProps, + InputFieldVariant, +} from "./utilities/InputField"; +import { Label } from "./utilities/Label"; +import styles from "./Input.module.scss"; + +export type { InputFieldVariant as InputVariant }; +export type { HintMessageType as InputHintType }; + +export interface InputProps extends InputFieldProps { + /** Visible label text */ + label?: string; + /** When true, renders "(Optional)" suffix in the label */ + isOptional?: boolean; + /** Visual density variant */ + variant?: InputFieldVariant; + /** Hint / error / warning message displayed below the field */ + hintMessage?: string; + /** Whether the hint is pre-formatted (preserves whitespace) */ + hintFormatted?: boolean; + /** Type of hint message */ + hintType?: HintMessageType; +} + +function InputInner( + { + id: idProp, + label, + isOptional = false, + variant = "dense", + hintMessage, + hintFormatted = false, + hintType, + className, + ...rest + }: InputProps, + ref: React.Ref, +) { + const generatedId = useId(); + const id = idProp ?? generatedId; + + const hasLabel = label != null && label.trim().length > 0; + const hasHint = hintMessage != null && hintMessage.trim().length > 0; + + const hintClass = + hintType !== "error" && hintFormatted + ? styles["hint-formatted"] + : undefined; + + return ( +
+ {hasLabel && ( + + )} + + {hasHint && ( + + )} +
+ ); +} + +export const Input = memo(forwardRef(InputInner)); diff --git a/frontend/packages/ui/src/lib/controls/utilities/InputField.module.scss b/frontend/packages/ui/src/lib/controls/utilities/InputField.module.scss new file mode 100644 index 0000000000..da18bbef8a --- /dev/null +++ b/frontend/packages/ui/src/lib/controls/utilities/InputField.module.scss @@ -0,0 +1,111 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +@use "../../_ds/_borders.scss" as *; +@use "../../_ds/_sizes.scss" as *; +@use "../../_ds/typography.scss" as t; + +.input-wrapper { + --input-bg-color: var(--color-background-tertiary); + --input-fg-color: var(--color-foreground-primary); + --input-icon-color: var(--color-foreground-secondary); + --input-outline-color: none; + --input-height: #{$sz-32}; + --input-margin: unset; + + display: inline-flex; + column-gap: var(--sp-xs); + align-items: center; + position: relative; + inline-size: 100%; + background: var(--input-bg-color); + border-radius: $br-8; + padding: 0 var(--input-padding-size, var(--sp-s)); + outline: $b-1 solid var(--input-outline-color); +} + +.input-wrapper:hover { + --input-bg-color: var(--color-background-quaternary); +} + +.input-wrapper:has(*:focus-visible) { + --input-bg-color: var(--color-background-primary); + --input-outline-color: var(--color-accent-primary); +} + +.input-wrapper:has(*:disabled) { + --input-bg-color: var(--color-background-primary); + --input-outline-color: var(--color-background-quaternary); +} + +.variant-dense, +.variant-seamless { + @include t.use-typography("body-small"); +} + +.variant-comfortable { + @include t.use-typography("body-medium"); +} + +.variant-seamless { + --input-bg-color: none; + --input-outline-color: none; + --input-height: auto; + --input-margin: 0; + + padding: 0; + border: none; +} + +.variant-seamless:hover { + --input-bg-color: none; + --input-outline-color: none; +} + +.variant-seamless:has(*:focus-visible) { + --input-bg-color: none; + --input-outline-color: none; +} + +.input { + margin: var(--input-margin); + padding: 0; + appearance: none; + height: var(--input-height); + border: none; + background: none; + inline-size: 100%; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + color: var(--input-fg-color); +} + +.input:focus-visible { + outline: none; +} + +.input::selection { + background: var(--color-accent-select); +} + +.input::placeholder { + --input-fg-color: var(--color-foreground-secondary); +} + +.input-with-icon { + margin-inline-start: var(--sp-xxs); +} + +.hint-type-error { + --input-outline-color: var(--color-foreground-error); +} + +.icon { + color: var(--color-foreground-secondary); + min-inline-size: var(--sp-l); +} diff --git a/frontend/packages/ui/src/lib/controls/utilities/InputField.spec.tsx b/frontend/packages/ui/src/lib/controls/utilities/InputField.spec.tsx new file mode 100644 index 0000000000..eadf74ab35 --- /dev/null +++ b/frontend/packages/ui/src/lib/controls/utilities/InputField.spec.tsx @@ -0,0 +1,84 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +import { render } from "@testing-library/react"; +import { InputField } from "./InputField"; + +describe("InputField", () => { + it("should render successfully", () => { + const { baseElement } = render(); + expect(baseElement).toBeTruthy(); + }); + + it("should render an element", () => { + const { container } = render(); + expect(container.querySelector("input")).toBeTruthy(); + }); + + it("should set id on the input", () => { + const { container } = render(); + const input = container.querySelector("input"); + expect(input?.getAttribute("id")).toBe("my-input"); + }); + + it("should render an icon when icon prop is provided", () => { + const { container } = render(); + expect(container.querySelector("svg")).toBeTruthy(); + }); + + it("should not render an icon by default", () => { + const { container } = render(); + expect(container.querySelector("svg")).toBeNull(); + }); + + it("should set aria-invalid=true when hasHint and hintType=error", () => { + const { container } = render( + , + ); + const input = container.querySelector("input"); + expect(input?.getAttribute("aria-invalid")).toBe("true"); + }); + + it("should not set aria-invalid when hasHint and hintType=warning", () => { + const { container } = render( + , + ); + const input = container.querySelector("input"); + expect(input?.getAttribute("aria-invalid")).toBeNull(); + }); + + it("should set aria-describedby to `${id}-hint` when hasHint", () => { + const { container } = render(); + const input = container.querySelector("input"); + expect(input?.getAttribute("aria-describedby")).toBe("my-field-hint"); + }); + + it("should render slotStart content", () => { + const { container } = render( + } />, + ); + expect(container.querySelector("[data-testid='slot-start']")).toBeTruthy(); + }); + + it("should render slotEnd content", () => { + const { container } = render( + } />, + ); + expect(container.querySelector("[data-testid='slot-end']")).toBeTruthy(); + }); + + it("should merge className on the wrapper", () => { + const { container } = render(); + const wrapper = container.firstElementChild; + expect(wrapper?.getAttribute("class")).toContain("extra"); + }); + + it("should pass through additional HTML attributes to the input", () => { + const { container } = render(); + const input = container.querySelector("input"); + expect(input?.getAttribute("data-testid")).toBe("my-input"); + }); +}); diff --git a/frontend/packages/ui/src/lib/controls/utilities/InputField.stories.tsx b/frontend/packages/ui/src/lib/controls/utilities/InputField.stories.tsx new file mode 100644 index 0000000000..e2f6dfd0f1 --- /dev/null +++ b/frontend/packages/ui/src/lib/controls/utilities/InputField.stories.tsx @@ -0,0 +1,42 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { InputField } from "./InputField"; + +const meta = { + title: "Controls/Utilities/InputField", + component: InputField, + args: { + id: "field", + placeholder: "Type something…", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Dense: Story = { + args: { variant: "dense" }, +}; + +export const Comfortable: Story = { + args: { variant: "comfortable" }, +}; + +export const Seamless: Story = { + args: { variant: "seamless" }, +}; + +export const WithIcon: Story = { + args: { icon: "search" }, +}; + +export const WithError: Story = { + args: { hasHint: true, hintType: "error" }, +}; diff --git a/frontend/packages/ui/src/lib/controls/utilities/InputField.tsx b/frontend/packages/ui/src/lib/controls/utilities/InputField.tsx new file mode 100644 index 0000000000..c56baf5e22 --- /dev/null +++ b/frontend/packages/ui/src/lib/controls/utilities/InputField.tsx @@ -0,0 +1,94 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +import { + type ComponentPropsWithRef, + type ReactNode, + forwardRef, + memo, +} from "react"; +import clsx from "clsx"; +import { type IconId, Icon } from "../../foundations/assets/Icon"; +import styles from "./InputField.module.scss"; + +export type InputFieldVariant = "seamless" | "dense" | "comfortable"; +export type InputFieldHintType = "hint" | "error" | "warning"; + +export interface InputFieldProps extends ComponentPropsWithRef<"input"> { + /** Icon displayed at the leading edge of the field */ + icon?: IconId; + /** Whether the field has an associated hint/error message */ + hasHint?: boolean; + /** The type of hint (affects outline colour) */ + hintType?: InputFieldHintType; + /** Visual density variant */ + variant?: InputFieldVariant; + /** Content rendered before the input (leading slot) */ + slotStart?: ReactNode; + /** Content rendered after the input (trailing slot) */ + slotEnd?: ReactNode; + /** Ref forwarded to the wrapper
*/ + inputWrapperRef?: React.Ref; +} + +const MAX_INPUT_LENGTH = 500; + +function InputFieldInner( + { + id, + icon, + hasHint = false, + hintType, + variant = "dense", + slotStart, + slotEnd, + className, + inputWrapperRef, + maxLength, + "aria-label": ariaLabel, + "aria-describedby": ariaDescribedby, + ...rest + }: InputFieldProps, + ref: React.Ref, +) { + const wrapperClass = clsx( + styles["input-wrapper"], + { + [styles["has-hint"]]: hasHint, + [styles["hint-type-hint"]]: hintType === "hint", + [styles["hint-type-warning"]]: hintType === "warning", + [styles["hint-type-error"]]: hintType === "error", + [styles["variant-seamless"]]: variant === "seamless", + [styles["variant-dense"]]: variant === "dense", + [styles["variant-comfortable"]]: variant === "comfortable", + }, + className, + ); + + const inputClass = clsx(styles.input, { + [styles["input-with-icon"]]: icon != null, + }); + + return ( +
+ {slotStart} + {icon != null && } + + {slotEnd} +
+ ); +} + +export const InputField = memo(forwardRef(InputFieldInner));