mirror of
https://github.com/penpot/penpot.git
synced 2026-04-28 04:38: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/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` |
|
||||
|
||||
@ -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";
|
||||
|
||||
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