Skip to content

Commit a3ea25a

Browse files
authored
feat(components): add Resizable component (DS-5253) (#426)
1 parent 6853984 commit a3ea25a

20 files changed

Lines changed: 1756 additions & 0 deletions
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import {
2+
Meta,
3+
Story,
4+
Props,
5+
Status,
6+
} from '../../../../../.storybook/components';
7+
8+
import * as Stories from './Resizable.stories';
9+
10+
<Meta of={Stories} />
11+
12+
# Resizable
13+
14+
<Status variant="experimental" />
15+
16+
Resizable changes an element's width and height using one or more composed handles.
17+
The element stays in place while its size changes.
18+
19+
## Import
20+
21+
```tsx
22+
import { Resizable } from '@koobiq/react-components';
23+
```
24+
25+
## Usage
26+
27+
<Story of={Stories.Base} />
28+
29+
Both `Resizable` and `Resizable.Handle` render a `div` by default. Use the `as` prop
30+
to render another element while preserving its native props and ref type.
31+
32+
```tsx
33+
<Resizable as="section">
34+
<Content />
35+
<Resizable.Handle as="button" type="button" direction={[1, 1]} />
36+
</Resizable>
37+
```
38+
39+
## Props
40+
41+
<Props of={Stories.Base} />
42+
43+
## State
44+
45+
Use `defaultSize` for uncontrolled state, or combine `size` and `onResize` to control the size.
46+
All values are measured in CSS pixels.
47+
48+
<Story of={Stories.Uncontrolled} />
49+
50+
### Intrinsic size
51+
52+
When both `size` and `defaultSize` are omitted, Resizable preserves its natural CSS size.
53+
The measured size is fixed in pixels only after the first resize interaction.
54+
55+
<Story of={Stories.IntrinsicSize} />
56+
57+
## Direction
58+
59+
`Resizable.Handle` accepts a physical `[x, y]` direction. Each axis supports `-1`, `0`, and `1`:
60+
61+
- `x`: `-1` resizes from the left, `0` disables horizontal resizing, and `1` resizes from the right.
62+
- `y`: `-1` resizes from the top, `0` disables vertical resizing, and `1` resizes from the bottom.
63+
- `[0, 0]` is not a valid direction.
64+
65+
The direction remains physical in right-to-left layouts.
66+
67+
<Story of={Stories.SingleDirection} />
68+
69+
## Constraints
70+
71+
Use `minSize` and `maxSize` to constrain either dimension independently. The default minimum is zero,
72+
and dimensions without a maximum are unbounded. If a minimum exceeds a maximum, the minimum wins.
73+
74+
## Custom handle
75+
76+
Handles can be styled without changing their hit area or resize behavior. This example adds a small
77+
indicator outside the right edge and composes the handle with a tooltip. See
78+
[Styling handles](#styling-handles) for available state attributes and [CSS Variables](#css-variables)
79+
for geometry customization.
80+
81+
<Story of={Stories.CustomHandle} />
82+
83+
## Keyboard interaction
84+
85+
Handles are focusable. Use the arrow keys to resize by one pixel, or hold <kbd>Shift</kbd> to resize
86+
by ten pixels. Edge handles expose the ARIA separator pattern; corner handles expose an accessible
87+
two-dimensional resize control.
88+
89+
## Disabled
90+
91+
Set `isDisabled` to remove all handles from the tab order and disable resize interactions.
92+
93+
<Story of={Stories.Disabled} />
94+
95+
## Styling handles
96+
97+
Handles provide positioning, hit areas, cursors, and state attributes, but no visible grip or line.
98+
Use `className` and the `data-direction-x`, `data-direction-y`, `data-hovered`, `data-focused`,
99+
`data-focus-visible`, `data-resizing`, and `data-disabled` attributes to provide an
100+
application-specific indicator.
101+
102+
## CSS Variables
103+
104+
Use CSS variables to customize the handle geometry without inline styles.
105+
106+
| Variable |
107+
| ---------------------------------- |
108+
| `--kbq-resizable-handle-width` |
109+
| `--kbq-resizable-handle-height` |
110+
| `--kbq-resizable-handle-top` |
111+
| `--kbq-resizable-handle-right` |
112+
| `--kbq-resizable-handle-bottom` |
113+
| `--kbq-resizable-handle-left` |
114+
| `--kbq-resizable-handle-transform` |
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
.base {
2+
position: relative;
3+
box-sizing: border-box;
4+
}
5+
6+
.handle {
7+
--resizable-handle-width: var(--kbq-size-l);
8+
--resizable-handle-height: var(--kbq-size-l);
9+
--resizable-handle-top: auto;
10+
--resizable-handle-right: auto;
11+
--resizable-handle-bottom: auto;
12+
--resizable-handle-left: auto;
13+
--resizable-handle-translate-x: 0;
14+
--resizable-handle-translate-y: 0;
15+
16+
position: absolute;
17+
z-index: 1;
18+
box-sizing: border-box;
19+
inline-size: var(--kbq-resizable-handle-width, var(--resizable-handle-width));
20+
block-size: var(
21+
--kbq-resizable-handle-height,
22+
var(--resizable-handle-height)
23+
);
24+
inset: var(--kbq-resizable-handle-top, var(--resizable-handle-top))
25+
var(--kbq-resizable-handle-right, var(--resizable-handle-right))
26+
var(--kbq-resizable-handle-bottom, var(--resizable-handle-bottom))
27+
var(--kbq-resizable-handle-left, var(--resizable-handle-left));
28+
touch-action: none;
29+
background: transparent;
30+
transform: var(
31+
--kbq-resizable-handle-transform,
32+
translate(
33+
var(--resizable-handle-translate-x),
34+
var(--resizable-handle-translate-y)
35+
)
36+
);
37+
}
38+
39+
.handle[data-direction-x='-1'] {
40+
--resizable-handle-left: 0;
41+
--resizable-handle-translate-x: -50%;
42+
}
43+
44+
.handle[data-direction-x='0'] {
45+
--resizable-handle-width: 100%;
46+
--resizable-handle-left: 0;
47+
48+
cursor: ns-resize;
49+
}
50+
51+
.handle[data-direction-x='1'] {
52+
--resizable-handle-right: 0;
53+
--resizable-handle-translate-x: 50%;
54+
}
55+
56+
.handle[data-direction-y='-1'] {
57+
--resizable-handle-top: 0;
58+
--resizable-handle-translate-y: -50%;
59+
}
60+
61+
.handle[data-direction-y='0'] {
62+
--resizable-handle-height: 100%;
63+
--resizable-handle-top: 0;
64+
65+
cursor: ew-resize;
66+
}
67+
68+
.handle[data-direction-y='1'] {
69+
--resizable-handle-bottom: 0;
70+
--resizable-handle-translate-y: 50%;
71+
}
72+
73+
.handle:is(
74+
[data-direction-x='-1'][data-direction-y='-1'],
75+
[data-direction-x='1'][data-direction-y='1']
76+
) {
77+
cursor: nwse-resize;
78+
}
79+
80+
.handle:is(
81+
[data-direction-x='1'][data-direction-y='-1'],
82+
[data-direction-x='-1'][data-direction-y='1']
83+
) {
84+
cursor: nesw-resize;
85+
}
86+
87+
.handle[data-disabled] {
88+
cursor: default;
89+
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { useState } from 'react';
2+
3+
import { clsx } from '@koobiq/react-core';
4+
import type { Meta, StoryObj } from '@storybook/react';
5+
6+
import { Tooltip } from '../Tooltip';
7+
import { Typography } from '../Typography';
8+
9+
import s from './__stories__/styles.module.css';
10+
import { Resizable } from './Resizable';
11+
import type { ResizableProps, ResizableSize } from './types';
12+
13+
const meta = {
14+
title: 'Components/Resizable',
15+
component: Resizable,
16+
subcomponents: {
17+
'Resizable.Handle': Resizable.Handle,
18+
},
19+
parameters: {
20+
layout: 'centered',
21+
},
22+
tags: ['status:new', 'date:2026-07-16'],
23+
} satisfies Meta<typeof Resizable>;
24+
25+
export default meta;
26+
type Story = StoryObj<ResizableProps>;
27+
28+
const handleClassName = s.handle;
29+
30+
function Handles() {
31+
return (
32+
<>
33+
<Resizable.Handle className={handleClassName} direction={[-1, -1]} />
34+
<Resizable.Handle className={handleClassName} direction={[0, -1]} />
35+
<Resizable.Handle className={handleClassName} direction={[1, -1]} />
36+
<Resizable.Handle className={handleClassName} direction={[-1, 0]} />
37+
<Resizable.Handle className={handleClassName} direction={[1, 0]} />
38+
<Resizable.Handle className={handleClassName} direction={[-1, 1]} />
39+
<Resizable.Handle className={handleClassName} direction={[0, 1]} />
40+
<Resizable.Handle className={handleClassName} direction={[1, 1]} />
41+
</>
42+
);
43+
}
44+
45+
export const Base: Story = {
46+
render: function Render(args) {
47+
const [size, setSize] = useState<ResizableSize>({
48+
width: 400,
49+
height: 300,
50+
});
51+
52+
return (
53+
<Resizable
54+
{...args}
55+
size={size}
56+
className={s.content}
57+
minSize={{ width: 200, height: 150 }}
58+
maxSize={{ width: 800, height: 600 }}
59+
onResize={setSize}
60+
>
61+
<Typography>
62+
{Math.round(size.width)} × {Math.round(size.height)} px
63+
</Typography>
64+
<Handles />
65+
</Resizable>
66+
);
67+
},
68+
};
69+
70+
export const Uncontrolled: Story = {
71+
render: (args) => (
72+
<Resizable
73+
{...args}
74+
className={s.content}
75+
defaultSize={{ width: 360, height: 240 }}
76+
minSize={{ width: 200, height: 120 }}
77+
maxSize={{ width: 560, height: 400 }}
78+
>
79+
<Typography>Uncontrolled size</Typography>
80+
<Handles />
81+
</Resizable>
82+
),
83+
};
84+
85+
export const IntrinsicSize: Story = {
86+
render: (args) => (
87+
<Resizable
88+
{...args}
89+
className={clsx(s.content, s.intrinsic)}
90+
minSize={{ width: 160, height: 80 }}
91+
maxSize={{ width: 560, height: 400 }}
92+
>
93+
<Typography>
94+
This element keeps its intrinsic size until a handle is moved
95+
</Typography>
96+
<Handles />
97+
</Resizable>
98+
),
99+
};
100+
101+
export const SingleDirection: Story = {
102+
render: (args) => (
103+
<Resizable
104+
{...args}
105+
className={s.content}
106+
defaultSize={{ width: 400, height: 240 }}
107+
minSize={{ width: 200 }}
108+
maxSize={{ width: 600 }}
109+
>
110+
<Typography>Resize from the right edge</Typography>
111+
<Resizable.Handle className={handleClassName} direction={[1, 0]} />
112+
</Resizable>
113+
),
114+
};
115+
116+
export const CustomHandle: Story = {
117+
render: (args) => (
118+
<>
119+
<style>{`
120+
.custom-resizable-handle {
121+
--kbq-resizable-handle-transform: translate(100%, 0px);
122+
123+
outline: none;
124+
}
125+
126+
.custom-resizable-handle::after {
127+
position: absolute;
128+
inset-block-start: 50%;
129+
inset-inline-start: 50%;
130+
inline-size: var(--kbq-size-xxs);
131+
block-size: var(--kbq-size-7xl);
132+
border-radius: var(--kbq-size-xxs);
133+
background-color: var(--kbq-line-contrast-less);
134+
content: '';
135+
opacity: 0;
136+
pointer-events: none;
137+
transform: translate(-50%, -50%);
138+
transition: opacity var(--kbq-transition-default);
139+
}
140+
141+
.custom-resizable-handle:is(
142+
[data-hovered],
143+
[data-focus-visible],
144+
[data-resizing]
145+
)::after {
146+
opacity: 1;
147+
}
148+
`}</style>
149+
150+
<Resizable
151+
{...args}
152+
className={s.content}
153+
defaultSize={{ width: 360, height: 240 }}
154+
minSize={{ width: 200 }}
155+
maxSize={{ width: 600 }}
156+
>
157+
<Typography>Hover over the right edge</Typography>
158+
<Tooltip
159+
placement="end"
160+
shouldCloseOnPress={false}
161+
control={(controlProps) => (
162+
<Resizable.Handle
163+
{...controlProps}
164+
aria-label="Resize"
165+
className="custom-resizable-handle"
166+
direction={[1, 0]}
167+
/>
168+
)}
169+
>
170+
Resize
171+
</Tooltip>
172+
</Resizable>
173+
</>
174+
),
175+
};
176+
177+
export const Disabled: Story = {
178+
render: (args) => (
179+
<Resizable
180+
{...args}
181+
className={s.content}
182+
defaultSize={{ width: 400, height: 240 }}
183+
isDisabled
184+
>
185+
<Typography>Resizing is disabled</Typography>
186+
<Handles />
187+
</Resizable>
188+
),
189+
};

0 commit comments

Comments
 (0)