mirror of
https://github.com/penpot/penpot.git
synced 2026-04-27 12:18:32 +00:00
✨ Add EmptyPlaceholder and NotificationPill components to @penpot/ui
This commit is contained in:
parent
5335a83abd
commit
c41e1a2fda
@ -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
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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.",
|
||||
},
|
||||
};
|
||||
@ -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);
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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 },
|
||||
};
|
||||
70
frontend/packages/ui/src/lib/product/EmptyPlaceholder.tsx
Normal file
70
frontend/packages/ui/src/lib/product/EmptyPlaceholder.tsx
Normal 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);
|
||||
Loading…
x
Reference in New Issue
Block a user