Add Loader component to @penpot/ui

This commit is contained in:
Andrey Antukh 2026-04-07 20:54:09 +00:00
parent fcc29f2152
commit b5803871a7
6 changed files with 415 additions and 0 deletions

View File

@ -63,6 +63,7 @@ Components are organised to mirror the CLJS source tree
| `ds/foundations/assets/icon.cljs` | `src/lib/foundations/assets/Icon.tsx` |
| `ds/foundations/assets/raw_svg.cljs` | `src/lib/foundations/assets/RawSvg.tsx` |
| `ds/product/cta.cljs` | `src/lib/product/Cta.tsx` |
| `ds/product/loader.cljs` | `src/lib/product/Loader.tsx` |
| `ds/buttons/button.cljs` | `src/lib/buttons/Button.tsx` |
| `ds/buttons/icon_button.cljs` | `src/lib/buttons/IconButton.tsx` |
| `ds/utilities/swatch.cljs` | `src/lib/utilities/Swatch.tsx` |

View File

@ -11,6 +11,8 @@ export {
export type { TypographyId } from "./lib/foundations/typography/typography";
export { Cta } from "./lib/product/Cta";
export type { CtaProps } from "./lib/product/Cta";
export { Loader } from "./lib/product/Loader";
export type { LoaderProps, LoaderTip } from "./lib/product/Loader";
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";

View File

@ -0,0 +1,81 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "../_ds/_utils.scss" as *;
@keyframes line-pencil {
0% {
transform: translateY(0);
}
100% {
transform: translateY(px2rem(-150));
}
}
.wrapper {
--loader-color-foreground: var(--color-foreground-secondary);
display: inline-flex;
column-gap: var(--sp-s);
align-items: center;
color: var(--loader-color-foreground);
}
.file-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
block-size: 100%;
inline-size: 100%;
padding: var(--sp-xxl);
}
.loader-content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-xl);
}
.tips-container {
text-align: center;
min-inline-size: px2rem(600);
max-inline-size: px2rem(800);
display: flex;
flex-direction: column;
gap: var(--sp-s);
border: 1px solid var(--color-background-tertiary);
border-radius: var(--sp-l);
padding: var(--sp-xxl) var(--sp-xxxl);
}
.tip-title {
color: var(--color-foreground-primary);
font-weight: 500;
}
.tip-message {
color: var(--color-foreground-secondary);
}
.wrapper-overlay {
display: grid;
place-content: center;
block-size: 100%;
inline-size: 100%;
row-gap: var(--sp-s);
}
.loader {
fill: currentcolor;
inline-size: var(--icon-width);
}
.loader-line {
animation: line-pencil 0.8s infinite linear;
}

View File

@ -0,0 +1,90 @@
// 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, expect, it } from "vitest";
import { Loader } from "./Loader";
describe("Loader", () => {
it("should render successfully", () => {
const { baseElement } = render(<Loader />);
expect(baseElement).toBeTruthy();
});
it("should render the SVG loader icon with role='status'", () => {
const { container } = render(<Loader />);
const svg = container.querySelector("svg[role='status']");
expect(svg).toBeTruthy();
});
it("should apply the wrapper class by default", () => {
const { container } = render(<Loader />);
expect(container.firstElementChild?.getAttribute("class")).toContain(
"wrapper",
);
});
it("should apply wrapper-overlay class when overlay=true", () => {
const { container } = render(<Loader overlay />);
expect(container.firstElementChild?.getAttribute("class")).toContain(
"wrapper-overlay",
);
});
it("should apply file-loading class when fileLoading=true", () => {
const { container } = render(<Loader fileLoading />);
expect(container.firstElementChild?.getAttribute("class")).toContain(
"file-loading",
);
});
it("should use default width and height of 100x27 when none given", () => {
const { container } = render(<Loader />);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("width")).toBe("100");
expect(svg?.getAttribute("height")).toBe("27");
});
it("should calculate height from width when only width is given", () => {
const { container } = render(<Loader width={200} />);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("width")).toBe("200");
// height = ceil(200 * 27/100) = 54
expect(svg?.getAttribute("height")).toBe("54");
});
it("should calculate width from height when only height is given", () => {
const { container } = render(<Loader height={54} />);
const svg = container.querySelector("svg");
// width = ceil(54 * 100/27) = 200
expect(svg?.getAttribute("width")).toBe("200");
expect(svg?.getAttribute("height")).toBe("54");
});
it("should use the provided title as the SVG <title>", () => {
const { container } = render(<Loader title="Please wait" />);
const title = container.querySelector("svg title");
expect(title?.textContent).toBe("Please wait");
});
it("should render children", () => {
const { getByText } = render(<Loader>Loading text</Loader>);
expect(getByText("Loading text")).toBeTruthy();
});
it("should forward className to the root div", () => {
const { container } = render(<Loader className="custom-loader" />);
expect(container.firstElementChild?.getAttribute("class")).toContain(
"custom-loader",
);
});
it("should not show tips container initially without fileLoading", () => {
const { container } = render(<Loader />);
const tips = container.querySelector("[class*='tips-container']");
expect(tips).toBeNull();
});
});

View File

@ -0,0 +1,60 @@
// 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 { Loader } from "./Loader";
const meta = {
title: "Product/Loader",
component: Loader,
args: {
overlay: false,
fileLoading: false,
},
argTypes: {
title: { control: "text" },
width: { control: "number" },
height: { control: "number" },
overlay: { control: "boolean" },
fileLoading: { control: "boolean" },
},
} satisfies Meta<typeof Loader>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithContent: Story = {
args: {
children: "Lorem ipsum",
},
};
export const Overlay: Story = {
args: {
overlay: true,
children: "Lorem ipsum",
},
parameters: {
layout: "fullscreen",
},
};
export const CustomSize: Story = {
args: {
width: 200,
},
};
export const FileLoading: Story = {
args: {
fileLoading: true,
},
parameters: {
layout: "fullscreen",
},
};

View File

@ -0,0 +1,181 @@
// 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,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import clsx from "clsx";
import styles from "./Loader.module.scss";
// ---------------------------------------------------------------------------
// SVG paths (from CLJS source)
// ---------------------------------------------------------------------------
const LOADER_PATH_1 =
"M128.273 0l-3.9 2.77L0 91.078l128.273 91.076 549.075-.006V.008L128.273 0zm20.852 30l498.223.006V152.15l-498.223.007V30zm-25 9.74v102.678l-49.033-34.813-.578-32.64 49.61-35.225z";
const LOADER_PATH_2 = "M134.482 157.147v25l518.57.008.002-25-518.572-.008z";
// ---------------------------------------------------------------------------
// Tips (i18n keys — in TS we receive already-translated strings or defaults)
// ---------------------------------------------------------------------------
const DEFAULT_TIPS = [
{
title: "Did you know?",
message: "You can use components to reuse elements across your designs.",
},
{
title: "Pro tip",
message: "Hold Ctrl/Cmd while dragging to disable snapping temporarily.",
},
{
title: "Pro tip",
message: "Double-click on a group to enter it and select individual items.",
},
{
title: "Did you know?",
message: "You can inspect code properties directly in the viewer.",
},
{
title: "Pro tip",
message:
"Use pages to organise different versions or sections of your project.",
},
];
// ---------------------------------------------------------------------------
// Loader icon (internal, private)
// ---------------------------------------------------------------------------
interface LoaderIconProps {
width: number;
height: number;
title: string;
}
function LoaderIcon({ width, height, title }: LoaderIconProps) {
return (
<svg
viewBox="0 0 677.34762 182.15429"
role="status"
width={width}
height={height}
className={styles.loader}
>
<title>{title}</title>
<g>
<path d={LOADER_PATH_1} />
<path className={styles["loader-line"]} d={LOADER_PATH_2} />
</g>
</svg>
);
}
// ---------------------------------------------------------------------------
// Loader
// ---------------------------------------------------------------------------
export interface LoaderTip {
title: string;
message: string;
}
export interface LoaderProps extends ComponentPropsWithRef<"div"> {
/** Width of the loader icon in px. Calculated from height if not given. */
width?: number;
/** Height of the loader icon in px. Calculated from width if not given. */
height?: number;
/** Accessible title for the loader icon. Defaults to "Loading". */
title?: string;
/** When true, the loader covers the full parent area. */
overlay?: boolean;
/**
* When true, the loader enters "file loading" mode full-screen centred
* layout with rotating tips shown every 4 seconds.
*/
fileLoading?: boolean;
/** Custom tips to cycle through in fileLoading mode. Falls back to built-in tips. */
tips?: LoaderTip[];
}
function LoaderInner({
width: widthProp,
height: heightProp,
title = "Loading",
overlay = false,
fileLoading = false,
tips: tipsProp,
className,
children,
...rest
}: LoaderProps) {
// Mirror CLJS width/height mutual calculation
const width =
widthProp ??
(heightProp != null ? Math.ceil(heightProp * (100 / 27)) : 100);
const height =
heightProp ?? (widthProp != null ? Math.ceil(widthProp * (27 / 100)) : 27);
const tips = useMemo(() => tipsProp ?? DEFAULT_TIPS, [tipsProp]);
const [tip, setTip] = useState<LoaderTip | null>(null);
const tipsRef = useRef(tips);
useEffect(() => {
tipsRef.current = tips;
}, [tips]);
useEffect(() => {
if (!fileLoading) return;
// First tip after 1 s, then rotate every 4 s
const initialTimer = setTimeout(() => {
setTip(
tipsRef.current[Math.floor(Math.random() * tipsRef.current.length)],
);
}, 1000);
const interval = setInterval(() => {
setTip(
tipsRef.current[Math.floor(Math.random() * tipsRef.current.length)],
);
}, 4000);
return () => {
clearTimeout(initialTimer);
clearInterval(interval);
};
}, [fileLoading]);
const rootClass = clsx(
styles.wrapper,
{
[styles["wrapper-overlay"]]: overlay,
[styles["file-loading"]]: fileLoading,
},
className,
);
return (
<div className={rootClass} {...rest}>
<div className={styles["loader-content"]}>
<LoaderIcon width={width} height={height} title={title} />
{fileLoading && tip != null && (
<div className={styles["tips-container"]}>
<div className={styles["tip-title"]}>{tip.title}</div>
<div className={styles["tip-message"]}>{tip.message}</div>
</div>
)}
</div>
{children}
</div>
);
}
export const Loader = memo(LoaderInner);