& {
+ placement?: SidebarProps['placement'];
+}) => (
+
+
+ {items.map(({ icon, label }) => (
+
+ {icon}
+ {isOpen && {label}}
+
+ ))}
+
+
+
+ ) : (
+
+ )
+ ) : undefined
+ }
+ className={spacing({ m: 'xxs' })}
+ style={{ marginBlockStart: 'auto' }}
+ >
+ {isOpen ? 'Close' : 'Open'}
+
+
+);
+
+export const Base: Story = {
+ render: (args) => (
+
+
+ {({ isOpen, toggle }) => }
+
+
+
+ Press [ to toggle the sidebar.
+
+
+
+ ),
+};
+
+export const Controlled: Story = {
+ render: function Render(args) {
+ const [isOpen, { toggle, set }] = useBoolean(true);
+
+ return (
+
+
+ {({ isOpen, toggle }) => }
+
+
+ The sidebar is {isOpen ? 'open' : 'closed'}.
+
+
+
+ );
+ },
+};
+
+export const Size: Story = {
+ render: function Render() {
+ const sizeExamples: Array<{
+ label: string;
+ size: SidebarProps['size'];
+ closedSize: SidebarProps['closedSize'];
+ }> = [
+ { label: 'Pixels: 320px → 56px', size: 320, closedSize: 56 },
+ { label: 'Percentages: 50% → 25%', size: '50%', closedSize: '25%' },
+ {
+ label: 'Clamp: min 240px, preferred 50%, max 320px',
+ size: 'clamp(240px, 50%, 320px)',
+ closedSize: 32,
+ },
+ ];
+
+ return (
+
+ {sizeExamples.map(({ label, size, closedSize }) => (
+
+ {label}
+
+
+
+ Sidebar
+
+
+
+
+ Content
+
+
+
+
+ ))}
+
+ );
+ },
+};
+
+export const Placement: Story = {
+ render: () => (
+
+
+ {({ isOpen, toggle }) => (
+
+ )}
+
+
+
+ [ toggles the left sidebar, ] the right one.
+
+
+
+ {({ isOpen, toggle }) => (
+
+ )}
+
+
+ ),
+};
+
+export const Keyboard: Story = {
+ render: (args) => (
+
+
+ {({ isOpen, toggle }) => }
+
+
+
+ Press B to toggle the sidebar.
+
+
+
+ ),
+};
diff --git a/packages/components/src/components/Sidebar/Sidebar.test.tsx b/packages/components/src/components/Sidebar/Sidebar.test.tsx
new file mode 100644
index 00000000..4867b531
--- /dev/null
+++ b/packages/components/src/components/Sidebar/Sidebar.test.tsx
@@ -0,0 +1,478 @@
+import { createRef } from 'react';
+
+import { screen, render, fireEvent, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, it, expect, vi } from 'vitest';
+
+import { Sidebar, sidebarPropPlacement } from './index.js';
+import type { SidebarRenderProps } from './index.js';
+
+describe('Sidebar', () => {
+ const baseProps = { 'data-testid': 'sidebar' };
+
+ const getRoot = () => screen.getByTestId('sidebar');
+
+ const content = ({ isOpen, open, close, toggle }: SidebarRenderProps) =>
+ isOpen ? (
+
+
+
+
+ ) : (
+
+
+
+
+ );
+
+ it('should receive ref', () => {
+ const ref = createRef();
+ const { container } = render();
+
+ expect(ref.current).toBe(container.querySelector('div'));
+ });
+
+ it('should render the component with the correct tag', () => {
+ render();
+
+ expect(getRoot().tagName).toBe('NAV');
+ });
+
+ it('should merge the consumer style with the component variables', () => {
+ render();
+
+ expect(getRoot().style.getPropertyValue('--kbq-sidebar-size')).toBe(
+ '240px'
+ );
+
+ expect(getRoot().style.color).toBe('red');
+ });
+
+ it('should add no role or aria attributes by default', () => {
+ render();
+
+ expect(getRoot()).not.toHaveAttribute('role');
+ expect(getRoot()).not.toHaveAttribute('aria-label');
+ });
+
+ it('should forward role and aria-label', () => {
+ render();
+
+ expect(getRoot()).toHaveAttribute('role', 'navigation');
+ expect(getRoot()).toHaveAttribute('aria-label', 'Main');
+ });
+
+ describe('check the content', () => {
+ it('should render static children', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId('static')).toBeInTheDocument();
+ });
+
+ it('should render a ReactNode returned by the render function', () => {
+ render({() => 'render content'});
+
+ expect(getRoot()).toHaveTextContent('render content');
+ });
+
+ it('should pass the state and the actions to the render function', () => {
+ const children = vi.fn().mockReturnValue(null);
+
+ render({children});
+
+ expect(children).toHaveBeenCalledWith({
+ isOpen: false,
+ open: expect.any(Function),
+ close: expect.any(Function),
+ toggle: expect.any(Function),
+ });
+ });
+
+ it('should keep the children mounted when closed', () => {
+ render(
+
+
+
+ );
+
+ expect(getRoot()).toHaveAttribute('data-transition', 'exited');
+ expect(screen.getByTestId('static')).toBeInTheDocument();
+ });
+ });
+
+ describe('check the open state', () => {
+ it('should be closed by default', () => {
+ render();
+
+ expect(getRoot()).toHaveAttribute('data-transition', 'exited');
+ expect(getRoot()).not.toHaveAttribute('data-open');
+ });
+
+ it('should be open when defaultOpen is set', () => {
+ render();
+
+ expect(getRoot()).toHaveAttribute('data-transition', 'entered');
+ expect(getRoot()).toHaveAttribute('data-open', 'true');
+ });
+
+ it('should follow the isOpen prop when controlled', () => {
+ const { rerender } = render();
+
+ expect(getRoot()).toHaveAttribute('data-transition', 'exited');
+
+ rerender();
+
+ expect(getRoot()).toHaveAttribute('data-transition', 'entering');
+ });
+
+ it('should not call onOpenChange on mount', () => {
+ const onOpenChange = vi.fn();
+
+ render(
+
+ );
+
+ expect(onOpenChange).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ['open', false, 'open', true],
+ ['close', true, 'close', false],
+ ['toggle from closed', false, 'toggle', true],
+ ['toggle from open', true, 'toggle', false],
+ ])('should %s', async (_, defaultOpen, button, expected) => {
+ const onOpenChange = vi.fn();
+
+ render(
+
+ {content}
+
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: button }));
+
+ expect(onOpenChange).toHaveBeenCalledWith(expected);
+ });
+
+ it('should not change the rendered state when the controlled parent ignores it', async () => {
+ render(
+
+ {content}
+
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: 'close' }));
+
+ expect(getRoot()).toHaveAttribute('data-transition', 'entered');
+ expect(screen.getByTestId('open-content')).toBeInTheDocument();
+ });
+ });
+
+ describe('check the content swap', () => {
+ it('should keep the open content for the whole collapse', async () => {
+ render(
+
+ {content}
+
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: 'close' }));
+
+ // The box is already shrinking, but the open content is still there.
+ expect(getRoot()).toHaveAttribute('data-transition', 'exiting');
+ expect(screen.getByTestId('open-content')).toBeInTheDocument();
+ expect(screen.queryByTestId('closed-content')).not.toBeInTheDocument();
+
+ await waitFor(() =>
+ expect(screen.getByTestId('closed-content')).toBeInTheDocument()
+ );
+
+ expect(getRoot()).toHaveAttribute('data-transition', 'exited');
+ });
+
+ it('should show the open content as soon as the expand starts', async () => {
+ render({content});
+
+ await userEvent.click(screen.getByRole('button', { name: 'open' }));
+
+ // Asymmetric with the collapse on purpose: no waiting here.
+ expect(getRoot()).toHaveAttribute('data-transition', 'entering');
+ expect(screen.getByTestId('open-content')).toBeInTheDocument();
+
+ await waitFor(() =>
+ expect(getRoot()).toHaveAttribute('data-transition', 'entered')
+ );
+ });
+ });
+
+ describe('check the size props', () => {
+ it('should set no size variables by default', () => {
+ render();
+
+ expect(getRoot().style.getPropertyValue('--kbq-sidebar-size')).toBe('');
+
+ expect(
+ getRoot().style.getPropertyValue('--kbq-sidebar-closed-size')
+ ).toBe('');
+ });
+
+ it('should set numeric size variables in pixels', () => {
+ render();
+
+ expect(getRoot().style.getPropertyValue('--kbq-sidebar-size')).toBe(
+ '320px'
+ );
+
+ expect(
+ getRoot().style.getPropertyValue('--kbq-sidebar-closed-size')
+ ).toBe('56px');
+ });
+
+ it.each([
+ ['auto', '32px'],
+ ['50%', '25%'],
+ ])('should pass CSS size values through: %s and %s', (size, closedSize) => {
+ render();
+
+ expect(getRoot().style.getPropertyValue('--kbq-sidebar-size')).toBe(size);
+
+ expect(
+ getRoot().style.getPropertyValue('--kbq-sidebar-closed-size')
+ ).toBe(closedSize);
+ });
+ });
+
+ describe('check the placement prop', () => {
+ it('should default to the start placement', () => {
+ render();
+
+ expect(getRoot()).toHaveAttribute('data-placement', 'start');
+ });
+
+ it.each(sidebarPropPlacement)(
+ 'should apply the placement as a "%s"',
+ (placement) => {
+ render();
+
+ expect(getRoot()).toHaveAttribute('data-placement', placement);
+ }
+ );
+ });
+
+ describe('check the keyboard shortcut', () => {
+ it.each([
+ ['start', 'BracketLeft'],
+ ['end', 'BracketRight'],
+ ] as const)('should toggle a "%s" sidebar with %s', (placement, code) => {
+ const onOpenChange = vi.fn();
+
+ render(
+
+ );
+
+ fireEvent.keyDown(window, { code });
+
+ expect(onOpenChange).toHaveBeenCalledWith(true);
+ });
+
+ it.each([
+ ['start', 'BracketRight'],
+ ['end', 'BracketLeft'],
+ ] as const)(
+ 'should not toggle a "%s" sidebar with %s',
+ (placement, code) => {
+ const onOpenChange = vi.fn();
+
+ render(
+
+ );
+
+ fireEvent.keyDown(window, { code });
+
+ expect(onOpenChange).not.toHaveBeenCalled();
+ }
+ );
+
+ // The reason we match `code` and not `key`: on the ЙЦУКЕН layout the bracket
+ // keys produce "х" and "ъ", so a `key` match would never fire.
+ it('should match the physical key regardless of the layout', () => {
+ const onOpenChange = vi.fn();
+
+ render();
+
+ fireEvent.keyDown(window, { code: 'BracketLeft', key: 'х' });
+
+ expect(onOpenChange).toHaveBeenCalledWith(true);
+ });
+
+ it('should use a custom key', () => {
+ const onOpenChange = vi.fn();
+
+ render(
+
+ );
+
+ fireEvent.keyDown(window, { code: 'BracketLeft' });
+ expect(onOpenChange).not.toHaveBeenCalled();
+
+ fireEvent.keyDown(window, { code: 'KeyB' });
+ expect(onOpenChange).toHaveBeenCalledWith(true);
+ });
+
+ it('should ignore repeated keydown events', () => {
+ const onOpenChange = vi.fn();
+
+ render();
+
+ fireEvent.keyDown(window, { code: 'BracketLeft' });
+ fireEvent.keyDown(window, { code: 'BracketLeft', repeat: true });
+
+ expect(onOpenChange).toHaveBeenCalledTimes(1);
+ });
+
+ it('should match custom modifiers exactly and prevent the default action', () => {
+ const onOpenChange = vi.fn();
+
+ render(
+
+ );
+
+ fireEvent.keyDown(window, { code: 'KeyO' });
+ expect(onOpenChange).not.toHaveBeenCalled();
+
+ const event = new KeyboardEvent('keydown', {
+ code: 'KeyO',
+ metaKey: true,
+ cancelable: true,
+ });
+
+ window.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(onOpenChange).toHaveBeenCalledWith(true);
+ });
+
+ it.each(['ctrlKey', 'metaKey', 'altKey', 'shiftKey'] as const)(
+ 'should ignore the shortcut when %s is held',
+ (modifier) => {
+ const onOpenChange = vi.fn();
+
+ render();
+
+ fireEvent.keyDown(window, { code: 'BracketLeft', [modifier]: true });
+
+ expect(onOpenChange).not.toHaveBeenCalled();
+ }
+ );
+
+ it('should ignore the shortcut while typing', () => {
+ const onOpenChange = vi.fn();
+
+ render(
+ <>
+
+
+ >
+ );
+
+ fireEvent.keyDown(screen.getByTestId('input'), { code: 'BracketLeft' });
+
+ expect(onOpenChange).not.toHaveBeenCalled();
+ });
+
+ it('should ignore the shortcut inside a contenteditable', () => {
+ const onOpenChange = vi.fn();
+
+ render(
+ <>
+
+
+ >
+ );
+
+ const editor = screen.getByTestId('editor');
+ // jsdom does not implement isContentEditable at all, so define it here.
+ Object.defineProperty(editor, 'isContentEditable', { value: true });
+
+ fireEvent.keyDown(editor, { code: 'BracketLeft' });
+
+ expect(onOpenChange).not.toHaveBeenCalled();
+ });
+
+ it('should not listen when keyboardShortcut is null', () => {
+ const onOpenChange = vi.fn();
+
+ render(
+
+ );
+
+ fireEvent.keyDown(window, { code: 'BracketLeft' });
+
+ expect(onOpenChange).not.toHaveBeenCalled();
+ });
+
+ it('should stop listening on unmount', () => {
+ const onOpenChange = vi.fn();
+
+ const { unmount } = render(
+
+ );
+
+ unmount();
+ fireEvent.keyDown(window, { code: 'BracketLeft' });
+
+ expect(onOpenChange).not.toHaveBeenCalled();
+ });
+ });
+
+ it('should merge the transition slot props', async () => {
+ const onExited = vi.fn();
+
+ render(
+
+ {content}
+
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: 'close' }));
+ await waitFor(() => expect(onExited).toHaveBeenCalled());
+
+ expect(screen.getByTestId('closed-content')).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/components/Sidebar/Sidebar.tsx b/packages/components/src/components/Sidebar/Sidebar.tsx
new file mode 100644
index 00000000..e7acf828
--- /dev/null
+++ b/packages/components/src/components/Sidebar/Sidebar.tsx
@@ -0,0 +1,122 @@
+'use client';
+
+import { useCallback } from 'react';
+import type { ComponentPropsWithRef, CSSProperties, ElementType } from 'react';
+
+import {
+ clsx,
+ polymorphicForwardRef,
+ useControlledState,
+ useDOMRef,
+ useEventListener,
+} from '@koobiq/react-core';
+import { Transition } from 'react-transition-group';
+
+import { TRANSITION_TIMEOUT } from './constants';
+import type { SidebarBaseProps } from './index';
+import s from './Sidebar.module.css';
+import {
+ isEditableTarget,
+ matchesKeyboardShortcut,
+ normalizeSize,
+} from './utils';
+
+/**
+ * Sidebar is a low-level layout box for a side area that toggles between an open
+ * and a closed state, animating its inline size between the two.
+ */
+export const Sidebar = polymorphicForwardRef<'div', SidebarBaseProps>(
+ (props, ref) => {
+ const {
+ as: Tag = 'div',
+ isOpen: isOpenProp,
+ defaultOpen,
+ onOpenChange,
+ size,
+ closedSize,
+ placement = 'start',
+ keyboardShortcut,
+ slotProps,
+ style,
+ className,
+ children,
+ ...other
+ } = props;
+
+ const domRef = useDOMRef(ref);
+
+ const [isOpen, setOpen] = useControlledState(
+ isOpenProp,
+ defaultOpen ?? false,
+ onOpenChange
+ );
+
+ const open = useCallback(() => setOpen(true), [setOpen]);
+ const close = useCallback(() => setOpen(false), [setOpen]);
+ const toggle = useCallback(() => setOpen((is) => !is), [setOpen]);
+
+ useEventListener({
+ eventName: 'keydown',
+ active: keyboardShortcut !== null,
+ handler: (event) => {
+ if (keyboardShortcut === null || event.repeat) return;
+
+ const shortcut = keyboardShortcut ?? {
+ code: placement === 'end' ? 'BracketRight' : 'BracketLeft',
+ };
+
+ if (!matchesKeyboardShortcut(event, shortcut)) return;
+ if (isEditableTarget(event.target)) return;
+
+ event.preventDefault();
+ toggle();
+ },
+ });
+
+ const sidebarStyle = {
+ ...style,
+ ...(size !== undefined && { '--kbq-sidebar-size': normalizeSize(size) }),
+ ...(closedSize !== undefined && {
+ '--kbq-sidebar-closed-size': normalizeSize(closedSize),
+ }),
+ '--kbq-sidebar-open-duration': `${TRANSITION_TIMEOUT.enter}ms`,
+ '--kbq-sidebar-close-duration': `${TRANSITION_TIMEOUT.exit}ms`,
+ } as CSSProperties;
+
+ const transitionProps = {
+ timeout: TRANSITION_TIMEOUT,
+ in: isOpen,
+ nodeRef: domRef,
+ ...slotProps?.transition,
+ };
+
+ return (
+
+ {(transition) => {
+ // The open content stays mounted for the whole collapse and is swapped
+ // for the closed content only once the sidebar has finished collapsing.
+ const isOpenState = transition !== 'exited';
+
+ return (
+
+ {typeof children === 'function'
+ ? children({ isOpen: isOpenState, open, close, toggle })
+ : children}
+
+ );
+ }}
+
+ );
+ }
+);
+
+export type SidebarProps =
+ ComponentPropsWithRef>;
diff --git a/packages/components/src/components/Sidebar/constants.ts b/packages/components/src/components/Sidebar/constants.ts
new file mode 100644
index 00000000..953c8cde
--- /dev/null
+++ b/packages/components/src/components/Sidebar/constants.ts
@@ -0,0 +1,6 @@
+/**
+ * Transition durations, in ms. Asymmetric on purpose: collapsing is snappier
+ * than expanding. The CSS reads these through custom properties, so this stays
+ * the single source of truth.
+ */
+export const TRANSITION_TIMEOUT = { enter: 200, exit: 100 } as const;
diff --git a/packages/components/src/components/Sidebar/index.ts b/packages/components/src/components/Sidebar/index.ts
new file mode 100644
index 00000000..dfc1c22d
--- /dev/null
+++ b/packages/components/src/components/Sidebar/index.ts
@@ -0,0 +1,2 @@
+export * from './Sidebar';
+export * from './types';
diff --git a/packages/components/src/components/Sidebar/types.ts b/packages/components/src/components/Sidebar/types.ts
new file mode 100644
index 00000000..5e69035f
--- /dev/null
+++ b/packages/components/src/components/Sidebar/types.ts
@@ -0,0 +1,84 @@
+import type { CSSProperties, ReactNode } from 'react';
+
+import type { TransitionProps } from 'react-transition-group/Transition';
+
+export const sidebarPropPlacement = ['start', 'end'] as const;
+
+export type SidebarPropPlacement = (typeof sidebarPropPlacement)[number];
+
+export type SidebarPropSize = CSSProperties['inlineSize'];
+
+export type SidebarPropKeyboardShortcut = {
+ /** The physical key code, for example `KeyB` or `KeyO`. */
+ code: string;
+ altKey?: boolean;
+ ctrlKey?: boolean;
+ metaKey?: boolean;
+ shiftKey?: boolean;
+};
+
+export type SidebarRenderProps = {
+ /** Whether the sidebar is currently showing its open content. */
+ isOpen: boolean;
+ /** Opens the sidebar. */
+ open: () => void;
+ /** Closes the sidebar. */
+ close: () => void;
+ /** Toggles the sidebar between its open and closed states. */
+ toggle: () => void;
+};
+
+export type SidebarPropContent =
+ | ReactNode
+ | ((props: SidebarRenderProps) => ReactNode);
+
+export type SidebarBaseProps = {
+ /**
+ * The content of the sidebar. Pass a function to render different content per
+ * state — it receives the current state and the `open`, `close` and `toggle`
+ * actions.
+ */
+ children?: SidebarPropContent;
+ /** If `true`, the sidebar is open. */
+ isOpen?: boolean;
+ /**
+ * The default open state. Use when the component is not controlled.
+ * @default false
+ */
+ defaultOpen?: boolean;
+ /** Handler that is called when the sidebar's open state changes. */
+ onOpenChange?: (isOpen: boolean) => void;
+ /**
+ * The inline size of the sidebar while it is open. Numbers are treated as
+ * pixels; strings are passed through as CSS values.
+ * @default 240
+ */
+ size?: SidebarPropSize;
+ /**
+ * The inline size of the sidebar while it is closed. Numbers are treated as
+ * pixels; strings are passed through as CSS values.
+ * @default 32
+ */
+ closedSize?: SidebarPropSize;
+ /**
+ * The side the sidebar is placed on. Anchors the content to that edge while
+ * the inline size animates, and picks the default keyboard shortcut: `start`
+ * is toggled by `[`, `end` by `]`.
+ * @default 'start'
+ */
+ placement?: SidebarPropPlacement;
+ /**
+ * The keyboard shortcut that toggles the sidebar. By default, it is selected
+ * from `placement`. Set to `null` to disable the keyboard shortcut.
+ * @example { code: 'KeyO', metaKey: true }
+ */
+ keyboardShortcut?: SidebarPropKeyboardShortcut | null;
+ /** Additional CSS-classes. */
+ className?: string;
+ /** Inline styles. */
+ style?: CSSProperties;
+ /** The props used for each slot inside. */
+ slotProps?: {
+ transition?: Partial>;
+ };
+};
diff --git a/packages/components/src/components/Sidebar/utils.ts b/packages/components/src/components/Sidebar/utils.ts
new file mode 100644
index 00000000..2c6023c8
--- /dev/null
+++ b/packages/components/src/components/Sidebar/utils.ts
@@ -0,0 +1,28 @@
+import { isNumber } from '@koobiq/react-core';
+
+import type { SidebarPropKeyboardShortcut, SidebarPropSize } from './types';
+
+export const normalizeSize = (value: SidebarPropSize) =>
+ isNumber(value) ? `${value}px` : value;
+
+export const matchesKeyboardShortcut = (
+ event: KeyboardEvent,
+ shortcut: SidebarPropKeyboardShortcut
+) =>
+ event.code === shortcut.code &&
+ event.altKey === Boolean(shortcut.altKey) &&
+ event.ctrlKey === Boolean(shortcut.ctrlKey) &&
+ event.metaKey === Boolean(shortcut.metaKey) &&
+ event.shiftKey === Boolean(shortcut.shiftKey);
+
+/**
+ * Whether the event target is somewhere the user types, so a printable-character
+ * shortcut must not steal the key.
+ */
+export const isEditableTarget = (target: EventTarget | null) => {
+ if (!(target instanceof HTMLElement)) return false;
+
+ if (target.isContentEditable) return true;
+
+ return ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName);
+};
diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts
index b02814d8..9837688b 100644
--- a/packages/components/src/components/index.ts
+++ b/packages/components/src/components/index.ts
@@ -55,6 +55,7 @@ export * from './Tree';
export * from './TreeSelect';
export * from './EmptyState';
export * from './Flag';
+export * from './Sidebar';
export * from './layout';
export {
useListData,
diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json
index 4faa8ef5..205f5c09 100644
--- a/tools/api-extractor/config.json
+++ b/tools/api-extractor/config.json
@@ -40,6 +40,7 @@
"RadioGroup",
"SearchInput",
"Select",
+ "Sidebar",
"SidePanel",
"SkeletonBlock",
"SkeletonTypography",
diff --git a/tools/public_api_guard/components/Sidebar.api.md b/tools/public_api_guard/components/Sidebar.api.md
new file mode 100644
index 00000000..e048c2d2
--- /dev/null
+++ b/tools/public_api_guard/components/Sidebar.api.md
@@ -0,0 +1,68 @@
+## 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 { CSSProperties } from 'react';
+import type { ElementType } from 'react';
+import { PolyForwardComponent } from '@koobiq/react-core';
+import type { ReactNode } from 'react';
+import type { TransitionProps } from 'react-transition-group/Transition';
+
+// @public
+export const Sidebar: PolyForwardComponent<"div", SidebarBaseProps, ElementType>;
+
+// @public (undocumented)
+export type SidebarBaseProps = {
+ children?: SidebarPropContent;
+ isOpen?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?: (isOpen: boolean) => void;
+ size?: SidebarPropSize;
+ closedSize?: SidebarPropSize;
+ placement?: SidebarPropPlacement;
+ keyboardShortcut?: SidebarPropKeyboardShortcut | null;
+ className?: string;
+ style?: CSSProperties;
+ slotProps?: {
+ transition?: Partial>;
+ };
+};
+
+// @public (undocumented)
+export type SidebarPropContent = ReactNode | ((props: SidebarRenderProps) => ReactNode);
+
+// @public (undocumented)
+export type SidebarPropKeyboardShortcut = {
+ code: string;
+ altKey?: boolean;
+ ctrlKey?: boolean;
+ metaKey?: boolean;
+ shiftKey?: boolean;
+};
+
+// @public (undocumented)
+export type SidebarPropPlacement = (typeof sidebarPropPlacement)[number];
+
+// @public (undocumented)
+export const sidebarPropPlacement: readonly ["start", "end"];
+
+// @public (undocumented)
+export type SidebarProps = ComponentPropsWithRef>;
+
+// @public (undocumented)
+export type SidebarPropSize = CSSProperties['inlineSize'];
+
+// @public (undocumented)
+export type SidebarRenderProps = {
+ isOpen: boolean;
+ open: () => void;
+ close: () => void;
+ toggle: () => void;
+};
+
+// (No @packageDocumentation comment for this package)
+
+```