mirror of
https://github.com/penpot/penpot.git
synced 2026-08-01 19:06:18 +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.stories.tsx
|
||||||
│ │ │ └── RawSvg.spec.tsx
|
│ │ │ └── RawSvg.spec.tsx
|
||||||
│ │ └── typography/ # Text, Heading components + shared utilities
|
│ │ └── 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)
|
│ └── utilities/ # Utility components (e.g. Swatch)
|
||||||
├── eslint.config.mjs # ESLint 9 flat config (TypeScript + React)
|
├── eslint.config.mjs # ESLint 9 flat config (TypeScript + React)
|
||||||
├── stylelint.config.mjs # Stylelint config (mirrors frontend/)
|
├── 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/switch.cljs` | `src/lib/controls/Switch.tsx` |
|
||||||
| `ds/controls/checkbox.cljs` | `src/lib/controls/Checkbox.tsx` |
|
| `ds/controls/checkbox.cljs` | `src/lib/controls/Checkbox.tsx` |
|
||||||
| `ds/controls/input.cljs` | `src/lib/controls/Input.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
|
### Known Tooling Notes
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,11 @@ export type {
|
|||||||
} from "./lib/product/Avatar";
|
} from "./lib/product/Avatar";
|
||||||
export { PanelTitle } from "./lib/product/PanelTitle";
|
export { PanelTitle } from "./lib/product/PanelTitle";
|
||||||
export type { PanelTitleProps } 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 { Icon, iconIds } from "./lib/foundations/assets/Icon";
|
||||||
export type { IconId, IconProps } from "./lib/foundations/assets/Icon";
|
export type { IconId, IconProps } from "./lib/foundations/assets/Icon";
|
||||||
export { RawSvg, rawSvgIds } from "./lib/foundations/assets/RawSvg";
|
export { RawSvg, rawSvgIds } from "./lib/foundations/assets/RawSvg";
|
||||||
@ -63,3 +68,10 @@ export type {
|
|||||||
InputVariant,
|
InputVariant,
|
||||||
InputHintType,
|
InputHintType,
|
||||||
} from "./lib/controls/Input";
|
} 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