Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions packages/styles/dist/gitlab-ui.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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%;
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
}
}
28 changes: 28 additions & 0 deletions packages/styles/scripts/legacy-prefix.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
30 changes: 30 additions & 0 deletions packages/styles/scripts/postcss-gitlab-tailwind.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/styles/src/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -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";
26 changes: 26 additions & 0 deletions packages/ui/src/base/skeleton-loader/skeleton-loader.css
Original file line number Diff line number Diff line change
@@ -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);
}
}
133 changes: 133 additions & 0 deletions packages/ui/src/base/skeleton-loader/skeleton-loader.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof GlSkeletonLoader>;

export default meta;
type Story = StoryObj<typeof meta>;

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) => <div style={customShapeWrapperStyle}><Story /></div>,
],
render: (args) => (
<GlSkeletonLoader {...args}>
<rect height="16" rx="4" width="276" />
<rect height="16" rx="4" width="237" y="18" />
<rect height="16" rx="8" width="118" y="42" />
<rect height="16" rx="8" width="130" x="122" y="42" />
<rect height="16" rx="8" width="106" y="62" />
<rect height="16" rx="8" width="56" x="110" y="62" />
<rect height="16" rx="8" width="71" x="256" y="42" />
<rect height="16" rx="4" width="38" y="86" />
</GlSkeletonLoader>
),
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: () => (
<div>
<div
className="gl-animate-skeleton-loader gl-h-4 gl-rounded-default gl-my-3 !gl-max-w-20"
data-testid="css-skeleton-line" />
<div
className="gl-animate-skeleton-loader gl-h-4 gl-rounded-default gl-my-3 !gl-max-w-30"
data-testid="css-skeleton-line" />
<div
className="gl-animate-skeleton-loader gl-h-4 gl-rounded-default gl-my-3 !gl-max-w-26"
data-testid="css-skeleton-line" />
</div>
),
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");
},
};
Loading