mirror of
https://github.com/penpot/penpot.git
synced 2026-04-27 12:18:32 +00:00
✨ Add InputField and Input components to @penpot/ui
This commit is contained in:
parent
da90e03197
commit
06f21cddac
@ -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";
|
||||
|
||||
16
frontend/packages/ui/src/lib/controls/Input.module.scss
Normal file
16
frontend/packages/ui/src/lib/controls/Input.module.scss
Normal file
@ -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;
|
||||
}
|
||||
84
frontend/packages/ui/src/lib/controls/Input.spec.tsx
Normal file
84
frontend/packages/ui/src/lib/controls/Input.spec.tsx
Normal file
@ -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(<Input />);
|
||||
expect(baseElement).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render an <input> element", () => {
|
||||
const { container } = render(<Input />);
|
||||
expect(container.querySelector("input")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render a label when label prop is provided", () => {
|
||||
const { container } = render(<Input label="Name" />);
|
||||
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(<Input />);
|
||||
expect(container.querySelector("label")).toBeNull();
|
||||
});
|
||||
|
||||
it("should associate label with input via id", () => {
|
||||
const { container } = render(<Input id="my-input" label="Name" />);
|
||||
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(<Input label="Name" />);
|
||||
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(<Input hintMessage="A hint" />);
|
||||
const span = container.querySelector("span");
|
||||
expect(span?.textContent).toBe("A hint");
|
||||
});
|
||||
|
||||
it("should not render hint when hintMessage is not provided", () => {
|
||||
const { container } = render(<Input />);
|
||||
// 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(<Input label="Name" isOptional />);
|
||||
expect(container.textContent).toContain("(Optional)");
|
||||
});
|
||||
|
||||
it("should merge className on the outer wrapper", () => {
|
||||
const { container } = render(<Input className="extra" />);
|
||||
const wrapper = container.firstElementChild;
|
||||
expect(wrapper?.getAttribute("class")).toContain("extra");
|
||||
});
|
||||
|
||||
it("should pass through additional HTML attributes to the input", () => {
|
||||
const { container } = render(<Input data-testid="my-input" />);
|
||||
const input = container.querySelector("input");
|
||||
expect(input?.getAttribute("data-testid")).toBe("my-input");
|
||||
});
|
||||
|
||||
it("should pass type prop to the input", () => {
|
||||
const { container } = render(<Input type="email" />);
|
||||
const input = container.querySelector("input");
|
||||
expect(input?.getAttribute("type")).toBe("email");
|
||||
});
|
||||
});
|
||||
46
frontend/packages/ui/src/lib/controls/Input.stories.tsx
Normal file
46
frontend/packages/ui/src/lib/controls/Input.stories.tsx
Normal file
@ -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<typeof Input>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
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 },
|
||||
};
|
||||
99
frontend/packages/ui/src/lib/controls/Input.tsx
Normal file
99
frontend/packages/ui/src/lib/controls/Input.tsx
Normal file
@ -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<HTMLInputElement>,
|
||||
) {
|
||||
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 (
|
||||
<div
|
||||
className={clsx(
|
||||
styles["input-wrapper"],
|
||||
{
|
||||
[styles["variant-dense"]]: variant === "dense",
|
||||
[styles["variant-comfortable"]]: variant === "comfortable",
|
||||
[styles["has-hint"]]: hasHint,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasLabel && (
|
||||
<Label htmlFor={id} isOptional={isOptional}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<InputField
|
||||
ref={ref}
|
||||
id={id}
|
||||
hasHint={hasHint}
|
||||
hintType={hintType}
|
||||
variant={variant}
|
||||
{...rest}
|
||||
/>
|
||||
{hasHint && (
|
||||
<HintMessage
|
||||
id={id}
|
||||
message={hintMessage}
|
||||
type={hintType}
|
||||
className={hintClass}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Input = memo(forwardRef(InputInner));
|
||||
@ -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);
|
||||
}
|
||||
@ -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(<InputField id="f" />);
|
||||
expect(baseElement).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render an <input> element", () => {
|
||||
const { container } = render(<InputField id="f" />);
|
||||
expect(container.querySelector("input")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should set id on the input", () => {
|
||||
const { container } = render(<InputField id="my-input" />);
|
||||
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(<InputField id="f" icon="search" />);
|
||||
expect(container.querySelector("svg")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not render an icon by default", () => {
|
||||
const { container } = render(<InputField id="f" />);
|
||||
expect(container.querySelector("svg")).toBeNull();
|
||||
});
|
||||
|
||||
it("should set aria-invalid=true when hasHint and hintType=error", () => {
|
||||
const { container } = render(
|
||||
<InputField id="f" hasHint hintType="error" />,
|
||||
);
|
||||
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(
|
||||
<InputField id="f" hasHint hintType="warning" />,
|
||||
);
|
||||
const input = container.querySelector("input");
|
||||
expect(input?.getAttribute("aria-invalid")).toBeNull();
|
||||
});
|
||||
|
||||
it("should set aria-describedby to `${id}-hint` when hasHint", () => {
|
||||
const { container } = render(<InputField id="my-field" hasHint />);
|
||||
const input = container.querySelector("input");
|
||||
expect(input?.getAttribute("aria-describedby")).toBe("my-field-hint");
|
||||
});
|
||||
|
||||
it("should render slotStart content", () => {
|
||||
const { container } = render(
|
||||
<InputField id="f" slotStart={<span data-testid="slot-start" />} />,
|
||||
);
|
||||
expect(container.querySelector("[data-testid='slot-start']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render slotEnd content", () => {
|
||||
const { container } = render(
|
||||
<InputField id="f" slotEnd={<span data-testid="slot-end" />} />,
|
||||
);
|
||||
expect(container.querySelector("[data-testid='slot-end']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should merge className on the wrapper", () => {
|
||||
const { container } = render(<InputField id="f" className="extra" />);
|
||||
const wrapper = container.firstElementChild;
|
||||
expect(wrapper?.getAttribute("class")).toContain("extra");
|
||||
});
|
||||
|
||||
it("should pass through additional HTML attributes to the input", () => {
|
||||
const { container } = render(<InputField id="f" data-testid="my-input" />);
|
||||
const input = container.querySelector("input");
|
||||
expect(input?.getAttribute("data-testid")).toBe("my-input");
|
||||
});
|
||||
});
|
||||
@ -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<typeof InputField>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
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" },
|
||||
};
|
||||
@ -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 <div> */
|
||||
inputWrapperRef?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
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<HTMLInputElement>,
|
||||
) {
|
||||
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 (
|
||||
<div className={wrapperClass} ref={inputWrapperRef}>
|
||||
{slotStart}
|
||||
{icon != null && <Icon iconId={icon} className={styles.icon} size="s" />}
|
||||
<input
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={inputClass}
|
||||
maxLength={maxLength ?? MAX_INPUT_LENGTH}
|
||||
aria-invalid={hasHint && hintType === "error" ? "true" : undefined}
|
||||
aria-describedby={hasHint && id ? `${id}-hint` : ariaDescribedby}
|
||||
aria-label={ariaLabel}
|
||||
{...rest}
|
||||
/>
|
||||
{slotEnd}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const InputField = memo(forwardRef(InputFieldInner));
|
||||
Loading…
x
Reference in New Issue
Block a user