From b32e826b0ba492e45ba61237a72f2a092155c668 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 25 Jun 2026 15:38:16 +0300 Subject: [PATCH 01/14] feat(EmptyState): scaffold component structure --- .../src/components/EmptyState/EmptyState.mdx | 42 +++++++++++++ .../EmptyState/EmptyState.module.css | 20 ++++++ .../EmptyState/EmptyState.stories.tsx | 46 ++++++++++++++ .../components/EmptyState/EmptyState.test.tsx | 62 ++++++++++++++++++ .../src/components/EmptyState/EmptyState.tsx | 63 +++++++++++++++++++ .../EmptyState/EmptyStateContext.ts | 15 +++++ .../EmptyStateActions.module.css | 16 +++++ .../EmptyStateActions/EmptyStateActions.tsx | 34 ++++++++++ .../components/EmptyStateActions/index.ts | 2 + .../components/EmptyStateActions/types.ts | 6 ++ .../EmptyStateContent.module.css | 22 +++++++ .../EmptyStateContent/EmptyStateContent.tsx | 34 ++++++++++ .../components/EmptyStateContent/index.ts | 2 + .../components/EmptyStateContent/types.ts | 6 ++ .../EmptyStateMedia.module.css | 17 +++++ .../EmptyStateMedia/EmptyStateMedia.tsx | 33 ++++++++++ .../components/EmptyStateMedia/index.ts | 2 + .../components/EmptyStateMedia/types.ts | 6 ++ .../EmptyStateTitle.module.css | 28 +++++++++ .../EmptyStateTitle/EmptyStateTitle.tsx | 33 ++++++++++ .../components/EmptyStateTitle/index.ts | 2 + .../components/EmptyStateTitle/types.ts | 6 ++ .../components/EmptyState/components/index.ts | 4 ++ .../src/components/EmptyState/index.ts | 3 + .../src/components/EmptyState/types.ts | 25 ++++++++ packages/components/src/components/index.ts | 1 + 26 files changed, 530 insertions(+) create mode 100644 packages/components/src/components/EmptyState/EmptyState.mdx create mode 100644 packages/components/src/components/EmptyState/EmptyState.module.css create mode 100644 packages/components/src/components/EmptyState/EmptyState.stories.tsx create mode 100644 packages/components/src/components/EmptyState/EmptyState.test.tsx create mode 100644 packages/components/src/components/EmptyState/EmptyState.tsx create mode 100644 packages/components/src/components/EmptyState/EmptyStateContext.ts create mode 100644 packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.module.css create mode 100644 packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.tsx create mode 100644 packages/components/src/components/EmptyState/components/EmptyStateActions/index.ts create mode 100644 packages/components/src/components/EmptyState/components/EmptyStateActions/types.ts create mode 100644 packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css create mode 100644 packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.tsx create mode 100644 packages/components/src/components/EmptyState/components/EmptyStateContent/index.ts create mode 100644 packages/components/src/components/EmptyState/components/EmptyStateContent/types.ts create mode 100644 packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.module.css create mode 100644 packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx create mode 100644 packages/components/src/components/EmptyState/components/EmptyStateMedia/index.ts create mode 100644 packages/components/src/components/EmptyState/components/EmptyStateMedia/types.ts create mode 100644 packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css create mode 100644 packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.tsx create mode 100644 packages/components/src/components/EmptyState/components/EmptyStateTitle/index.ts create mode 100644 packages/components/src/components/EmptyState/components/EmptyStateTitle/types.ts create mode 100644 packages/components/src/components/EmptyState/components/index.ts create mode 100644 packages/components/src/components/EmptyState/index.ts create mode 100644 packages/components/src/components/EmptyState/types.ts diff --git a/packages/components/src/components/EmptyState/EmptyState.mdx b/packages/components/src/components/EmptyState/EmptyState.mdx new file mode 100644 index 000000000..d2eb43dbd --- /dev/null +++ b/packages/components/src/components/EmptyState/EmptyState.mdx @@ -0,0 +1,42 @@ +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'; +``` + +## Anatomy + +```tsx + + + + + + +``` + +## Usage + + + +## Props + + 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 000000000..3a41ba4e6 --- /dev/null +++ b/packages/components/src/components/EmptyState/EmptyState.module.css @@ -0,0 +1,20 @@ +.base { + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + inline-size: 100%; + text-align: center; +} + +.base[data-size='big'] { + max-inline-size: 480px; + padding-block: var(--kbq-size-5xl); + padding-inline: var(--kbq-size-6xl); +} + +.base[data-size='normal'], +.base[data-size='compact'] { + max-inline-size: 320px; + 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 000000000..80ecb41e9 --- /dev/null +++ b/packages/components/src/components/EmptyState/EmptyState.stories.tsx @@ -0,0 +1,46 @@ +import { IconBoxOpen24 } from '@koobiq/react-icons'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { Button } from '../Button'; + +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-25'], + parameters: { + layout: 'centered', + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Base: Story = { + args: { + size: 'normal', + state: 'default', + }, + render: (args) => ( + + + + + No data + + There is nothing here yet. Create your first item to get started. + + + + + + ), +}; 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 000000000..ba99b9e9f --- /dev/null +++ b/packages/components/src/components/EmptyState/EmptyState.test.tsx @@ -0,0 +1,62 @@ +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 and "default" state', () => { + render(); + + const root = screen.getByTestId('empty-state'); + + expect(root).toHaveAttribute('data-size', 'normal'); + expect(root).toHaveAttribute('data-state', 'default'); + }); + + it('should propagate size and state to the subcomponents via context', () => { + render( + + Title + + ); + + const title = screen.getByTestId('title'); + + expect(title).toHaveAttribute('data-size', 'big'); + expect(title).toHaveAttribute('data-state', 'error'); + }); +}); diff --git a/packages/components/src/components/EmptyState/EmptyState.tsx b/packages/components/src/components/EmptyState/EmptyState.tsx new file mode 100644 index 000000000..a1bb01215 --- /dev/null +++ b/packages/components/src/components/EmptyState/EmptyState.tsx @@ -0,0 +1,63 @@ +'use client'; + +import type { ComponentPropsWithRef } from 'react'; +import { forwardRef, useMemo } from 'react'; + +import { clsx, mergeProps } 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 = forwardRef( + (props, ref) => { + const { + size = 'normal', + state = 'default', + className, + children, + ...other + } = props; + + const contextValue = useMemo(() => ({ size, state }), [size, state]); + + const rootProps = mergeProps[]>( + { className: clsx(s.base, className) }, + other + ); + + 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 000000000..c10330a19 --- /dev/null +++ b/packages/components/src/components/EmptyState/EmptyStateContext.ts @@ -0,0 +1,15 @@ +'use client'; + +import { createContext } from 'react'; + +import type { EmptyStatePropSize, EmptyStatePropState } from './types'; + +export type EmptyStateContextValue = { + size: EmptyStatePropSize; + state: EmptyStatePropState; +}; + +export const EmptyStateContext = createContext({ + size: 'normal', + state: 'default', +}); 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 000000000..5279f694f --- /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); +} + +.base[data-size='big'] { + margin-block-start: var(--kbq-size-xl); +} + +.base[data-size='normal'], +.base[data-size='compact'] { + margin-block-start: var(--kbq-size-s); +} 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 000000000..a8ddb18c9 --- /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, 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 000000000..cc529b6f3 --- /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 000000000..fb606a39c --- /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 000000000..7bdad54da --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css @@ -0,0 +1,22 @@ +@import url('../../../../styles/mixins.css'); + +.base { + margin: 0; + color: var(--kbq-foreground-contrast-secondary); +} + +.base[data-state='error'] { + color: var(--kbq-foreground-error); +} + +.base[data-size='big'] { + @mixin typography text-big; +} + +.base[data-size='normal'] { + @mixin typography text-normal; +} + +.base[data-size='compact'] { + @mixin typography text-compact; +} 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 000000000..91381a5af --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.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 './EmptyStateContent.module.css'; +import type { EmptyStateContentProps } from './types'; + +/** EmptyState.Content — the supporting text slot of the EmptyState. */ +export const EmptyStateContent = forwardRef< + HTMLDivElement, + EmptyStateContentProps +>((props, ref) => { + const { className, children, ...other } = props; + + const { size, state } = useContext(EmptyStateContext); + + const rootProps = mergeProps[]>( + { className: clsx(s.base, className) }, + other + ); + + 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 000000000..65e412d39 --- /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 000000000..bf069c852 --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateContent/types.ts @@ -0,0 +1,6 @@ +import type { ComponentPropsWithRef } from 'react'; + +import type { DataAttributeProps } from '@koobiq/react-core'; + +export type EmptyStateContentProps = ComponentPropsWithRef<'div'> & + 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 000000000..4c9359d64 --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.module.css @@ -0,0 +1,17 @@ +.base { + display: flex; + align-items: center; + justify-content: center; +} + +.base[data-size='big'] { + margin-block-end: var(--kbq-size-3xl); +} + +.base[data-size='normal'] { + margin-block-end: var(--kbq-size-xl); +} + +.base[data-size='compact'] { + margin-block-end: var(--kbq-size-m); +} 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 000000000..a470307d9 --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx @@ -0,0 +1,33 @@ +'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 './EmptyStateMedia.module.css'; +import type { EmptyStateMediaProps } from './types'; + +/** EmptyState.Media — the illustration or icon slot of the EmptyState. */ +export const EmptyStateMedia = forwardRef( + (props, ref) => { + const { className, children, ...other } = props; + + const { size } = useContext(EmptyStateContext); + + const rootProps = mergeProps[]>( + { className: clsx(s.base, className) }, + other + ); + + 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 000000000..998d3ddda --- /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 000000000..9e98f9393 --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateMedia/types.ts @@ -0,0 +1,6 @@ +import type { ComponentPropsWithRef } from 'react'; + +import type { DataAttributeProps } from '@koobiq/react-core'; + +export type EmptyStateMediaProps = ComponentPropsWithRef<'div'> & + 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 000000000..82ec39ce1 --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css @@ -0,0 +1,28 @@ +@import url('../../../../styles/mixins.css'); + +.base { + margin: 0; + color: var(--kbq-foreground-contrast); +} + +.base[data-state='error'] { + color: var(--kbq-foreground-error); +} + +.base[data-size='big'] { + @mixin typography headline; + + margin-block-end: var(--kbq-size-l); +} + +.base[data-size='normal'] { + @mixin typography subheading; + + margin-block-end: var(--kbq-size-xxs); +} + +.base[data-size='compact'] { + @mixin typography text-normal; + + margin-block-end: var(--kbq-size-xxs); +} 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 000000000..f774d0f42 --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.tsx @@ -0,0 +1,33 @@ +'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 './EmptyStateTitle.module.css'; +import type { EmptyStateTitleProps } from './types'; + +/** EmptyState.Title — the heading slot of the EmptyState. */ +export const EmptyStateTitle = forwardRef( + (props, ref) => { + const { className, children, ...other } = props; + + const { size, state } = useContext(EmptyStateContext); + + const rootProps = mergeProps[]>( + { className: clsx(s.base, className) }, + other + ); + + 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 000000000..141836a3d --- /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 000000000..b25de2905 --- /dev/null +++ b/packages/components/src/components/EmptyState/components/EmptyStateTitle/types.ts @@ -0,0 +1,6 @@ +import type { ComponentPropsWithRef } from 'react'; + +import type { DataAttributeProps } from '@koobiq/react-core'; + +export type EmptyStateTitleProps = ComponentPropsWithRef<'div'> & + 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 000000000..0edbd298a --- /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 000000000..523c81443 --- /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 000000000..a462a6ca6 --- /dev/null +++ b/packages/components/src/components/EmptyState/types.ts @@ -0,0 +1,25 @@ +import type { ComponentPropsWithRef } 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 emptyStatePropState = ['default', 'error'] as const; + +export type EmptyStatePropState = (typeof emptyStatePropState)[number]; + +export type EmptyStateBaseProps = { + /** + * The size of the component. + * @default 'normal' + */ + size?: EmptyStatePropSize; + /** + * The visual state of the component. + * @default 'default' + */ + state?: EmptyStatePropState; +} & ComponentPropsWithRef<'div'> & + DataAttributeProps; diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index 58af13bd2..47a4a8632 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -49,6 +49,7 @@ export * from './ContentPanel'; export * from './Navbar'; export * from './ActionsPanel'; export * from './Tree'; +export * from './EmptyState'; export * from './layout'; export { useListData, From e561f70ece08cf0c4c7538c4cb0600fcc6de5ff5 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 25 Jun 2026 16:25:58 +0300 Subject: [PATCH 02/14] feat(EmptyState): base API + stories --- .../src/components/EmptyState/EmptyState.mdx | 35 +++++++ .../EmptyState/EmptyState.module.css | 13 ++- .../EmptyState/EmptyState.stories.tsx | 96 +++++++++++++++++-- .../components/EmptyState/EmptyState.test.tsx | 20 +++- .../src/components/EmptyState/EmptyState.tsx | 16 +++- .../EmptyState/EmptyStateContext.ts | 8 +- .../EmptyStateActions.module.css | 2 + .../EmptyStateContent.module.css | 8 +- .../EmptyStateContent/EmptyStateContent.tsx | 9 +- .../EmptyStateMedia.module.css | 4 + .../EmptyStateMedia/EmptyStateMedia.tsx | 10 +- .../EmptyStateTitle.module.css | 5 +- .../EmptyStateTitle/EmptyStateTitle.tsx | 9 +- .../src/components/EmptyState/types.ts | 15 ++- 14 files changed, 216 insertions(+), 34 deletions(-) diff --git a/packages/components/src/components/EmptyState/EmptyState.mdx b/packages/components/src/components/EmptyState/EmptyState.mdx index d2eb43dbd..2417d9a36 100644 --- a/packages/components/src/components/EmptyState/EmptyState.mdx +++ b/packages/components/src/components/EmptyState/EmptyState.mdx @@ -24,6 +24,8 @@ import { EmptyState } from '@koobiq/react-components'; ## Anatomy +The component is composed of four optional slots. Render only the ones you need. + ```tsx @@ -33,6 +35,11 @@ import { EmptyState } from '@koobiq/react-components'; ``` +- `EmptyState.Media` — an illustration or icon. +- `EmptyState.Title` — the heading. +- `EmptyState.Content` — the supporting text. +- `EmptyState.Actions` — buttons, links or pseudo-links. + ## Usage @@ -40,3 +47,31 @@ import { EmptyState } from '@koobiq/react-components'; ## Props + +## Sizes + +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. + + + +## Error state + +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. + + + +## Text only + +Every slot is optional. Drop the media and actions for a minimal, text-only +empty state. + + diff --git a/packages/components/src/components/EmptyState/EmptyState.module.css b/packages/components/src/components/EmptyState/EmptyState.module.css index 3a41ba4e6..c8e4ccee8 100644 --- a/packages/components/src/components/EmptyState/EmptyState.module.css +++ b/packages/components/src/components/EmptyState/EmptyState.module.css @@ -1,20 +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 */ +.base[data-align='center'] { + justify-content: center; +} + +.base[data-align='start'] { + justify-content: flex-start; +} + +/* size */ .base[data-size='big'] { - max-inline-size: 480px; padding-block: var(--kbq-size-5xl); padding-inline: var(--kbq-size-6xl); } .base[data-size='normal'], .base[data-size='compact'] { - max-inline-size: 320px; 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 index 80ecb41e9..187307ce3 100644 --- a/packages/components/src/components/EmptyState/EmptyState.stories.tsx +++ b/packages/components/src/components/EmptyState/EmptyState.stories.tsx @@ -1,7 +1,9 @@ -import { IconBoxOpen24 } from '@koobiq/react-icons'; +import { IconFileDocO48 } from '@koobiq/react-icons'; import type { Meta, StoryObj } from '@storybook/react'; import { Button } from '../Button'; +import { FlexBox } from '../FlexBox'; +import { Link } from '../Link'; import { EmptyState } from './index'; @@ -25,22 +27,100 @@ export default meta; type Story = StoryObj; export const Base: Story = { - args: { - size: 'normal', - state: 'default', - }, render: (args) => ( - + - No data + No documents yet - There is nothing here yet. Create your first item to get started. + 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 Error: 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 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 index ba99b9e9f..eab7ac6d8 100644 --- a/packages/components/src/components/EmptyState/EmptyState.test.tsx +++ b/packages/components/src/components/EmptyState/EmptyState.test.tsx @@ -38,18 +38,28 @@ describe('EmptyState', () => { expect(screen.getByTestId('actions')).toHaveTextContent('Actions'); }); - it('should default to the "normal" size and "default" state', () => { + 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-state', 'default'); + expect(root).toHaveAttribute('data-align', 'center'); + expect(root).not.toHaveAttribute('data-invalid'); }); - it('should propagate size and state to the subcomponents via context', () => { + it('should reflect the align prop', () => { + render(); + + expect(screen.getByTestId('empty-state')).toHaveAttribute( + 'data-align', + 'start' + ); + }); + + it('should propagate size and invalid state to the subcomponents via context', () => { render( - + Title ); @@ -57,6 +67,6 @@ describe('EmptyState', () => { const title = screen.getByTestId('title'); expect(title).toHaveAttribute('data-size', 'big'); - expect(title).toHaveAttribute('data-state', 'error'); + expect(title).toHaveAttribute('data-invalid'); }); }); diff --git a/packages/components/src/components/EmptyState/EmptyState.tsx b/packages/components/src/components/EmptyState/EmptyState.tsx index a1bb01215..613066093 100644 --- a/packages/components/src/components/EmptyState/EmptyState.tsx +++ b/packages/components/src/components/EmptyState/EmptyState.tsx @@ -19,13 +19,17 @@ const EmptyStateComponent = forwardRef( (props, ref) => { const { size = 'normal', - state = 'default', + isInvalid = false, + align = 'center', className, children, ...other } = props; - const contextValue = useMemo(() => ({ size, state }), [size, state]); + const contextValue = useMemo( + () => ({ size, isInvalid, align }), + [size, isInvalid, align] + ); const rootProps = mergeProps[]>( { className: clsx(s.base, className) }, @@ -34,7 +38,13 @@ const EmptyStateComponent = forwardRef( return ( -
+
{children}
diff --git a/packages/components/src/components/EmptyState/EmptyStateContext.ts b/packages/components/src/components/EmptyState/EmptyStateContext.ts index c10330a19..8e313a7d6 100644 --- a/packages/components/src/components/EmptyState/EmptyStateContext.ts +++ b/packages/components/src/components/EmptyState/EmptyStateContext.ts @@ -2,14 +2,16 @@ import { createContext } from 'react'; -import type { EmptyStatePropSize, EmptyStatePropState } from './types'; +import type { EmptyStatePropAlign, EmptyStatePropSize } from './types'; export type EmptyStateContextValue = { size: EmptyStatePropSize; - state: EmptyStatePropState; + isInvalid: boolean; + align: EmptyStatePropAlign; }; export const EmptyStateContext = createContext({ size: 'normal', - state: 'default', + 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 index 5279f694f..37b0898a0 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.module.css +++ b/packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.module.css @@ -7,10 +7,12 @@ } .base[data-size='big'] { + max-inline-size: 480px; margin-block-start: var(--kbq-size-xl); } .base[data-size='normal'], .base[data-size='compact'] { + max-inline-size: 320px; margin-block-start: var(--kbq-size-s); } diff --git a/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css index 7bdad54da..2d607f806 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css +++ b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css @@ -5,18 +5,24 @@ color: var(--kbq-foreground-contrast-secondary); } -.base[data-state='error'] { +.base[data-invalid] { color: var(--kbq-foreground-error); } .base[data-size='big'] { @mixin typography text-big; + + max-inline-size: 480px; } .base[data-size='normal'] { @mixin typography text-normal; + + max-inline-size: 320px; } .base[data-size='compact'] { @mixin typography text-compact; + + max-inline-size: 320px; } diff --git a/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.tsx b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.tsx index 91381a5af..44794ce48 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.tsx +++ b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.tsx @@ -17,7 +17,7 @@ export const EmptyStateContent = forwardRef< >((props, ref) => { const { className, children, ...other } = props; - const { size, state } = useContext(EmptyStateContext); + const { size, isInvalid } = useContext(EmptyStateContext); const rootProps = mergeProps[]>( { className: clsx(s.base, className) }, @@ -25,7 +25,12 @@ export const EmptyStateContent = forwardRef< ); return ( -
+
{children}
); diff --git a/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.module.css b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.module.css index 4c9359d64..bd249fc53 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.module.css +++ b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.module.css @@ -4,6 +4,10 @@ justify-content: center; } +.base[data-invalid] { + color: var(--kbq-foreground-error); +} + .base[data-size='big'] { margin-block-end: var(--kbq-size-3xl); } diff --git a/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx index a470307d9..9abc275b0 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx +++ b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx @@ -15,7 +15,7 @@ export const EmptyStateMedia = forwardRef( (props, ref) => { const { className, children, ...other } = props; - const { size } = useContext(EmptyStateContext); + const { size, align, isInvalid } = useContext(EmptyStateContext); const rootProps = mergeProps[]>( { className: clsx(s.base, className) }, @@ -23,7 +23,13 @@ export const EmptyStateMedia = forwardRef( ); return ( -
+
{children}
); diff --git a/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css index 82ec39ce1..8b8d1654d 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css +++ b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css @@ -5,24 +5,27 @@ color: var(--kbq-foreground-contrast); } -.base[data-state='error'] { +.base[data-invalid] { color: var(--kbq-foreground-error); } .base[data-size='big'] { @mixin typography headline; + max-inline-size: 480px; margin-block-end: var(--kbq-size-l); } .base[data-size='normal'] { @mixin typography subheading; + max-inline-size: 320px; margin-block-end: var(--kbq-size-xxs); } .base[data-size='compact'] { @mixin typography text-normal; + max-inline-size: 320px; margin-block-end: var(--kbq-size-xxs); } diff --git a/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.tsx b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.tsx index f774d0f42..be364fcdc 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.tsx +++ b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.tsx @@ -15,7 +15,7 @@ export const EmptyStateTitle = forwardRef( (props, ref) => { const { className, children, ...other } = props; - const { size, state } = useContext(EmptyStateContext); + const { size, isInvalid } = useContext(EmptyStateContext); const rootProps = mergeProps[]>( { className: clsx(s.base, className) }, @@ -23,7 +23,12 @@ export const EmptyStateTitle = forwardRef( ); return ( -
+
{children}
); diff --git a/packages/components/src/components/EmptyState/types.ts b/packages/components/src/components/EmptyState/types.ts index a462a6ca6..ab6778c33 100644 --- a/packages/components/src/components/EmptyState/types.ts +++ b/packages/components/src/components/EmptyState/types.ts @@ -6,9 +6,9 @@ export const emptyStatePropSize = ['big', 'normal', 'compact'] as const; export type EmptyStatePropSize = (typeof emptyStatePropSize)[number]; -export const emptyStatePropState = ['default', 'error'] as const; +export const emptyStatePropAlign = ['start', 'center'] as const; -export type EmptyStatePropState = (typeof emptyStatePropState)[number]; +export type EmptyStatePropAlign = (typeof emptyStatePropAlign)[number]; export type EmptyStateBaseProps = { /** @@ -17,9 +17,14 @@ export type EmptyStateBaseProps = { */ size?: EmptyStatePropSize; /** - * The visual state of the component. - * @default 'default' + * Whether the EmptyState represents an invalid (error) state. + * Paints the title, text and media with the error color. */ - state?: EmptyStatePropState; + isInvalid?: boolean; + /** + * The block alignment of the content within the available space. + * @default 'center' + */ + align?: EmptyStatePropAlign; } & ComponentPropsWithRef<'div'> & DataAttributeProps; From 271e14ab36ea3012eec945268ba8954a96b2a430 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 26 Jun 2026 08:45:18 +0300 Subject: [PATCH 03/14] docs(EmptyState): improve stories --- .../src/components/EmptyState/EmptyState.mdx | 24 +++++++++---------- .../EmptyState/EmptyState.stories.tsx | 21 +++++++++++----- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/packages/components/src/components/EmptyState/EmptyState.mdx b/packages/components/src/components/EmptyState/EmptyState.mdx index 2417d9a36..879c2b8c2 100644 --- a/packages/components/src/components/EmptyState/EmptyState.mdx +++ b/packages/components/src/components/EmptyState/EmptyState.mdx @@ -11,7 +11,7 @@ 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. @@ -22,6 +22,14 @@ suggests the next action the user can take. import { EmptyState } from '@koobiq/react-components'; ``` +## Usage + + + +## Props + + + ## Anatomy The component is composed of four optional slots. Render only the ones you need. @@ -40,27 +48,19 @@ The component is composed of four optional slots. Render only the ones you need. - `EmptyState.Content` — the supporting text. - `EmptyState.Actions` — buttons, links or pseudo-links. -## Usage - - - -## Props - - - -## Sizes +## 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. -## Error state +## Invalid Set the `isInvalid` prop to paint the title, text and media with the error color — for example, when data failed to load. - + ## Alignment diff --git a/packages/components/src/components/EmptyState/EmptyState.stories.tsx b/packages/components/src/components/EmptyState/EmptyState.stories.tsx index 187307ce3..d61903c7c 100644 --- a/packages/components/src/components/EmptyState/EmptyState.stories.tsx +++ b/packages/components/src/components/EmptyState/EmptyState.stories.tsx @@ -1,8 +1,9 @@ -import { IconFileDocO48 } from '@koobiq/react-icons'; +import { IconBell16, IconTriangleExclamation16 } from '@koobiq/react-icons'; import type { Meta, StoryObj } from '@storybook/react'; import { Button } from '../Button'; import { FlexBox } from '../FlexBox'; +import { IconItem } from '../IconItem'; import { Link } from '../Link'; import { EmptyState } from './index'; @@ -30,7 +31,9 @@ export const Base: Story = { render: (args) => ( - + + + No documents yet @@ -56,7 +59,9 @@ export const Sizes: Story = { {(['big', 'normal', 'compact'] as const).map((size) => ( - + + + No documents yet @@ -71,11 +76,13 @@ export const Sizes: Story = { ), }; -export const Error: Story = { +export const Invalid: Story = { render: (args) => ( - + + + Failed to load documents @@ -99,7 +106,9 @@ export const Align: Story = { > - + + + No documents yet From 28a6b397e3f7cb567273086ab709713ddad193b9 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 26 Jun 2026 10:13:03 +0300 Subject: [PATCH 04/14] feat(EmptyState): polymorphic Title/Content --- .../EmptyState/EmptyState.stories.tsx | 29 +++++------ .../components/EmptyState/EmptyState.test.tsx | 28 +++++++++++ .../EmptyStateContent/EmptyStateContent.tsx | 23 ++++----- .../components/EmptyStateContent/types.ts | 15 ++++-- .../EmptyStateTitle/EmptyStateTitle.tsx | 48 +++++++++---------- .../components/EmptyStateTitle/types.ts | 15 ++++-- 6 files changed, 94 insertions(+), 64 deletions(-) diff --git a/packages/components/src/components/EmptyState/EmptyState.stories.tsx b/packages/components/src/components/EmptyState/EmptyState.stories.tsx index d61903c7c..547e20eeb 100644 --- a/packages/components/src/components/EmptyState/EmptyState.stories.tsx +++ b/packages/components/src/components/EmptyState/EmptyState.stories.tsx @@ -98,24 +98,17 @@ export const Invalid: Story = { export const Align: Story = { parameters: { layout: 'padded' }, render: (args) => ( - - - - - - - - No documents yet - - The content is aligned to the start of the available space. - - - + + + + + + + No documents yet + + The content is aligned to the start of the available space. + + ), }; diff --git a/packages/components/src/components/EmptyState/EmptyState.test.tsx b/packages/components/src/components/EmptyState/EmptyState.test.tsx index eab7ac6d8..6310b8629 100644 --- a/packages/components/src/components/EmptyState/EmptyState.test.tsx +++ b/packages/components/src/components/EmptyState/EmptyState.test.tsx @@ -69,4 +69,32 @@ describe('EmptyState', () => { 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'); + }); }); diff --git a/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.tsx b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.tsx index 44794ce48..2087be6f9 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.tsx +++ b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.tsx @@ -1,9 +1,8 @@ 'use client'; -import type { ComponentPropsWithRef } from 'react'; -import { forwardRef, useContext } from 'react'; +import { useContext } from 'react'; -import { clsx, mergeProps } from '@koobiq/react-core'; +import { clsx, polymorphicForwardRef } from '@koobiq/react-core'; import { EmptyStateContext } from '../../EmptyStateContext'; @@ -11,28 +10,24 @@ import s from './EmptyStateContent.module.css'; import type { EmptyStateContentProps } from './types'; /** EmptyState.Content — the supporting text slot of the EmptyState. */ -export const EmptyStateContent = forwardRef< - HTMLDivElement, +export const EmptyStateContent = polymorphicForwardRef< + 'p', EmptyStateContentProps >((props, ref) => { - const { className, children, ...other } = props; + const { as: Tag = 'p', className, children, ...other } = props; const { size, isInvalid } = useContext(EmptyStateContext); - const rootProps = mergeProps[]>( - { className: clsx(s.base, className) }, - other - ); - return ( -
{children} -
+ ); }); diff --git a/packages/components/src/components/EmptyState/components/EmptyStateContent/types.ts b/packages/components/src/components/EmptyState/components/EmptyStateContent/types.ts index bf069c852..0cedbf13d 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateContent/types.ts +++ b/packages/components/src/components/EmptyState/components/EmptyStateContent/types.ts @@ -1,6 +1,15 @@ -import type { ComponentPropsWithRef } from 'react'; +import type { ElementType, ReactNode } from 'react'; import type { DataAttributeProps } from '@koobiq/react-core'; -export type EmptyStateContentProps = ComponentPropsWithRef<'div'> & - DataAttributeProps; +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/EmptyStateTitle/EmptyStateTitle.tsx b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.tsx index be364fcdc..98eae81d6 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.tsx +++ b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.tsx @@ -1,9 +1,8 @@ 'use client'; -import type { ComponentPropsWithRef } from 'react'; -import { forwardRef, useContext } from 'react'; +import { useContext } from 'react'; -import { clsx, mergeProps } from '@koobiq/react-core'; +import { clsx, polymorphicForwardRef } from '@koobiq/react-core'; import { EmptyStateContext } from '../../EmptyStateContext'; @@ -11,28 +10,25 @@ import s from './EmptyStateTitle.module.css'; import type { EmptyStateTitleProps } from './types'; /** EmptyState.Title — the heading slot of the EmptyState. */ -export const EmptyStateTitle = forwardRef( - (props, ref) => { - const { className, children, ...other } = props; - - const { size, isInvalid } = useContext(EmptyStateContext); - - const rootProps = mergeProps[]>( - { className: clsx(s.base, className) }, - other - ); - - return ( -
- {children} -
- ); - } -); +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/types.ts b/packages/components/src/components/EmptyState/components/EmptyStateTitle/types.ts index b25de2905..f27d4090a 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateTitle/types.ts +++ b/packages/components/src/components/EmptyState/components/EmptyStateTitle/types.ts @@ -1,6 +1,15 @@ -import type { ComponentPropsWithRef } from 'react'; +import type { ElementType, ReactNode } from 'react'; import type { DataAttributeProps } from '@koobiq/react-core'; -export type EmptyStateTitleProps = ComponentPropsWithRef<'div'> & - DataAttributeProps; +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; From b15ed30791a5b0d6aed6d6d26c039813c16c3022 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 26 Jun 2026 10:55:44 +0300 Subject: [PATCH 05/14] feat(EmptyState): illustration example via @koobiq/visuals --- package.json | 5 ++- .../src/components/EmptyState/EmptyState.mdx | 9 ++++ .../EmptyState/EmptyState.stories.tsx | 35 ++++++++++++++++ pnpm-lock.yaml | 41 ++++++++++++------- 4 files changed, 73 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 4331c1058..d538562f7 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 index 879c2b8c2..378409d06 100644 --- a/packages/components/src/components/EmptyState/EmptyState.mdx +++ b/packages/components/src/components/EmptyState/EmptyState.mdx @@ -69,6 +69,15 @@ 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 diff --git a/packages/components/src/components/EmptyState/EmptyState.stories.tsx b/packages/components/src/components/EmptyState/EmptyState.stories.tsx index 547e20eeb..8fbcac330 100644 --- a/packages/components/src/components/EmptyState/EmptyState.stories.tsx +++ b/packages/components/src/components/EmptyState/EmptyState.stories.tsx @@ -1,5 +1,10 @@ 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'; @@ -112,6 +117,36 @@ export const Align: Story = { ), }; +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, or import an existing one. + + + + + + ); + }, +}; + export const TextOnly: Story = { render: (args) => ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06c053b3c..277a7df2e 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)) @@ -530,7 +533,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 @@ -1828,6 +1831,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: @@ -10160,6 +10166,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 @@ -11944,10 +11952,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) @@ -12193,7 +12201,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: @@ -13828,13 +13836,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)) @@ -13868,7 +13876,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 @@ -13879,21 +13887,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 @@ -13904,7 +13913,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 @@ -13915,6 +13924,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 @@ -18346,7 +18357,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) From 003437a50ba3481c2b910b3e3debbf003428a4d4 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 26 Jun 2026 11:02:20 +0300 Subject: [PATCH 06/14] test(EmptyState): cover subcomponents --- .../components/EmptyState/EmptyState.test.tsx | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/packages/components/src/components/EmptyState/EmptyState.test.tsx b/packages/components/src/components/EmptyState/EmptyState.test.tsx index 6310b8629..251ff4a66 100644 --- a/packages/components/src/components/EmptyState/EmptyState.test.tsx +++ b/packages/components/src/components/EmptyState/EmptyState.test.tsx @@ -98,3 +98,89 @@ describe('EmptyState', () => { 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'); + }); +}); From 0842dfa43fdbf312225f6eab386dac73a98c4e35 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 26 Jun 2026 11:14:49 +0300 Subject: [PATCH 07/14] docs(EmptyState): add accessibility guidance --- .../src/components/EmptyState/EmptyState.mdx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/components/src/components/EmptyState/EmptyState.mdx b/packages/components/src/components/EmptyState/EmptyState.mdx index 378409d06..5823cdf15 100644 --- a/packages/components/src/components/EmptyState/EmptyState.mdx +++ b/packages/components/src/components/EmptyState/EmptyState.mdx @@ -84,3 +84,18 @@ 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. From 0a9dfd800cdd6dd7890bc62064fa630487c75885 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 26 Jun 2026 11:18:00 +0300 Subject: [PATCH 08/14] feat(EmptyState): polymorphic root --- .../components/EmptyState/EmptyState.test.tsx | 6 ++++++ .../src/components/EmptyState/EmptyState.tsx | 20 ++++++++----------- .../src/components/EmptyState/types.ts | 14 ++++++++++--- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/components/src/components/EmptyState/EmptyState.test.tsx b/packages/components/src/components/EmptyState/EmptyState.test.tsx index 251ff4a66..31c5c7bce 100644 --- a/packages/components/src/components/EmptyState/EmptyState.test.tsx +++ b/packages/components/src/components/EmptyState/EmptyState.test.tsx @@ -57,6 +57,12 @@ describe('EmptyState', () => { ); }); + 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( diff --git a/packages/components/src/components/EmptyState/EmptyState.tsx b/packages/components/src/components/EmptyState/EmptyState.tsx index 613066093..8e2af773b 100644 --- a/packages/components/src/components/EmptyState/EmptyState.tsx +++ b/packages/components/src/components/EmptyState/EmptyState.tsx @@ -1,9 +1,8 @@ 'use client'; -import type { ComponentPropsWithRef } from 'react'; -import { forwardRef, useMemo } from 'react'; +import { useMemo } from 'react'; -import { clsx, mergeProps } from '@koobiq/react-core'; +import { clsx, polymorphicForwardRef } from '@koobiq/react-core'; import { EmptyStateActions, @@ -15,9 +14,10 @@ import s from './EmptyState.module.css'; import { EmptyStateContext } from './EmptyStateContext'; import type { EmptyStateBaseProps } from './types'; -const EmptyStateComponent = forwardRef( +const EmptyStateComponent = polymorphicForwardRef<'div', EmptyStateBaseProps>( (props, ref) => { const { + as: Tag = 'div', size = 'normal', isInvalid = false, align = 'center', @@ -31,22 +31,18 @@ const EmptyStateComponent = forwardRef( [size, isInvalid, align] ); - const rootProps = mergeProps[]>( - { className: clsx(s.base, className) }, - other - ); - return ( -
{children} -
+
); } diff --git a/packages/components/src/components/EmptyState/types.ts b/packages/components/src/components/EmptyState/types.ts index ab6778c33..99f55f774 100644 --- a/packages/components/src/components/EmptyState/types.ts +++ b/packages/components/src/components/EmptyState/types.ts @@ -1,4 +1,4 @@ -import type { ComponentPropsWithRef } from 'react'; +import type { ElementType, ReactNode } from 'react'; import type { DataAttributeProps } from '@koobiq/react-core'; @@ -26,5 +26,13 @@ export type EmptyStateBaseProps = { * @default 'center' */ align?: EmptyStatePropAlign; -} & ComponentPropsWithRef<'div'> & - DataAttributeProps; + /** + * The HTML element to render as. + * @default 'div' + */ + as?: ElementType; + /** Additional CSS-classes. */ + className?: string; + /** The content of the component. */ + children?: ReactNode; +} & DataAttributeProps; From 152ae76ceaab346e1493d40f4b7796e6c8ed1731 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 26 Jun 2026 12:38:33 +0300 Subject: [PATCH 09/14] docs(EmptyState): improve stories --- .../components/EmptyState/EmptyState.stories.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/components/src/components/EmptyState/EmptyState.stories.tsx b/packages/components/src/components/EmptyState/EmptyState.stories.tsx index 8fbcac330..226133f6e 100644 --- a/packages/components/src/components/EmptyState/EmptyState.stories.tsx +++ b/packages/components/src/components/EmptyState/EmptyState.stories.tsx @@ -10,6 +10,7 @@ import { Button } from '../Button'; import { FlexBox } from '../FlexBox'; import { IconItem } from '../IconItem'; import { Link } from '../Link'; +import { Typography } from '../Typography'; import { EmptyState } from './index'; @@ -136,8 +137,10 @@ export const WithIllustration: Story = { /> No documents yet - - Create your first document to get started, or import an existing one. + + { + 'Create your first document to get started,\nor import an existing one.' + } @@ -151,8 +154,10 @@ export const TextOnly: Story = { render: (args) => ( Nothing found - - Try changing the search query or{' '} + + + Try changing the search query or + reset the filters From 6da09e0ba703ee41eb2daaa9d53f8bb12dd374ac Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 26 Jun 2026 12:50:51 +0300 Subject: [PATCH 10/14] refactor(EmptyState): flat css specificity --- .../EmptyState/EmptyState.module.css | 10 +++++----- .../src/components/EmptyState/EmptyState.tsx | 2 +- .../EmptyStateActions.module.css | 14 ++++++------- .../EmptyStateActions/EmptyStateActions.tsx | 2 +- .../EmptyStateContent.module.css | 16 +++++++-------- .../EmptyStateContent/EmptyStateContent.tsx | 2 +- .../EmptyStateMedia.module.css | 12 +++++------ .../EmptyStateMedia/EmptyStateMedia.tsx | 2 +- .../EmptyStateTitle.module.css | 20 +++++++++---------- .../EmptyStateTitle/EmptyStateTitle.tsx | 2 +- 10 files changed, 41 insertions(+), 41 deletions(-) diff --git a/packages/components/src/components/EmptyState/EmptyState.module.css b/packages/components/src/components/EmptyState/EmptyState.module.css index c8e4ccee8..e5c813420 100644 --- a/packages/components/src/components/EmptyState/EmptyState.module.css +++ b/packages/components/src/components/EmptyState/EmptyState.module.css @@ -9,21 +9,21 @@ } /* align */ -.base[data-align='center'] { +.center { justify-content: center; } -.base[data-align='start'] { +.start { justify-content: flex-start; } /* size */ -.base[data-size='big'] { +.big { padding-block: var(--kbq-size-5xl); padding-inline: var(--kbq-size-6xl); } -.base[data-size='normal'], -.base[data-size='compact'] { +.normal, +.compact { padding: var(--kbq-size-3xl); } diff --git a/packages/components/src/components/EmptyState/EmptyState.tsx b/packages/components/src/components/EmptyState/EmptyState.tsx index 8e2af773b..ef2dbd98f 100644 --- a/packages/components/src/components/EmptyState/EmptyState.tsx +++ b/packages/components/src/components/EmptyState/EmptyState.tsx @@ -36,7 +36,7 @@ const EmptyStateComponent = polymorphicForwardRef<'div', EmptyStateBaseProps>( []>( - { className: clsx(s.base, className) }, + { className: clsx(s.base, s[size], className) }, other ); diff --git a/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css index 2d607f806..a32a51d62 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css +++ b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css @@ -5,24 +5,24 @@ color: var(--kbq-foreground-contrast-secondary); } -.base[data-invalid] { +.invalid { color: var(--kbq-foreground-error); } -.base[data-size='big'] { - @mixin typography text-big; +.compact { + @mixin typography text-compact; - max-inline-size: 480px; + max-inline-size: 320px; } -.base[data-size='normal'] { +.normal { @mixin typography text-normal; max-inline-size: 320px; } -.base[data-size='compact'] { - @mixin typography text-compact; +.big { + @mixin typography text-big; - max-inline-size: 320px; + max-inline-size: 480px; } diff --git a/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.tsx b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.tsx index 2087be6f9..9058c307a 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.tsx +++ b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.tsx @@ -22,7 +22,7 @@ export const EmptyStateContent = polymorphicForwardRef< diff --git a/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.module.css b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.module.css index bd249fc53..8c99be986 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.module.css +++ b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.module.css @@ -4,18 +4,18 @@ justify-content: center; } -.base[data-invalid] { +.invalid { color: var(--kbq-foreground-error); } -.base[data-size='big'] { - margin-block-end: var(--kbq-size-3xl); +.compact { + margin-block-end: var(--kbq-size-m); } -.base[data-size='normal'] { +.normal { margin-block-end: var(--kbq-size-xl); } -.base[data-size='compact'] { - margin-block-end: var(--kbq-size-m); +.big { + margin-block-end: var(--kbq-size-3xl); } diff --git a/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx index 9abc275b0..4d701d918 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx +++ b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx @@ -18,7 +18,7 @@ export const EmptyStateMedia = forwardRef( const { size, align, isInvalid } = useContext(EmptyStateContext); const rootProps = mergeProps[]>( - { className: clsx(s.base, className) }, + { className: clsx(s.base, s[size], isInvalid && s.invalid, className) }, other ); diff --git a/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css index 8b8d1654d..a25424b58 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css +++ b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css @@ -5,27 +5,27 @@ color: var(--kbq-foreground-contrast); } -.base[data-invalid] { +.invalid { color: var(--kbq-foreground-error); } -.base[data-size='big'] { - @mixin typography headline; +.compact { + @mixin typography text-normal; - max-inline-size: 480px; - margin-block-end: var(--kbq-size-l); + max-inline-size: 320px; + margin-block-end: var(--kbq-size-xxs); } -.base[data-size='normal'] { +.normal { @mixin typography subheading; max-inline-size: 320px; margin-block-end: var(--kbq-size-xxs); } -.base[data-size='compact'] { - @mixin typography text-normal; +.big { + @mixin typography headline; - max-inline-size: 320px; - margin-block-end: var(--kbq-size-xxs); + max-inline-size: 480px; + margin-block-end: var(--kbq-size-l); } diff --git a/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.tsx b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.tsx index 98eae81d6..94291d6ab 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.tsx +++ b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.tsx @@ -22,7 +22,7 @@ export const EmptyStateTitle = polymorphicForwardRef< From fd36875a75c919e442eda764dcc00d7931daa5ab Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 26 Jun 2026 13:07:27 +0300 Subject: [PATCH 11/14] refactor(EmptyState): composition-proof spacing via :last-child --- .../EmptyStateActions/EmptyStateActions.module.css | 2 -- .../EmptyStateContent/EmptyStateContent.module.css | 7 +++++++ .../EmptyStateMedia/EmptyStateMedia.module.css | 12 ++++++++---- .../EmptyStateTitle/EmptyStateTitle.module.css | 4 ++++ 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.module.css b/packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.module.css index 388c01074..2b8be205e 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.module.css +++ b/packages/components/src/components/EmptyState/components/EmptyStateActions/EmptyStateActions.module.css @@ -9,10 +9,8 @@ .compact, .normal { max-inline-size: 320px; - margin-block-start: var(--kbq-size-s); } .big { max-inline-size: 480px; - margin-block-start: var(--kbq-size-xl); } diff --git a/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css index a32a51d62..fad025262 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css +++ b/packages/components/src/components/EmptyState/components/EmptyStateContent/EmptyStateContent.module.css @@ -13,16 +13,23 @@ @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/EmptyStateMedia/EmptyStateMedia.module.css b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.module.css index 8c99be986..0b8e4624f 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.module.css +++ b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.module.css @@ -8,14 +8,18 @@ color: var(--kbq-foreground-error); } -.compact { - margin-block-end: var(--kbq-size-m); +.big { + margin-block-end: var(--kbq-size-3xl); } .normal { margin-block-end: var(--kbq-size-xl); } -.big { - margin-block-end: var(--kbq-size-3xl); +.compact { + margin-block-end: var(--kbq-size-m); +} + +.base:last-child { + margin-block-end: 0; } diff --git a/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css index a25424b58..f6a3b971b 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css +++ b/packages/components/src/components/EmptyState/components/EmptyStateTitle/EmptyStateTitle.module.css @@ -29,3 +29,7 @@ max-inline-size: 480px; margin-block-end: var(--kbq-size-l); } + +.base:last-child { + margin-block-end: 0; +} From 667ac6f6f3e754837deaedddeb64972033dd5a83 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 26 Jun 2026 13:10:30 +0300 Subject: [PATCH 12/14] docs: update roadmap'26 --- .storybook/components/Roadmap/data.ts | 6 ++++++ .../src/components/EmptyState/EmptyState.stories.tsx | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts index 0f4d2f420..d89e129cd 100644 --- a/.storybook/components/Roadmap/data.ts +++ b/.storybook/components/Roadmap/data.ts @@ -332,4 +332,10 @@ export const rows: Rows = [ stage: '🔵 experimental', planned: 'Q2 2026', }, + { + component: 'EmptyState', + status: '✅ Done', + stage: '🔵 experimental', + planned: 'Q2 2026', + }, ]; diff --git a/packages/components/src/components/EmptyState/EmptyState.stories.tsx b/packages/components/src/components/EmptyState/EmptyState.stories.tsx index 226133f6e..1de3a6cd0 100644 --- a/packages/components/src/components/EmptyState/EmptyState.stories.tsx +++ b/packages/components/src/components/EmptyState/EmptyState.stories.tsx @@ -23,7 +23,7 @@ const meta = { 'EmptyState.Content': EmptyState.Content, 'EmptyState.Actions': EmptyState.Actions, }, - tags: ['status:new', 'date:2026-06-25'], + tags: ['status:new', 'date:2026-06-26'], parameters: { layout: 'centered', }, From 9ddeaaa393c4d3d96fac118658c77f8ce875fe19 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 26 Jun 2026 13:14:48 +0300 Subject: [PATCH 13/14] feat(EmptyState): polymorphic Media --- .../EmptyStateMedia/EmptyStateMedia.tsx | 50 +++++++++---------- .../components/EmptyStateMedia/types.ts | 15 ++++-- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx index 4d701d918..4739adcb9 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx +++ b/packages/components/src/components/EmptyState/components/EmptyStateMedia/EmptyStateMedia.tsx @@ -1,9 +1,8 @@ 'use client'; -import type { ComponentPropsWithRef } from 'react'; -import { forwardRef, useContext } from 'react'; +import { useContext } from 'react'; -import { clsx, mergeProps } from '@koobiq/react-core'; +import { clsx, polymorphicForwardRef } from '@koobiq/react-core'; import { EmptyStateContext } from '../../EmptyStateContext'; @@ -11,29 +10,26 @@ import s from './EmptyStateMedia.module.css'; import type { EmptyStateMediaProps } from './types'; /** EmptyState.Media — the illustration or icon slot of the EmptyState. */ -export const EmptyStateMedia = forwardRef( - (props, ref) => { - const { className, children, ...other } = props; - - const { size, align, isInvalid } = useContext(EmptyStateContext); - - const rootProps = mergeProps[]>( - { className: clsx(s.base, s[size], isInvalid && s.invalid, className) }, - other - ); - - return ( -
- {children} -
- ); - } -); +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/types.ts b/packages/components/src/components/EmptyState/components/EmptyStateMedia/types.ts index 9e98f9393..0011cf801 100644 --- a/packages/components/src/components/EmptyState/components/EmptyStateMedia/types.ts +++ b/packages/components/src/components/EmptyState/components/EmptyStateMedia/types.ts @@ -1,6 +1,15 @@ -import type { ComponentPropsWithRef } from 'react'; +import type { ElementType, ReactNode } from 'react'; import type { DataAttributeProps } from '@koobiq/react-core'; -export type EmptyStateMediaProps = ComponentPropsWithRef<'div'> & - DataAttributeProps; +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; From e37d12ad096ae9456fb37f30cf3a79878b825e78 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 26 Jun 2026 13:24:56 +0300 Subject: [PATCH 14/14] chore: approve api --- tools/api-extractor/config.json | 1 + .../components/EmptyState.api.md | 80 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 tools/public_api_guard/components/EmptyState.api.md diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index b4452fe50..27c505016 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 000000000..8bae650dd --- /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) + +```