Skip to content

Commit 35b1fc8

Browse files
authored
feat(SplitterLayout): introduce onResize event (#7519)
Closes #7477
1 parent 4e902af commit 35b1fc8

File tree

7 files changed

+214
-12
lines changed

7 files changed

+214
-12
lines changed

packages/main/src/components/Splitter/index.tsx

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@ import horizontalGripIcon from '@ui5/webcomponents-icons/dist/horizontal-grip.js
55
import verticalGripIcon from '@ui5/webcomponents-icons/dist/vertical-grip.js';
66
import { useCurrentTheme, useI18nBundle, useIsRTL, useSyncRef, useStylesheet } from '@ui5/webcomponents-react-base';
77
import { forwardRef, useEffect, useRef, useState } from 'react';
8+
import type { KeyboardEventHandler, PointerEventHandler } from 'react';
89
import { PRESS_ARROW_KEYS_TO_MOVE } from '../../i18n/i18n-defaults.js';
9-
import type { CommonProps } from '../../types/index.js';
10-
import { Button, Icon } from '../../webComponents/index.js';
10+
import { Button } from '../../webComponents/Button/index.js';
11+
import { Icon } from '../../webComponents/Icon/index.js';
12+
import type { SplitterLayoutPropTypes } from '../SplitterLayout/types.js';
1113
import { classNames, styleData } from './Splitter.module.css.js';
1214

13-
export interface SplitterPropTypes extends CommonProps {
15+
export interface SplitterPropTypes {
1416
height: string | number;
1517
width: string | number;
1618
vertical: boolean;
19+
onResize: SplitterLayoutPropTypes['onResize'] | undefined;
1720
}
1821

1922
const verticalPositionInfo = {
@@ -39,7 +42,7 @@ const horizontalPositionInfo = {
3942
};
4043

4144
const Splitter = forwardRef<HTMLDivElement, SplitterPropTypes>((props, ref) => {
42-
const { vertical } = props;
45+
const { vertical, onResize } = props;
4346
const i18nBundle = useI18nBundle('@ui5/webcomponents-react');
4447
const [componentRef, localRef] = useSyncRef<HTMLDivElement>(ref);
4548
const isRtl = useIsRTL(localRef);
@@ -58,6 +61,38 @@ const Splitter = forwardRef<HTMLDivElement, SplitterPropTypes>((props, ref) => {
5861
const [isDragging, setIsDragging] = useState<boolean | string>(false);
5962
const [isSiblings, setIsSiblings] = useState(['previousSibling', 'nextSibling']);
6063

64+
const animationFrameIdRef = useRef(null);
65+
const fireOnResize = (prevSibling: HTMLElement, nextSibling: HTMLElement) => {
66+
if (animationFrameIdRef.current) {
67+
cancelAnimationFrame(animationFrameIdRef.current);
68+
}
69+
if (typeof onResize !== 'function') {
70+
return;
71+
}
72+
animationFrameIdRef.current = requestAnimationFrame(() => {
73+
const logicalPrevSibling = isRtl ? nextSibling : prevSibling;
74+
const logicalNextSibling = isRtl ? prevSibling : nextSibling;
75+
const splitterWidth = localRef.current.getBoundingClientRect()[positionKeys.size];
76+
onResize({
77+
areas: [
78+
{
79+
size: logicalPrevSibling.getBoundingClientRect()?.[positionKeys.size] + splitterWidth,
80+
area: logicalPrevSibling,
81+
},
82+
{
83+
// last element doesn't have splitter
84+
size:
85+
logicalNextSibling.getBoundingClientRect()?.[positionKeys.size] +
86+
(logicalNextSibling.nextElementSibling !== null ? splitterWidth : 0),
87+
area: logicalNextSibling,
88+
},
89+
],
90+
splitter: localRef.current,
91+
});
92+
animationFrameIdRef.current = null;
93+
});
94+
};
95+
6196
const handleSplitterMove = (e) => {
6297
const offset = resizerClickOffset.current;
6398
const previousSibling = localRef.current[isSiblings[0]] as HTMLDivElement;
@@ -71,10 +106,10 @@ const Splitter = forwardRef<HTMLDivElement, SplitterPropTypes>((props, ref) => {
71106

72107
const move = () => {
73108
previousSibling.style.flex = `0 0 ${previousSiblingSize.current + sizeDiv}px`;
74-
75109
if (nextSibling.nextSibling && previousSiblingSize.current + sizeDiv > 0) {
76110
nextSibling.style.flex = `0 0 ${nextSiblingSize.current - sizeDiv}px`;
77111
}
112+
fireOnResize(previousSibling, nextSibling);
78113
};
79114

80115
if (
@@ -126,6 +161,7 @@ const Splitter = forwardRef<HTMLDivElement, SplitterPropTypes>((props, ref) => {
126161
(nextSiblingRect?.[positionKeys.size] as number) + prevSiblingRect?.[positionKeys.size]
127162
}px`;
128163
}
164+
fireOnResize(prevSibling, nextSibling);
129165
}
130166

131167
// right
@@ -142,10 +178,12 @@ const Splitter = forwardRef<HTMLDivElement, SplitterPropTypes>((props, ref) => {
142178
(prevSiblingRect?.[positionKeys.size] as number) + nextSiblingRect?.[positionKeys.size]
143179
}px`;
144180
}
181+
182+
fireOnResize(prevSibling, nextSibling);
145183
}
146184
};
147185

148-
const handleMoveSplitterStart = (e) => {
186+
const handleMoveSplitterStart: PointerEventHandler<HTMLDivElement> = (e) => {
149187
if (e.type === 'pointerdown' && e.pointerType !== 'touch') {
150188
return;
151189
}
@@ -175,7 +213,7 @@ const Splitter = forwardRef<HTMLDivElement, SplitterPropTypes>((props, ref) => {
175213
start.current = e[`client${positionKeys.position}`];
176214
};
177215

178-
const onHandleKeyDown = (e) => {
216+
const onHandleKeyDown: KeyboardEventHandler<HTMLDivElement> = (e) => {
179217
const keyEventProperties = e.code ?? e.key;
180218
if (
181219
keyEventProperties === 'ArrowRight' ||
@@ -203,6 +241,12 @@ const Splitter = forwardRef<HTMLDivElement, SplitterPropTypes>((props, ref) => {
203241
const secondSiblingSize = secondSibling.getBoundingClientRect()?.[positionKeys.size] as number;
204242
secondSibling.style.flex = `0 0 ${secondSiblingSize - tickSize}px`;
205243
firstSibling.style.flex = `0 0 ${firstSiblingSize + tickSize}px`;
244+
245+
if (keyEventProperties === 'ArrowLeft' || keyEventProperties === 'ArrowUp') {
246+
fireOnResize(secondSibling, firstSibling);
247+
} else {
248+
fireOnResize(firstSibling, secondSibling);
249+
}
206250
}
207251
}
208252
};

packages/main/src/components/SplitterElement/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export interface SplitterElementPropTypes extends CommonProps {
2020
/**
2121
* Defines the initial size of the `SplitterElement`.
2222
*
23+
* __Note:__ In order to preserve the intended design, at least one `SplitterElement` should have a dynamic `size`.
24+
*
2325
* @default `"auto"`
2426
*/
2527
size?: CSSProperties['width'] | CSSProperties['height'];

packages/main/src/components/SplitterLayout/SplitterLayout.cy.tsx

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState } from 'react';
22
import type { SplitterLayoutPropTypes } from '../..';
3-
import { Button, Label, SplitterElement, SplitterLayout } from '../..';
3+
import { FlexBox, Text, Button, Label, SplitterElement, SplitterLayout } from '../..';
44
import { cypressPassThroughTestsFactory } from '@/cypress/support/utils';
55

66
function TestComp({ vertical, dir }: { vertical: SplitterLayoutPropTypes['vertical']; dir: string }) {
@@ -107,7 +107,7 @@ describe('SplitterLayout', () => {
107107
);
108108
cy.findByTestId('btn').click();
109109
cy.get('[role="separator"]').first().click();
110-
// fallback click to prevent fuzzyness
110+
// fallback click to prevent flakyness
111111
cy.get('[role="separator"]')
112112
.first()
113113
.click()
@@ -129,5 +129,133 @@ describe('SplitterLayout', () => {
129129
cy.findByTestId('sl').should('not.be.visible').should('exist');
130130
});
131131

132+
[true, false].forEach((vertical) => {
133+
it(`controlled width (${vertical ? 'vertical' : 'horizontal'})`, () => {
134+
function getMouseMoveArgs(amount: number): [number, number] {
135+
return vertical ? [0, amount] : [amount, 0];
136+
}
137+
const resize = cy.spy().as('resize');
138+
const TestComp = () => {
139+
const [size0, setSize0] = useState('200px');
140+
const [size1, setSize1] = useState(200);
141+
const [size2, setSize2] = useState('auto');
142+
const [size3, setSize3] = useState('200px');
143+
const setter = [setSize0, setSize1, setSize2, setSize3];
144+
return (
145+
<>
146+
<SplitterLayout
147+
vertical={vertical}
148+
style={{ height: '900px', width: '900px', backgroundColor: 'black' }}
149+
onResize={(e) => {
150+
resize(e);
151+
e.areas.forEach((item) => {
152+
if (item.area.dataset.index === '1') {
153+
setter[Number(item.area.dataset.index)](item.size);
154+
} else {
155+
//@ts-expect-error: supported
156+
setter[Number(item.area.dataset.index)](item.size + 'px');
157+
}
158+
});
159+
}}
160+
>
161+
<SplitterElement size={size0} data-index={0} style={{ backgroundColor: 'lightcoral' }}>
162+
<FlexBox style={{ height: '100%', width: '100%' }} alignItems="Center" justifyContent="Center">
163+
<Text>Content 1</Text>
164+
</FlexBox>
165+
</SplitterElement>
166+
<SplitterElement size={size1} data-index={1} style={{ backgroundColor: 'lightblue' }}>
167+
<FlexBox style={{ height: '100%', width: '100%' }} alignItems="Center" justifyContent="Center">
168+
<Text style={{ whiteSpace: 'pre-line' }}>{`Content 2
169+
with
170+
multi
171+
lines`}</Text>
172+
</FlexBox>
173+
</SplitterElement>
174+
<SplitterElement size={'auto'} data-index={2} style={{ backgroundColor: 'lightgreen' }}>
175+
<FlexBox style={{ height: '100%', width: '100%' }} alignItems="Center" justifyContent="Center">
176+
<Text>
177+
Content 3 with long text: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy
178+
eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et
179+
accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est
180+
Lorem ipsum dolor sit amet.
181+
</Text>
182+
</FlexBox>
183+
</SplitterElement>
184+
<SplitterElement size={size3} data-index={3} style={{ backgroundColor: 'lightgoldenrodyellow' }}>
185+
<FlexBox style={{ height: '100%', width: '100%' }} alignItems="Center" justifyContent="Center">
186+
<Text>Content 4</Text>
187+
</FlexBox>
188+
</SplitterElement>
189+
</SplitterLayout>
190+
<span data-testid="0">{size0}</span>
191+
<br />
192+
<span data-testid="1">{size1}</span>
193+
<br />
194+
<span data-testid="2">{size2}</span>
195+
<br />
196+
<span data-testid="3">{size3}</span>
197+
</>
198+
);
199+
};
200+
201+
cy.mount(<TestComp />);
202+
203+
cy.get('@resize').should('not.have.been.called');
204+
cy.findAllByRole('separator')
205+
.eq(0)
206+
.realMouseDown({ position: 'center' })
207+
.realMouseMove(...getMouseMoveArgs(-100), {
208+
position: 'center',
209+
scrollBehavior: false,
210+
})
211+
.realMouseUp({ position: 'center' });
212+
213+
cy.findByTestId('0')
214+
.invoke('text')
215+
.then((txt) => parseInt(txt, 10))
216+
.should('be.within', 99, 101);
217+
cy.findByTestId('1')
218+
.invoke('text')
219+
.then((txt) => parseInt(txt, 10))
220+
.should('be.within', 299, 301);
221+
cy.findByTestId('2').should('have.text', 'auto');
222+
cy.findByTestId('3').invoke('text').should('equal', '200px');
223+
224+
cy.findAllByRole('separator').eq(0).realMouseDown({ position: 'center' });
225+
// drag across bounding box
226+
cy.get('body')
227+
.realMouseMove(...getMouseMoveArgs(300), {
228+
position: 'center',
229+
scrollBehavior: false,
230+
})
231+
.realMouseUp({ position: 'center' });
232+
233+
cy.wait(50);
234+
cy.findByTestId('0')
235+
.invoke('text')
236+
.then((txt) => parseInt(txt, 10))
237+
.should('be.within', 383, 385);
238+
cy.findByTestId('1')
239+
.invoke('text')
240+
.then((txt) => parseInt(txt, 10))
241+
.should('be.within', 15, 17);
242+
cy.findByTestId('2').should('have.text', 'auto');
243+
cy.findByTestId('3').invoke('text').should('equal', '200px');
244+
245+
cy.findAllByRole('separator').eq(2).click().realPress('ArrowDown').realPress('ArrowDown').realPress('ArrowDown');
246+
247+
cy.findByTestId('0')
248+
.invoke('text')
249+
.then((txt) => parseInt(txt, 10))
250+
.should('be.within', 383, 385);
251+
cy.findByTestId('1')
252+
.invoke('text')
253+
.then((txt) => parseInt(txt, 10))
254+
.should('be.within', 15, 17);
255+
cy.findByTestId('2').should('have.text', '360px');
256+
cy.findByTestId('3').should('have.text', '140px');
257+
});
258+
});
259+
132260
cypressPassThroughTestsFactory(SplitterLayout, { children: <SplitterElement>Content</SplitterElement> });
133261
});

packages/main/src/components/SplitterLayout/SplitterLayout.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export const Nested: Story = {
106106
render(args) {
107107
const [vertical, setVertical] = useState(args.vertical);
108108
const handleChange = (e) => {
109-
setVertical(e.detail.selectedItem.textContent === 'Vertical');
109+
setVertical(e.detail.selectedItems[0].textContent === 'Vertical');
110110
};
111111
useEffect(() => {
112112
setVertical(args.vertical);

packages/main/src/components/SplitterLayout/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@ import { useConcatSplitterElements } from './useConcatSplitterElements.js';
2020
* can be set.
2121
* The splitter bars are focusable to enable resizing of the content areas via keyboard. The size of the content areas
2222
* can be manipulated when the splitter bar is focused and Left/Down/Right/Up are pressed.
23+
*
24+
* __Note:__ In order to preserve the intended design, at least one `SplitterElement` should have a dynamic `size`.
2325
*/
2426
const SplitterLayout = forwardRef<HTMLDivElement, SplitterLayoutPropTypes>((props, ref) => {
25-
const { vertical, children, title, style, className, options, ...rest } = props;
27+
const { vertical, children, title, style, className, options, onResize, ...rest } = props;
2628
const [componentRef, sLRef] = useSyncRef(ref);
2729
const [reset, setReset] = useState(undefined);
2830
const prevSize = useRef({ width: undefined, height: undefined });
@@ -34,6 +36,7 @@ const SplitterLayout = forwardRef<HTMLDivElement, SplitterLayoutPropTypes>((prop
3436
width: style?.width,
3537
height: style?.height,
3638
vertical,
39+
onResize,
3740
});
3841

3942
useStylesheet(styleData, SplitterLayout.displayName);

packages/main/src/components/SplitterLayout/types.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,22 @@ interface SplitterLayoutOptions {
2121

2222
type SplitterLayoutChild = ReactElement<SplitterElementPropTypes> | undefined | false | null;
2323

24-
export interface SplitterLayoutPropTypes extends CommonProps {
24+
interface ResizeArea {
25+
size: number;
26+
area: HTMLElement;
27+
}
28+
interface OnResizeParam {
29+
/**
30+
* The `SplitterElement`s that are being resized.
31+
* The first element is the previous sibling of the splitter bar, the second element is the next sibling.
32+
*
33+
* __Note:__ The array reflects the logical position of the `SplitterElement`s.
34+
*/
35+
areas: [ResizeArea, ResizeArea];
36+
splitter: HTMLElement;
37+
}
38+
39+
export interface SplitterLayoutPropTypes extends Omit<CommonProps<HTMLDivElement>, 'onResize'> {
2540
/**
2641
* Controls if a vertical or horizontal `SplitterLayout` is rendered.
2742
*/
@@ -34,4 +49,12 @@ export interface SplitterLayoutPropTypes extends CommonProps {
3449
* Defines options to customize the behavior of the SplitterLayout.
3550
*/
3651
options?: SplitterLayoutOptions;
52+
/**
53+
* Fired when contents are resized.
54+
*
55+
* __Note:__
56+
* - Resize events can fire many times in quick succession, it’s therefore strongly recommended to debounce your handler if you’re updating React state or causing other expensive operations.
57+
* - The `areas` array reflects the logical position of the `SplitterElement`s relative to the "Splitter".
58+
*/
59+
onResize?: (e: OnResizeParam) => void;
3760
}

packages/main/src/components/SplitterLayout/useConcatSplitterElements.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ interface ConcatSplitterElements {
99
width: CSSProperties['width'];
1010
height: CSSProperties['height'];
1111
vertical: boolean;
12+
onResize: SplitterLayoutPropTypes['onResize'] | undefined;
1213
}
1314

1415
export const useConcatSplitterElements = (concatSplitterElements: ConcatSplitterElements) => {
@@ -42,6 +43,7 @@ export const useConcatSplitterElements = (concatSplitterElements: ConcatSplitter
4243
height={concatSplitterElements?.height}
4344
width={concatSplitterElements?.width}
4445
vertical={concatSplitterElements?.vertical}
46+
onResize={concatSplitterElements?.onResize}
4547
/>,
4648
);
4749
// -1 => prev element

0 commit comments

Comments
 (0)