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
1 change: 1 addition & 0 deletions packages/ui-components/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export * from './resizable-area';
export * from './run-workflow-manually-success-toast/run-workflow-manually-success-toast';
export * from './search-input/search-input';
export * from './sidebar';
export * from './skeleton';
export * from './test-run-limits-form/test-run-limits-form';
export * from './test-step-data-viewer/test-step-data-viewer';
export * from './toggle-switch/toggle-switch';
Expand Down
2 changes: 2 additions & 0 deletions packages/ui-components/src/components/skeleton/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './skeleton-field';
export * from './skeleton-field-context';
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React, { createContext, useContext, useMemo, useState } from 'react';

type SkeletonFieldContextType = {
showSkeleton: boolean;
setShowSkeleton: React.Dispatch<React.SetStateAction<boolean>>;
};

const defaultSetShowSkeleton: React.Dispatch<
React.SetStateAction<boolean>
> = () => undefined;

const SkeletonFieldContext = createContext<SkeletonFieldContextType>({
showSkeleton: false,
setShowSkeleton: defaultSetShowSkeleton,
});

type SkeletonFieldProviderProps = {
initialShow?: boolean;
children: React.ReactNode;
};

const SkeletonFieldProvider = ({
initialShow = false,
children,
}: SkeletonFieldProviderProps) => {
const [showSkeleton, setShowSkeleton] = useState(initialShow);

const value = useMemo(
() => ({ showSkeleton, setShowSkeleton }),
[showSkeleton, setShowSkeleton],
);

return (
<SkeletonFieldContext.Provider value={value}>
{children}
</SkeletonFieldContext.Provider>
);
};

const useSkeletonField = (): SkeletonFieldContextType => {
return useContext(SkeletonFieldContext);
};

SkeletonFieldProvider.displayName = 'SkeletonFieldProvider';

export { SkeletonFieldProvider, useSkeletonField };
37 changes: 37 additions & 0 deletions packages/ui-components/src/components/skeleton/skeleton-field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';
import { cn } from '../../lib/cn';
import { useSkeletonField } from './skeleton-field-context';

type SkeletonFieldProps = {
children: React.ReactNode;
className?: string;
show?: boolean;
};

const SkeletonField = ({
children,
className,
show: showProp,
}: SkeletonFieldProps) => {
const { showSkeleton: showContext } = useSkeletonField();
const show = showProp ?? showContext;

if (show) {
return (
<div
data-testid="skeleton-field"
className={cn(
'w-full h-full rounded-xl',
'bg-gradient-to-r from-muted to-border',
className,
)}
/>
);
}

return children;
};

SkeletonField.displayName = 'SkeletonField';

export { SkeletonField };
150 changes: 150 additions & 0 deletions packages/ui-components/src/stories/skeleton/skeleton-field.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { expect } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import React from 'react';
import { ThemeAwareDecorator } from '../../../.storybook/decorators';
import { SkeletonField } from '../../components/skeleton/skeleton-field';
import { SkeletonFieldProvider } from '../../components/skeleton/skeleton-field-context';
import { selectLightOrDarkCanvas } from '../../test-utils/select-themed-canvas.util';

/**
* `SkeletonField` renders a skeleton placeholder when loading, or its children when content is ready.
* It can be controlled via a `show` prop directly, or via a `SkeletonFieldProvider` context.
*/
const meta: Meta<typeof SkeletonField> = {
title: 'Components/SkeletonField',
component: SkeletonField,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
decorators: [ThemeAwareDecorator],
};

export default meta;

type Story = StoryObj<typeof SkeletonField>;

/**
* When `show` is `true`, the skeleton placeholder is rendered instead of children.
*/
export const ShowingSkeleton: Story = {
render: () => (
<div className="w-64 h-10">
<SkeletonField show={true}>
<p className="text-sm text-foreground">This content is hidden.</p>
</SkeletonField>
</div>
),
play: async ({ canvasElement }) => {
const canvas = selectLightOrDarkCanvas(canvasElement);
expect(
canvas.queryByText('This content is hidden.'),
).not.toBeInTheDocument();
expect(canvas.getByTestId('skeleton-field')).toBeInTheDocument();
},
};

/**
* When `show` is `false`, the children are rendered normally.
*/
export const ShowingContent: Story = {
render: () => (
<div className="w-64 h-10">
<SkeletonField show={false}>
<p className="text-sm text-foreground">Actual content is visible.</p>
</SkeletonField>
</div>
),
play: async ({ canvasElement }) => {
const canvas = selectLightOrDarkCanvas(canvasElement);
expect(canvas.getByText('Actual content is visible.')).toBeInTheDocument();
},
};

/**
* When wrapped in `SkeletonFieldProvider` with `initialShow={true}`, the skeleton is shown
* for all child `SkeletonField` components that don't override the `show` prop.
*/
export const WithContextShowingSkeleton: Story = {
render: () => (
<SkeletonFieldProvider initialShow={true}>
<div className="flex flex-col gap-4 w-64">
<div className="h-10">
<SkeletonField>
<p className="text-sm text-foreground">Field one content.</p>
</SkeletonField>
</div>
<div className="h-10">
<SkeletonField>
<p className="text-sm text-foreground">Field two content.</p>
</SkeletonField>
</div>
</div>
</SkeletonFieldProvider>
),
play: async ({ canvasElement }) => {
const canvas = selectLightOrDarkCanvas(canvasElement);
expect(canvas.queryByText('Field one content.')).not.toBeInTheDocument();
expect(canvas.queryByText('Field two content.')).not.toBeInTheDocument();
expect(
canvas.getAllByTestId('skeleton-field').length,
).toBeGreaterThanOrEqual(2);
},
};

/**
* When wrapped in `SkeletonFieldProvider` with `initialShow={false}`, children are rendered normally.
*/
export const WithContextShowingContent: Story = {
render: () => (
<SkeletonFieldProvider initialShow={false}>
<div className="flex flex-col gap-4 w-64">
<div className="h-10">
<SkeletonField>
<p className="text-sm text-foreground">Field one content.</p>
</SkeletonField>
</div>
<div className="h-10">
<SkeletonField>
<p className="text-sm text-foreground">Field two content.</p>
</SkeletonField>
</div>
</div>
</SkeletonFieldProvider>
),
play: async ({ canvasElement }) => {
const canvas = selectLightOrDarkCanvas(canvasElement);
expect(canvas.getByText('Field one content.')).toBeInTheDocument();
expect(canvas.getByText('Field two content.')).toBeInTheDocument();
},
};

/**
* A `show` prop on `SkeletonField` takes precedence over the context value.
* Here the context says `show=true`, but one field overrides it with `show=false`.
*/
export const PropOverridesContext: Story = {
render: () => (
<SkeletonFieldProvider initialShow={true}>
<div className="flex flex-col gap-4 w-64">
<div className="h-10">
<SkeletonField>
<p className="text-sm text-foreground">Hidden by context.</p>
</SkeletonField>
</div>
<div className="h-10">
<SkeletonField show={false}>
<p className="text-sm text-foreground">
Visible via prop override.
</p>
</SkeletonField>
</div>
</div>
</SkeletonFieldProvider>
),
play: async ({ canvasElement }) => {
const canvas = selectLightOrDarkCanvas(canvasElement);
expect(canvas.queryByText('Hidden by context.')).not.toBeInTheDocument();
expect(canvas.getByText('Visible via prop override.')).toBeInTheDocument();
},
};
Loading