From 398d2d8848b73e6b2cf1d67b86bde8803f90f81c Mon Sep 17 00:00:00 2001 From: Roman Snapko Date: Fri, 27 Mar 2026 09:19:39 +0100 Subject: [PATCH 1/3] Add SkeletonField component with context provider and Storybook stories --- .../src/components/skeleton/index.ts | 2 + .../skeleton/skeleton-field-context.tsx | 41 +++++ .../components/skeleton/skeleton-field.tsx | 36 +++++ .../skeleton/skeleton-field.stories.tsx | 150 ++++++++++++++++++ 4 files changed, 229 insertions(+) create mode 100644 packages/ui-components/src/components/skeleton/index.ts create mode 100644 packages/ui-components/src/components/skeleton/skeleton-field-context.tsx create mode 100644 packages/ui-components/src/components/skeleton/skeleton-field.tsx create mode 100644 packages/ui-components/src/stories/skeleton/skeleton-field.stories.tsx diff --git a/packages/ui-components/src/components/skeleton/index.ts b/packages/ui-components/src/components/skeleton/index.ts new file mode 100644 index 000000000..034b0ea56 --- /dev/null +++ b/packages/ui-components/src/components/skeleton/index.ts @@ -0,0 +1,2 @@ +export * from './skeleton-field'; +export * from './skeleton-field-context'; diff --git a/packages/ui-components/src/components/skeleton/skeleton-field-context.tsx b/packages/ui-components/src/components/skeleton/skeleton-field-context.tsx new file mode 100644 index 000000000..e8d6faa8d --- /dev/null +++ b/packages/ui-components/src/components/skeleton/skeleton-field-context.tsx @@ -0,0 +1,41 @@ +import React, { createContext, useContext, useState } from 'react'; + +type SkeletonFieldContextType = { + showSkeleton: boolean; + setShowSkeleton: React.Dispatch>; +}; + +const defaultSetShowSkeleton: React.Dispatch< + React.SetStateAction +> = () => undefined; + +const SkeletonFieldContext = createContext({ + showSkeleton: false, + setShowSkeleton: defaultSetShowSkeleton, +}); + +type SkeletonFieldProviderProps = { + initialShow?: boolean; + children: React.ReactNode; +}; + +const SkeletonFieldProvider = ({ + initialShow = false, + children, +}: SkeletonFieldProviderProps) => { + const [showSkeleton, setShowSkeleton] = useState(initialShow); + + return ( + + {children} + + ); +}; + +const useSkeletonField = (): SkeletonFieldContextType => { + return useContext(SkeletonFieldContext); +}; + +SkeletonFieldProvider.displayName = 'SkeletonFieldProvider'; + +export { SkeletonFieldProvider, useSkeletonField }; diff --git a/packages/ui-components/src/components/skeleton/skeleton-field.tsx b/packages/ui-components/src/components/skeleton/skeleton-field.tsx new file mode 100644 index 000000000..27b33075b --- /dev/null +++ b/packages/ui-components/src/components/skeleton/skeleton-field.tsx @@ -0,0 +1,36 @@ +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 !== undefined ? showProp : showContext; + + if (show) { + return ( +
+ ); + } + + return children; +}; + +SkeletonField.displayName = 'SkeletonField'; + +export { SkeletonField }; diff --git a/packages/ui-components/src/stories/skeleton/skeleton-field.stories.tsx b/packages/ui-components/src/stories/skeleton/skeleton-field.stories.tsx new file mode 100644 index 000000000..09953e352 --- /dev/null +++ b/packages/ui-components/src/stories/skeleton/skeleton-field.stories.tsx @@ -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 = { + title: 'Components/SkeletonField', + component: SkeletonField, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, + decorators: [ThemeAwareDecorator], +}; + +export default meta; + +type Story = StoryObj; + +/** + * When `show` is `true`, the skeleton placeholder is rendered instead of children. + */ +export const ShowingSkeleton: Story = { + render: () => ( +
+ +

This content is hidden.

+
+
+ ), + play: async ({ canvasElement }) => { + const canvas = selectLightOrDarkCanvas(canvasElement); + expect( + canvas.queryByText('This content is hidden.'), + ).not.toBeInTheDocument(); + const skeleton = canvasElement.querySelector('.rounded-xl'); + expect(skeleton).not.toBeNull(); + }, +}; + +/** + * When `show` is `false`, the children are rendered normally. + */ +export const ShowingContent: Story = { + render: () => ( +
+ +

Actual content is visible.

+
+
+ ), + 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: () => ( + +
+
+ +

Field one content.

+
+
+
+ +

Field two content.

+
+
+
+
+ ), + play: async ({ canvasElement }) => { + const canvas = selectLightOrDarkCanvas(canvasElement); + expect(canvas.queryByText('Field one content.')).not.toBeInTheDocument(); + expect(canvas.queryByText('Field two content.')).not.toBeInTheDocument(); + const skeletons = canvasElement.querySelectorAll('.rounded-xl'); + expect(skeletons.length).toBeGreaterThanOrEqual(2); + }, +}; + +/** + * When wrapped in `SkeletonFieldProvider` with `initialShow={false}`, children are rendered normally. + */ +export const WithContextShowingContent: Story = { + render: () => ( + +
+
+ +

Field one content.

+
+
+
+ +

Field two content.

+
+
+
+
+ ), + 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: () => ( + +
+
+ +

Hidden by context.

+
+
+
+ +

+ Visible via prop override. +

+
+
+
+
+ ), + play: async ({ canvasElement }) => { + const canvas = selectLightOrDarkCanvas(canvasElement); + expect(canvas.queryByText('Hidden by context.')).not.toBeInTheDocument(); + expect(canvas.getByText('Visible via prop override.')).toBeInTheDocument(); + }, +}; From 8f10907e42897830acdf7b282b494da39e3296dc Mon Sep 17 00:00:00 2001 From: Roman Snapko Date: Fri, 27 Mar 2026 09:38:22 +0100 Subject: [PATCH 2/3] Export skeleton components and improve test selectors --- packages/ui-components/src/components/index.ts | 1 + .../src/components/skeleton/skeleton-field.tsx | 1 + .../src/stories/skeleton/skeleton-field.stories.tsx | 8 ++++---- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/ui-components/src/components/index.ts b/packages/ui-components/src/components/index.ts index 51404effa..a03c4c24a 100644 --- a/packages/ui-components/src/components/index.ts +++ b/packages/ui-components/src/components/index.ts @@ -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'; diff --git a/packages/ui-components/src/components/skeleton/skeleton-field.tsx b/packages/ui-components/src/components/skeleton/skeleton-field.tsx index 27b33075b..2bbd7224e 100644 --- a/packages/ui-components/src/components/skeleton/skeleton-field.tsx +++ b/packages/ui-components/src/components/skeleton/skeleton-field.tsx @@ -19,6 +19,7 @@ const SkeletonField = ({ if (show) { return (
Date: Fri, 27 Mar 2026 09:42:46 +0100 Subject: [PATCH 3/3] Optimize SkeletonField context with useMemo and simplify conditional --- .../src/components/skeleton/skeleton-field-context.tsx | 9 +++++++-- .../src/components/skeleton/skeleton-field.tsx | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/ui-components/src/components/skeleton/skeleton-field-context.tsx b/packages/ui-components/src/components/skeleton/skeleton-field-context.tsx index e8d6faa8d..0fb498543 100644 --- a/packages/ui-components/src/components/skeleton/skeleton-field-context.tsx +++ b/packages/ui-components/src/components/skeleton/skeleton-field-context.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState } from 'react'; +import React, { createContext, useContext, useMemo, useState } from 'react'; type SkeletonFieldContextType = { showSkeleton: boolean; @@ -25,8 +25,13 @@ const SkeletonFieldProvider = ({ }: SkeletonFieldProviderProps) => { const [showSkeleton, setShowSkeleton] = useState(initialShow); + const value = useMemo( + () => ({ showSkeleton, setShowSkeleton }), + [showSkeleton, setShowSkeleton], + ); + return ( - + {children} ); diff --git a/packages/ui-components/src/components/skeleton/skeleton-field.tsx b/packages/ui-components/src/components/skeleton/skeleton-field.tsx index 2bbd7224e..f2b5f29e4 100644 --- a/packages/ui-components/src/components/skeleton/skeleton-field.tsx +++ b/packages/ui-components/src/components/skeleton/skeleton-field.tsx @@ -14,7 +14,7 @@ const SkeletonField = ({ show: showProp, }: SkeletonFieldProps) => { const { showSkeleton: showContext } = useSkeletonField(); - const show = showProp !== undefined ? showProp : showContext; + const show = showProp ?? showContext; if (show) { return (