Add Label component to @penpot/ui

This commit is contained in:
Andrey Antukh 2026-04-07 22:32:53 +00:00
parent 6436e18074
commit ee7bb5589d
5 changed files with 191 additions and 0 deletions

View File

@ -40,3 +40,5 @@ export type {
SwatchGradientStop,
SwatchSize,
} from "./lib/utilities/Swatch";
export { Label } from "./lib/controls/utilities/Label";
export type { LabelProps } from "./lib/controls/utilities/Label";

View File

@ -0,0 +1,32 @@
// 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;
.label {
--label-color: var(--color-foreground-primary);
--label-optional-color: var(--color-foreground-secondary);
@include t.use-typography("body-small");
color: var(--label-color);
display: flex;
gap: var(--sp-xs);
align-items: center;
}
.label-text,
.label-optional {
line-height: inherit;
}
.label-text {
color: var(--label-color);
}
.label-optional {
color: var(--label-optional-color);
}

View File

@ -0,0 +1,81 @@
// 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 { Label } from "./Label";
describe("Label", () => {
it("should render successfully", () => {
const { baseElement } = render(<Label htmlFor="my-input">Name</Label>);
expect(baseElement).toBeTruthy();
});
it("should render a <label> element", () => {
const { container } = render(<Label htmlFor="my-input">Name</Label>);
const label = container.querySelector("label");
expect(label).toBeTruthy();
});
it("should set htmlFor on the label", () => {
const { container } = render(<Label htmlFor="my-input">Name</Label>);
const label = container.querySelector("label");
expect(label?.getAttribute("for")).toBe("my-input");
});
it("should render children inside label-text span", () => {
const { container } = render(<Label htmlFor="x">My Field</Label>);
const span = container.querySelector("label span");
expect(span?.textContent).toBe("My Field");
});
it("should not render label-text span when children is undefined", () => {
const { container } = render(<Label htmlFor="x" isOptional={true} />);
// Only the optional span should be present
const spans = container.querySelectorAll("label span");
expect(spans.length).toBe(1);
expect(spans[0]?.textContent).toBe("(Optional)");
});
it("should not render optional span by default", () => {
const { container } = render(<Label htmlFor="x">Label</Label>);
const spans = container.querySelectorAll("label span");
expect(spans.length).toBe(1);
// The single span is label-text, not optional
expect(spans[0]?.textContent).toBe("Label");
});
it("should render optional span when isOptional is true", () => {
const { container } = render(
<Label htmlFor="x" isOptional={true}>
Label
</Label>,
);
const spans = container.querySelectorAll("label span");
expect(spans.length).toBe(2);
expect(spans[1]?.textContent).toBe("(Optional)");
});
it("should merge className", () => {
const { container } = render(
<Label htmlFor="x" className="custom">
Label
</Label>,
);
const label = container.querySelector("label");
const cls = label?.getAttribute("class") ?? "";
expect(cls).toContain("custom");
});
it("should pass through additional HTML attributes", () => {
const { container } = render(
<Label htmlFor="x" data-testid="my-label">
Label
</Label>,
);
const label = container.querySelector("label");
expect(label?.getAttribute("data-testid")).toBe("my-label");
});
});

View File

@ -0,0 +1,35 @@
// 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 { Label } from "./Label";
const meta = {
title: "Controls/Utilities/Label",
component: Label,
args: {
htmlFor: "input-id",
children: "Field label",
},
} satisfies Meta<typeof Label>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Optional: Story = {
args: {
isOptional: true,
},
};
export const NoChildren: Story = {
args: {
children: undefined,
isOptional: true,
},
};

View File

@ -0,0 +1,41 @@
// 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 "./Label.module.scss";
export interface LabelProps extends ComponentPropsWithRef<"label"> {
/** The id of the form control this label is associated with */
htmlFor: string;
/** When true, renders an "(Optional)" suffix */
isOptional?: boolean;
}
function LabelInner({
htmlFor,
isOptional = false,
className,
children,
...rest
}: LabelProps) {
return (
<label
htmlFor={htmlFor}
className={clsx(styles.label, className)}
{...rest}
>
{children != null && (
<span className={styles["label-text"]}>{children}</span>
)}
{isOptional && (
<span className={styles["label-optional"]}>(Optional)</span>
)}
</label>
);
}
export const Label = memo(LabelInner);