Skip to content

feat(SplitterLayout): introduce onResize event #7519

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
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
58 changes: 51 additions & 7 deletions packages/main/src/components/Splitter/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ import horizontalGripIcon from '@ui5/webcomponents-icons/dist/horizontal-grip.js
import verticalGripIcon from '@ui5/webcomponents-icons/dist/vertical-grip.js';
import { useCurrentTheme, useI18nBundle, useIsRTL, useSyncRef, useStylesheet } from '@ui5/webcomponents-react-base';
import { forwardRef, useEffect, useRef, useState } from 'react';
import type { KeyboardEventHandler, PointerEventHandler } from 'react';
import { PRESS_ARROW_KEYS_TO_MOVE } from '../../i18n/i18n-defaults.js';
import type { CommonProps } from '../../types/index.js';
import { Button, Icon } from '../../webComponents/index.js';
import { Button } from '../../webComponents/Button/index.js';
import { Icon } from '../../webComponents/Icon/index.js';
import type { SplitterLayoutPropTypes } from '../SplitterLayout/types.js';
import { classNames, styleData } from './Splitter.module.css.js';

export interface SplitterPropTypes extends CommonProps {
export interface SplitterPropTypes {
height: string | number;
width: string | number;
vertical: boolean;
onResize: SplitterLayoutPropTypes['onResize'] | undefined;
}

const verticalPositionInfo = {
Expand All @@ -39,7 +42,7 @@ const horizontalPositionInfo = {
};

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

const animationFrameIdRef = useRef(null);
const fireOnResize = (prevSibling: HTMLElement, nextSibling: HTMLElement) => {
if (animationFrameIdRef.current) {
cancelAnimationFrame(animationFrameIdRef.current);
}
if (typeof onResize !== 'function') {
return;
}
animationFrameIdRef.current = requestAnimationFrame(() => {
const logicalPrevSibling = isRtl ? nextSibling : prevSibling;
const logicalNextSibling = isRtl ? prevSibling : nextSibling;
const splitterWidth = localRef.current.getBoundingClientRect()[positionKeys.size];
onResize({
areas: [
{
size: logicalPrevSibling.getBoundingClientRect()?.[positionKeys.size] + splitterWidth,
area: logicalPrevSibling,
},
{
// last element doesn't have splitter
size:
logicalNextSibling.getBoundingClientRect()?.[positionKeys.size] +
(logicalNextSibling.nextElementSibling !== null ? splitterWidth : 0),
area: logicalNextSibling,
},
],
splitter: localRef.current,
});
animationFrameIdRef.current = null;
});
};

const handleSplitterMove = (e) => {
const offset = resizerClickOffset.current;
const previousSibling = localRef.current[isSiblings[0]] as HTMLDivElement;
Expand All @@ -71,10 +106,10 @@ const Splitter = forwardRef<HTMLDivElement, SplitterPropTypes>((props, ref) => {

const move = () => {
previousSibling.style.flex = `0 0 ${previousSiblingSize.current + sizeDiv}px`;

if (nextSibling.nextSibling && previousSiblingSize.current + sizeDiv > 0) {
nextSibling.style.flex = `0 0 ${nextSiblingSize.current - sizeDiv}px`;
}
fireOnResize(previousSibling, nextSibling);
};

if (
Expand Down Expand Up @@ -126,6 +161,7 @@ const Splitter = forwardRef<HTMLDivElement, SplitterPropTypes>((props, ref) => {
(nextSiblingRect?.[positionKeys.size] as number) + prevSiblingRect?.[positionKeys.size]
}px`;
}
fireOnResize(prevSibling, nextSibling);
}

// right
Expand All @@ -142,10 +178,12 @@ const Splitter = forwardRef<HTMLDivElement, SplitterPropTypes>((props, ref) => {
(prevSiblingRect?.[positionKeys.size] as number) + nextSiblingRect?.[positionKeys.size]
}px`;
}

fireOnResize(prevSibling, nextSibling);
}
};

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

const onHandleKeyDown = (e) => {
const onHandleKeyDown: KeyboardEventHandler<HTMLDivElement> = (e) => {
const keyEventProperties = e.code ?? e.key;
if (
keyEventProperties === 'ArrowRight' ||
Expand Down Expand Up @@ -203,6 +241,12 @@ const Splitter = forwardRef<HTMLDivElement, SplitterPropTypes>((props, ref) => {
const secondSiblingSize = secondSibling.getBoundingClientRect()?.[positionKeys.size] as number;
secondSibling.style.flex = `0 0 ${secondSiblingSize - tickSize}px`;
firstSibling.style.flex = `0 0 ${firstSiblingSize + tickSize}px`;

if (keyEventProperties === 'ArrowLeft' || keyEventProperties === 'ArrowUp') {
fireOnResize(secondSibling, firstSibling);
} else {
fireOnResize(firstSibling, secondSibling);
}
}
}
};
Expand Down
2 changes: 2 additions & 0 deletions packages/main/src/components/SplitterElement/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface SplitterElementPropTypes extends CommonProps {
/**
* Defines the initial size of the `SplitterElement`.
*
* __Note:__ In order to preserve the intended design, at least one `SplitterElement` should have a dynamic `size`.
*
* @default `"auto"`
*/
size?: CSSProperties['width'] | CSSProperties['height'];
Expand Down
132 changes: 130 additions & 2 deletions packages/main/src/components/SplitterLayout/SplitterLayout.cy.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from 'react';
import type { SplitterLayoutPropTypes } from '../..';
import { Button, Label, SplitterElement, SplitterLayout } from '../..';
import { FlexBox, Text, Button, Label, SplitterElement, SplitterLayout } from '../..';
import { cypressPassThroughTestsFactory } from '@/cypress/support/utils';

function TestComp({ vertical, dir }: { vertical: SplitterLayoutPropTypes['vertical']; dir: string }) {
Expand Down Expand Up @@ -107,7 +107,7 @@ describe('SplitterLayout', () => {
);
cy.findByTestId('btn').click();
cy.get('[role="separator"]').first().click();
// fallback click to prevent fuzzyness
// fallback click to prevent flakyness
cy.get('[role="separator"]')
.first()
.click()
Expand All @@ -129,5 +129,133 @@ describe('SplitterLayout', () => {
cy.findByTestId('sl').should('not.be.visible').should('exist');
});

[true, false].forEach((vertical) => {
it(`controlled width (${vertical ? 'vertical' : 'horizontal'})`, () => {
function getMouseMoveArgs(amount: number): [number, number] {
return vertical ? [0, amount] : [amount, 0];
}
const resize = cy.spy().as('resize');
const TestComp = () => {
const [size0, setSize0] = useState('200px');
const [size1, setSize1] = useState(200);
const [size2, setSize2] = useState('auto');
const [size3, setSize3] = useState('200px');
const setter = [setSize0, setSize1, setSize2, setSize3];
return (
<>
<SplitterLayout
vertical={vertical}
style={{ height: '900px', width: '900px', backgroundColor: 'black' }}
onResize={(e) => {
resize(e);
e.areas.forEach((item) => {
if (item.area.dataset.index === '1') {
setter[Number(item.area.dataset.index)](item.size);
} else {
//@ts-expect-error: supported
setter[Number(item.area.dataset.index)](item.size + 'px');
}
});
}}
>
<SplitterElement size={size0} data-index={0} style={{ backgroundColor: 'lightcoral' }}>
<FlexBox style={{ height: '100%', width: '100%' }} alignItems="Center" justifyContent="Center">
<Text>Content 1</Text>
</FlexBox>
</SplitterElement>
<SplitterElement size={size1} data-index={1} style={{ backgroundColor: 'lightblue' }}>
<FlexBox style={{ height: '100%', width: '100%' }} alignItems="Center" justifyContent="Center">
<Text style={{ whiteSpace: 'pre-line' }}>{`Content 2
with
multi
lines`}</Text>
</FlexBox>
</SplitterElement>
<SplitterElement size={'auto'} data-index={2} style={{ backgroundColor: 'lightgreen' }}>
<FlexBox style={{ height: '100%', width: '100%' }} alignItems="Center" justifyContent="Center">
<Text>
Content 3 with long text: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy
eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et
accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est
Lorem ipsum dolor sit amet.
</Text>
</FlexBox>
</SplitterElement>
<SplitterElement size={size3} data-index={3} style={{ backgroundColor: 'lightgoldenrodyellow' }}>
<FlexBox style={{ height: '100%', width: '100%' }} alignItems="Center" justifyContent="Center">
<Text>Content 4</Text>
</FlexBox>
</SplitterElement>
</SplitterLayout>
<span data-testid="0">{size0}</span>
<br />
<span data-testid="1">{size1}</span>
<br />
<span data-testid="2">{size2}</span>
<br />
<span data-testid="3">{size3}</span>
</>
);
};

cy.mount(<TestComp />);

cy.get('@resize').should('not.have.been.called');
cy.findAllByRole('separator')
.eq(0)
.realMouseDown({ position: 'center' })
.realMouseMove(...getMouseMoveArgs(-100), {
position: 'center',
scrollBehavior: false,
})
.realMouseUp({ position: 'center' });

cy.findByTestId('0')
.invoke('text')
.then((txt) => parseInt(txt, 10))
.should('be.within', 99, 101);
cy.findByTestId('1')
.invoke('text')
.then((txt) => parseInt(txt, 10))
.should('be.within', 299, 301);
cy.findByTestId('2').should('have.text', 'auto');
cy.findByTestId('3').invoke('text').should('equal', '200px');

cy.findAllByRole('separator').eq(0).realMouseDown({ position: 'center' });
// drag across bounding box
cy.get('body')
.realMouseMove(...getMouseMoveArgs(300), {
position: 'center',
scrollBehavior: false,
})
.realMouseUp({ position: 'center' });

cy.wait(50);
cy.findByTestId('0')
.invoke('text')
.then((txt) => parseInt(txt, 10))
.should('be.within', 383, 385);
cy.findByTestId('1')
.invoke('text')
.then((txt) => parseInt(txt, 10))
.should('be.within', 15, 17);
cy.findByTestId('2').should('have.text', 'auto');
cy.findByTestId('3').invoke('text').should('equal', '200px');

cy.findAllByRole('separator').eq(2).click().realPress('ArrowDown').realPress('ArrowDown').realPress('ArrowDown');

cy.findByTestId('0')
.invoke('text')
.then((txt) => parseInt(txt, 10))
.should('be.within', 383, 385);
cy.findByTestId('1')
.invoke('text')
.then((txt) => parseInt(txt, 10))
.should('be.within', 15, 17);
cy.findByTestId('2').should('have.text', '360px');
cy.findByTestId('3').should('have.text', '140px');
});
});

cypressPassThroughTestsFactory(SplitterLayout, { children: <SplitterElement>Content</SplitterElement> });
});
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export const Nested: Story = {
render(args) {
const [vertical, setVertical] = useState(args.vertical);
const handleChange = (e) => {
setVertical(e.detail.selectedItem.textContent === 'Vertical');
setVertical(e.detail.selectedItems[0].textContent === 'Vertical');
};
useEffect(() => {
setVertical(args.vertical);
Expand Down
5 changes: 4 additions & 1 deletion packages/main/src/components/SplitterLayout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ import { useConcatSplitterElements } from './useConcatSplitterElements.js';
* can be set.
* The splitter bars are focusable to enable resizing of the content areas via keyboard. The size of the content areas
* can be manipulated when the splitter bar is focused and Left/Down/Right/Up are pressed.
*
* __Note:__ In order to preserve the intended design, at least one `SplitterElement` should have a dynamic `size`.
*/
const SplitterLayout = forwardRef<HTMLDivElement, SplitterLayoutPropTypes>((props, ref) => {
const { vertical, children, title, style, className, options, ...rest } = props;
const { vertical, children, title, style, className, options, onResize, ...rest } = props;
const [componentRef, sLRef] = useSyncRef(ref);
const [reset, setReset] = useState(undefined);
const prevSize = useRef({ width: undefined, height: undefined });
Expand All @@ -34,6 +36,7 @@ const SplitterLayout = forwardRef<HTMLDivElement, SplitterLayoutPropTypes>((prop
width: style?.width,
height: style?.height,
vertical,
onResize,
});

useStylesheet(styleData, SplitterLayout.displayName);
Expand Down
25 changes: 24 additions & 1 deletion packages/main/src/components/SplitterLayout/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,22 @@ interface SplitterLayoutOptions {

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

export interface SplitterLayoutPropTypes extends CommonProps {
interface ResizeArea {
size: number;
area: HTMLElement;
}
interface OnResizeParam {
/**
* The `SplitterElement`s that are being resized.
* The first element is the previous sibling of the splitter bar, the second element is the next sibling.
*
* __Note:__ The array reflects the logical position of the `SplitterElement`s.
*/
areas: [ResizeArea, ResizeArea];
splitter: HTMLElement;
}

export interface SplitterLayoutPropTypes extends Omit<CommonProps<HTMLDivElement>, 'onResize'> {
/**
* Controls if a vertical or horizontal `SplitterLayout` is rendered.
*/
Expand All @@ -34,4 +49,12 @@ export interface SplitterLayoutPropTypes extends CommonProps {
* Defines options to customize the behavior of the SplitterLayout.
*/
options?: SplitterLayoutOptions;
/**
* Fired when contents are resized.
*
* __Note:__
* - 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.
* - The `areas` array reflects the logical position of the `SplitterElement`s relative to the "Splitter".
*/
onResize?: (e: OnResizeParam) => void;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface ConcatSplitterElements {
width: CSSProperties['width'];
height: CSSProperties['height'];
vertical: boolean;
onResize: SplitterLayoutPropTypes['onResize'] | undefined;
}

export const useConcatSplitterElements = (concatSplitterElements: ConcatSplitterElements) => {
Expand Down Expand Up @@ -42,6 +43,7 @@ export const useConcatSplitterElements = (concatSplitterElements: ConcatSplitter
height={concatSplitterElements?.height}
width={concatSplitterElements?.width}
vertical={concatSplitterElements?.vertical}
onResize={concatSplitterElements?.onResize}
/>,
);
// -1 => prev element
Expand Down
Loading