mirror of
https://github.com/penpot/penpot.git
synced 2026-09-18 18:06:14 +00:00
✨ Add Loader component to @penpot/ui
This commit is contained in:
parent
fcc29f2152
commit
b5803871a7
@ -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/icon.cljs` | `src/lib/foundations/assets/Icon.tsx` |
|
||||||
| `ds/foundations/assets/raw_svg.cljs` | `src/lib/foundations/assets/RawSvg.tsx` |
|
| `ds/foundations/assets/raw_svg.cljs` | `src/lib/foundations/assets/RawSvg.tsx` |
|
||||||
| `ds/product/cta.cljs` | `src/lib/product/Cta.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/button.cljs` | `src/lib/buttons/Button.tsx` |
|
||||||
| `ds/buttons/icon_button.cljs` | `src/lib/buttons/IconButton.tsx` |
|
| `ds/buttons/icon_button.cljs` | `src/lib/buttons/IconButton.tsx` |
|
||||||
| `ds/utilities/swatch.cljs` | `src/lib/utilities/Swatch.tsx` |
|
| `ds/utilities/swatch.cljs` | `src/lib/utilities/Swatch.tsx` |
|
||||||
|
|||||||
@ -11,6 +11,8 @@ export {
|
|||||||
export type { TypographyId } from "./lib/foundations/typography/typography";
|
export type { TypographyId } from "./lib/foundations/typography/typography";
|
||||||
export { Cta } from "./lib/product/Cta";
|
export { Cta } from "./lib/product/Cta";
|
||||||
export type { CtaProps } 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 { 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";
|
||||||
|
|||||||
81
frontend/packages/ui/src/lib/product/Loader.module.scss
Normal file
81
frontend/packages/ui/src/lib/product/Loader.module.scss
Normal 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;
|
||||||
|
}
|
||||||
90
frontend/packages/ui/src/lib/product/Loader.spec.tsx
Normal file
90
frontend/packages/ui/src/lib/product/Loader.spec.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
60
frontend/packages/ui/src/lib/product/Loader.stories.tsx
Normal file
60
frontend/packages/ui/src/lib/product/Loader.stories.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
};
|
||||||
181
frontend/packages/ui/src/lib/product/Loader.tsx
Normal file
181
frontend/packages/ui/src/lib/product/Loader.tsx
Normal 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);
|
||||||
Loading…
x
Reference in New Issue
Block a user