Add HintMessage component to @penpot/ui

This commit is contained in:
Andrey Antukh 2026-04-07 22:34:07 +00:00
parent ee7bb5589d
commit e33b820f98
5 changed files with 194 additions and 0 deletions

View File

@ -42,3 +42,8 @@ export type {
} from "./lib/utilities/Swatch";
export { Label } from "./lib/controls/utilities/Label";
export type { LabelProps } from "./lib/controls/utilities/Label";
export { HintMessage } from "./lib/controls/utilities/HintMessage";
export type {
HintMessageProps,
HintMessageType,
} from "./lib/controls/utilities/HintMessage";

View File

@ -0,0 +1,27 @@
// 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/typography.scss" as t;
.hint-message {
--hint-color: var(--color-foreground-secondary);
@include t.use-typography("body-small");
color: var(--hint-color);
}
.type-hint {
--hint-color: var(--color-foreground-secondary);
}
.type-warning {
--hint-color: var(--color-accent-warning);
}
.type-error {
--hint-color: var(--color-foreground-error);
}

View File

@ -0,0 +1,77 @@
// 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 { HintMessage } from "./HintMessage";
describe("HintMessage", () => {
it("should render successfully", () => {
const { baseElement } = render(<HintMessage id="field" message="A hint" />);
expect(baseElement).toBeTruthy();
});
it("should render a <div> root element", () => {
const { container } = render(<HintMessage id="field" message="A hint" />);
expect(container.firstElementChild?.tagName).toBe("DIV");
});
it("should render the message in a span", () => {
const { container } = render(
<HintMessage id="field" message="Some hint text" />,
);
const span = container.querySelector("span");
expect(span?.textContent).toBe("Some hint text");
});
it("should set span id to `${id}-hint`", () => {
const { container } = render(<HintMessage id="my-field" message="hint" />);
const span = container.querySelector("span");
expect(span?.getAttribute("id")).toBe("my-field-hint");
});
it("should not render span when message is undefined", () => {
const { container } = render(<HintMessage id="field" />);
expect(container.querySelector("span")).toBeNull();
});
it("should not set aria-live for type hint (default)", () => {
const { container } = render(<HintMessage id="field" message="hint" />);
const div = container.firstElementChild;
expect(div?.getAttribute("aria-live")).toBeNull();
});
it("should set aria-live=polite for type warning", () => {
const { container } = render(
<HintMessage id="field" message="warning" type="warning" />,
);
const div = container.firstElementChild;
expect(div?.getAttribute("aria-live")).toBe("polite");
});
it("should set aria-live=polite for type error", () => {
const { container } = render(
<HintMessage id="field" message="error" type="error" />,
);
const div = container.firstElementChild;
expect(div?.getAttribute("aria-live")).toBe("polite");
});
it("should merge className", () => {
const { container } = render(
<HintMessage id="field" message="hint" className="extra" />,
);
const div = container.firstElementChild;
expect(div?.getAttribute("class")).toContain("extra");
});
it("should pass through HTML attributes", () => {
const { container } = render(
<HintMessage id="field" message="hint" data-testid="h" />,
);
const div = container.firstElementChild;
expect(div?.getAttribute("data-testid")).toBe("h");
});
});

View File

@ -0,0 +1,34 @@
// 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 { HintMessage } from "./HintMessage";
const meta = {
title: "Controls/Utilities/HintMessage",
component: HintMessage,
args: {
id: "field",
message: "This is a hint message.",
},
} satisfies Meta<typeof HintMessage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Warning: Story = {
args: { type: "warning", message: "This value is unusual." },
};
export const Error: Story = {
args: { type: "error", message: "This field is required." },
};
export const NoMessage: Story = {
args: { message: undefined },
};

View File

@ -0,0 +1,51 @@
// 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, memo } from "react";
import clsx from "clsx";
import styles from "./HintMessage.module.scss";
export type HintMessageType = "hint" | "warning" | "error";
export interface HintMessageProps extends ComponentPropsWithRef<"div"> {
/** Unique id the inner span gets id `${id}-hint` */
id: string;
/** The message to display */
message?: string;
/** Visual/semantic type of the hint */
type?: HintMessageType;
}
function HintMessageInner({
id,
message,
type = "hint",
className,
...rest
}: HintMessageProps) {
const ariaLive =
type === "warning" || type === "error" ? "polite" : undefined;
return (
<div
className={clsx(
styles["hint-message"],
styles[`type-${type}`],
className,
)}
aria-live={ariaLive}
{...rest}
>
{message != null && (
<span className={styles["hint-message-text"]} id={`${id}-hint`}>
{message}
</span>
)}
</div>
);
}
export const HintMessage = memo(HintMessageInner);