diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts index 7e3fd461..1b9ce183 100644 --- a/.storybook/components/Roadmap/data.ts +++ b/.storybook/components/Roadmap/data.ts @@ -356,4 +356,10 @@ export const rows: Rows = [ stage: '🔵 experimental', planned: 'Q2 2026', }, + { + component: 'EmptyState', + status: '✅ Done', + stage: '🔵 experimental', + planned: 'Q2 2026', + }, ]; diff --git a/package.json b/package.json index 12c22814..119d67fe 100644 --- a/package.json +++ b/package.json @@ -45,11 +45,13 @@ "@chromatic-com/storybook": "^5.1.2", "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", + "@eslint/js": "^9.39.4", "@internationalized/date": "^3.12.1", "@koobiq/date-adapter": "^3.5.1", "@koobiq/date-formatter": "^3.5.1", "@koobiq/design-tokens": "^3.17.2", "@koobiq/internationalized-date-adapter": "^3.5.1", + "@koobiq/visuals": "^12.1.0", "@microsoft/api-extractor": "^7.56.0", "@rushstack/node-core-library": "^3.66.1", "@storybook/addon-a11y": "^10.3.6", @@ -58,7 +60,6 @@ "@storybook/addon-mcp": "^0.6.0", "@storybook/react": "^10.3.6", "@storybook/react-vite": "^10.3.6", - "@eslint/js": "^9.39.4", "@stylistic/eslint-plugin": "^5.10.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -72,8 +73,8 @@ "@vueless/storybook-dark-mode": "^10.0.8", "browserslist": "^4.28.1", "browserslist-to-esbuild": "^2.1.1", - "chalk": "^4.1.2", "bumpp": "^10.1.0", + "chalk": "^4.1.2", "commit-and-tag-version": "^12.5.0", "dotenv": "^16.4.7", "eslint": "^9.39.4", diff --git a/packages/components/src/components/EmptyState/EmptyState.mdx b/packages/components/src/components/EmptyState/EmptyState.mdx new file mode 100644 index 00000000..5823cdf1 --- /dev/null +++ b/packages/components/src/components/EmptyState/EmptyState.mdx @@ -0,0 +1,101 @@ +import { + Meta, + Story, + Props, + Status, +} from '../../../../../.storybook/components'; + +import * as Stories from './EmptyState.stories'; + + + +# EmptyState + + + +EmptyState communicates that there is no data to display and, optionally, +suggests the next action the user can take. + +## Import + +```tsx +import { EmptyState } from '@koobiq/react-components'; +``` + +## Usage + + + +## Props + + + +## Anatomy + +The component is composed of four optional slots. Render only the ones you need. + +```tsx + + + + + + +``` + +- `EmptyState.Media` — an illustration or icon. +- `EmptyState.Title` — the heading. +- `EmptyState.Content` — the supporting text. +- `EmptyState.Actions` — buttons, links or pseudo-links. + +## Size + +Use the `size` prop to switch between `big`, `normal` (default) and `compact`. +The size adjusts the typography, paddings and the max width of the content. + + + +## Invalid + +Set the `isInvalid` prop to paint the title, text and media with the error color — +for example, when data failed to load. + + + +## Alignment + +Use the `align` prop to align the content to the `start` or `center` (default) +of the available block space. + + + +## With illustration + +Put any visual into `EmptyState.Media` — an icon, an `IconItem` or a raster +illustration. The example below uses the illustrations from the +[`@koobiq/visuals`](https://www.npmjs.com/package/@koobiq/visuals) package and +switches the `light` / `dark` asset to match the active theme. + + + +## Text only + +Every slot is optional. Drop the media and actions for a minimal, text-only +empty state. + + + +## Accessibility + +- If the empty state appears dynamically (e.g. after a search returns nothing), + add `role="status"` so screen readers announce it: + + ```tsx + + Nothing found + Try a different search query. + + ``` + +- `EmptyState.Title` is an `h3` by default — use `as` to fit the page headings. +- Add `alt` to illustrations, or `alt=""` if they are decorative. diff --git a/packages/components/src/components/EmptyState/EmptyState.module.css b/packages/components/src/components/EmptyState/EmptyState.module.css new file mode 100644 index 00000000..e5c81342 --- /dev/null +++ b/packages/components/src/components/EmptyState/EmptyState.module.css @@ -0,0 +1,29 @@ +.base { + box-sizing: border-box; + display: flex; + flex: 1 1 auto; + flex-direction: column; + align-items: center; + inline-size: 100%; + text-align: center; +} + +/* align */ +.center { + justify-content: center; +} + +.start { + justify-content: flex-start; +} + +/* size */ +.big { + padding-block: var(--kbq-size-5xl); + padding-inline: var(--kbq-size-6xl); +} + +.normal, +.compact { + padding: var(--kbq-size-3xl); +} diff --git a/packages/components/src/components/EmptyState/EmptyState.stories.tsx b/packages/components/src/components/EmptyState/EmptyState.stories.tsx new file mode 100644 index 00000000..1de3a6cd --- /dev/null +++ b/packages/components/src/components/EmptyState/EmptyState.stories.tsx @@ -0,0 +1,168 @@ +import { IconBell16, IconTriangleExclamation16 } from '@koobiq/react-icons'; +import emptyDark from '@koobiq/visuals/dark/empty_256.webp'; +import emptyDark2x from '@koobiq/visuals/dark/empty_256@2x.webp'; +import emptyLight from '@koobiq/visuals/light/empty_256.webp'; +import emptyLight2x from '@koobiq/visuals/light/empty_256@2x.webp'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useDarkMode } from '@vueless/storybook-dark-mode'; + +import { Button } from '../Button'; +import { FlexBox } from '../FlexBox'; +import { IconItem } from '../IconItem'; +import { Link } from '../Link'; +import { Typography } from '../Typography'; + +import { EmptyState } from './index'; + +const meta = { + title: 'Components/EmptyState', + component: EmptyState, + subcomponents: { + 'EmptyState.Media': EmptyState.Media, + 'EmptyState.Title': EmptyState.Title, + 'EmptyState.Content': EmptyState.Content, + 'EmptyState.Actions': EmptyState.Actions, + }, + tags: ['status:new', 'date:2026-06-26'], + parameters: { + layout: 'centered', + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Base: Story = { + render: (args) => ( + + + + + + + No documents yet + + Create your first document to get started, or import an existing one. + + + + + + + ), +}; + +export const Sizes: Story = { + parameters: { layout: 'padded' }, + render: (args) => ( + + {(['big', 'normal', 'compact'] as const).map((size) => ( + + + + + + + No documents yet + + Create your first document to get started. + + + + + + ))} + + ), +}; + +export const Invalid: Story = { + render: (args) => ( + + + + + + + Failed to load documents + + Something went wrong while loading the data. Please try again. + + + + + + ), +}; + +export const Align: Story = { + parameters: { layout: 'padded' }, + render: (args) => ( + + + + + + + No documents yet + + The content is aligned to the start of the available space. + + + ), +}; + +export const WithIllustration: Story = { + render: function Render(args) { + const isDark = useDarkMode(); + + const src = isDark ? emptyDark : emptyLight; + const src2x = isDark ? emptyDark2x : emptyLight2x; + + return ( + + + No documents + + No documents yet + + { + 'Create your first document to get started,\nor import an existing one.' + } + + + + + + ); + }, +}; + +export const TextOnly: Story = { + render: (args) => ( + + Nothing found + + + Try changing the search query or + + + reset the filters + + . + + + ), +}; diff --git a/packages/components/src/components/EmptyState/EmptyState.test.tsx b/packages/components/src/components/EmptyState/EmptyState.test.tsx new file mode 100644 index 00000000..31c5c7bc --- /dev/null +++ b/packages/components/src/components/EmptyState/EmptyState.test.tsx @@ -0,0 +1,192 @@ +import { createRef } from 'react'; + +import { screen, render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; + +import { EmptyState } from './index'; + +describe('EmptyState', () => { + const baseProps = { 'data-testid': 'empty-state' }; + + it('should accept the ref', () => { + const ref = createRef(); + + render(); + + expect(ref.current).toBe(screen.getByTestId('empty-state')); + }); + + it('should merge a custom class name with the default ones', () => { + render(); + + expect(screen.getByTestId('empty-state')).toHaveClass('foo'); + }); + + it('should render the subcomponents', () => { + render( + + + Title + Content + Actions + + ); + + expect(screen.getByTestId('media')).toBeInTheDocument(); + expect(screen.getByTestId('title')).toHaveTextContent('Title'); + expect(screen.getByTestId('content')).toHaveTextContent('Content'); + expect(screen.getByTestId('actions')).toHaveTextContent('Actions'); + }); + + it('should default to the "normal" size, centered and non-error', () => { + render(); + + const root = screen.getByTestId('empty-state'); + + expect(root).toHaveAttribute('data-size', 'normal'); + expect(root).toHaveAttribute('data-align', 'center'); + expect(root).not.toHaveAttribute('data-invalid'); + }); + + it('should reflect the align prop', () => { + render(); + + expect(screen.getByTestId('empty-state')).toHaveAttribute( + 'data-align', + 'start' + ); + }); + + it('should support the polymorphic "as" prop on the root', () => { + render(); + + expect(screen.getByTestId('empty-state').tagName).toBe('SECTION'); + }); + + it('should propagate size and invalid state to the subcomponents via context', () => { + render( + + Title + + ); + + const title = screen.getByTestId('title'); + + expect(title).toHaveAttribute('data-size', 'big'); + expect(title).toHaveAttribute('data-invalid'); + }); + + it('should render Title as "h3" and Content as "p" by default', () => { + render( + + Title + Content + + ); + + expect(screen.getByTestId('title').tagName).toBe('H3'); + expect(screen.getByTestId('content').tagName).toBe('P'); + }); + + it('should support the polymorphic "as" prop on Title and Content', () => { + render( + + + Title + + + Content + + + ); + + expect(screen.getByTestId('title').tagName).toBe('H1'); + expect(screen.getByTestId('content').tagName).toBe('SPAN'); + }); +}); + +describe('EmptyState subcomponents', () => { + it('should forward refs to the underlying elements', () => { + const mediaRef = createRef(); + const titleRef = createRef(); + const contentRef = createRef(); + const actionsRef = createRef(); + + render( + + + + Title + + + Content + + + + ); + + expect(mediaRef.current).toBe(screen.getByTestId('media')); + expect(titleRef.current).toBe(screen.getByTestId('title')); + expect(contentRef.current).toBe(screen.getByTestId('content')); + expect(actionsRef.current).toBe(screen.getByTestId('actions')); + }); + + it('should merge a custom class name on each subcomponent', () => { + render( + + + + Title + + + Content + + + + ); + + expect(screen.getByTestId('media')).toHaveClass('m'); + expect(screen.getByTestId('title')).toHaveClass('t'); + expect(screen.getByTestId('content')).toHaveClass('c'); + expect(screen.getByTestId('actions')).toHaveClass('a'); + }); + + it('should propagate the size to every subcomponent', () => { + render( + + + Title + Content + + + ); + + for (const id of ['media', 'title', 'content', 'actions']) { + expect(screen.getByTestId(id)).toHaveAttribute('data-size', 'compact'); + } + }); + + it('should propagate the align value to the media', () => { + render( + + + + ); + + expect(screen.getByTestId('media')).toHaveAttribute('data-align', 'start'); + }); + + it('should mark media, title and content as invalid', () => { + render( + + + Title + Content + + ); + + expect(screen.getByTestId('media')).toHaveAttribute('data-invalid'); + expect(screen.getByTestId('title')).toHaveAttribute('data-invalid'); + expect(screen.getByTestId('content')).toHaveAttribute('data-invalid'); + }); +}); diff --git a/packages/components/src/components/EmptyState/EmptyState.tsx b/packages/components/src/components/EmptyState/EmptyState.tsx new file mode 100644 index 00000000..ef2dbd98 --- /dev/null +++ b/packages/components/src/components/EmptyState/EmptyState.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { useMemo } from 'react'; + +import { clsx, polymorphicForwardRef } from '@koobiq/react-core'; + +import { + EmptyStateActions, + EmptyStateContent, + EmptyStateMedia, + EmptyStateTitle, +} from './components'; +import s from './EmptyState.module.css'; +import { EmptyStateContext } from './EmptyStateContext'; +import type { EmptyStateBaseProps } from './types'; + +const EmptyStateComponent = polymorphicForwardRef<'div', EmptyStateBaseProps>( + (props, ref) => { + const { + as: Tag = 'div', + size = 'normal', + isInvalid = false, + align = 'center', + className, + children, + ...other + } = props; + + const contextValue = useMemo( + () => ({ size, isInvalid, align }), + [size, isInvalid, align] + ); + + return ( + + + {children} + + + ); + } +); + +EmptyStateComponent.displayName = 'EmptyState'; + +type CompoundedComponent = typeof EmptyStateComponent & { + Media: typeof EmptyStateMedia; + Title: typeof EmptyStateTitle; + Content: typeof EmptyStateContent; + Actions: typeof EmptyStateActions; +}; + +/** + * EmptyState communicates that there is no data to display and, optionally, + * suggests the next action the user can take. + */ +export const EmptyState = EmptyStateComponent as CompoundedComponent; + +EmptyState.Media = EmptyStateMedia; +EmptyState.Title = EmptyStateTitle; +EmptyState.Content = EmptyStateContent; +EmptyState.Actions = EmptyStateActions; diff --git a/packages/components/src/components/EmptyState/EmptyStateContext.ts b/packages/components/src/components/EmptyState/EmptyStateContext.ts new file mode 100644 index 00000000..8e313a7d --- /dev/null +++ b/packages/components/src/components/EmptyState/EmptyStateContext.ts @@ -0,0 +1,17 @@ +'use client'; + +import { createContext } from 'react'; + +import type { EmptyStatePropAlign, EmptyStatePropSize } from './types'; + +export type EmptyStateContextValue = { + size: EmptyStatePropSize; + isInvalid: boolean; + align: EmptyStatePropAlign; +}; + +export const EmptyStateContext = createContext({ + size: 'normal', + isInvalid: false, + align: 'center', +}); diff --git a/packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.module.css b/packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.module.css new file mode 100644 index 00000000..2b8be205 --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.module.css @@ -0,0 +1,16 @@ +.base { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: var(--kbq-size-s); +} + +.compact, +.normal { + max-inline-size: 320px; +} + +.big { + max-inline-size: 480px; +} diff --git a/packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.tsx b/packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.tsx new file mode 100644 index 00000000..8f01849b --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.tsx @@ -0,0 +1,34 @@ +'use client'; + +import type { ComponentPropsWithRef } from 'react'; +import { forwardRef, useContext } from 'react'; + +import { clsx, mergeProps } from '@koobiq/react-core'; + +import { EmptyStateContext } from '../../EmptyStateContext'; + +import s from './EmptyStateActions.module.css'; +import type { EmptyStateActionsProps } from './types'; + +/** EmptyState.Actions — the actions slot (buttons, links) of the EmptyState. */ +export const EmptyStateActions = forwardRef< + HTMLDivElement, + EmptyStateActionsProps +>((props, ref) => { + const { className, children, ...other } = props; + + const { size } = useContext(EmptyStateContext); + + const rootProps = mergeProps[]>( + { className: clsx(s.base, s[size], className) }, + other + ); + + return ( +
+ {children} +
+ ); +}); + +EmptyStateActions.displayName = 'EmptyState.Actions'; diff --git a/packages/components/src/components/EmptyState/components/EmptyStateActions/index.ts b/packages/components/src/components/EmptyState/components/EmptyStateActions/index.ts new file mode 100644 index 00000000..cc529b6f --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateActions/index.ts @@ -0,0 +1,2 @@ +export * from './EmptyStateActions'; +export * from './types'; diff --git a/packages/components/src/components/EmptyState/components/EmptyStateActions/types.ts b/packages/components/src/components/EmptyState/components/EmptyStateActions/types.ts new file mode 100644 index 00000000..fb606a39 --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateActions/types.ts @@ -0,0 +1,6 @@ +import type { ComponentPropsWithRef } from 'react'; + +import type { DataAttributeProps } from '@koobiq/react-core'; + +export type EmptyStateActionsProps = ComponentPropsWithRef<'div'> & + DataAttributeProps; diff --git a/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css new file mode 100644 index 00000000..fad02526 --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css @@ -0,0 +1,35 @@ +@import url('../../../../styles/mixins.css'); + +.base { + margin: 0; + color: var(--kbq-foreground-contrast-secondary); +} + +.invalid { + color: var(--kbq-foreground-error); +} + +.compact { + @mixin typography text-compact; + + max-inline-size: 320px; + margin-block-end: var(--kbq-size-s); +} + +.normal { + @mixin typography text-normal; + + max-inline-size: 320px; + margin-block-end: var(--kbq-size-s); +} + +.big { + @mixin typography text-big; + + max-inline-size: 480px; + margin-block-end: var(--kbq-size-xl); +} + +.base:last-child { + margin-block-end: 0; +} diff --git a/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.tsx b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.tsx new file mode 100644 index 00000000..9058c307 --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { useContext } from 'react'; + +import { clsx, polymorphicForwardRef } from '@koobiq/react-core'; + +import { EmptyStateContext } from '../../EmptyStateContext'; + +import s from './EmptyStateContent.module.css'; +import type { EmptyStateContentProps } from './types'; + +/** EmptyState.Content — the supporting text slot of the EmptyState. */ +export const EmptyStateContent = polymorphicForwardRef< + 'p', + EmptyStateContentProps +>((props, ref) => { + const { as: Tag = 'p', className, children, ...other } = props; + + const { size, isInvalid } = useContext(EmptyStateContext); + + return ( + + {children} + + ); +}); + +EmptyStateContent.displayName = 'EmptyState.Content'; diff --git a/packages/components/src/components/EmptyState/components/EmptyStateContent/index.ts b/packages/components/src/components/EmptyState/components/EmptyStateContent/index.ts new file mode 100644 index 00000000..65e412d3 --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateContent/index.ts @@ -0,0 +1,2 @@ +export * from './EmptyStateContent'; +export * from './types'; diff --git a/packages/components/src/components/EmptyState/components/EmptyStateContent/types.ts b/packages/components/src/components/EmptyState/components/EmptyStateContent/types.ts new file mode 100644 index 00000000..0cedbf13 --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateContent/types.ts @@ -0,0 +1,15 @@ +import type { ElementType, ReactNode } from 'react'; + +import type { DataAttributeProps } from '@koobiq/react-core'; + +export type EmptyStateContentProps = { + /** + * The HTML element to render as. + * @default 'p' + */ + as?: ElementType; + /** Additional CSS-classes. */ + className?: string; + /** The content of the text. */ + children?: ReactNode; +} & DataAttributeProps; diff --git a/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.module.css b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.module.css new file mode 100644 index 00000000..0b8e4624 --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.module.css @@ -0,0 +1,25 @@ +.base { + display: flex; + align-items: center; + justify-content: center; +} + +.invalid { + color: var(--kbq-foreground-error); +} + +.big { + margin-block-end: var(--kbq-size-3xl); +} + +.normal { + margin-block-end: var(--kbq-size-xl); +} + +.compact { + margin-block-end: var(--kbq-size-m); +} + +.base:last-child { + margin-block-end: 0; +} diff --git a/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx new file mode 100644 index 00000000..4739adcb --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { useContext } from 'react'; + +import { clsx, polymorphicForwardRef } from '@koobiq/react-core'; + +import { EmptyStateContext } from '../../EmptyStateContext'; + +import s from './EmptyStateMedia.module.css'; +import type { EmptyStateMediaProps } from './types'; + +/** EmptyState.Media — the illustration or icon slot of the EmptyState. */ +export const EmptyStateMedia = polymorphicForwardRef< + 'div', + EmptyStateMediaProps +>((props, ref) => { + const { as: Tag = 'div', className, children, ...other } = props; + + const { size, align, isInvalid } = useContext(EmptyStateContext); + + return ( + + {children} + + ); +}); + +EmptyStateMedia.displayName = 'EmptyState.Media'; diff --git a/packages/components/src/components/EmptyState/components/EmptyStateMedia/index.ts b/packages/components/src/components/EmptyState/components/EmptyStateMedia/index.ts new file mode 100644 index 00000000..998d3ddd --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateMedia/index.ts @@ -0,0 +1,2 @@ +export * from './EmptyStateMedia'; +export * from './types'; diff --git a/packages/components/src/components/EmptyState/components/EmptyStateMedia/types.ts b/packages/components/src/components/EmptyState/components/EmptyStateMedia/types.ts new file mode 100644 index 00000000..0011cf80 --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateMedia/types.ts @@ -0,0 +1,15 @@ +import type { ElementType, ReactNode } from 'react'; + +import type { DataAttributeProps } from '@koobiq/react-core'; + +export type EmptyStateMediaProps = { + /** + * The HTML element to render as. + * @default 'div' + */ + as?: ElementType; + /** Additional CSS-classes. */ + className?: string; + /** The content of the slot. */ + children?: ReactNode; +} & DataAttributeProps; diff --git a/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css new file mode 100644 index 00000000..f6a3b971 --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css @@ -0,0 +1,35 @@ +@import url('../../../../styles/mixins.css'); + +.base { + margin: 0; + color: var(--kbq-foreground-contrast); +} + +.invalid { + color: var(--kbq-foreground-error); +} + +.compact { + @mixin typography text-normal; + + max-inline-size: 320px; + margin-block-end: var(--kbq-size-xxs); +} + +.normal { + @mixin typography subheading; + + max-inline-size: 320px; + margin-block-end: var(--kbq-size-xxs); +} + +.big { + @mixin typography headline; + + max-inline-size: 480px; + margin-block-end: var(--kbq-size-l); +} + +.base:last-child { + margin-block-end: 0; +} diff --git a/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.tsx b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.tsx new file mode 100644 index 00000000..94291d6a --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { useContext } from 'react'; + +import { clsx, polymorphicForwardRef } from '@koobiq/react-core'; + +import { EmptyStateContext } from '../../EmptyStateContext'; + +import s from './EmptyStateTitle.module.css'; +import type { EmptyStateTitleProps } from './types'; + +/** EmptyState.Title — the heading slot of the EmptyState. */ +export const EmptyStateTitle = polymorphicForwardRef< + 'h3', + EmptyStateTitleProps +>((props, ref) => { + const { as: Tag = 'h3', className, children, ...other } = props; + + const { size, isInvalid } = useContext(EmptyStateContext); + + return ( + + {children} + + ); +}); + +EmptyStateTitle.displayName = 'EmptyState.Title'; diff --git a/packages/components/src/components/EmptyState/components/EmptyStateTitle/index.ts b/packages/components/src/components/EmptyState/components/EmptyStateTitle/index.ts new file mode 100644 index 00000000..141836a3 --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateTitle/index.ts @@ -0,0 +1,2 @@ +export * from './EmptyStateTitle'; +export * from './types'; diff --git a/packages/components/src/components/EmptyState/components/EmptyStateTitle/types.ts b/packages/components/src/components/EmptyState/components/EmptyStateTitle/types.ts new file mode 100644 index 00000000..f27d4090 --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateTitle/types.ts @@ -0,0 +1,15 @@ +import type { ElementType, ReactNode } from 'react'; + +import type { DataAttributeProps } from '@koobiq/react-core'; + +export type EmptyStateTitleProps = { + /** + * The HTML element to render as. + * @default 'h3' + */ + as?: ElementType; + /** Additional CSS-classes. */ + className?: string; + /** The content of the title. */ + children?: ReactNode; +} & DataAttributeProps; diff --git a/packages/components/src/components/EmptyState/components/index.ts b/packages/components/src/components/EmptyState/components/index.ts new file mode 100644 index 00000000..0edbd298 --- /dev/null +++ b/packages/components/src/components/EmptyState/components/index.ts @@ -0,0 +1,4 @@ +export * from './EmptyStateMedia'; +export * from './EmptyStateTitle'; +export * from './EmptyStateContent'; +export * from './EmptyStateActions'; diff --git a/packages/components/src/components/EmptyState/index.ts b/packages/components/src/components/EmptyState/index.ts new file mode 100644 index 00000000..523c8144 --- /dev/null +++ b/packages/components/src/components/EmptyState/index.ts @@ -0,0 +1,3 @@ +export * from './EmptyState'; +export * from './types'; +export * from './components'; diff --git a/packages/components/src/components/EmptyState/types.ts b/packages/components/src/components/EmptyState/types.ts new file mode 100644 index 00000000..99f55f77 --- /dev/null +++ b/packages/components/src/components/EmptyState/types.ts @@ -0,0 +1,38 @@ +import type { ElementType, ReactNode } from 'react'; + +import type { DataAttributeProps } from '@koobiq/react-core'; + +export const emptyStatePropSize = ['big', 'normal', 'compact'] as const; + +export type EmptyStatePropSize = (typeof emptyStatePropSize)[number]; + +export const emptyStatePropAlign = ['start', 'center'] as const; + +export type EmptyStatePropAlign = (typeof emptyStatePropAlign)[number]; + +export type EmptyStateBaseProps = { + /** + * The size of the component. + * @default 'normal' + */ + size?: EmptyStatePropSize; + /** + * Whether the EmptyState represents an invalid (error) state. + * Paints the title, text and media with the error color. + */ + isInvalid?: boolean; + /** + * The block alignment of the content within the available space. + * @default 'center' + */ + align?: EmptyStatePropAlign; + /** + * The HTML element to render as. + * @default 'div' + */ + as?: ElementType; + /** Additional CSS-classes. */ + className?: string; + /** The content of the component. */ + children?: ReactNode; +} & DataAttributeProps; diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index c8b199a6..9cb9d87c 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -53,6 +53,7 @@ export * from './Navbar'; export * from './ActionsPanel'; export * from './Tree'; export * from './TreeSelect'; +export * from './EmptyState'; export * from './layout'; export { useListData, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb670559..21abf963 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ importers: '@koobiq/internationalized-date-adapter': specifier: ^3.5.1 version: 3.5.1(@internationalized/date@3.12.1)(@koobiq/date-adapter@3.5.1) + '@koobiq/visuals': + specifier: ^12.1.0 + version: 12.1.0 '@microsoft/api-extractor': specifier: ^7.56.0 version: 7.58.7(@types/node@25.8.0) @@ -126,7 +129,7 @@ importers: version: 9.1.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + version: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsdoc: specifier: ^51.4.1 version: 51.4.1(eslint@9.39.4(jiti@2.6.1)) @@ -533,7 +536,7 @@ importers: version: 9.39.4(jiti@2.6.1) eslint-config-next: specifier: ^16.2.9 - version: 16.2.9(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) + version: 16.2.9(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) jsdom: specifier: ^25.0.1 version: 25.0.1 @@ -1831,6 +1834,9 @@ packages: react: ^19.0.0 react-dom: ^19.0.0 + '@koobiq/visuals@12.1.0': + resolution: {integrity: sha512-JdrGcYrH6EpEXC+POWRa8uT8zv8XH9H6EG9Aib9PxS5jOa0zvkcqLz09dLasQOKrQe3tRb3wksruMH2YxQad7Q==} + '@mdx-js/react@3.1.1': resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} peerDependencies: @@ -10163,6 +10169,8 @@ snapshots: react-aria-components: 1.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react-dom: 19.2.3(react@19.2.3) + '@koobiq/visuals@12.1.0': {} + '@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.3)': dependencies: '@types/mdx': 2.0.13 @@ -11947,10 +11955,10 @@ snapshots: '@types/unist@3.0.3': {} - '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3))(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.61.0(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.61.0 '@typescript-eslint/type-utils': 8.61.0(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/utils': 8.61.0(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3) @@ -12196,7 +12204,7 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@25.8.0)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.15))(tsx@4.19.3)(yaml@2.9.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@25.8.0)(@vitest/coverage-v8@4.1.9)(jsdom@25.0.1)(vite@7.3.3(@types/node@25.8.0)(jiti@2.6.1)(lightningcss@1.29.3)(sugarss@5.0.1(postcss@8.5.15))(tsx@4.19.3)(yaml@2.9.0)) '@vitest/expect@3.2.4': dependencies: @@ -13831,13 +13839,13 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-next@16.2.9(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3): + eslint-config-next@16.2.9(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3): dependencies: '@next/eslint-plugin-next': 16.2.9 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.6.1)) @@ -13871,7 +13879,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -13882,21 +13890,22 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: + '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -13907,7 +13916,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.3 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -13918,6 +13927,8 @@ snapshots: semver: 6.3.1 string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -18349,7 +18360,7 @@ snapshots: typescript-eslint@8.61.0(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3))(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/parser': 8.61.0(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) '@typescript-eslint/utils': 8.61.0(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3) diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index 4d9716fc..202e10d5 100644 --- a/tools/api-extractor/config.json +++ b/tools/api-extractor/config.json @@ -17,6 +17,7 @@ "DateInput", "DatePicker", "Divider", + "EmptyState", "FlexBox", "Form", "FormField", diff --git a/tools/public_api_guard/components/EmptyState.api.md b/tools/public_api_guard/components/EmptyState.api.md new file mode 100644 index 00000000..8bae650d --- /dev/null +++ b/tools/public_api_guard/components/EmptyState.api.md @@ -0,0 +1,80 @@ +## API Report File for "koobiq-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ComponentPropsWithRef } from 'react'; +import type { DataAttributeProps } from '@koobiq/react-core'; +import { ElementType } from 'react'; +import { ForwardRefExoticComponent } from 'react'; +import { PolyForwardComponent } from '@koobiq/react-core'; +import type { ReactNode } from 'react'; +import { RefAttributes } from 'react'; + +// Warning: (ae-forgotten-export) The symbol "CompoundedComponent" needs to be exported by the entry point index.d.ts +// +// @public +export const EmptyState: CompoundedComponent; + +// @public +export const EmptyStateActions: ForwardRefExoticComponent & RefAttributes>; + +// @public (undocumented) +export type EmptyStateActionsProps = ComponentPropsWithRef<'div'> & DataAttributeProps; + +// @public (undocumented) +export type EmptyStateBaseProps = { + size?: EmptyStatePropSize; + isInvalid?: boolean; + align?: EmptyStatePropAlign; + as?: ElementType; + className?: string; + children?: ReactNode; +} & DataAttributeProps; + +// @public +export const EmptyStateContent: PolyForwardComponent<"p", EmptyStateContentProps, ElementType>; + +// @public (undocumented) +export type EmptyStateContentProps = { + as?: ElementType; + className?: string; + children?: ReactNode; +} & DataAttributeProps; + +// @public +export const EmptyStateMedia: PolyForwardComponent<"div", EmptyStateMediaProps, ElementType>; + +// @public (undocumented) +export type EmptyStateMediaProps = { + as?: ElementType; + className?: string; + children?: ReactNode; +} & DataAttributeProps; + +// @public (undocumented) +export type EmptyStatePropAlign = (typeof emptyStatePropAlign)[number]; + +// @public (undocumented) +export const emptyStatePropAlign: readonly ["start", "center"]; + +// @public (undocumented) +export type EmptyStatePropSize = (typeof emptyStatePropSize)[number]; + +// @public (undocumented) +export const emptyStatePropSize: readonly ["big", "normal", "compact"]; + +// @public +export const EmptyStateTitle: PolyForwardComponent<"h3", EmptyStateTitleProps, ElementType>; + +// @public (undocumented) +export type EmptyStateTitleProps = { + as?: ElementType; + className?: string; + children?: ReactNode; +} & DataAttributeProps; + +// (No @packageDocumentation comment for this package) + +```