Skip to content

Commit 368af90

Browse files
author
Roman Snapko
authored
Add SkeletonField component with context provider and Storybook stories (#2176)
<!-- Ensure the title clearly reflects what was changed. Provide a clear and concise description of the changes made. The PR should only contain the changes related to the issue, and no other unrelated changes. --> Fixes OPS-4009
1 parent 2b580b7 commit 368af90

5 files changed

Lines changed: 236 additions & 0 deletions

File tree

packages/ui-components/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export * from './resizable-area';
6969
export * from './run-workflow-manually-success-toast/run-workflow-manually-success-toast';
7070
export * from './search-input/search-input';
7171
export * from './sidebar';
72+
export * from './skeleton';
7273
export * from './test-run-limits-form/test-run-limits-form';
7374
export * from './test-step-data-viewer/test-step-data-viewer';
7475
export * from './toggle-switch/toggle-switch';
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './skeleton-field';
2+
export * from './skeleton-field-context';
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import React, { createContext, useContext, useMemo, useState } from 'react';
2+
3+
type SkeletonFieldContextType = {
4+
showSkeleton: boolean;
5+
setShowSkeleton: React.Dispatch<React.SetStateAction<boolean>>;
6+
};
7+
8+
const defaultSetShowSkeleton: React.Dispatch<
9+
React.SetStateAction<boolean>
10+
> = () => undefined;
11+
12+
const SkeletonFieldContext = createContext<SkeletonFieldContextType>({
13+
showSkeleton: false,
14+
setShowSkeleton: defaultSetShowSkeleton,
15+
});
16+
17+
type SkeletonFieldProviderProps = {
18+
initialShow?: boolean;
19+
children: React.ReactNode;
20+
};
21+
22+
const SkeletonFieldProvider = ({
23+
initialShow = false,
24+
children,
25+
}: SkeletonFieldProviderProps) => {
26+
const [showSkeleton, setShowSkeleton] = useState(initialShow);
27+
28+
const value = useMemo(
29+
() => ({ showSkeleton, setShowSkeleton }),
30+
[showSkeleton, setShowSkeleton],
31+
);
32+
33+
return (
34+
<SkeletonFieldContext.Provider value={value}>
35+
{children}
36+
</SkeletonFieldContext.Provider>
37+
);
38+
};
39+
40+
const useSkeletonField = (): SkeletonFieldContextType => {
41+
return useContext(SkeletonFieldContext);
42+
};
43+
44+
SkeletonFieldProvider.displayName = 'SkeletonFieldProvider';
45+
46+
export { SkeletonFieldProvider, useSkeletonField };
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import React from 'react';
2+
import { cn } from '../../lib/cn';
3+
import { useSkeletonField } from './skeleton-field-context';
4+
5+
type SkeletonFieldProps = {
6+
children: React.ReactNode;
7+
className?: string;
8+
show?: boolean;
9+
};
10+
11+
const SkeletonField = ({
12+
children,
13+
className,
14+
show: showProp,
15+
}: SkeletonFieldProps) => {
16+
const { showSkeleton: showContext } = useSkeletonField();
17+
const show = showProp ?? showContext;
18+
19+
if (show) {
20+
return (
21+
<div
22+
data-testid="skeleton-field"
23+
className={cn(
24+
'w-full h-full rounded-xl',
25+
'bg-gradient-to-r from-muted to-border',
26+
className,
27+
)}
28+
/>
29+
);
30+
}
31+
32+
return children;
33+
};
34+
35+
SkeletonField.displayName = 'SkeletonField';
36+
37+
export { SkeletonField };
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { expect } from '@storybook/jest';
2+
import { Meta, StoryObj } from '@storybook/react';
3+
import React from 'react';
4+
import { ThemeAwareDecorator } from '../../../.storybook/decorators';
5+
import { SkeletonField } from '../../components/skeleton/skeleton-field';
6+
import { SkeletonFieldProvider } from '../../components/skeleton/skeleton-field-context';
7+
import { selectLightOrDarkCanvas } from '../../test-utils/select-themed-canvas.util';
8+
9+
/**
10+
* `SkeletonField` renders a skeleton placeholder when loading, or its children when content is ready.
11+
* It can be controlled via a `show` prop directly, or via a `SkeletonFieldProvider` context.
12+
*/
13+
const meta: Meta<typeof SkeletonField> = {
14+
title: 'Components/SkeletonField',
15+
component: SkeletonField,
16+
tags: ['autodocs'],
17+
parameters: {
18+
layout: 'centered',
19+
},
20+
decorators: [ThemeAwareDecorator],
21+
};
22+
23+
export default meta;
24+
25+
type Story = StoryObj<typeof SkeletonField>;
26+
27+
/**
28+
* When `show` is `true`, the skeleton placeholder is rendered instead of children.
29+
*/
30+
export const ShowingSkeleton: Story = {
31+
render: () => (
32+
<div className="w-64 h-10">
33+
<SkeletonField show={true}>
34+
<p className="text-sm text-foreground">This content is hidden.</p>
35+
</SkeletonField>
36+
</div>
37+
),
38+
play: async ({ canvasElement }) => {
39+
const canvas = selectLightOrDarkCanvas(canvasElement);
40+
expect(
41+
canvas.queryByText('This content is hidden.'),
42+
).not.toBeInTheDocument();
43+
expect(canvas.getByTestId('skeleton-field')).toBeInTheDocument();
44+
},
45+
};
46+
47+
/**
48+
* When `show` is `false`, the children are rendered normally.
49+
*/
50+
export const ShowingContent: Story = {
51+
render: () => (
52+
<div className="w-64 h-10">
53+
<SkeletonField show={false}>
54+
<p className="text-sm text-foreground">Actual content is visible.</p>
55+
</SkeletonField>
56+
</div>
57+
),
58+
play: async ({ canvasElement }) => {
59+
const canvas = selectLightOrDarkCanvas(canvasElement);
60+
expect(canvas.getByText('Actual content is visible.')).toBeInTheDocument();
61+
},
62+
};
63+
64+
/**
65+
* When wrapped in `SkeletonFieldProvider` with `initialShow={true}`, the skeleton is shown
66+
* for all child `SkeletonField` components that don't override the `show` prop.
67+
*/
68+
export const WithContextShowingSkeleton: Story = {
69+
render: () => (
70+
<SkeletonFieldProvider initialShow={true}>
71+
<div className="flex flex-col gap-4 w-64">
72+
<div className="h-10">
73+
<SkeletonField>
74+
<p className="text-sm text-foreground">Field one content.</p>
75+
</SkeletonField>
76+
</div>
77+
<div className="h-10">
78+
<SkeletonField>
79+
<p className="text-sm text-foreground">Field two content.</p>
80+
</SkeletonField>
81+
</div>
82+
</div>
83+
</SkeletonFieldProvider>
84+
),
85+
play: async ({ canvasElement }) => {
86+
const canvas = selectLightOrDarkCanvas(canvasElement);
87+
expect(canvas.queryByText('Field one content.')).not.toBeInTheDocument();
88+
expect(canvas.queryByText('Field two content.')).not.toBeInTheDocument();
89+
expect(
90+
canvas.getAllByTestId('skeleton-field').length,
91+
).toBeGreaterThanOrEqual(2);
92+
},
93+
};
94+
95+
/**
96+
* When wrapped in `SkeletonFieldProvider` with `initialShow={false}`, children are rendered normally.
97+
*/
98+
export const WithContextShowingContent: Story = {
99+
render: () => (
100+
<SkeletonFieldProvider initialShow={false}>
101+
<div className="flex flex-col gap-4 w-64">
102+
<div className="h-10">
103+
<SkeletonField>
104+
<p className="text-sm text-foreground">Field one content.</p>
105+
</SkeletonField>
106+
</div>
107+
<div className="h-10">
108+
<SkeletonField>
109+
<p className="text-sm text-foreground">Field two content.</p>
110+
</SkeletonField>
111+
</div>
112+
</div>
113+
</SkeletonFieldProvider>
114+
),
115+
play: async ({ canvasElement }) => {
116+
const canvas = selectLightOrDarkCanvas(canvasElement);
117+
expect(canvas.getByText('Field one content.')).toBeInTheDocument();
118+
expect(canvas.getByText('Field two content.')).toBeInTheDocument();
119+
},
120+
};
121+
122+
/**
123+
* A `show` prop on `SkeletonField` takes precedence over the context value.
124+
* Here the context says `show=true`, but one field overrides it with `show=false`.
125+
*/
126+
export const PropOverridesContext: Story = {
127+
render: () => (
128+
<SkeletonFieldProvider initialShow={true}>
129+
<div className="flex flex-col gap-4 w-64">
130+
<div className="h-10">
131+
<SkeletonField>
132+
<p className="text-sm text-foreground">Hidden by context.</p>
133+
</SkeletonField>
134+
</div>
135+
<div className="h-10">
136+
<SkeletonField show={false}>
137+
<p className="text-sm text-foreground">
138+
Visible via prop override.
139+
</p>
140+
</SkeletonField>
141+
</div>
142+
</div>
143+
</SkeletonFieldProvider>
144+
),
145+
play: async ({ canvasElement }) => {
146+
const canvas = selectLightOrDarkCanvas(canvasElement);
147+
expect(canvas.queryByText('Hidden by context.')).not.toBeInTheDocument();
148+
expect(canvas.getByText('Visible via prop override.')).toBeInTheDocument();
149+
},
150+
};

0 commit comments

Comments
 (0)