Add EmptyPlaceholder and NotificationPill components to @penpot/ui

This commit is contained in:
Andrey Antukh 2026-04-07 22:59:44 +00:00
parent 5335a83abd
commit c41e1a2fda
10 changed files with 738 additions and 1 deletions

View File

@ -68,7 +68,13 @@ frontend/packages/ui/
│ │ │ ├── RawSvg.stories.tsx
│ │ │ └── RawSvg.spec.tsx
│ │ └── typography/ # Text, Heading components + shared utilities
│ └── product/ # Product-level components (e.g. Cta)
│ ├── notifications/
│ │ └── shared/
│ │ ├── NotificationPill.tsx
│ │ ├── NotificationPill.module.scss
│ │ ├── NotificationPill.stories.tsx
│ │ └── NotificationPill.spec.tsx
│ └── product/ # Product-level components (e.g. Cta, EmptyPlaceholder)
│ └── utilities/ # Utility components (e.g. Swatch)
├── eslint.config.mjs # ESLint 9 flat config (TypeScript + React)
├── stylelint.config.mjs # Stylelint config (mirrors frontend/)
@ -101,6 +107,8 @@ Components are organised to mirror the CLJS source tree
| `ds/controls/switch.cljs` | `src/lib/controls/Switch.tsx` |
| `ds/controls/checkbox.cljs` | `src/lib/controls/Checkbox.tsx` |
| `ds/controls/input.cljs` | `src/lib/controls/Input.tsx` |
| `ds/product/empty_placeholder.cljs` | `src/lib/product/EmptyPlaceholder.tsx` |
| `ds/notifications/shared/notification_pill.cljs` | `src/lib/notifications/shared/NotificationPill.tsx` |
### Known Tooling Notes

View File

@ -21,6 +21,11 @@ export type {
} from "./lib/product/Avatar";
export { PanelTitle } from "./lib/product/PanelTitle";
export type { PanelTitleProps } from "./lib/product/PanelTitle";
export { EmptyPlaceholder } from "./lib/product/EmptyPlaceholder";
export type {
EmptyPlaceholderProps,
EmptyPlaceholderType,
} from "./lib/product/EmptyPlaceholder";
export { Icon, iconIds } from "./lib/foundations/assets/Icon";
export type { IconId, IconProps } from "./lib/foundations/assets/Icon";
export { RawSvg, rawSvgIds } from "./lib/foundations/assets/RawSvg";
@ -63,3 +68,10 @@ export type {
InputVariant,
InputHintType,
} from "./lib/controls/Input";
export { NotificationPill } from "./lib/notifications/shared/NotificationPill";
export type {
NotificationPillProps,
NotificationPillLevel,
NotificationPillType,
NotificationPillAppearance,
} from "./lib/notifications/shared/NotificationPill";

View File

@ -0,0 +1,115 @@
// 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/typography.scss" as t;
.notification-pill {
@include t.use-typography("body-medium");
--notification-bg-color: var(--color-background-primary);
--notification-fg-color: var(--color-foreground-primary);
--notification-border-color: var(--color-background-quaternary);
--notification-padding: var(--sp-m);
--notification-icon-color: var(--color-foreground-secondary);
--notification-icon-margin: var(--sp-xxs);
background-color: var(--notification-bg-color);
border: $b-1 solid var(--notification-border-color);
border-radius: $br-8;
padding: var(--notification-padding);
display: grid;
max-height: 92vh;
overflow: hidden;
gap: var(--sp-s);
color: var(--notification-fg-color);
& a {
color: var(--color-accent-primary);
text-decoration: none;
}
}
.type-toast {
padding-inline-end: var(--sp-xxxl);
}
.appearance-ghost {
background-color: transparent;
}
.with-detail {
grid-template-rows: auto 1fr;
}
.level-default {
--notification-bg-color: var(--color-background-default);
--notification-fg-color: var(--color-foreground-primary);
--notification-border-color: var(--color-accent-default);
--notification-icon-color: var(--color-icon-default);
}
.level-info {
--notification-bg-color: var(--color-background-info);
--notification-fg-color: var(--color-foreground-primary);
--notification-border-color: var(--color-accent-info);
--notification-icon-color: var(--color-accent-info);
}
.level-error {
--notification-bg-color: var(--color-background-error);
--notification-fg-color: var(--color-foreground-primary);
--notification-border-color: var(--color-accent-error);
--notification-icon-color: var(--color-accent-error);
}
.level-warning {
--notification-bg-color: var(--color-background-warning);
--notification-fg-color: var(--color-foreground-primary);
--notification-border-color: var(--color-accent-warning);
--notification-icon-color: var(--color-accent-warning);
}
.level-success {
--notification-bg-color: var(--color-background-success);
--notification-fg-color: var(--color-foreground-primary);
--notification-border-color: var(--color-accent-success);
--notification-icon-color: var(--color-accent-success);
}
.icon {
flex-shrink: 0;
color: var(--notification-icon-color);
margin-block-start: var(--notification-icon-margin);
}
.error-message {
display: flex;
gap: var(--sp-s);
}
.error-detail {
overflow: auto;
}
.error-detail-title {
display: flex;
align-items: center;
cursor: pointer;
}
.expand-icon {
--icon-fill-color: var(--color-foreground-primary);
--icon-stroke-color: var(--color-foreground-primary);
}
.error-detail-content {
padding-inline-start: var(--sp-xxxl);
}
.context-text {
flex: 1;
}

View File

@ -0,0 +1,165 @@
// 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, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { NotificationPill } from "./NotificationPill";
describe("NotificationPill", () => {
it("should render successfully", () => {
const { baseElement } = render(
<NotificationPill level="info" type="context">
A message
</NotificationPill>,
);
expect(baseElement).toBeTruthy();
});
it("should render children text content", () => {
const { getByText } = render(
<NotificationPill level="info" type="context">
Hello notification
</NotificationPill>,
);
expect(getByText("Hello notification")).toBeTruthy();
});
it("should render an icon element", () => {
const { baseElement } = render(
<NotificationPill level="info" type="context">
msg
</NotificationPill>,
);
const svg = baseElement.querySelector("svg");
expect(svg).toBeTruthy();
});
it("should apply level-error class for error level", () => {
const { baseElement } = render(
<NotificationPill level="error" type="context">
msg
</NotificationPill>,
);
const root = baseElement.firstElementChild?.firstElementChild;
expect(root?.getAttribute("class")).toMatch(/level-error/);
});
it("should apply level-warning class for warning level", () => {
const { baseElement } = render(
<NotificationPill level="warning" type="context">
msg
</NotificationPill>,
);
const root = baseElement.firstElementChild?.firstElementChild;
expect(root?.getAttribute("class")).toMatch(/level-warning/);
});
it("should apply level-success class for success level", () => {
const { baseElement } = render(
<NotificationPill level="success" type="context">
msg
</NotificationPill>,
);
const root = baseElement.firstElementChild?.firstElementChild;
expect(root?.getAttribute("class")).toMatch(/level-success/);
});
it("should apply type-toast class when type is toast", () => {
const { baseElement } = render(
<NotificationPill level="info" type="toast">
msg
</NotificationPill>,
);
const root = baseElement.firstElementChild?.firstElementChild;
expect(root?.getAttribute("class")).toMatch(/type-toast/);
});
it("should apply appearance-ghost class when appearance is ghost", () => {
const { baseElement } = render(
<NotificationPill level="info" type="context" appearance="ghost">
msg
</NotificationPill>,
);
const root = baseElement.firstElementChild?.firstElementChild;
expect(root?.getAttribute("class")).toMatch(/appearance-ghost/);
});
it("should not render detail section when detail is not provided", () => {
const { queryByText } = render(
<NotificationPill level="info" type="context">
msg
</NotificationPill>,
);
expect(queryByText("Detail")).toBeNull();
});
it("should render detail toggle button when detail is provided", () => {
const { getByText } = render(
<NotificationPill
level="info"
type="context"
detail="<p>Details here</p>"
>
msg
</NotificationPill>,
);
expect(getByText("Detail")).toBeTruthy();
});
it("should call onToggleDetail when detail title is clicked", () => {
const onToggleDetail = vi.fn();
const { getByText } = render(
<NotificationPill
level="info"
type="context"
detail="<p>Details here</p>"
showDetail={false}
onToggleDetail={onToggleDetail}
>
msg
</NotificationPill>,
);
fireEvent.click(getByText("Detail"));
expect(onToggleDetail).toHaveBeenCalledTimes(1);
});
it("should render detail content when showDetail is true", () => {
const { baseElement } = render(
<NotificationPill
level="info"
type="context"
detail="<p>Secret details</p>"
showDetail={true}
>
msg
</NotificationPill>,
);
expect(baseElement.innerHTML).toContain("Secret details");
});
it("should not render detail content when showDetail is false", () => {
const { baseElement } = render(
<NotificationPill
level="info"
type="context"
detail="<p>Secret details</p>"
showDetail={false}
>
msg
</NotificationPill>,
);
expect(baseElement.innerHTML).not.toContain("Secret details");
});
it("should inject HTML when isHtml is true", () => {
const { baseElement } = render(
<NotificationPill level="info" type="context" isHtml>
{"<strong>Bold text</strong>"}
</NotificationPill>,
);
expect(baseElement.querySelector("strong")).toBeTruthy();
});
});

View File

@ -0,0 +1,68 @@
// 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 { NotificationPill } from "./NotificationPill";
const meta = {
title: "Notifications/NotificationPill",
component: NotificationPill,
args: {
level: "info",
type: "context",
children: "This is a notification message.",
},
} satisfies Meta<typeof NotificationPill>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Warning: Story = {
args: { level: "warning" },
};
export const Error: Story = {
args: { level: "error" },
};
export const Success: Story = {
args: { level: "success" },
};
export const LevelDefault: Story = {
args: { level: "default" },
};
export const Toast: Story = {
args: { type: "toast" },
};
export const Ghost: Story = {
args: { appearance: "ghost" },
};
export const WithDetail: Story = {
args: {
detail: "<pre>Stack trace line 1\nStack trace line 2</pre>",
showDetail: false,
},
};
export const WithDetailExpanded: Story = {
args: {
detail: "<pre>Stack trace line 1\nStack trace line 2</pre>",
showDetail: true,
},
};
export const HtmlContent: Story = {
args: {
isHtml: true,
children: "Visit <a href='#'>this link</a> for details.",
},
};

View File

@ -0,0 +1,135 @@
// 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 { memo } from "react";
import clsx from "clsx";
import { Icon, type IconId } from "../../foundations/assets/Icon";
import { IconButton } from "../../buttons/IconButton";
import styles from "./NotificationPill.module.scss";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type NotificationPillLevel =
| "default"
| "info"
| "warning"
| "error"
| "success";
export type NotificationPillType = "toast" | "context";
export type NotificationPillAppearance = "neutral" | "ghost";
export interface NotificationPillProps {
/** Severity level controls colours. */
level: NotificationPillLevel;
/** Where this pill is displayed. */
type: NotificationPillType;
/** Visual appearance variant. */
appearance?: NotificationPillAppearance;
/**
* When `true`, `children` is treated as an HTML string and injected via
* `dangerouslySetInnerHTML`. Use only with trusted content.
*/
isHtml?: boolean;
/** Optional detail section HTML string. */
detail?: string;
/** Controls whether the detail section is expanded. */
showDetail?: boolean;
/** Called when the user toggles the detail section. */
onToggleDetail?: () => void;
/** Main content. Either a React node or (when `isHtml` is true) an HTML string. */
children?: React.ReactNode | string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function iconByLevel(level: NotificationPillLevel): IconId {
switch (level) {
case "info":
case "default":
return "info";
case "warning":
return "msg-neutral";
case "error":
return "delete-text";
case "success":
return "status-tick";
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
function NotificationPillInner({
level,
type,
appearance,
isHtml = false,
detail,
showDetail,
onToggleDetail,
children,
}: NotificationPillProps) {
const rootClass = clsx(styles["notification-pill"], {
[styles["appearance-neutral"]]: appearance === "neutral",
[styles["appearance-ghost"]]: appearance === "ghost",
[styles["with-detail"]]: Boolean(detail),
[styles["type-toast"]]: type === "toast",
[styles["type-context"]]: type === "context",
[styles["level-default"]]: level === "default",
[styles["level-warning"]]: level === "warning",
[styles["level-error"]]: level === "error",
[styles["level-success"]]: level === "success",
[styles["level-info"]]: level === "info",
});
const iconId = iconByLevel(level);
return (
<div className={rootClass}>
<div className={styles["error-message"]}>
<Icon iconId={iconId} className={styles.icon} />
{isHtml ? (
<div
className={styles["context-text"]}
dangerouslySetInnerHTML={{ __html: children as string }}
/>
) : (
children
)}
</div>
{detail && (
<div className={styles["error-detail"]}>
<div className={styles["error-detail-title"]}>
<IconButton
icon={showDetail ? "arrow-down" : "arrow"}
aria-label="Detail"
iconClass={styles["expand-icon"]}
variant="action"
onClick={onToggleDetail}
/>
<div onClick={onToggleDetail}>Detail</div>
</div>
{showDetail && (
<div
className={styles["error-detail-content"]}
dangerouslySetInnerHTML={{ __html: detail }}
/>
)}
</div>
)}
</div>
);
}
export const NotificationPill = memo(NotificationPillInner);

View File

@ -0,0 +1,38 @@
// 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/_sizes.scss" as *;
@use "../_ds/_borders.scss" as *;
@use "../_ds/_utils.scss" as *;
.empty-placeholder {
display: grid;
grid-template-columns: auto 1fr auto;
place-content: center;
background: none;
color: var(--color-foreground-secondary);
height: $sz-160;
max-width: px2rem(964);
border-radius: $br-8;
border: $b-1 solid var(--color-background-quaternary);
}
.text-wrapper {
display: grid;
grid-auto-rows: auto;
place-self: center center;
max-width: $sz-400;
}
.placeholder-title {
color: var(--color-foreground-primary);
}
.svg-decor {
height: $sz-160;
width: $sz-200;
color: var(--color-background-quaternary);
}

View File

@ -0,0 +1,95 @@
// 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 { describe, it, expect } from "vitest";
import { EmptyPlaceholder } from "./EmptyPlaceholder";
describe("EmptyPlaceholder", () => {
it("should render with required title", () => {
const { baseElement } = render(<EmptyPlaceholder title="No items" />);
expect(baseElement).toBeTruthy();
});
it("should set data-testid on root", () => {
const { getByTestId } = render(<EmptyPlaceholder title="No items" />);
expect(getByTestId("empty-placeholder")).toBeTruthy();
});
it("should render the title", () => {
const { getByText } = render(<EmptyPlaceholder title="No items" />);
expect(getByText("No items")).toBeTruthy();
});
it("should render subtitle when provided", () => {
const { getByText } = render(
<EmptyPlaceholder title="No items" subtitle="Try again later" />,
);
expect(getByText("Try again later")).toBeTruthy();
});
it("should not render subtitle when omitted", () => {
const { queryByText } = render(<EmptyPlaceholder title="No items" />);
expect(queryByText("Try again later")).toBeNull();
});
it("should render two svg decoration elements", () => {
const { baseElement } = render(<EmptyPlaceholder title="No items" />);
const svgs = baseElement.querySelectorAll("svg");
expect(svgs.length).toBe(2);
});
it("should use type-1 svg ids by default", () => {
const { baseElement } = render(<EmptyPlaceholder title="No items" />);
const uses = baseElement.querySelectorAll("use");
expect(uses[0].getAttribute("href")).toBe(
"#asset-empty-placeholder-1-left",
);
expect(uses[1].getAttribute("href")).toBe(
"#asset-empty-placeholder-1-right",
);
});
it("should use type-2 svg ids when type is 2", () => {
const { baseElement } = render(
<EmptyPlaceholder title="No items" type={2} />,
);
const uses = baseElement.querySelectorAll("use");
expect(uses[0].getAttribute("href")).toBe(
"#asset-empty-placeholder-2-left",
);
expect(uses[1].getAttribute("href")).toBe(
"#asset-empty-placeholder-2-right",
);
});
it("should forward extra className", () => {
const { getByTestId } = render(
<EmptyPlaceholder title="No items" className="extra" />,
);
expect(getByTestId("empty-placeholder").getAttribute("class")).toContain(
"extra",
);
});
it("should forward extra HTML attributes", () => {
const { getByTestId } = render(
<EmptyPlaceholder title="No items" data-foo="bar" />,
);
expect(getByTestId("empty-placeholder").getAttribute("data-foo")).toBe(
"bar",
);
});
it("should render children inside text-wrapper", () => {
const { getByText } = render(
<EmptyPlaceholder title="No items">
<span>child content</span>
</EmptyPlaceholder>,
);
expect(getByText("child content")).toBeTruthy();
});
});

View File

@ -0,0 +1,31 @@
// 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 { EmptyPlaceholder } from "./EmptyPlaceholder";
const meta = {
title: "Product/EmptyPlaceholder",
component: EmptyPlaceholder,
args: {
title: "Nothing here yet",
subtitle: "Create something to get started.",
type: 1,
},
} satisfies Meta<typeof EmptyPlaceholder>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Type2: Story = {
args: { type: 2 },
};
export const NoSubtitle: Story = {
args: { subtitle: undefined },
};

View File

@ -0,0 +1,70 @@
// 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 { RawSvg, type RawSvgId } from "../foundations/assets/RawSvg";
import { Text } from "../foundations/typography/Text";
import styles from "./EmptyPlaceholder.module.scss";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type EmptyPlaceholderType = 1 | 2;
export interface EmptyPlaceholderProps extends ComponentPropsWithRef<"div"> {
/** Title text shown in the placeholder. */
title: string;
/** Optional subtitle shown below the title. */
subtitle?: string;
/** Illustration variant to use. Defaults to 1. */
type?: EmptyPlaceholderType;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
function EmptyPlaceholderInner({
title,
subtitle,
type = 1,
className,
children,
...rest
}: EmptyPlaceholderProps) {
const leftId: RawSvgId = `empty-placeholder-${type}-left`;
const rightId: RawSvgId = `empty-placeholder-${type}-right`;
return (
<div
className={clsx(styles["empty-placeholder"], className)}
data-testid="empty-placeholder"
{...rest}
>
<RawSvg id={leftId} className={styles["svg-decor"]} />
<div className={styles["text-wrapper"]}>
<Text
as="span"
typography="title-medium"
className={styles["placeholder-title"]}
>
{title}
</Text>
{subtitle && (
<Text as="span" typography="body-large">
{subtitle}
</Text>
)}
{children}
</div>
<RawSvg id={rightId} className={styles["svg-decor"]} />
</div>
);
}
export const EmptyPlaceholder = memo(EmptyPlaceholderInner);