From c41e1a2fda62eec9995e80f3a345abff9a1f1431 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 7 Apr 2026 22:59:44 +0000 Subject: [PATCH] :sparkles: Add EmptyPlaceholder and NotificationPill components to @penpot/ui --- frontend/packages/ui/AGENTS.md | 10 +- frontend/packages/ui/src/index.ts | 12 ++ .../shared/NotificationPill.module.scss | 115 ++++++++++++ .../shared/NotificationPill.spec.tsx | 165 ++++++++++++++++++ .../shared/NotificationPill.stories.tsx | 68 ++++++++ .../notifications/shared/NotificationPill.tsx | 135 ++++++++++++++ .../lib/product/EmptyPlaceholder.module.scss | 38 ++++ .../src/lib/product/EmptyPlaceholder.spec.tsx | 95 ++++++++++ .../lib/product/EmptyPlaceholder.stories.tsx | 31 ++++ .../ui/src/lib/product/EmptyPlaceholder.tsx | 70 ++++++++ 10 files changed, 738 insertions(+), 1 deletion(-) create mode 100644 frontend/packages/ui/src/lib/notifications/shared/NotificationPill.module.scss create mode 100644 frontend/packages/ui/src/lib/notifications/shared/NotificationPill.spec.tsx create mode 100644 frontend/packages/ui/src/lib/notifications/shared/NotificationPill.stories.tsx create mode 100644 frontend/packages/ui/src/lib/notifications/shared/NotificationPill.tsx create mode 100644 frontend/packages/ui/src/lib/product/EmptyPlaceholder.module.scss create mode 100644 frontend/packages/ui/src/lib/product/EmptyPlaceholder.spec.tsx create mode 100644 frontend/packages/ui/src/lib/product/EmptyPlaceholder.stories.tsx create mode 100644 frontend/packages/ui/src/lib/product/EmptyPlaceholder.tsx diff --git a/frontend/packages/ui/AGENTS.md b/frontend/packages/ui/AGENTS.md index d7280f7064..10fd25bc09 100644 --- a/frontend/packages/ui/AGENTS.md +++ b/frontend/packages/ui/AGENTS.md @@ -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 diff --git a/frontend/packages/ui/src/index.ts b/frontend/packages/ui/src/index.ts index 529fd41386..889e861aca 100644 --- a/frontend/packages/ui/src/index.ts +++ b/frontend/packages/ui/src/index.ts @@ -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"; diff --git a/frontend/packages/ui/src/lib/notifications/shared/NotificationPill.module.scss b/frontend/packages/ui/src/lib/notifications/shared/NotificationPill.module.scss new file mode 100644 index 0000000000..20af637ab4 --- /dev/null +++ b/frontend/packages/ui/src/lib/notifications/shared/NotificationPill.module.scss @@ -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; +} diff --git a/frontend/packages/ui/src/lib/notifications/shared/NotificationPill.spec.tsx b/frontend/packages/ui/src/lib/notifications/shared/NotificationPill.spec.tsx new file mode 100644 index 0000000000..884eb94832 --- /dev/null +++ b/frontend/packages/ui/src/lib/notifications/shared/NotificationPill.spec.tsx @@ -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( + + A message + , + ); + expect(baseElement).toBeTruthy(); + }); + + it("should render children text content", () => { + const { getByText } = render( + + Hello notification + , + ); + expect(getByText("Hello notification")).toBeTruthy(); + }); + + it("should render an icon element", () => { + const { baseElement } = render( + + msg + , + ); + const svg = baseElement.querySelector("svg"); + expect(svg).toBeTruthy(); + }); + + it("should apply level-error class for error level", () => { + const { baseElement } = render( + + msg + , + ); + const root = baseElement.firstElementChild?.firstElementChild; + expect(root?.getAttribute("class")).toMatch(/level-error/); + }); + + it("should apply level-warning class for warning level", () => { + const { baseElement } = render( + + msg + , + ); + const root = baseElement.firstElementChild?.firstElementChild; + expect(root?.getAttribute("class")).toMatch(/level-warning/); + }); + + it("should apply level-success class for success level", () => { + const { baseElement } = render( + + msg + , + ); + 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( + + msg + , + ); + 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( + + msg + , + ); + 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( + + msg + , + ); + expect(queryByText("Detail")).toBeNull(); + }); + + it("should render detail toggle button when detail is provided", () => { + const { getByText } = render( + + msg + , + ); + expect(getByText("Detail")).toBeTruthy(); + }); + + it("should call onToggleDetail when detail title is clicked", () => { + const onToggleDetail = vi.fn(); + const { getByText } = render( + + msg + , + ); + fireEvent.click(getByText("Detail")); + expect(onToggleDetail).toHaveBeenCalledTimes(1); + }); + + it("should render detail content when showDetail is true", () => { + const { baseElement } = render( + + msg + , + ); + expect(baseElement.innerHTML).toContain("Secret details"); + }); + + it("should not render detail content when showDetail is false", () => { + const { baseElement } = render( + + msg + , + ); + expect(baseElement.innerHTML).not.toContain("Secret details"); + }); + + it("should inject HTML when isHtml is true", () => { + const { baseElement } = render( + + {"Bold text"} + , + ); + expect(baseElement.querySelector("strong")).toBeTruthy(); + }); +}); diff --git a/frontend/packages/ui/src/lib/notifications/shared/NotificationPill.stories.tsx b/frontend/packages/ui/src/lib/notifications/shared/NotificationPill.stories.tsx new file mode 100644 index 0000000000..542d509633 --- /dev/null +++ b/frontend/packages/ui/src/lib/notifications/shared/NotificationPill.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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: "
Stack trace line 1\nStack trace line 2
", + showDetail: false, + }, +}; + +export const WithDetailExpanded: Story = { + args: { + detail: "
Stack trace line 1\nStack trace line 2
", + showDetail: true, + }, +}; + +export const HtmlContent: Story = { + args: { + isHtml: true, + children: "Visit this link for details.", + }, +}; diff --git a/frontend/packages/ui/src/lib/notifications/shared/NotificationPill.tsx b/frontend/packages/ui/src/lib/notifications/shared/NotificationPill.tsx new file mode 100644 index 0000000000..540f744045 --- /dev/null +++ b/frontend/packages/ui/src/lib/notifications/shared/NotificationPill.tsx @@ -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 ( +
+
+ + {isHtml ? ( +
+ ) : ( + children + )} +
+ + {detail && ( +
+
+ +
Detail
+
+ {showDetail && ( +
+ )} +
+ )} +
+ ); +} + +export const NotificationPill = memo(NotificationPillInner); diff --git a/frontend/packages/ui/src/lib/product/EmptyPlaceholder.module.scss b/frontend/packages/ui/src/lib/product/EmptyPlaceholder.module.scss new file mode 100644 index 0000000000..26d5d94eb6 --- /dev/null +++ b/frontend/packages/ui/src/lib/product/EmptyPlaceholder.module.scss @@ -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); +} diff --git a/frontend/packages/ui/src/lib/product/EmptyPlaceholder.spec.tsx b/frontend/packages/ui/src/lib/product/EmptyPlaceholder.spec.tsx new file mode 100644 index 0000000000..3cb6a1a4bd --- /dev/null +++ b/frontend/packages/ui/src/lib/product/EmptyPlaceholder.spec.tsx @@ -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(); + expect(baseElement).toBeTruthy(); + }); + + it("should set data-testid on root", () => { + const { getByTestId } = render(); + expect(getByTestId("empty-placeholder")).toBeTruthy(); + }); + + it("should render the title", () => { + const { getByText } = render(); + expect(getByText("No items")).toBeTruthy(); + }); + + it("should render subtitle when provided", () => { + const { getByText } = render( + , + ); + expect(getByText("Try again later")).toBeTruthy(); + }); + + it("should not render subtitle when omitted", () => { + const { queryByText } = render(); + expect(queryByText("Try again later")).toBeNull(); + }); + + it("should render two svg decoration elements", () => { + const { baseElement } = render(); + const svgs = baseElement.querySelectorAll("svg"); + expect(svgs.length).toBe(2); + }); + + it("should use type-1 svg ids by default", () => { + const { baseElement } = render(); + 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( + , + ); + 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( + , + ); + expect(getByTestId("empty-placeholder").getAttribute("class")).toContain( + "extra", + ); + }); + + it("should forward extra HTML attributes", () => { + const { getByTestId } = render( + , + ); + expect(getByTestId("empty-placeholder").getAttribute("data-foo")).toBe( + "bar", + ); + }); + + it("should render children inside text-wrapper", () => { + const { getByText } = render( + + child content + , + ); + expect(getByText("child content")).toBeTruthy(); + }); +}); diff --git a/frontend/packages/ui/src/lib/product/EmptyPlaceholder.stories.tsx b/frontend/packages/ui/src/lib/product/EmptyPlaceholder.stories.tsx new file mode 100644 index 0000000000..25eee515be --- /dev/null +++ b/frontend/packages/ui/src/lib/product/EmptyPlaceholder.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Type2: Story = { + args: { type: 2 }, +}; + +export const NoSubtitle: Story = { + args: { subtitle: undefined }, +}; diff --git a/frontend/packages/ui/src/lib/product/EmptyPlaceholder.tsx b/frontend/packages/ui/src/lib/product/EmptyPlaceholder.tsx new file mode 100644 index 0000000000..bd1e04927f --- /dev/null +++ b/frontend/packages/ui/src/lib/product/EmptyPlaceholder.tsx @@ -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 ( +
+ +
+ + {title} + + {subtitle && ( + + {subtitle} + + )} + {children} +
+ +
+ ); +} + +export const EmptyPlaceholder = memo(EmptyPlaceholderInner);