Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion packages/components/src/components/Resizable/Resizable.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,23 @@ 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.
The first resize interaction fixes the resized axes in pixels, the others keep their CSS size.

<Story of={Stories.IntrinsicSize} />

### One dimension

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs — the ### Intrinsic size text just above is now stale.

Line 53 still reads “The measured size is fixed in pixels only after the first resize interaction”, which describes the pre-PR behavior. This PR changes exactly that, and its own test edit proves it: the intrinsic-size assertion went from { width: '261px', height: '140px' } to width plus style.height === ''.

A reader following ### Intrinsic size still expects both axes to freeze after one drag and gets the opposite, with the contradicting rule buried in the next section. Suggest “the resized axis is fixed in pixels after the first resize interaction”.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded: the first resize interaction fixes the resized axes in pixels, the others keep their CSS size.


`size` and `defaultSize` accept a single dimension. The omitted one keeps its CSS size and is
never written to the element, which suits elements sized by their layout in the other axis —
a side panel stretched to the full height of its container, for example.

An axis becomes managed as soon as it's given in `size` / `defaultSize`, or resized by a handle.
The `onResize`, `onResizeStart` and `onResizeEnd` handlers always report both dimensions.
An axis that isn't managed is measured once, when the resize starts. To control a single axis,
take only that axis from the handler: passing the whole size back would manage the other one too.

<Story of={Stories.OneDimension} />

## Direction

`Resizable.Handle` accepts a physical `[x, y]` direction. Each axis supports `-1`, `0`, and `1`:
Expand Down Expand Up @@ -86,6 +99,9 @@ Handles are focusable. Use the arrow keys to resize by one pixel, or hold <kbd>S
by ten pixels. Edge handles expose the ARIA separator pattern; corner handles expose an accessible
two-dimensional resize control.

Set `disableKeyboardResize` on a handle to leave it drag-only. It also drops out of the tab order,
unless `tabIndex` says otherwise.

## Disabled

Set `isDisabled` to remove all handles from the tab order and disable resize interactions.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const meta = {
parameters: {
layout: 'centered',
},
tags: ['status:new', 'date:2026-07-16'],
tags: ['status:updated', 'date:2026-09-11'],
} satisfies Meta<typeof Resizable>;

export default meta;
Expand Down Expand Up @@ -98,6 +98,28 @@ export const IntrinsicSize: Story = {
),
};

export const OneDimension: Story = {
render: function Render(args) {
const [width, setWidth] = useState(240);

return (
<div className={s.layout}>
<Resizable
{...args}
className={s.content}
size={{ width }}
minSize={{ width: 160 }}
maxSize={{ width: 400 }}
onResize={(size) => setWidth(size.width)}
>
<Typography>{Math.round(width)} px × 100%</Typography>
<Resizable.Handle className={handleClassName} direction={[1, 0]} />
</Resizable>
</div>
);
},
};

export const SingleDirection: Story = {
render: (args) => (
<Resizable
Expand Down
152 changes: 123 additions & 29 deletions packages/components/src/components/Resizable/Resizable.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createRef, type ComponentProps } from 'react';
import { createRef, useState, type ComponentProps } from 'react';

import type * as ReactCore from '@koobiq/react-core';
import { fireEvent, render, screen } from '@testing-library/react';
Expand Down Expand Up @@ -70,33 +70,6 @@ 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(() => {
Expand Down Expand Up @@ -280,7 +253,110 @@ describe('Resizable', () => {

fireEvent.keyDown(getHandle(), { key: 'ArrowRight' });

expect(getRoot()).toHaveStyle({ width: '261px', height: '140px' });
// Only the resized axis becomes managed, the other one is left to CSS.
expect(getRoot()).toHaveStyle({ width: '261px' });
expect(getRoot().style.height).toBe('');
});

it('manages a single dimension when the size defines one axis', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test-coverage — every new single-axis test is uncontrolled.

Nothing here exercises size + onResize with one axis omitted, even though the suite already has a controlled test to model it on (line 247). The controlled round-trip is the case that is actually broken — echoing onResize back into size re-freezes the omitted axis after one drag (see the comment on useResizableState.ts).

A test asserting getRoot().style.height === '' after a controlled [1, 0] drag would have caught it, and is the guard that keeps it fixed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added keeps the omitted axis unmanaged in controlled state: after a controlled [1, 0] drag, style.height stays empty.

const onResize = vi.fn();

mocks.observedSize = { width: 300, height: 200 };

renderResizable([1, 0], {
defaultSize: { width: 300 },
minSize: { width: 100 },
maxSize: { width: 500 },
onResize,
});

expect(getRoot()).toHaveStyle({
width: '300px',
minWidth: '100px',
maxWidth: '500px',
});

expect(getRoot().style.height).toBe('');
expect(getRoot().style.minHeight).toBe('');
expect(getRoot().style.maxHeight).toBe('');

drag(25, 40);

// The callbacks still report both dimensions, measured where unmanaged.
expect(onResize).toHaveBeenLastCalledWith({ width: 325, height: 200 });
expect(getRoot()).toHaveStyle({ width: '325px' });
expect(getRoot().style.height).toBe('');
});

it('keeps the omitted axis unmanaged in controlled state', () => {
function ControlledWidth() {
const [width, setWidth] = useState(300);

return (
<Resizable
data-testid="resizable"
size={{ width }}
onResize={(size) => setWidth(size.width)}
>
<Resizable.Handle data-testid="handle" direction={[1, 0]} />
</Resizable>
);
}

render(<ControlledWidth />);
drag(20, 10);

expect(getRoot()).toHaveStyle({ width: '320px' });
expect(getRoot().style.height).toBe('');
});

it('keeps the managed axis when another axis is resized', () => {
render(
<Resizable data-testid="resizable" defaultSize={{ width: 300 }}>
<Resizable.Handle data-testid="handle" direction={[0, 1]} />
</Resizable>
);

fireEvent.keyDown(getHandle(), { key: 'ArrowDown' });

expect(getRoot()).toHaveStyle({ width: '300px', height: '201px' });
});

it('disables arrow key resizing without breaking dragging', () => {
render(
<Resizable data-testid="resizable" defaultSize={{ width: 300 }}>
<Resizable.Handle
data-testid="handle"
direction={[1, 0]}
disableKeyboardResize
/>
</Resizable>
);

expect(getHandle()).toHaveAttribute('tabindex', '-1');

fireEvent.keyDown(getHandle(), { key: 'ArrowRight' });

expect(getRoot()).toHaveStyle({ width: '300px' });

drag(20, 0);

expect(getRoot()).toHaveStyle({ width: '320px' });
});

it('keeps the handle focusable when tabIndex is set explicitly', () => {
render(
<Resizable data-testid="resizable" defaultSize={{ width: 300 }}>
<Resizable.Handle
data-testid="handle"
direction={[1, 0]}
tabIndex={0}
disableKeyboardResize
/>
</Resizable>
);

expect(getHandle()).toHaveAttribute('tabindex', '0');
});

it('calls lifecycle callbacks with the snapshot and final size', () => {
Expand Down Expand Up @@ -438,6 +514,24 @@ describe('Resizable', () => {
expect(getHandle()).toHaveAttribute('aria-controls', getRoot().id);
});

it('does not advertise arrow keys when keyboard resizing is disabled', () => {
render(
<Resizable
data-testid="resizable"
defaultSize={{ width: 300, height: 200 }}
>
<Resizable.Handle
data-testid="handle"
direction={[1, 1]}
disableKeyboardResize
/>
</Resizable>
);

expect(getHandle()).toHaveAttribute('role', 'button');
expect(getHandle()).not.toHaveAttribute('aria-keyshortcuts');
});

it('localizes default accessible labels', () => {
render(
<Provider locale="ru-RU">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const ResizableHandle = polymorphicForwardRef<
const {
as: Tag = 'div',
direction,
disableKeyboardResize,
className,
style: styleProp,
tabIndex: tabIndexProp,
Expand All @@ -33,6 +34,7 @@ export const ResizableHandle = polymorphicForwardRef<
const { handleProps } = useResizableHandle(
{
direction,
disableKeyboardResize,
'aria-label': ariaLabelProp,
tabIndex: tabIndexProp,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@
max-inline-size: 480px;
}

.layout {
box-sizing: border-box;
border: 1px dashed var(--kbq-line-contrast-less);
border-radius: var(--kbq-size-s);
display: flex;
inline-size: 560px;
block-size: 320px;
}

.handle {
background-color: var(--kbq-background-transparent);
opacity: 0.25;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ import { useElementSize } from '@koobiq/react-core';
import type {
ResizableHandleDirection,
ResizableMoveEvent,
ResizableSize,
ResizableSizeConstraints,
} from './types';
import type { ResizableState } from './useResizableState';
import { clampResizableSize } from './utils';

export type ResizableContextValue = {
rootId: string;
size: NonNullable<ResizableState['size']>;
/** The current size of both axes, measured where an axis isn't managed. */
size: ResizableSize;
bounds: ResizableState['bounds'];
isDisabled: boolean;
activeDirection: string | null;
Expand Down Expand Up @@ -55,19 +57,24 @@ export const useResizable = <T extends HTMLElement = HTMLElement>(
height,
} = useElementSize<T>({ box: 'border-box' });

const observedSize = clampResizableSize({ width, height }, bounds);
const currentSize = managedSize ?? observedSize;
const currentSize = useMemo(
() =>
clampResizableSize(
{
width: managedSize?.width ?? width,
height: managedSize?.height ?? height,
},
bounds
),
[managedSize?.width, managedSize?.height, width, height, bounds]
);

// Starts from the size the handles announce. The element may lag behind a
// managed axis while it animates, and CSS transforms would scale its rect.
const handleMoveStart = useCallback(
(direction: ResizableHandleDirection) => {
const rect = targetRef.current?.getBoundingClientRect();

startResize(
direction,
rect ? { width: rect.width, height: rect.height } : currentSize
);
},
[currentSize, startResize, targetRef]
(direction: ResizableHandleDirection) =>
startResize(direction, currentSize),
[currentSize, startResize]
);

const contextValue = useMemo<ResizableContextValue>(
Expand Down Expand Up @@ -98,10 +105,8 @@ export const useResizable = <T extends HTMLElement = HTMLElement>(
...(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,
}),
...(managedSize?.width !== undefined && { width: managedSize.width }),
...(managedSize?.height !== undefined && { height: managedSize.height }),
} satisfies CSSProperties;

const resizableProps = {
Expand Down
Loading
Loading