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/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..0fb498543 --- /dev/null +++ b/packages/ui-components/src/components/skeleton/skeleton-field-context.tsx @@ -0,0 +1,46 @@ +import React, { createContext, useContext, useMemo, 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); + + const value = useMemo( + () => ({ showSkeleton, setShowSkeleton }), + [showSkeleton, setShowSkeleton], + ); + + 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..f2b5f29e4 --- /dev/null +++ b/packages/ui-components/src/components/skeleton/skeleton-field.tsx @@ -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 ( +
+ ); + } + + 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..0a1b968fb --- /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(); + expect(canvas.getByTestId('skeleton-field')).toBeInTheDocument(); + }, +}; + +/** + * 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(); + expect( + canvas.getAllByTestId('skeleton-field').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(); + }, +};