diff --git a/.changeset/edit-intellisenseeventicon.md b/.changeset/edit-intellisenseeventicon.md new file mode 100644 index 000000000..6706a9ba0 --- /dev/null +++ b/.changeset/edit-intellisenseeventicon.md @@ -0,0 +1,5 @@ +--- +'@vapor-ui/icons': patch +--- + +Check the center alignment of the IntelliSenseEventIcon diff --git a/.changeset/shiny-skeleton-loading.md b/.changeset/shiny-skeleton-loading.md new file mode 100644 index 000000000..a7ec6cd71 --- /dev/null +++ b/.changeset/shiny-skeleton-loading.md @@ -0,0 +1,7 @@ +--- +'@vapor-ui/core': minor +--- + +New Skeleton component + +You can see more details about the Skeleton component in the [Skeleton documentation](https://vapor-ui.goorm.io/docs/components/skeleton). diff --git a/apps/website/content/docs/components/skeleton.mdx b/apps/website/content/docs/components/skeleton.mdx new file mode 100644 index 000000000..4ec8f6a29 --- /dev/null +++ b/apps/website/content/docs/components/skeleton.mdx @@ -0,0 +1,82 @@ +--- +title: 'Skeleton' +site_name: 'Skeleton - Vapor Core' +description: '콘텐츠가 로딩 중임을 나타내는 플레이스홀더 요소입니다.' +--- + + +```json doc-gen:file +{ + "file": "./src/components/demo/examples/skeleton/default-skeleton.tsx", + "codeblock": true +} +``` + + +## Property + +--- + +### Shape + +Skeleton의 모서리 둥글기를 설정합니다. + + +```json doc-gen:file +{ + "file": "./src/components/demo/examples/skeleton/skeleton-shape.tsx", + "codeblock": true +} +``` + + +### Animation + +Skeleton의 애니메이션 스타일을 설정합니다. + + +```json doc-gen:file +{ + "file": "./src/components/demo/examples/skeleton/skeleton-animation.tsx", + "codeblock": true +} +``` + + +### Size + +Skeleton의 높이를 설정합니다. + + +```json doc-gen:file +{ + "file": "./src/components/demo/examples/skeleton/skeleton-size.tsx", + "codeblock": true +} +``` + + +## Examples + +--- + +### Profile Card + +여러 Skeleton 요소를 조합하여 프로필 카드 플레이스홀더를 구성하는 예제입니다. + + +```json doc-gen:file +{ + "file": "./src/components/demo/examples/skeleton/skeleton-composition.tsx", + "codeblock": true +} +``` + + +## Props Table + +--- + +### Skeleton + + diff --git a/apps/website/public/components/generated/skeleton.json b/apps/website/public/components/generated/skeleton.json new file mode 100644 index 000000000..305751cc1 --- /dev/null +++ b/apps/website/public/components/generated/skeleton.json @@ -0,0 +1,35 @@ +{ + "name": "Skeleton", + "displayName": "Skeleton", + "description": "콘텐츠가 로딩 중임을 나타내는 placeholder 요소입니다. `
` 요소를 렌더링합니다.", + "props": [ + { + "name": "animation", + "type": ["none", "shimmer", "pulse"], + "required": false, + "description": "skeleton의 애니메이션 스타일을 제어합니다.\n\n- `shimmer`: 빛이 스치는 효과\n- `pulse`: 깜빡이는 효과\n- `none`: 애니메이션 비활성화", + "defaultValue": "shimmer" + }, + { + "name": "shape", + "type": ["square", "rounded"], + "required": false, + "description": "skeleton의 테두리 반경을 제어합니다.", + "defaultValue": "rounded" + }, + { + "name": "size", + "type": ["sm", "md", "lg", "xl"], + "required": false, + "description": "skeleton의 높이를 제어합니다.", + "defaultValue": "md" + }, + { + "name": "render", + "type": ["ReactElement", "(props: HTMLProps) => ReactElement"], + "required": false, + "description": "component의 HTML element를 다른 태그로 교체하거나 다른 component와 조합할 수 있습니다.\n\nReactElement 또는 렌더링할 요소를 반환하는 함수를 받습니다." + } + ], + "defaultElement": "div" +} diff --git a/apps/website/src/components/demo/examples/skeleton/default-skeleton.tsx b/apps/website/src/components/demo/examples/skeleton/default-skeleton.tsx new file mode 100644 index 000000000..1c8e87f91 --- /dev/null +++ b/apps/website/src/components/demo/examples/skeleton/default-skeleton.tsx @@ -0,0 +1,5 @@ +import { Skeleton } from '@vapor-ui/core'; + +export default function DefaultSkeleton() { + return ; +} diff --git a/apps/website/src/components/demo/examples/skeleton/skeleton-animation.tsx b/apps/website/src/components/demo/examples/skeleton/skeleton-animation.tsx new file mode 100644 index 000000000..3b6b3b342 --- /dev/null +++ b/apps/website/src/components/demo/examples/skeleton/skeleton-animation.tsx @@ -0,0 +1,26 @@ +import { HStack, Skeleton, Text, VStack } from '@vapor-ui/core'; + +export default function SkeletonAnimation() { + return ( + + + + shimmer + + + + + + pulse + + + + + + none + + + + + ); +} diff --git a/apps/website/src/components/demo/examples/skeleton/skeleton-composition.tsx b/apps/website/src/components/demo/examples/skeleton/skeleton-composition.tsx new file mode 100644 index 000000000..f9f67c69b --- /dev/null +++ b/apps/website/src/components/demo/examples/skeleton/skeleton-composition.tsx @@ -0,0 +1,28 @@ +import { Box, HStack, Skeleton, VStack } from '@vapor-ui/core'; + +export default function SkeletonComposition() { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/apps/website/src/components/demo/examples/skeleton/skeleton-shape.tsx b/apps/website/src/components/demo/examples/skeleton/skeleton-shape.tsx new file mode 100644 index 000000000..a8350aa94 --- /dev/null +++ b/apps/website/src/components/demo/examples/skeleton/skeleton-shape.tsx @@ -0,0 +1,20 @@ +import { HStack, Skeleton, Text, VStack } from '@vapor-ui/core'; + +export default function SkeletonShape() { + return ( + + + + rounded + + + + + + square + + + + + ); +} diff --git a/apps/website/src/components/demo/examples/skeleton/skeleton-size.tsx b/apps/website/src/components/demo/examples/skeleton/skeleton-size.tsx new file mode 100644 index 000000000..e5325ebd0 --- /dev/null +++ b/apps/website/src/components/demo/examples/skeleton/skeleton-size.tsx @@ -0,0 +1,32 @@ +import { HStack, Skeleton, Text, VStack } from '@vapor-ui/core'; + +export default function SkeletonSize() { + return ( + + + + sm + + + + + + md + + + + + + lg + + + + + + xl + + + + + ); +} diff --git a/packages/core/__tests__/screenshots/skeleton--test-bed-1-chrome-darwin-.png b/packages/core/__tests__/screenshots/skeleton--test-bed-1-chrome-darwin-.png new file mode 100644 index 000000000..b55c26917 Binary files /dev/null and b/packages/core/__tests__/screenshots/skeleton--test-bed-1-chrome-darwin-.png differ diff --git a/packages/core/__tests__/screenshots/skeleton--test-bed-1-edge-darwin-.png b/packages/core/__tests__/screenshots/skeleton--test-bed-1-edge-darwin-.png new file mode 100644 index 000000000..b55c26917 Binary files /dev/null and b/packages/core/__tests__/screenshots/skeleton--test-bed-1-edge-darwin-.png differ diff --git a/packages/core/__tests__/screenshots/skeleton--test-bed-1-firefox-darwin-.png b/packages/core/__tests__/screenshots/skeleton--test-bed-1-firefox-darwin-.png new file mode 100644 index 000000000..6524be814 Binary files /dev/null and b/packages/core/__tests__/screenshots/skeleton--test-bed-1-firefox-darwin-.png differ diff --git a/packages/core/__tests__/screenshots/skeleton--test-bed-1-safari-darwin-.png b/packages/core/__tests__/screenshots/skeleton--test-bed-1-safari-darwin-.png new file mode 100644 index 000000000..e28a80e89 Binary files /dev/null and b/packages/core/__tests__/screenshots/skeleton--test-bed-1-safari-darwin-.png differ diff --git a/packages/core/src/components/skeleton/index.ts b/packages/core/src/components/skeleton/index.ts new file mode 100644 index 000000000..25c51ad04 --- /dev/null +++ b/packages/core/src/components/skeleton/index.ts @@ -0,0 +1 @@ +export * from './skeleton'; diff --git a/packages/core/src/components/skeleton/skeleton.css.ts b/packages/core/src/components/skeleton/skeleton.css.ts new file mode 100644 index 000000000..9bdd1e1d4 --- /dev/null +++ b/packages/core/src/components/skeleton/skeleton.css.ts @@ -0,0 +1,79 @@ +import { keyframes } from '@vanilla-extract/css'; +import type { RecipeVariants } from '@vanilla-extract/recipes'; +import { recipe } from '@vanilla-extract/recipes'; + +import { layerStyle } from '~/styles/mixins/layer-style.css'; +import { vars } from '~/styles/themes.css'; + +const shimmerKeyframes = keyframes({ + to: { backgroundPosition: 'right -6.25rem top 0' }, +}); + +const pulseKeyframes = keyframes({ + '0%, 100%': { opacity: 1 }, + '50%': { opacity: 0.5 }, +}); + +/** + * Style variants for the Skeleton component. + */ +export const root = recipe({ + base: layerStyle('components', { + display: 'block', + overflow: 'hidden', + width: '100%', + backgroundColor: vars.color.gray['100'], + + '@media': { + '(prefers-reduced-motion: reduce)': { + animation: 'none', + }, + }, + }), + + defaultVariants: { shape: 'rounded', size: 'md', animation: 'shimmer' }, + variants: { + /** + * Controls the border radius of the skeleton. + */ + shape: { + rounded: layerStyle('components', { + borderRadius: '9999px', + }), + square: layerStyle('components', { + borderRadius: vars.size.borderRadius['300'], + }), + }, + + /** + * Controls the height of the skeleton. + */ + size: { + sm: layerStyle('components', { height: vars.size.dimension['200'] }), + md: layerStyle('components', { height: vars.size.dimension['300'] }), + lg: layerStyle('components', { height: vars.size.dimension['400'] }), + xl: layerStyle('components', { height: vars.size.dimension['500'] }), + }, + + /** + * Controls the animation style of the skeleton. + */ + animation: { + shimmer: layerStyle('components', { + backgroundImage: `linear-gradient(90deg, ${vars.color.gray['100']}, ${vars.color.gray['050']}, ${vars.color.gray['100']})`, + backgroundPosition: 'left -6.25rem top 0', + backgroundSize: '6.25rem 100%', + backgroundRepeat: 'no-repeat', + animation: `${shimmerKeyframes} 1s ease-in-out infinite`, + }), + pulse: layerStyle('components', { + animation: `${pulseKeyframes} 1.2s ease-in-out infinite`, + }), + none: layerStyle('components', { + animation: 'none', + }), + }, + }, +}); + +export type SkeletonVariants = NonNullable>; diff --git a/packages/core/src/components/skeleton/skeleton.stories.tsx b/packages/core/src/components/skeleton/skeleton.stories.tsx new file mode 100644 index 000000000..01888001e --- /dev/null +++ b/packages/core/src/components/skeleton/skeleton.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Box } from '../box'; +import { HStack } from '../h-stack'; +import { VStack } from '../v-stack'; +import { Skeleton } from './skeleton'; + +export default { + title: 'Skeleton', + argTypes: { + shape: { control: 'inline-radio', options: ['rounded', 'square'] }, + size: { control: 'inline-radio', options: ['sm', 'md', 'lg', 'xl'] }, + animation: { control: 'inline-radio', options: ['shimmer', 'pulse', 'none'] }, + }, +} as Meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => , +}; + +export const TestBed: Story = { + render: () => ( + + +

Shape

+ + + + + +
+ + +

Size

+ + + + + + +
+ + +

Animation

+ + + + + +
+ + +

Composition Example - Profile Card

+ + + + + + + + + + + + + + +
+
+ ), +}; diff --git a/packages/core/src/components/skeleton/skeleton.test.tsx b/packages/core/src/components/skeleton/skeleton.test.tsx new file mode 100644 index 000000000..e9f791fa4 --- /dev/null +++ b/packages/core/src/components/skeleton/skeleton.test.tsx @@ -0,0 +1,13 @@ +import { render } from '@testing-library/react'; +import { axe } from 'vitest-axe'; + +import { Skeleton } from './skeleton'; + +describe('Skeleton', () => { + it('should have no a11y violations', async () => { + const rendered = render(); + const result = await axe(rendered.container); + + expect(result).toHaveNoViolations(); + }); +}); diff --git a/packages/core/src/components/skeleton/skeleton.tsx b/packages/core/src/components/skeleton/skeleton.tsx new file mode 100644 index 000000000..f0f8ff757 --- /dev/null +++ b/packages/core/src/components/skeleton/skeleton.tsx @@ -0,0 +1,39 @@ +import { forwardRef } from 'react'; + +import { useRender } from '@base-ui/react/use-render'; +import clsx from 'clsx'; + +import { createSplitProps } from '~/utils/create-split-props'; +import { resolveStyles } from '~/utils/resolve-styles'; +import type { VComponentProps } from '~/utils/types'; + +import type { SkeletonVariants } from './skeleton.css'; +import * as styles from './skeleton.css'; + +/** + * A placeholder element that indicates content is loading. Renders a `
` element. + */ +export const Skeleton = forwardRef((props, ref) => { + const { render, className, ...componentProps } = resolveStyles(props); + const [variantsProps, otherProps] = createSplitProps()(componentProps, [ + 'shape', + 'size', + 'animation', + ]); + + return useRender({ + ref, + render: render ||
, + props: { + className: clsx(styles.root(variantsProps), className), + ...otherProps, + }, + }); +}); +Skeleton.displayName = 'Skeleton'; + +export namespace Skeleton { + type SkeletonPrimitiveProps = VComponentProps<'div'>; + + export interface Props extends SkeletonPrimitiveProps, SkeletonVariants {} +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 046bd77b5..0469061f9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,6 +26,7 @@ export * from './components/radio'; export * from './components/radio-group'; export * from './components/select'; export * from './components/sheet'; +export * from './components/skeleton'; export * from './components/switch'; export * from './components/table'; export * from './components/tabs';