diff --git a/packages/components/src/components/Resizable/Resizable.mdx b/packages/components/src/components/Resizable/Resizable.mdx
new file mode 100644
index 00000000..d101af59
--- /dev/null
+++ b/packages/components/src/components/Resizable/Resizable.mdx
@@ -0,0 +1,114 @@
+import {
+ Meta,
+ Story,
+ Props,
+ Status,
+} from '../../../../../.storybook/components';
+
+import * as Stories from './Resizable.stories';
+
+
+
+# Resizable
+
+
+
+Resizable changes an element's width and height using one or more composed handles.
+The element stays in place while its size changes.
+
+## Import
+
+```tsx
+import { Resizable } from '@koobiq/react-components';
+```
+
+## Usage
+
+
+
+Both `Resizable` and `Resizable.Handle` render a `div` by default. Use the `as` prop
+to render another element while preserving its native props and ref type.
+
+```tsx
+
+
+
+
+```
+
+## Props
+
+
+
+## State
+
+Use `defaultSize` for uncontrolled state, or combine `size` and `onResize` to control the size.
+All values are measured in CSS pixels.
+
+
+
+### Intrinsic size
+
+When both `size` and `defaultSize` are omitted, Resizable preserves its natural CSS size.
+The measured size is fixed in pixels only after the first resize interaction.
+
+
+
+## Direction
+
+`Resizable.Handle` accepts a physical `[x, y]` direction. Each axis supports `-1`, `0`, and `1`:
+
+- `x`: `-1` resizes from the left, `0` disables horizontal resizing, and `1` resizes from the right.
+- `y`: `-1` resizes from the top, `0` disables vertical resizing, and `1` resizes from the bottom.
+- `[0, 0]` is not a valid direction.
+
+The direction remains physical in right-to-left layouts.
+
+
+
+## Constraints
+
+Use `minSize` and `maxSize` to constrain either dimension independently. The default minimum is zero,
+and dimensions without a maximum are unbounded. If a minimum exceeds a maximum, the minimum wins.
+
+## Custom handle
+
+Handles can be styled without changing their hit area or resize behavior. This example adds a small
+indicator outside the right edge and composes the handle with a tooltip. See
+[Styling handles](#styling-handles) for available state attributes and [CSS Variables](#css-variables)
+for geometry customization.
+
+
+
+## Keyboard interaction
+
+Handles are focusable. Use the arrow keys to resize by one pixel, or hold Shift to resize
+by ten pixels. Edge handles expose the ARIA separator pattern; corner handles expose an accessible
+two-dimensional resize control.
+
+## Disabled
+
+Set `isDisabled` to remove all handles from the tab order and disable resize interactions.
+
+
+
+## Styling handles
+
+Handles provide positioning, hit areas, cursors, and state attributes, but no visible grip or line.
+Use `className` and the `data-direction-x`, `data-direction-y`, `data-hovered`, `data-focused`,
+`data-focus-visible`, `data-resizing`, and `data-disabled` attributes to provide an
+application-specific indicator.
+
+## CSS Variables
+
+Use CSS variables to customize the handle geometry without inline styles.
+
+| Variable |
+| ---------------------------------- |
+| `--kbq-resizable-handle-width` |
+| `--kbq-resizable-handle-height` |
+| `--kbq-resizable-handle-top` |
+| `--kbq-resizable-handle-right` |
+| `--kbq-resizable-handle-bottom` |
+| `--kbq-resizable-handle-left` |
+| `--kbq-resizable-handle-transform` |
diff --git a/packages/components/src/components/Resizable/Resizable.module.css b/packages/components/src/components/Resizable/Resizable.module.css
new file mode 100644
index 00000000..a776f35b
--- /dev/null
+++ b/packages/components/src/components/Resizable/Resizable.module.css
@@ -0,0 +1,89 @@
+.base {
+ position: relative;
+ box-sizing: border-box;
+}
+
+.handle {
+ --resizable-handle-width: var(--kbq-size-l);
+ --resizable-handle-height: var(--kbq-size-l);
+ --resizable-handle-top: auto;
+ --resizable-handle-right: auto;
+ --resizable-handle-bottom: auto;
+ --resizable-handle-left: auto;
+ --resizable-handle-translate-x: 0;
+ --resizable-handle-translate-y: 0;
+
+ position: absolute;
+ z-index: 1;
+ box-sizing: border-box;
+ inline-size: var(--kbq-resizable-handle-width, var(--resizable-handle-width));
+ block-size: var(
+ --kbq-resizable-handle-height,
+ var(--resizable-handle-height)
+ );
+ inset: var(--kbq-resizable-handle-top, var(--resizable-handle-top))
+ var(--kbq-resizable-handle-right, var(--resizable-handle-right))
+ var(--kbq-resizable-handle-bottom, var(--resizable-handle-bottom))
+ var(--kbq-resizable-handle-left, var(--resizable-handle-left));
+ touch-action: none;
+ background: transparent;
+ transform: var(
+ --kbq-resizable-handle-transform,
+ translate(
+ var(--resizable-handle-translate-x),
+ var(--resizable-handle-translate-y)
+ )
+ );
+}
+
+.handle[data-direction-x='-1'] {
+ --resizable-handle-left: 0;
+ --resizable-handle-translate-x: -50%;
+}
+
+.handle[data-direction-x='0'] {
+ --resizable-handle-width: 100%;
+ --resizable-handle-left: 0;
+
+ cursor: ns-resize;
+}
+
+.handle[data-direction-x='1'] {
+ --resizable-handle-right: 0;
+ --resizable-handle-translate-x: 50%;
+}
+
+.handle[data-direction-y='-1'] {
+ --resizable-handle-top: 0;
+ --resizable-handle-translate-y: -50%;
+}
+
+.handle[data-direction-y='0'] {
+ --resizable-handle-height: 100%;
+ --resizable-handle-top: 0;
+
+ cursor: ew-resize;
+}
+
+.handle[data-direction-y='1'] {
+ --resizable-handle-bottom: 0;
+ --resizable-handle-translate-y: 50%;
+}
+
+.handle:is(
+ [data-direction-x='-1'][data-direction-y='-1'],
+ [data-direction-x='1'][data-direction-y='1']
+) {
+ cursor: nwse-resize;
+}
+
+.handle:is(
+ [data-direction-x='1'][data-direction-y='-1'],
+ [data-direction-x='-1'][data-direction-y='1']
+) {
+ cursor: nesw-resize;
+}
+
+.handle[data-disabled] {
+ cursor: default;
+}
diff --git a/packages/components/src/components/Resizable/Resizable.stories.tsx b/packages/components/src/components/Resizable/Resizable.stories.tsx
new file mode 100644
index 00000000..ffd047e6
--- /dev/null
+++ b/packages/components/src/components/Resizable/Resizable.stories.tsx
@@ -0,0 +1,189 @@
+import { useState } from 'react';
+
+import { clsx } from '@koobiq/react-core';
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { Tooltip } from '../Tooltip';
+import { Typography } from '../Typography';
+
+import s from './__stories__/styles.module.css';
+import { Resizable } from './Resizable';
+import type { ResizableProps, ResizableSize } from './types';
+
+const meta = {
+ title: 'Components/Resizable',
+ component: Resizable,
+ subcomponents: {
+ 'Resizable.Handle': Resizable.Handle,
+ },
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['status:new', 'date:2026-07-16'],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const handleClassName = s.handle;
+
+function Handles() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export const Base: Story = {
+ render: function Render(args) {
+ const [size, setSize] = useState({
+ width: 400,
+ height: 300,
+ });
+
+ return (
+
+
+ {Math.round(size.width)} × {Math.round(size.height)} px
+
+
+
+ );
+ },
+};
+
+export const Uncontrolled: Story = {
+ render: (args) => (
+
+ Uncontrolled size
+
+
+ ),
+};
+
+export const IntrinsicSize: Story = {
+ render: (args) => (
+
+
+ This element keeps its intrinsic size until a handle is moved
+
+
+
+ ),
+};
+
+export const SingleDirection: Story = {
+ render: (args) => (
+
+ Resize from the right edge
+
+
+ ),
+};
+
+export const CustomHandle: Story = {
+ render: (args) => (
+ <>
+
+
+
+ Hover over the right edge
+ (
+
+ )}
+ >
+ Resize
+
+
+ >
+ ),
+};
+
+export const Disabled: Story = {
+ render: (args) => (
+
+ Resizing is disabled
+
+
+ ),
+};
diff --git a/packages/components/src/components/Resizable/Resizable.test.tsx b/packages/components/src/components/Resizable/Resizable.test.tsx
new file mode 100644
index 00000000..0abfbc76
--- /dev/null
+++ b/packages/components/src/components/Resizable/Resizable.test.tsx
@@ -0,0 +1,499 @@
+import { createRef, type ComponentProps } from 'react';
+
+import type * as ReactCore from '@koobiq/react-core';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { Provider } from '../Provider';
+
+import {
+ Resizable,
+ resizableHandleDirections,
+ type ResizableHandleDirection,
+ type ResizableSize,
+} from './index.js';
+
+const mocks = vi.hoisted(() => ({
+ observedSize: { width: 300, height: 200 },
+}));
+
+vi.mock('@koobiq/react-core', async () => {
+ const React = await import('react');
+ const actual = await vi.importActual('@koobiq/react-core');
+
+ return {
+ ...actual,
+ useElementSize: () => ({
+ ref: React.useRef(null),
+ width: mocks.observedSize.width,
+ height: mocks.observedSize.height,
+ }),
+ };
+});
+
+const getRoot = () => screen.getByTestId('resizable');
+const getHandle = () => screen.getByTestId('handle');
+
+const renderResizable = (
+ direction: ResizableHandleDirection = [1, 1],
+ props: Partial> = {}
+) =>
+ render(
+
+ content
+
+
+ );
+
+const drag = (deltaX: number, deltaY: number) => {
+ fireEvent.mouseDown(getHandle(), { button: 0, clientX: 10, clientY: 10 });
+
+ fireEvent.mouseMove(window, {
+ button: 0,
+ clientX: 10 + deltaX,
+ clientY: 10 + deltaY,
+ });
+
+ fireEvent.mouseUp(window, {
+ button: 0,
+ clientX: 10 + deltaX,
+ clientY: 10 + deltaY,
+ });
+};
+
+describe('Resizable', () => {
+ beforeEach(() => {
+ mocks.observedSize = { width: 300, height: 200 };
+ vi.stubGlobal('PointerEvent', undefined);
+
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
+ function getBoundingClientRect(this: HTMLElement) {
+ const width = Number.parseFloat(this.style.width);
+ const height = Number.parseFloat(this.style.height);
+
+ const resolvedWidth = Number.isNaN(width)
+ ? mocks.observedSize.width
+ : width;
+
+ const resolvedHeight = Number.isNaN(height)
+ ? mocks.observedSize.height
+ : height;
+
+ return {
+ x: 0,
+ y: 0,
+ top: 0,
+ left: 0,
+ right: resolvedWidth,
+ bottom: resolvedHeight,
+ width: resolvedWidth,
+ height: resolvedHeight,
+ toJSON: () => ({}),
+ };
+ }
+ );
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('forwards refs and merges consumer props', () => {
+ const rootRef = createRef();
+ const handleRef = createRef();
+ const onKeyDown = vi.fn();
+
+ render(
+
+
+
+ );
+
+ fireEvent.keyDown(getHandle(), { key: 'ArrowRight' });
+
+ expect(rootRef.current).toBe(getRoot());
+ expect(handleRef.current).toBe(getHandle());
+ expect(getRoot()).toHaveClass('custom-root');
+ expect(getRoot().style.color).toBe('red');
+ expect(getHandle()).toHaveClass('custom-handle');
+ expect(onKeyDown).toHaveBeenCalledOnce();
+ });
+
+ it('supports polymorphic root and handle elements with typed refs', () => {
+ const rootRef = createRef();
+ const handleRef = createRef();
+
+ render(
+
+
+
+ );
+
+ expect(getRoot().tagName).toBe('SECTION');
+ expect(getHandle().tagName).toBe('BUTTON');
+ expect(rootRef.current).toBe(getRoot());
+ expect(handleRef.current).toBe(getHandle());
+ });
+
+ it.each(resizableHandleDirections)(
+ 'resizes in direction [%s, %s] from the drag snapshot',
+ (x, y) => {
+ renderResizable([x, y] as ResizableHandleDirection);
+
+ drag(20, 15);
+
+ expect(getRoot()).toHaveStyle({
+ width: `${300 + 20 * x}px`,
+ height: `${200 + 15 * y}px`,
+ });
+
+ expect(getRoot().style.top).toBe('');
+ expect(getRoot().style.left).toBe('');
+ expect(getRoot().style.transform).toBe('');
+ }
+ );
+
+ it('accumulates movement without applying total deltas more than once', () => {
+ renderResizable([1, 1]);
+
+ fireEvent.mouseDown(getHandle(), { button: 0, clientX: 10, clientY: 10 });
+
+ fireEvent.mouseMove(window, {
+ button: 0,
+ clientX: 30,
+ clientY: 20,
+ });
+
+ fireEvent.mouseMove(window, {
+ button: 0,
+ clientX: 40,
+ clientY: 30,
+ });
+
+ fireEvent.mouseUp(window, { button: 0, clientX: 40, clientY: 30 });
+
+ expect(getRoot()).toHaveStyle({ width: '330px', height: '220px' });
+ });
+
+ it('clamps sizes and preserves the drag relationship beyond a boundary', () => {
+ renderResizable([1, 0], {
+ minSize: { width: 280 },
+ maxSize: { width: 320 },
+ });
+
+ fireEvent.mouseDown(getHandle(), { button: 0, clientX: 0, clientY: 0 });
+ fireEvent.mouseMove(window, { button: 0, clientX: 50, clientY: 0 });
+ expect(getRoot()).toHaveStyle({ width: '320px', height: '200px' });
+
+ fireEvent.mouseMove(window, { button: 0, clientX: 40, clientY: 0 });
+ expect(getRoot()).toHaveStyle({ width: '320px', height: '200px' });
+
+ fireEvent.mouseMove(window, { button: 0, clientX: 10, clientY: 0 });
+ expect(getRoot()).toHaveStyle({ width: '310px', height: '200px' });
+ });
+
+ it('lets minimum constraints win over maximum constraints', () => {
+ renderResizable([1, 0], {
+ defaultSize: { width: 150, height: 200 },
+ minSize: { width: 200 },
+ maxSize: { width: 100 },
+ });
+
+ expect(getRoot()).toHaveStyle({
+ width: '200px',
+ minWidth: '200px',
+ maxWidth: '200px',
+ });
+ });
+
+ it('normalizes negative and non-finite sizes to the minimum', () => {
+ renderResizable([1, 0], {
+ defaultSize: { width: Number.NaN, height: -20 },
+ minSize: { width: 40, height: 30 },
+ });
+
+ expect(getRoot()).toHaveStyle({ width: '40px', height: '30px' });
+ });
+
+ it('supports controlled state without optimistically changing the DOM size', () => {
+ const onResize = vi.fn();
+ const initialSize = { width: 300, height: 200 };
+
+ const { rerender } = render(
+
+
+
+ );
+
+ drag(20, 10);
+
+ expect(onResize).toHaveBeenLastCalledWith({ width: 320, height: 210 });
+ expect(getRoot()).toHaveStyle({ width: '300px', height: '200px' });
+
+ const nextSize = onResize.mock.lastCall?.[0] as ResizableSize;
+
+ rerender(
+
+
+
+ );
+
+ expect(getRoot()).toHaveStyle({ width: '320px', height: '210px' });
+ });
+
+ it('preserves intrinsic sizing until the first resize', () => {
+ mocks.observedSize = { width: 260, height: 140 };
+ renderResizable([1, 0], { defaultSize: undefined });
+
+ expect(getRoot().style.width).toBe('');
+ expect(getRoot().style.height).toBe('');
+ expect(getHandle()).toHaveAttribute('aria-valuenow', '260');
+
+ fireEvent.keyDown(getHandle(), { key: 'ArrowRight' });
+
+ expect(getRoot()).toHaveStyle({ width: '261px', height: '140px' });
+ });
+
+ it('calls lifecycle callbacks with the snapshot and final size', () => {
+ const onResizeStart = vi.fn();
+ const onResizeEnd = vi.fn();
+
+ renderResizable([1, 1], { onResizeStart, onResizeEnd });
+ drag(25, 10);
+
+ expect(onResizeStart).toHaveBeenCalledOnce();
+ expect(onResizeStart).toHaveBeenCalledWith({ width: 300, height: 200 });
+ expect(onResizeEnd).toHaveBeenCalledOnce();
+ expect(onResizeEnd).toHaveBeenCalledWith({ width: 325, height: 210 });
+ expect(getRoot()).not.toHaveAttribute('data-resizing');
+ });
+
+ it('supports Pointer Events and exposes the active resize state', () => {
+ vi.stubGlobal('PointerEvent', MouseEvent);
+ renderResizable([1, 1]);
+
+ fireEvent.pointerDown(getHandle(), {
+ button: 0,
+ pointerId: 1,
+ pointerType: 'touch',
+ clientX: 10,
+ clientY: 10,
+ });
+
+ fireEvent.pointerMove(window, {
+ button: 0,
+ pointerId: 1,
+ pointerType: 'touch',
+ clientX: 30,
+ clientY: 25,
+ });
+
+ expect(getRoot()).toHaveStyle({ width: '320px', height: '215px' });
+ expect(getRoot()).toHaveAttribute('data-resizing', 'true');
+ expect(getHandle()).toHaveAttribute('data-resizing', 'true');
+
+ fireEvent.pointerUp(window, {
+ button: 0,
+ pointerId: 1,
+ pointerType: 'touch',
+ clientX: 30,
+ clientY: 25,
+ });
+
+ expect(getRoot()).not.toHaveAttribute('data-resizing');
+ });
+
+ it('supports touch events when Pointer Events are unavailable', () => {
+ renderResizable([-1, -1]);
+
+ fireEvent.touchStart(getHandle(), {
+ changedTouches: [{ identifier: 7, pageX: 20, pageY: 20 }],
+ });
+
+ fireEvent.touchMove(window, {
+ changedTouches: [{ identifier: 7, pageX: 30, pageY: 25 }],
+ });
+
+ fireEvent.touchEnd(window, {
+ changedTouches: [{ identifier: 7, pageX: 30, pageY: 25 }],
+ });
+
+ expect(getRoot()).toHaveStyle({ width: '290px', height: '195px' });
+ });
+
+ it('resizes by one pixel with arrows and ten pixels with Shift', () => {
+ renderResizable([1, 0]);
+
+ fireEvent.keyDown(getHandle(), { key: 'ArrowRight' });
+ expect(getRoot()).toHaveStyle({ width: '301px', height: '200px' });
+
+ fireEvent.keyDown(getHandle(), { key: 'ArrowRight', shiftKey: true });
+ expect(getRoot()).toHaveStyle({ width: '311px', height: '200px' });
+ });
+
+ it('disables interactions and removes handles from the tab order', () => {
+ const onResize = vi.fn();
+ renderResizable([1, 0], { isDisabled: true, onResize });
+
+ fireEvent.mouseEnter(getHandle());
+ fireEvent.focus(getHandle());
+ fireEvent.keyDown(getHandle(), { key: 'ArrowRight' });
+ drag(20, 0);
+
+ expect(onResize).not.toHaveBeenCalled();
+ expect(getRoot()).toHaveAttribute('data-disabled', 'true');
+ expect(getHandle()).toHaveAttribute('data-disabled', 'true');
+ expect(getHandle()).toHaveAttribute('aria-disabled', 'true');
+ expect(getHandle()).toHaveAttribute('tabindex', '-1');
+ expect(getHandle()).not.toHaveAttribute('data-hovered');
+ expect(getHandle()).not.toHaveAttribute('data-focused');
+ });
+
+ it('exposes hover and focus states', async () => {
+ const user = userEvent.setup();
+
+ renderResizable([1, 0]);
+
+ await user.hover(getHandle());
+ expect(getHandle()).toHaveAttribute('data-hovered', 'true');
+
+ await user.tab();
+ expect(getHandle()).toHaveAttribute('data-focused', 'true');
+
+ await user.unhover(getHandle());
+ await user.tab();
+ expect(getHandle()).not.toHaveAttribute('data-hovered');
+ expect(getHandle()).not.toHaveAttribute('data-focused');
+ });
+
+ it('exposes separator semantics for edge handles', () => {
+ renderResizable([1, 0], {
+ minSize: { width: 200 },
+ maxSize: { width: 500 },
+ });
+
+ expect(getHandle()).toHaveAttribute('role', 'separator');
+ expect(getHandle()).toHaveAttribute('aria-orientation', 'vertical');
+ expect(getHandle()).toHaveAttribute('aria-valuenow', '300');
+ expect(getHandle()).toHaveAttribute('aria-valuemin', '200');
+ expect(getHandle()).toHaveAttribute('aria-valuemax', '500');
+ expect(getHandle()).toHaveAttribute('aria-controls', getRoot().id);
+ expect(getHandle()).toHaveAttribute('aria-label', 'Resize width');
+ expect(getHandle()).not.toHaveAttribute('aria-keyshortcuts');
+ });
+
+ it('exposes button semantics for corner handles and allows a custom label', () => {
+ render(
+
+
+
+ );
+
+ expect(getHandle()).toHaveAttribute('role', 'button');
+ expect(getHandle()).toHaveAttribute('aria-label', 'Resize preview');
+
+ expect(getHandle()).toHaveAttribute(
+ 'aria-keyshortcuts',
+ 'ArrowUp ArrowDown ArrowLeft ArrowRight'
+ );
+
+ expect(getHandle()).not.toHaveAttribute('aria-valuenow');
+ expect(getHandle()).not.toHaveAttribute('aria-orientation');
+ expect(getHandle()).toHaveAttribute('aria-controls', getRoot().id);
+ });
+
+ it('localizes default accessible labels', () => {
+ render(
+
+
+
+
+
+ );
+
+ expect(getHandle()).toHaveAttribute('aria-label', 'Изменить высоту');
+ });
+
+ it('exposes physical direction through data attributes', () => {
+ renderResizable([-1, 1]);
+
+ expect(getHandle()).not.toHaveAttribute('style');
+ expect(getHandle()).toHaveAttribute('data-direction-x', '-1');
+ expect(getHandle()).toHaveAttribute('data-direction-y', '1');
+ });
+
+ it('preserves custom handle positioning styles', () => {
+ render(
+
+
+
+ );
+
+ expect(getHandle()).toHaveStyle({ transform: 'translateX(100%)' });
+ });
+
+ it('throws when a handle is rendered without Resizable', () => {
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
+
+ expect(() => render()).toThrow(
+ 'Resizable.Handle must be rendered inside Resizable.'
+ );
+ });
+
+ it('throws for an invalid runtime direction', () => {
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
+
+ expect(() =>
+ render(
+
+
+
+ )
+ ).toThrow('eight non-zero [x, y] directions');
+ });
+});
diff --git a/packages/components/src/components/Resizable/Resizable.tsx b/packages/components/src/components/Resizable/Resizable.tsx
new file mode 100644
index 00000000..92d8601a
--- /dev/null
+++ b/packages/components/src/components/Resizable/Resizable.tsx
@@ -0,0 +1,81 @@
+'use client';
+
+import { clsx, mergeRefs, polymorphicForwardRef } from '@koobiq/react-core';
+
+import { useResizable, useResizableState } from './hooks';
+import s from './Resizable.module.css';
+import { ResizableContext } from './ResizableContext';
+import { ResizableHandle } from './ResizableHandle';
+import type { ResizableBaseProps } from './types';
+
+const ResizableComponent = polymorphicForwardRef<'div', ResizableBaseProps>(
+ (props, forwardedRef) => {
+ const {
+ as: Tag = 'div',
+ size: sizeProp,
+ defaultSize: defaultSizeProp,
+ minSize,
+ maxSize,
+ isDisabled = false,
+ onResize,
+ onResizeStart,
+ onResizeEnd,
+ id,
+ className,
+ style: styleProp,
+ children,
+ ...other
+ } = props;
+
+ const state = useResizableState({
+ size: sizeProp,
+ defaultSize: defaultSizeProp,
+ minSize,
+ maxSize,
+ isDisabled,
+ onResize,
+ onResizeStart,
+ onResizeEnd,
+ });
+
+ const {
+ resizableProps: {
+ ref: targetRef,
+ style: resizableStyle,
+ ...behaviorProps
+ },
+ contextValue,
+ } = useResizable(
+ {
+ id,
+ minSize,
+ },
+ state
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+);
+
+ResizableComponent.displayName = 'Resizable';
+
+type CompoundedComponent = typeof ResizableComponent & {
+ Handle: typeof ResizableHandle;
+};
+
+/** An element whose width and height can be changed with composed handles. */
+export const Resizable = ResizableComponent as CompoundedComponent;
+
+Resizable.Handle = ResizableHandle;
diff --git a/packages/components/src/components/Resizable/ResizableContext.ts b/packages/components/src/components/Resizable/ResizableContext.ts
new file mode 100644
index 00000000..bffee7a6
--- /dev/null
+++ b/packages/components/src/components/Resizable/ResizableContext.ts
@@ -0,0 +1,17 @@
+import { createContext, useContext } from 'react';
+
+import type { ResizableContextValue } from './hooks';
+
+export const ResizableContext = createContext(
+ null
+);
+
+export const useResizableContext = () => {
+ const context = useContext(ResizableContext);
+
+ if (!context) {
+ throw new Error('Resizable.Handle must be rendered inside Resizable.');
+ }
+
+ return context;
+};
diff --git a/packages/components/src/components/Resizable/ResizableHandle.tsx b/packages/components/src/components/Resizable/ResizableHandle.tsx
new file mode 100644
index 00000000..e972cd75
--- /dev/null
+++ b/packages/components/src/components/Resizable/ResizableHandle.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import { clsx, mergeProps, polymorphicForwardRef } from '@koobiq/react-core';
+
+import { isResizableHandleDirection, useResizableHandle } from './hooks';
+import s from './Resizable.module.css';
+import { useResizableContext } from './ResizableContext';
+import type { ResizableHandleBaseProps } from './types';
+
+/** A draggable handle that changes the size of its parent Resizable. */
+export const ResizableHandle = polymorphicForwardRef<
+ 'div',
+ ResizableHandleBaseProps
+>((props, ref) => {
+ const {
+ as: Tag = 'div',
+ direction,
+ className,
+ style: styleProp,
+ tabIndex: tabIndexProp,
+ 'aria-label': ariaLabelProp,
+ ...other
+ } = props;
+
+ if (!isResizableHandleDirection(direction)) {
+ throw new Error(
+ 'Resizable.Handle direction must be one of the eight non-zero [x, y] directions.'
+ );
+ }
+
+ const state = useResizableContext();
+
+ const { handleProps } = useResizableHandle(
+ {
+ direction,
+ 'aria-label': ariaLabelProp,
+ tabIndex: tabIndexProp,
+ },
+ state
+ );
+
+ return (
+
+ );
+});
+
+ResizableHandle.displayName = 'Resizable.Handle';
diff --git a/packages/components/src/components/Resizable/__stories__/styles.module.css b/packages/components/src/components/Resizable/__stories__/styles.module.css
new file mode 100644
index 00000000..1a294530
--- /dev/null
+++ b/packages/components/src/components/Resizable/__stories__/styles.module.css
@@ -0,0 +1,29 @@
+.content {
+ box-sizing: border-box;
+ border: 1px solid var(--kbq-line-contrast-less);
+ border-radius: var(--kbq-size-s);
+ background-color: var(--kbq-background-card);
+ padding: var(--kbq-size-l);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.intrinsic {
+ display: inline-flex;
+ max-inline-size: 480px;
+}
+
+.handle {
+ background-color: var(--kbq-background-transparent);
+ opacity: 0.25;
+ outline: none;
+ border-radius: var(--kbq-size-xxs);
+ transition:
+ background-color var(--kbq-transition-slow),
+ opacity var(--kbq-transition-slow);
+}
+
+.handle:is([data-hovered], [data-focus-visible], [data-resizing]) {
+ background-color: var(--kbq-states-background-theme-active);
+}
diff --git a/packages/components/src/components/Resizable/hooks/index.ts b/packages/components/src/components/Resizable/hooks/index.ts
new file mode 100644
index 00000000..36d47359
--- /dev/null
+++ b/packages/components/src/components/Resizable/hooks/index.ts
@@ -0,0 +1,5 @@
+export * from './types';
+export * from './useResizable';
+export * from './useResizableHandle';
+export * from './useResizableState';
+export * from './utils';
diff --git a/packages/components/src/components/Resizable/hooks/intl.json b/packages/components/src/components/Resizable/hooks/intl.json
new file mode 100644
index 00000000..db8e36c0
--- /dev/null
+++ b/packages/components/src/components/Resizable/hooks/intl.json
@@ -0,0 +1,12 @@
+{
+ "ru-RU": {
+ "resize": "Изменить размер",
+ "resize width": "Изменить ширину",
+ "resize height": "Изменить высоту"
+ },
+ "en-US": {
+ "resize": "Resize",
+ "resize width": "Resize width",
+ "resize height": "Resize height"
+ }
+}
diff --git a/packages/components/src/components/Resizable/hooks/types.ts b/packages/components/src/components/Resizable/hooks/types.ts
new file mode 100644
index 00000000..05309896
--- /dev/null
+++ b/packages/components/src/components/Resizable/hooks/types.ts
@@ -0,0 +1,36 @@
+export type ResizableSize = {
+ /** Width in CSS pixels. */
+ width: number;
+ /** Height in CSS pixels. */
+ height: number;
+};
+
+export type ResizableSizeConstraints = Partial;
+
+export const resizableHandleDirections = [
+ [-1, -1],
+ [0, -1],
+ [1, -1],
+ [-1, 0],
+ [1, 0],
+ [-1, 1],
+ [0, 1],
+ [1, 1],
+] as const;
+
+export type ResizableHandleDirection =
+ (typeof resizableHandleDirections)[number];
+
+export type ResizableBounds = {
+ minWidth: number;
+ maxWidth: number;
+ minHeight: number;
+ maxHeight: number;
+};
+
+export type ResizableMoveEvent = {
+ deltaX: number;
+ deltaY: number;
+ pointerType: string;
+ shiftKey: boolean;
+};
diff --git a/packages/components/src/components/Resizable/hooks/useResizable.ts b/packages/components/src/components/Resizable/hooks/useResizable.ts
new file mode 100644
index 00000000..c3adc7e5
--- /dev/null
+++ b/packages/components/src/components/Resizable/hooks/useResizable.ts
@@ -0,0 +1,116 @@
+'use client';
+
+import type { CSSProperties } from 'react';
+import { useCallback, useId, useMemo } from 'react';
+
+import { useElementSize } from '@koobiq/react-core';
+
+import type {
+ ResizableHandleDirection,
+ ResizableMoveEvent,
+ ResizableSizeConstraints,
+} from './types';
+import type { ResizableState } from './useResizableState';
+import { clampResizableSize } from './utils';
+
+export type ResizableContextValue = {
+ rootId: string;
+ size: NonNullable;
+ bounds: ResizableState['bounds'];
+ isDisabled: boolean;
+ activeDirection: string | null;
+ onMoveStart: (direction: ResizableHandleDirection) => void;
+ onMove: (event: ResizableMoveEvent) => void;
+ onMoveEnd: () => void;
+};
+
+export type UseResizableProps = {
+ id?: string;
+ minSize?: ResizableSizeConstraints;
+};
+
+/** Provides DOM props and observed sizing for a resizable target element. */
+export const useResizable = (
+ props: UseResizableProps,
+ state: ResizableState
+) => {
+ const { id: idProp, minSize } = props;
+
+ const {
+ size: managedSize,
+ bounds,
+ isDisabled,
+ activeDirection,
+ startResize,
+ resize,
+ endResize,
+ } = state;
+
+ const generatedId = useId();
+ const rootId = idProp ?? generatedId;
+
+ const {
+ ref: targetRef,
+ width,
+ height,
+ } = useElementSize({ box: 'border-box' });
+
+ const observedSize = clampResizableSize({ width, height }, bounds);
+ const currentSize = managedSize ?? observedSize;
+
+ const handleMoveStart = useCallback(
+ (direction: ResizableHandleDirection) => {
+ const rect = targetRef.current?.getBoundingClientRect();
+
+ startResize(
+ direction,
+ rect ? { width: rect.width, height: rect.height } : currentSize
+ );
+ },
+ [currentSize, startResize, targetRef]
+ );
+
+ const contextValue = useMemo(
+ () => ({
+ rootId,
+ size: currentSize,
+ bounds,
+ isDisabled,
+ activeDirection,
+ onMoveStart: handleMoveStart,
+ onMove: resize,
+ onMoveEnd: endResize,
+ }),
+ [
+ rootId,
+ currentSize,
+ bounds,
+ isDisabled,
+ activeDirection,
+ handleMoveStart,
+ resize,
+ endResize,
+ ]
+ );
+
+ const style = {
+ ...(minSize?.width !== undefined && { minWidth: bounds.minWidth }),
+ ...(minSize?.height !== undefined && { minHeight: bounds.minHeight }),
+ ...(Number.isFinite(bounds.maxWidth) && { maxWidth: bounds.maxWidth }),
+ ...(Number.isFinite(bounds.maxHeight) && { maxHeight: bounds.maxHeight }),
+ ...(managedSize && {
+ width: managedSize.width,
+ height: managedSize.height,
+ }),
+ } satisfies CSSProperties;
+
+ const resizableProps = {
+ ref: targetRef,
+ id: rootId,
+ style,
+ 'data-resizing': activeDirection ? true : undefined,
+ 'data-disabled': isDisabled || undefined,
+ };
+
+ return { resizableProps, contextValue };
+};
diff --git a/packages/components/src/components/Resizable/hooks/useResizableHandle.ts b/packages/components/src/components/Resizable/hooks/useResizableHandle.ts
new file mode 100644
index 00000000..edbba249
--- /dev/null
+++ b/packages/components/src/components/Resizable/hooks/useResizableHandle.ts
@@ -0,0 +1,137 @@
+'use client';
+
+import {
+ type DOMAttributes,
+ mergeProps,
+ useFocusRing,
+ useHover,
+ useLocalizedStringFormatter,
+ useMove,
+} from '@koobiq/react-core';
+
+import intlMessages from './intl.json';
+import type { ResizableHandleDirection } from './types';
+import type { ResizableContextValue } from './useResizable';
+import { getDirectionKey } from './utils';
+
+type ResizableHandleDOMProps = DOMAttributes & {
+ [key: `data-${string}`]: string | number | boolean | undefined;
+};
+
+export type UseResizableHandleProps = {
+ direction: ResizableHandleDirection;
+ 'aria-label'?: string;
+ tabIndex?: number;
+};
+
+/** Provides interaction and accessibility props for a resize handle. */
+export const useResizableHandle = (
+ props: UseResizableHandleProps,
+ state: ResizableContextValue
+) => {
+ const { direction, 'aria-label': ariaLabel, tabIndex: tabIndexProp } = props;
+
+ const {
+ rootId,
+ size,
+ bounds,
+ isDisabled,
+ activeDirection,
+ onMoveStart,
+ onMove,
+ onMoveEnd,
+ } = state;
+
+ const t = useLocalizedStringFormatter(intlMessages);
+ const [x, y] = direction;
+ const isWidthHandle = y === 0;
+ const isHeightHandle = x === 0;
+ const isCornerHandle = x !== 0 && y !== 0;
+ const isResizing = activeDirection === getDirectionKey(direction);
+
+ const { moveProps } = useMove({
+ onMoveStart: () => onMoveStart(direction),
+ onMove,
+ onMoveEnd,
+ });
+
+ const { focusProps, isFocused, isFocusVisible } = useFocusRing({
+ isTextInput: false,
+ autoFocus: false,
+ });
+
+ const { hoverProps, isHovered } = useHover({ isDisabled });
+
+ let defaultLabel = t.format('resize');
+
+ if (isWidthHandle) {
+ defaultLabel = t.format('resize width');
+ } else if (isHeightHandle) {
+ defaultLabel = t.format('resize height');
+ }
+
+ let tabIndex = tabIndexProp ?? 0;
+
+ if (isDisabled) {
+ tabIndex = -1;
+ }
+
+ const accessibilityProps: ResizableHandleDOMProps = {
+ tabIndex,
+ 'aria-label': ariaLabel ?? defaultLabel,
+ 'aria-controls': rootId,
+ 'aria-disabled': isDisabled || undefined,
+ 'data-direction-x': x,
+ 'data-direction-y': y,
+ 'data-hovered': isHovered || undefined,
+ 'data-focused': isFocused || undefined,
+ 'data-focus-visible': isFocusVisible || undefined,
+ 'data-resizing': isResizing || undefined,
+ 'data-disabled': isDisabled || undefined,
+ };
+
+ if (isCornerHandle) {
+ accessibilityProps.role = 'button';
+
+ accessibilityProps['aria-keyshortcuts'] =
+ 'ArrowUp ArrowDown ArrowLeft ArrowRight';
+ } else {
+ let value = size.height;
+ let minValue = bounds.minHeight;
+ let maxValue = bounds.maxHeight;
+ let orientation: 'horizontal' | 'vertical' = 'horizontal';
+
+ if (isWidthHandle) {
+ value = size.width;
+ minValue = bounds.minWidth;
+ maxValue = bounds.maxWidth;
+ orientation = 'vertical';
+ }
+
+ let ariaMaxValue = Number.MAX_SAFE_INTEGER;
+
+ if (Number.isFinite(maxValue)) {
+ ariaMaxValue = maxValue;
+ }
+
+ accessibilityProps.role = 'separator';
+ accessibilityProps['aria-orientation'] = orientation;
+ accessibilityProps['aria-valuenow'] = Math.round(value);
+ accessibilityProps['aria-valuemin'] = Math.round(minValue);
+ accessibilityProps['aria-valuemax'] = Math.round(ariaMaxValue);
+ accessibilityProps['aria-valuetext'] = `${Math.round(value)} px`;
+ }
+
+ let handleProps = accessibilityProps;
+
+ if (!isDisabled) {
+ handleProps = mergeProps(
+ moveProps,
+ hoverProps,
+ focusProps,
+ accessibilityProps
+ );
+ }
+
+ return { handleProps };
+};
diff --git a/packages/components/src/components/Resizable/hooks/useResizableState.ts b/packages/components/src/components/Resizable/hooks/useResizableState.ts
new file mode 100644
index 00000000..37de259a
--- /dev/null
+++ b/packages/components/src/components/Resizable/hooks/useResizableState.ts
@@ -0,0 +1,155 @@
+'use client';
+
+import { useCallback, useMemo, useRef, useState } from 'react';
+
+import { useControlledState } from '@koobiq/react-core';
+
+import type {
+ ResizableHandleDirection,
+ ResizableMoveEvent,
+ ResizableSize,
+ ResizableSizeConstraints,
+} from './types';
+import {
+ clampResizableSize,
+ getDirectionKey,
+ getResizableBounds,
+ normalizeResizableSize,
+} from './utils';
+
+export type ResizableState = {
+ size: ResizableSize | null;
+ bounds: ReturnType;
+ isDisabled: boolean;
+ activeDirection: string | null;
+ startResize: (
+ direction: ResizableHandleDirection,
+ startSize: ResizableSize
+ ) => void;
+ resize: (event: ResizableMoveEvent) => void;
+ endResize: () => void;
+};
+
+export type UseResizableStateProps = {
+ size?: ResizableSize;
+ defaultSize?: ResizableSize;
+ minSize?: ResizableSizeConstraints;
+ maxSize?: ResizableSizeConstraints;
+ isDisabled?: boolean;
+ onResize?: (size: ResizableSize) => void;
+ onResizeStart?: (size: ResizableSize) => void;
+ onResizeEnd?: (size: ResizableSize) => void;
+};
+
+/** Provides state and calculations for a resizable element. */
+export const useResizableState = (
+ props: UseResizableStateProps
+): ResizableState => {
+ const {
+ size: sizeProp,
+ defaultSize: defaultSizeProp,
+ minSize,
+ maxSize,
+ isDisabled = false,
+ onResize,
+ onResizeStart,
+ onResizeEnd,
+ } = props;
+
+ const bounds = useMemo(
+ () => getResizableBounds(minSize, maxSize),
+ [minSize?.width, minSize?.height, maxSize?.width, maxSize?.height]
+ );
+
+ const controlledSize = useMemo(
+ () => normalizeResizableSize(sizeProp, bounds),
+ [sizeProp?.width, sizeProp?.height, bounds]
+ );
+
+ const defaultSize = useMemo(
+ () => normalizeResizableSize(defaultSizeProp, bounds),
+ [defaultSizeProp?.width, defaultSizeProp?.height, bounds]
+ );
+
+ const [managedSize, setManagedSize] = useControlledState<
+ ResizableSize | null,
+ ResizableSize
+ >(controlledSize, defaultSize ?? null, onResize);
+
+ const startSizeRef = useRef({ width: 0, height: 0 });
+ const lastSizeRef = useRef({ width: 0, height: 0 });
+ const accumulatedRef = useRef({ x: 0, y: 0 });
+ const directionRef = useRef([1, 1]);
+ const [activeDirection, setActiveDirection] = useState(null);
+
+ const startResize = useCallback(
+ (direction: ResizableHandleDirection, startSize: ResizableSize) => {
+ const clampedStartSize = clampResizableSize(startSize, bounds);
+
+ startSizeRef.current = clampedStartSize;
+ lastSizeRef.current = clampedStartSize;
+ accumulatedRef.current = { x: 0, y: 0 };
+ directionRef.current = direction;
+ setActiveDirection(getDirectionKey(direction));
+ onResizeStart?.(clampedStartSize);
+ },
+ [bounds, onResizeStart]
+ );
+
+ const resize = useCallback(
+ (event: ResizableMoveEvent) => {
+ const multiplier =
+ event.pointerType === 'keyboard' && event.shiftKey ? 10 : 1;
+
+ accumulatedRef.current.x += event.deltaX * multiplier;
+ accumulatedRef.current.y += event.deltaY * multiplier;
+
+ const [x, y] = directionRef.current;
+ const startSize = startSizeRef.current;
+
+ const nextSize = clampResizableSize(
+ {
+ width:
+ x === 0
+ ? startSize.width
+ : startSize.width + accumulatedRef.current.x * x,
+ height:
+ y === 0
+ ? startSize.height
+ : startSize.height + accumulatedRef.current.y * y,
+ },
+ bounds
+ );
+
+ lastSizeRef.current = nextSize;
+ setManagedSize(nextSize);
+ },
+ [bounds, setManagedSize]
+ );
+
+ const endResize = useCallback(() => {
+ setActiveDirection(null);
+ onResizeEnd?.(lastSizeRef.current);
+ }, [onResizeEnd]);
+
+ return useMemo(
+ () => ({
+ size: managedSize,
+ bounds,
+ isDisabled,
+ activeDirection,
+ startResize,
+ resize,
+ endResize,
+ }),
+ [
+ managedSize,
+ bounds,
+ isDisabled,
+ activeDirection,
+ startResize,
+ resize,
+ endResize,
+ ]
+ );
+};
diff --git a/packages/components/src/components/Resizable/hooks/utils.ts b/packages/components/src/components/Resizable/hooks/utils.ts
new file mode 100644
index 00000000..ac52a654
--- /dev/null
+++ b/packages/components/src/components/Resizable/hooks/utils.ts
@@ -0,0 +1,75 @@
+import type {
+ ResizableBounds,
+ ResizableHandleDirection,
+ ResizableSize,
+ ResizableSizeConstraints,
+} from './types';
+
+const isFiniteNumber = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isFinite(value);
+
+export const getResizableBounds = (
+ minSize?: ResizableSizeConstraints,
+ maxSize?: ResizableSizeConstraints
+): ResizableBounds => {
+ const minWidth = Math.max(
+ 0,
+ isFiniteNumber(minSize?.width) ? minSize.width : 0
+ );
+
+ const minHeight = Math.max(
+ 0,
+ isFiniteNumber(minSize?.height) ? minSize.height : 0
+ );
+
+ return {
+ minWidth,
+ minHeight,
+ maxWidth: isFiniteNumber(maxSize?.width)
+ ? Math.max(minWidth, maxSize.width)
+ : Number.POSITIVE_INFINITY,
+ maxHeight: isFiniteNumber(maxSize?.height)
+ ? Math.max(minHeight, maxSize.height)
+ : Number.POSITIVE_INFINITY,
+ };
+};
+
+const clamp = (value: number, min: number, max: number) =>
+ Math.max(min, Math.min(max, value));
+
+export const clampResizableSize = (
+ size: ResizableSize,
+ bounds: ResizableBounds
+): ResizableSize => ({
+ width: clamp(
+ isFiniteNumber(size.width) ? size.width : bounds.minWidth,
+ bounds.minWidth,
+ bounds.maxWidth
+ ),
+ height: clamp(
+ isFiniteNumber(size.height) ? size.height : bounds.minHeight,
+ bounds.minHeight,
+ bounds.maxHeight
+ ),
+});
+
+export const normalizeResizableSize = (
+ size: ResizableSize | undefined,
+ bounds: ResizableBounds
+) => (size ? clampResizableSize(size, bounds) : undefined);
+
+export const getDirectionKey = ([x, y]: ResizableHandleDirection) =>
+ `${x}:${y}`;
+
+export const isResizableHandleDirection = (
+ direction: readonly number[]
+): direction is ResizableHandleDirection => {
+ const [x, y] = direction;
+
+ return (
+ direction.length === 2 &&
+ (x === -1 || x === 0 || x === 1) &&
+ (y === -1 || y === 0 || y === 1) &&
+ (x !== 0 || y !== 0)
+ );
+};
diff --git a/packages/components/src/components/Resizable/index.ts b/packages/components/src/components/Resizable/index.ts
new file mode 100644
index 00000000..45800000
--- /dev/null
+++ b/packages/components/src/components/Resizable/index.ts
@@ -0,0 +1,2 @@
+export * from './Resizable';
+export * from './types';
diff --git a/packages/components/src/components/Resizable/types.ts b/packages/components/src/components/Resizable/types.ts
new file mode 100644
index 00000000..356a8f61
--- /dev/null
+++ b/packages/components/src/components/Resizable/types.ts
@@ -0,0 +1,78 @@
+import type {
+ ComponentPropsWithRef,
+ CSSProperties,
+ ElementType,
+ ReactNode,
+} from 'react';
+
+import type { AsProps } from '@koobiq/react-core';
+
+import type {
+ ResizableHandleDirection,
+ ResizableSize,
+ ResizableSizeConstraints,
+} from './hooks';
+
+export { resizableHandleDirections } from './hooks';
+export type {
+ ResizableHandleDirection,
+ ResizableSize,
+ ResizableSizeConstraints,
+} from './hooks';
+
+export type ResizableBaseProps = {
+ /** The controlled size of the element, in CSS pixels. */
+ size?: ResizableSize;
+ /** The initial size of the element when uncontrolled, in CSS pixels. */
+ defaultSize?: ResizableSize;
+ /** The minimum allowed size. Omitted dimensions default to zero. */
+ minSize?: ResizableSizeConstraints;
+ /** The maximum allowed size. Omitted dimensions have no upper limit. */
+ maxSize?: ResizableSizeConstraints;
+ /** Whether resizing is disabled. */
+ isDisabled?: boolean;
+ /** Handler called whenever the size changes. */
+ onResize?: (size: ResizableSize) => void;
+ /** Handler called when a resize interaction starts. */
+ onResizeStart?: (size: ResizableSize) => void;
+ /** Handler called when a resize interaction ends. */
+ onResizeEnd?: (size: ResizableSize) => void;
+ /** The id of the resizable target. */
+ id?: string;
+ /** Additional CSS-classes. */
+ className?: string;
+ /** Inline styles. */
+ style?: CSSProperties;
+ /** The content of the component. */
+ children?: ReactNode;
+};
+
+export type ResizableProps = AsProps<
+ As,
+ ResizableBaseProps,
+ ComponentPropsWithRef
+>;
+
+export type ResizableHandleBaseProps = {
+ /**
+ * Physical resize direction: `-1` means left/up, `0` disables the axis,
+ * and `1` means right/down.
+ */
+ direction: ResizableHandleDirection;
+ /** The accessible name of the handle. */
+ 'aria-label'?: string;
+ /** Overrides the handle's position in the tab order. */
+ tabIndex?: number;
+ /** Additional CSS-classes. */
+ className?: string;
+ /** Inline styles. */
+ style?: CSSProperties;
+ /** The content of the handle. */
+ children?: ReactNode;
+};
+
+export type ResizableHandleProps = AsProps<
+ As,
+ ResizableHandleBaseProps,
+ ComponentPropsWithRef
+>;
diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts
index 9837688b..81d735bc 100644
--- a/packages/components/src/components/index.ts
+++ b/packages/components/src/components/index.ts
@@ -56,6 +56,7 @@ export * from './TreeSelect';
export * from './EmptyState';
export * from './Flag';
export * from './Sidebar';
+export * from './Resizable';
export * from './layout';
export {
useListData,
diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json
index 205f5c09..8484bfc6 100644
--- a/tools/api-extractor/config.json
+++ b/tools/api-extractor/config.json
@@ -38,6 +38,7 @@
"ProgressSpinner",
"Provider",
"RadioGroup",
+ "Resizable",
"SearchInput",
"Select",
"Sidebar",
diff --git a/tools/public_api_guard/components/Resizable.api.md b/tools/public_api_guard/components/Resizable.api.md
new file mode 100644
index 00000000..97edbb65
--- /dev/null
+++ b/tools/public_api_guard/components/Resizable.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 { AsProps } from '@koobiq/react-core';
+import type { ComponentPropsWithRef } from 'react';
+import type { CSSProperties } from 'react';
+import { ElementType } from 'react';
+import { PolyForwardComponent } from '@koobiq/react-core';
+import type { ReactNode } from 'react';
+
+// Warning: (ae-forgotten-export) The symbol "CompoundedComponent" needs to be exported by the entry point index.d.ts
+//
+// @public
+export const Resizable: CompoundedComponent;
+
+// @public (undocumented)
+export type ResizableBaseProps = {
+ size?: ResizableSize;
+ defaultSize?: ResizableSize;
+ minSize?: ResizableSizeConstraints;
+ maxSize?: ResizableSizeConstraints;
+ isDisabled?: boolean;
+ onResize?: (size: ResizableSize) => void;
+ onResizeStart?: (size: ResizableSize) => void;
+ onResizeEnd?: (size: ResizableSize) => void;
+ id?: string;
+ className?: string;
+ style?: CSSProperties;
+ children?: ReactNode;
+};
+
+// @public (undocumented)
+export type ResizableHandleBaseProps = {
+ direction: ResizableHandleDirection;
+ 'aria-label'?: string;
+ tabIndex?: number;
+ className?: string;
+ style?: CSSProperties;
+ children?: ReactNode;
+};
+
+// @public (undocumented)
+export type ResizableHandleDirection = (typeof resizableHandleDirections)[number];
+
+// @public (undocumented)
+export const resizableHandleDirections: readonly [readonly [-1, -1], readonly [0, -1], readonly [1, -1], readonly [-1, 0], readonly [1, 0], readonly [-1, 1], readonly [0, 1], readonly [1, 1]];
+
+// @public (undocumented)
+export type ResizableHandleProps = AsProps>;
+
+// @public (undocumented)
+export type ResizableProps = AsProps>;
+
+// @public (undocumented)
+export type ResizableSize = {
+ width: number;
+ height: number;
+};
+
+// @public (undocumented)
+export type ResizableSizeConstraints = Partial;
+
+// (No @packageDocumentation comment for this package)
+
+```