From cf655caef9a098ddc5e347aee4beb66658bfa0aa Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Fri, 4 Sep 2026 14:28:22 +0800 Subject: [PATCH 1/2] feat(ui): skeleton loader component --- packages/styles/dist/gitlab-ui.css | 61 +++++ .../styles/scripts/legacy-prefix.test.mjs | 28 +++ .../scripts/postcss-gitlab-tailwind.mjs | 30 +++ packages/styles/src/components.css | 1 + .../base/skeleton-loader/skeleton-loader.css | 26 ++ .../skeleton-loader.stories.tsx | 133 ++++++++++ .../skeleton-loader/skeleton-loader.test.tsx | 160 ++++++++++++ .../base/skeleton-loader/skeleton-loader.tsx | 235 ++++++++++++++++++ packages/ui/src/index.ts | 4 + 9 files changed, 678 insertions(+) create mode 100644 packages/ui/src/base/skeleton-loader/skeleton-loader.css create mode 100644 packages/ui/src/base/skeleton-loader/skeleton-loader.stories.tsx create mode 100644 packages/ui/src/base/skeleton-loader/skeleton-loader.test.tsx create mode 100644 packages/ui/src/base/skeleton-loader/skeleton-loader.tsx diff --git a/packages/styles/dist/gitlab-ui.css b/packages/styles/dist/gitlab-ui.css index 69ea96e..eec152b 100644 --- a/packages/styles/dist/gitlab-ui.css +++ b/packages/styles/dist/gitlab-ui.css @@ -6405,6 +6405,21 @@ input[type="color"].gl-form-input.form-control:disabled { .gl-progress-bar-danger { background-color: var(--gl-progress-bar-indicator-color-danger); } +.gl-skeleton-loader-default-container { + width: 235px; +} +.gl-skeleton-loader-fill-background-color { + fill: var(--gl-skeleton-loader-background-color); +} +.gl-skeleton-loader stop { + opacity: var(--gl-opacity-10); +} +.gl-skeleton-loader .background-stop { + stop-color: var(--gl-skeleton-loader-background-color); +} +.gl-skeleton-loader .shimmer-stop { + stop-color: var(--gl-skeleton-loader-shimmer-color); +} table.gl-table { color: var(--gl-text-color-default); background-color: var(--gl-color-alpha-0); @@ -6813,6 +6828,9 @@ table.gl-table.table-borderless tr > :is(th, td) { white-space: nowrap; border-width: 0; } +.gl-my-3 { + margin-block: var(--gl-spacing-scale-3); +} .gl-heading-scale-300 { font-weight: var(--gl-heading-scale-300-font-weight); margin-top: var(--gl-heading-scale-300-margin-top); @@ -6869,6 +6887,38 @@ table.gl-table.table-borderless tr > :is(th, td) { .gl-table-row { display: table-row; } +.gl-h-4 { + height: var(--gl-spacing-scale-4); +} +.gl-h-full { + height: 100%; +} +.gl-w-full { + width: 100%; +} +.gl-animate-skeleton-loader { + overflow: hidden; + max-width: 32rem; + background-size: 32rem 100%; + background-position: -32rem 0; + background-color: var(--gl-skeleton-loader-background-color); + background-image: linear-gradient(to right, var(--gl-skeleton-loader-background-color) 0, var(--gl-skeleton-loader-shimmer-color) 23%, var(--gl-skeleton-loader-shimmer-color) 27%, var(--gl-skeleton-loader-background-color) 50%); + background-repeat: no-repeat; + @media (prefers-reduced-motion: no-preference) { + animation: gl-keyframes-skeleton-loader 2.5s linear; + animation-delay: inherit; + animation-iteration-count: 3; + } +} +.\!gl-max-w-20 { + max-width: var(--gl-spacing-scale-20) !important; +} +.\!gl-max-w-26 { + max-width: var(--gl-spacing-scale-26) !important; +} +.\!gl-max-w-30 { + max-width: var(--gl-spacing-scale-30) !important; +} .gl-max-w-full { max-width: 100%; } @@ -6914,6 +6964,9 @@ table.gl-table.table-borderless tr > :is(th, td) { .\!gl-rounded-lg { border-radius: var(--gl-border-radius-lg) !important; } +.gl-rounded-default { + border-radius: var(--gl-border-radius-default); +} .gl-rounded-none { border-radius: var(--gl-border-radius-none); } @@ -7253,3 +7306,11 @@ table.gl-table.table-borderless tr > :is(th, td) { } } } +@keyframes gl-keyframes-skeleton-loader { + 0% { + background-position-x: -32rem; + } + 100% { + background-position-x: 32rem; + } +} diff --git a/packages/styles/scripts/legacy-prefix.test.mjs b/packages/styles/scripts/legacy-prefix.test.mjs index f3b9072..d519770 100644 --- a/packages/styles/scripts/legacy-prefix.test.mjs +++ b/packages/styles/scripts/legacy-prefix.test.mjs @@ -39,3 +39,31 @@ test("compiles @apply and restores upstream gl-* selectors", async () => { ); expect(result.css).not.toMatch(/:where\(\.gl-dark \*\)/u); }, 30000); + +test("hoists plugin keyframes while preserving nested media rules", async () => { + const input = await readFile(inputPath, "utf8"); + const result = await postcss([ + gitlabTailwind({ candidates: ["animate-skeleton-loader"] }), + ]).process(input, { from: inputPath }); + const root = postcss.parse(result.css); + const skeletonRule = root.nodes.find( + (node) => node.type === "rule" && node.selector === ".gl-animate-skeleton-loader", + ); + const keyframes = root.nodes.find( + (node) => node.type === "atrule" + && node.name === "keyframes" + && node.params === "gl-keyframes-skeleton-loader", + ); + + expect(skeletonRule).toBeDefined(); + expect(skeletonRule.nodes.some( + (node) => node.type === "atrule" && node.name === "media", + )).toBe(true); + expect(skeletonRule.nodes.some( + (node) => node.type === "atrule" && node.name === "keyframes", + )).toBe(false); + expect(keyframes).toBeDefined(); + expect(result.css).toContain( + "\n@keyframes gl-keyframes-skeleton-loader {\n 0% {", + ); +}, 30000); diff --git a/packages/styles/scripts/postcss-gitlab-tailwind.mjs b/packages/styles/scripts/postcss-gitlab-tailwind.mjs index 3d31b32..fd9a078 100644 --- a/packages/styles/scripts/postcss-gitlab-tailwind.mjs +++ b/packages/styles/scripts/postcss-gitlab-tailwind.mjs @@ -33,6 +33,33 @@ function compilerSources(compiler, base) { return sources; } +function removeOneIndentLevel(node) { + for(const property of ["before", "after"]) { + const rawValue = node.raws[property]; + if(typeof rawValue === "string") { + node.raws[property] = rawValue.replace(/(\r?\n) {2}/gu, "$1"); + } + } + + for(const child of node.nodes ?? []) removeOneIndentLevel(child); +} + +function hoistKeyframesFromStyleRules(root) { + const keyframesToHoist = []; + + root.walkAtRules((atRule) => { + if(!atRule.name.endsWith("keyframes") || atRule.parent?.type !== "rule") return; + + atRule.remove(); + atRule.raws.before = "\n"; + atRule.raws.after = "\n"; + for(const child of atRule.nodes ?? []) removeOneIndentLevel(child); + keyframesToHoist.push(atRule); + }); + + root.append(keyframesToHoist); +} + export default function gitlabTailwind({ candidates = [], sources: additionalSources = [] } = {}) { return { postcssPlugin: "gitlab-tailwind-v3-prefix-compatibility", @@ -74,6 +101,9 @@ export default function gitlabTailwind({ candidates = [], sources: additionalSou const compiledRoot = postcss.parse(css, { from }); restoreLegacySelectors(compiledRoot, candidateMap); + // Tailwind 4 preserves plugin-authored nested keyframes, but production + // CSS minifiers expect keyframes at the stylesheet root. + hoistKeyframesFromStyleRules(compiledRoot); root.removeAll(); root.append(compiledRoot.nodes); diff --git a/packages/styles/src/components.css b/packages/styles/src/components.css index 6d6d6ba..99051f9 100644 --- a/packages/styles/src/components.css +++ b/packages/styles/src/components.css @@ -27,6 +27,7 @@ @import "../../ui/src/base/listbox/listbox.css"; @import "../../ui/src/base/loading-icon/loading-icon.css"; @import "../../ui/src/base/progress-bar/progress-bar.css"; +@import "../../ui/src/base/skeleton-loader/skeleton-loader.css"; @import "../../ui/src/base/table/table.css"; @import "../../ui/src/base/toggle/toggle.css"; @import "../../ui/src/base/tooltip/tooltip.css"; diff --git a/packages/ui/src/base/skeleton-loader/skeleton-loader.css b/packages/ui/src/base/skeleton-loader/skeleton-loader.css new file mode 100644 index 0000000..7178212 --- /dev/null +++ b/packages/ui/src/base/skeleton-loader/skeleton-loader.css @@ -0,0 +1,26 @@ +/** + * Ported from GitLab UI: + * packages/gitlab-ui/src/components/base/skeleton_loader/skeleton_loader.scss + */ + +.gl-skeleton-loader-default-container { + width: 235px; +} + +.gl-skeleton-loader-fill-background-color { + fill: var(--gl-skeleton-loader-background-color); +} + +.gl-skeleton-loader { + stop { + @apply gl-opacity-10; + } + + .background-stop { + stop-color: var(--gl-skeleton-loader-background-color); + } + + .shimmer-stop { + stop-color: var(--gl-skeleton-loader-shimmer-color); + } +} diff --git a/packages/ui/src/base/skeleton-loader/skeleton-loader.stories.tsx b/packages/ui/src/base/skeleton-loader/skeleton-loader.stories.tsx new file mode 100644 index 0000000..289c45c --- /dev/null +++ b/packages/ui/src/base/skeleton-loader/skeleton-loader.stories.tsx @@ -0,0 +1,133 @@ +import type { CSSProperties } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect } from "storybook/test"; +import GlSkeletonLoader from "./skeleton-loader"; + +const customShapeWrapperStyle: CSSProperties = { + width: 250, +}; + +const meta = { + title: "UI/Base/Skeleton Loader", + component: GlSkeletonLoader, + args: { + baseUrl: "", + equalWidthLines: false, + height: null, + lines: 3, + preserveAspectRatio: "xMidYMid meet", + width: null, + }, + parameters: { + docs: { + description: { + component: + "See the [Pajamas skeleton loader documentation](https://design.gitlab.com/components/skeleton-loader/) for usage and implementation guidance.", + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const root = canvasElement.querySelector( + ".gl-skeleton-loader-default-container", + ); + const svg = root?.querySelector("svg"); + const lines = svg?.querySelectorAll("clipPath rect"); + + await expect(root).toBeInTheDocument(); + await expect(svg).toHaveAttribute("viewBox", "0 0 235 38"); + await expect(svg?.querySelector("title")).toHaveTextContent("Loading"); + await expect(lines).toHaveLength(3); + }, +}; + +export const WithCustomShapes: Story = { + args: { + height: 102, + width: 327, + }, + decorators: [ + (Story) =>
, + ], + render: (args) => ( + + + + + + + + + + + ), + play: async ({ canvasElement }) => { + const root = canvasElement.querySelector("svg.gl-skeleton-loader"); + + await expect(root).toHaveClass("gl-skeleton-loader"); + await expect(root).toHaveAttribute("viewBox", "0 0 327 102"); + await expect(root?.querySelectorAll("clipPath rect")).toHaveLength(8); + await expect(root?.parentElement).not.toHaveClass( + "gl-skeleton-loader-default-container", + ); + }, +}; + +export const CSSBased: Story = { + render: () => ( +
+
+
+
+
+ ), + play: async ({ canvas }) => { + const lines = canvas.getAllByTestId("css-skeleton-line"); + + await expect(lines).toHaveLength(3); + for(const line of lines) { + await expect(line).toHaveClass("gl-animate-skeleton-loader"); + } + }, +}; + +export const ReducedMotion: Story = { + beforeEach: () => { + const originalMatchMedia = window.matchMedia; + + window.matchMedia = (query) => ({ + addEventListener: () => undefined, + addListener: () => undefined, + dispatchEvent: () => false, + matches: query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + removeEventListener: () => undefined, + removeListener: () => undefined, + }); + + return () => { + window.matchMedia = originalMatchMedia; + }; + }, + play: async ({ canvasElement }) => { + const svg = canvasElement.querySelector("svg"); + const fill = svg?.querySelector(":scope > rect"); + + await expect(svg?.querySelector("linearGradient")).not.toBeInTheDocument(); + await expect(svg?.querySelector("animate")).not.toBeInTheDocument(); + await expect(fill).toHaveClass("gl-skeleton-loader-fill-background-color"); + await expect(fill).not.toHaveAttribute("fill"); + }, +}; diff --git a/packages/ui/src/base/skeleton-loader/skeleton-loader.test.tsx b/packages/ui/src/base/skeleton-loader/skeleton-loader.test.tsx new file mode 100644 index 0000000..fedaf59 --- /dev/null +++ b/packages/ui/src/base/skeleton-loader/skeleton-loader.test.tsx @@ -0,0 +1,160 @@ +/** + * Ported from GitLab UI: + * packages/gitlab-ui/src/components/base/skeleton_loader/skeleton_loader.spec.js + */ + +import type { ComponentProps, ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import GlSkeletonLoader from "./skeleton-loader"; + +const renderLoader = ( + props: Partial> = {}, + children?: ReactNode, +) => renderToStaticMarkup( + {children}, +); + +const getClipPathMarkup = (markup: string) => ( + markup.match(/]*>(.*?)<\/clipPath>/su)?.[1] ?? "" +); + +describe("GlSkeletonLoader", () => { + it("renders the upstream default structure and accessibility title", () => { + const markup = renderLoader(); + + expect(markup).toMatch(/^
Loading"); + expect(markup).not.toMatch(/aria-busy|aria-live|role=/u); + }); + + it("renders three default lines with the upstream positions and width cycle", () => { + const clipPath = getClipPathMarkup(renderLoader()); + + expect(clipPath.match(/ { + const markup = renderLoader({ lines: 5 }); + const clipPath = getClipPathMarkup(markup); + + expect(markup).toContain("viewBox=\"0 0 235 66\""); + expect(clipPath.match(/ { + const clipPath = getClipPathMarkup(renderLoader({ equalWidthLines: true })); + + expect(clipPath.match(/width="100%"/gmu)).toHaveLength(3); + expect(clipPath).not.toContain("width=\"65%\""); + expect(clipPath).not.toContain("width=\"85%\""); + }); + + it("uses explicit dimensions for the default viewBox and wrapper", () => { + const markup = renderLoader({ height: 400, width: 500 }); + + expect(markup).toContain("style=\"height:400px;width:500px\""); + expect(markup).toContain("viewBox=\"0 0 500 400\""); + }); + + it("forwards native attributes and merges class and style on the div root", () => { + const markup = renderLoader({ + "aria-label": "Loading content", + className: "custom-loader", + height: 80, + id: "issue-skeleton", + style: { color: "red", height: 20, width: 30 }, + width: 90, + }); + + expect(markup).toContain( + "class=\"gl-skeleton-loader-default-container gl-max-w-full custom-loader\"", + ); + expect(markup).toContain("aria-label=\"Loading content\""); + expect(markup).toContain("id=\"issue-skeleton\""); + expect(markup).toContain("style=\"color:red;height:80px;width:90px\""); + }); + + it("renders custom shapes in a root SVG without an extra wrapper", () => { + const markup = renderLoader({}, ); + const clipPath = getClipPathMarkup(markup); + + expect(markup).toMatch(/^"); + }); + + it("supports custom SVG dimensions, aspect ratio, attributes, class, and style", () => { + const markup = renderLoader({ + className: "custom-shape-loader", + height: 60, + id: "avatar-skeleton", + preserveAspectRatio: "none", + style: { display: "block" }, + width: 120, + }, ); + + expect(markup).toMatch(/^ { + const markup = renderLoader({ + baseUrl: "/issues/123", + uniqueKey: "issue-content", + }); + + expect(markup).toContain("id=\"issue-content-idClip\""); + expect(markup).toContain("id=\"issue-content-idGradient\""); + expect(markup).toContain("clip-path=\"url(/issues/123#issue-content-idClip)\""); + expect(markup).toContain("fill=\"url(/issues/123#issue-content-idGradient)\""); + }); + + it("generates stable non-conflicting IDs for multiple instances", () => { + const markup = renderToStaticMarkup( + <> + + + , + ); + const clipIds = [...markup.matchAll(/id="([^"]+-idClip)"/gu)] + .map((match) => match[1]); + const gradientIds = [...markup.matchAll(/id="([^"]+-idGradient)"/gu)] + .map((match) => match[1]); + + expect(clipIds).toHaveLength(2); + expect(new Set(clipIds).size).toBe(2); + expect(gradientIds).toHaveLength(2); + expect(new Set(gradientIds).size).toBe(2); + for(const id of [...clipIds, ...gradientIds]) { + expect(markup).toContain(`url(#${id})`); + } + }); + + it("treats empty children as the default line layout", () => { + const markup = renderLoader({}, <>); + + expect(markup).toMatch(/^
, + "children" +>; + +export type GlSkeletonLoaderProps = SkeletonLoaderElementProps & { + /** Relative URL prefixed to SVG fragment references when the page uses a base URL. */ + baseUrl?: string; + /** SVG shapes used instead of the default line skeleton. */ + children?: ReactNode; + /** Makes every default skeleton line span the full available width. */ + equalWidthLines?: boolean; + /** SVG viewBox height. Also fixes the default wrapper height when provided. */ + height?: number | null; + /** Number of lines rendered by the default skeleton. */ + lines?: number; + /** Value of the SVG `preserveAspectRatio` attribute. */ + preserveAspectRatio?: string; + /** Stable prefix for the internal clip path and gradient IDs. */ + uniqueKey?: string; + /** SVG viewBox width. Also fixes the default wrapper width when provided. */ + width?: number | null; +}; + +const defaultContainerVariants = cva([ + "gl-skeleton-loader-default-container", + "gl-max-w-full", +]); + +const skeletonVariants = cva("gl-skeleton-loader", { + variants: { + defaultLayout: { + false: null, + true: "gl-w-full gl-h-full", + }, + }, +}); + +function getReducedMotionSnapshot() { + return typeof window !== "undefined" + && typeof window.matchMedia === "function" + && window.matchMedia(REDUCED_MOTION_QUERY).matches; +} + +function subscribeToReducedMotion(onStoreChange: () => void) { + if(typeof window === "undefined" || typeof window.matchMedia !== "function") { + return () => undefined; + } + + const mediaQuery = window.matchMedia(REDUCED_MOTION_QUERY); + + if(typeof mediaQuery.addEventListener === "function") { + mediaQuery.addEventListener("change", onStoreChange); + return () => mediaQuery.removeEventListener("change", onStoreChange); + } + + mediaQuery.addListener(onStoreChange); + return () => mediaQuery.removeListener(onStoreChange); +} + +function usePrefersReducedMotion() { + return useSyncExternalStore( + subscribeToReducedMotion, + getReducedMotionSnapshot, + () => false, + ); +} + +function hasRenderableChildren(children: ReactNode): boolean { + return Children.toArray(children).some((child) => { + if(isValidElement<{ children?: ReactNode }>(child) && child.type === Fragment) { + return hasRenderableChildren(child.props.children); + } + + return child !== ""; + }); +} + +const GlSkeletonLoader = forwardRef< + HTMLDivElement | SVGSVGElement, + GlSkeletonLoaderProps +>(function GlSkeletonLoader({ + baseUrl = "", + children, + className, + equalWidthLines = false, + height = null, + lines = 3, + preserveAspectRatio = "xMidYMid meet", + style, + uniqueKey, + width = null, + ...elementProps +}, forwardedRef) { + const generatedKey = useId().replace(/[^a-zA-Z0-9_-]/gu, ""); + const resolvedUniqueKey = uniqueKey ?? generatedKey; + const hasCustomShapes = hasRenderableChildren(children); + const svgWidth = width ?? (hasCustomShapes ? DEFAULT_SVG_WIDTH : DEFAULT_LINE_MAX_WIDTH); + const svgHeight = height ?? (hasCustomShapes + ? DEFAULT_SVG_HEIGHT + : lines * DEFAULT_LINE_HEIGHT + (lines - 1) * DEFAULT_LINE_SPACING); + const clipId = `${resolvedUniqueKey}-idClip`; + const gradientId = `${resolvedUniqueKey}-idGradient`; + const reducedMotion = usePrefersReducedMotion(); + + const defaultLines = Array.from({ length: lines }, (_, index) => ( + + )); + + const svgChildren = ( + <> + Loading + + + + {hasCustomShapes ? children : defaultLines} + + {reducedMotion ? null : ( + + + + + + + + + + + + )} + + + ); + + if(hasCustomShapes) { + return ( + } + ref={forwardedRef as Ref} + className={skeletonVariants({ className, defaultLayout: false })} + preserveAspectRatio={preserveAspectRatio} + style={style} + version="1.1" + viewBox={`0 0 ${svgWidth} ${svgHeight}`}> + {svgChildren} + + ); + } + + return ( +
} + ref={forwardedRef as Ref} + className={defaultContainerVariants({ className })} + style={{ + ...style, + height: height === null ? style?.height : `${height}px`, + width: width === null ? style?.width : `${width}px`, + }}> + + {svgChildren} + +
+ ); +}); + +export default GlSkeletonLoader; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 56da7a4..04cacf1 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -250,6 +250,10 @@ export type { GlTableRowProps, GlTableStackedBreakpoint, } from "./base/table/table"; +export { default as GlSkeletonLoader } from "./base/skeleton-loader/skeleton-loader"; +export type { + GlSkeletonLoaderProps, +} from "./base/skeleton-loader/skeleton-loader"; export { default as GlToggle } from "./base/toggle/toggle"; export type { GlToggleLabelPosition, From bfd5620e456904e3593d16c6a123ccbedd943ea6 Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Fri, 4 Sep 2026 14:41:55 +0800 Subject: [PATCH 2/2] fix(test): typecheck error --- packages/ui/src/base/skeleton-loader/skeleton-loader.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/base/skeleton-loader/skeleton-loader.test.tsx b/packages/ui/src/base/skeleton-loader/skeleton-loader.test.tsx index fedaf59..2859697 100644 --- a/packages/ui/src/base/skeleton-loader/skeleton-loader.test.tsx +++ b/packages/ui/src/base/skeleton-loader/skeleton-loader.test.tsx @@ -16,7 +16,7 @@ const renderLoader = ( ); const getClipPathMarkup = (markup: string) => ( - markup.match(/]*>(.*?)<\/clipPath>/su)?.[1] ?? "" + markup.match(/]*>([\s\S]*?)<\/clipPath>/u)?.[1] ?? "" ); describe("GlSkeletonLoader", () => {