diff --git a/frontend/packages/ui/AGENTS.md b/frontend/packages/ui/AGENTS.md index 599b2be8e1..963fb90ca1 100644 --- a/frontend/packages/ui/AGENTS.md +++ b/frontend/packages/ui/AGENTS.md @@ -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` | diff --git a/frontend/packages/ui/src/index.ts b/frontend/packages/ui/src/index.ts index 1c86dc2828..cb581766a9 100644 --- a/frontend/packages/ui/src/index.ts +++ b/frontend/packages/ui/src/index.ts @@ -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"; diff --git a/frontend/packages/ui/src/lib/product/Loader.module.scss b/frontend/packages/ui/src/lib/product/Loader.module.scss new file mode 100644 index 0000000000..c39abe4056 --- /dev/null +++ b/frontend/packages/ui/src/lib/product/Loader.module.scss @@ -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; +} diff --git a/frontend/packages/ui/src/lib/product/Loader.spec.tsx b/frontend/packages/ui/src/lib/product/Loader.spec.tsx new file mode 100644 index 0000000000..b4c766ee6f --- /dev/null +++ b/frontend/packages/ui/src/lib/product/Loader.spec.tsx @@ -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(); + expect(baseElement).toBeTruthy(); + }); + + it("should render the SVG loader icon with role='status'", () => { + const { container } = render(); + const svg = container.querySelector("svg[role='status']"); + expect(svg).toBeTruthy(); + }); + + it("should apply the wrapper class by default", () => { + const { container } = render(); + expect(container.firstElementChild?.getAttribute("class")).toContain( + "wrapper", + ); + }); + + it("should apply wrapper-overlay class when overlay=true", () => { + const { container } = render(); + expect(container.firstElementChild?.getAttribute("class")).toContain( + "wrapper-overlay", + ); + }); + + it("should apply file-loading class when fileLoading=true", () => { + const { container } = render(); + expect(container.firstElementChild?.getAttribute("class")).toContain( + "file-loading", + ); + }); + + it("should use default width and height of 100x27 when none given", () => { + const { container } = render(); + 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(); + 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(); + 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 ", () => { + 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(); + }); +}); diff --git a/frontend/packages/ui/src/lib/product/Loader.stories.tsx b/frontend/packages/ui/src/lib/product/Loader.stories.tsx new file mode 100644 index 0000000000..9075ce84fe --- /dev/null +++ b/frontend/packages/ui/src/lib/product/Loader.stories.tsx @@ -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", + }, +}; diff --git a/frontend/packages/ui/src/lib/product/Loader.tsx b/frontend/packages/ui/src/lib/product/Loader.tsx new file mode 100644 index 0000000000..d1c55711bb --- /dev/null +++ b/frontend/packages/ui/src/lib/product/Loader.tsx @@ -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} + + + + + + ); +} + +// --------------------------------------------------------------------------- +// 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(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 ( +
+
+ + {fileLoading && tip != null && ( +
+
{tip.title}
+
{tip.message}
+
+ )} +
+ {children} +
+ ); +} + +export const Loader = memo(LoaderInner);