diff --git a/packages/main/src/components/Splitter/index.tsx b/packages/main/src/components/Splitter/index.tsx index 9b715d9fbc7..b70a13644c2 100644 --- a/packages/main/src/components/Splitter/index.tsx +++ b/packages/main/src/components/Splitter/index.tsx @@ -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 = { @@ -39,7 +42,7 @@ const horizontalPositionInfo = { }; const Splitter = forwardRef((props, ref) => { - const { vertical } = props; + const { vertical, onResize } = props; const i18nBundle = useI18nBundle('@ui5/webcomponents-react'); const [componentRef, localRef] = useSyncRef(ref); const isRtl = useIsRTL(localRef); @@ -58,6 +61,38 @@ const Splitter = forwardRef((props, ref) => { const [isDragging, setIsDragging] = useState(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; @@ -71,10 +106,10 @@ const Splitter = forwardRef((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 ( @@ -126,6 +161,7 @@ const Splitter = forwardRef((props, ref) => { (nextSiblingRect?.[positionKeys.size] as number) + prevSiblingRect?.[positionKeys.size] }px`; } + fireOnResize(prevSibling, nextSibling); } // right @@ -142,10 +178,12 @@ const Splitter = forwardRef((props, ref) => { (prevSiblingRect?.[positionKeys.size] as number) + nextSiblingRect?.[positionKeys.size] }px`; } + + fireOnResize(prevSibling, nextSibling); } }; - const handleMoveSplitterStart = (e) => { + const handleMoveSplitterStart: PointerEventHandler = (e) => { if (e.type === 'pointerdown' && e.pointerType !== 'touch') { return; } @@ -175,7 +213,7 @@ const Splitter = forwardRef((props, ref) => { start.current = e[`client${positionKeys.position}`]; }; - const onHandleKeyDown = (e) => { + const onHandleKeyDown: KeyboardEventHandler = (e) => { const keyEventProperties = e.code ?? e.key; if ( keyEventProperties === 'ArrowRight' || @@ -203,6 +241,12 @@ const Splitter = forwardRef((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); + } } } }; diff --git a/packages/main/src/components/SplitterElement/index.tsx b/packages/main/src/components/SplitterElement/index.tsx index d36bd2d8eea..aabf7a74184 100644 --- a/packages/main/src/components/SplitterElement/index.tsx +++ b/packages/main/src/components/SplitterElement/index.tsx @@ -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']; diff --git a/packages/main/src/components/SplitterLayout/SplitterLayout.cy.tsx b/packages/main/src/components/SplitterLayout/SplitterLayout.cy.tsx index 6d885e9015a..48ed362d7cf 100644 --- a/packages/main/src/components/SplitterLayout/SplitterLayout.cy.tsx +++ b/packages/main/src/components/SplitterLayout/SplitterLayout.cy.tsx @@ -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 }) { @@ -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() @@ -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 ( + <> + { + 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'); + } + }); + }} + > + + + Content 1 + + + + + {`Content 2 + with + multi + lines`} + + + + + + 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. + + + + + + Content 4 + + + + {size0} +
+ {size1} +
+ {size2} +
+ {size3} + + ); + }; + + cy.mount(); + + 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: Content }); }); diff --git a/packages/main/src/components/SplitterLayout/SplitterLayout.stories.tsx b/packages/main/src/components/SplitterLayout/SplitterLayout.stories.tsx index 4df3464f290..345977b3d1c 100644 --- a/packages/main/src/components/SplitterLayout/SplitterLayout.stories.tsx +++ b/packages/main/src/components/SplitterLayout/SplitterLayout.stories.tsx @@ -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); diff --git a/packages/main/src/components/SplitterLayout/index.tsx b/packages/main/src/components/SplitterLayout/index.tsx index 34d9c7dc533..92f22f39542 100644 --- a/packages/main/src/components/SplitterLayout/index.tsx +++ b/packages/main/src/components/SplitterLayout/index.tsx @@ -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((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 }); @@ -34,6 +36,7 @@ const SplitterLayout = forwardRef((prop width: style?.width, height: style?.height, vertical, + onResize, }); useStylesheet(styleData, SplitterLayout.displayName); diff --git a/packages/main/src/components/SplitterLayout/types.ts b/packages/main/src/components/SplitterLayout/types.ts index 2ca922802da..2c4abed0159 100644 --- a/packages/main/src/components/SplitterLayout/types.ts +++ b/packages/main/src/components/SplitterLayout/types.ts @@ -21,7 +21,22 @@ interface SplitterLayoutOptions { type SplitterLayoutChild = ReactElement | 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, 'onResize'> { /** * Controls if a vertical or horizontal `SplitterLayout` is rendered. */ @@ -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; } diff --git a/packages/main/src/components/SplitterLayout/useConcatSplitterElements.tsx b/packages/main/src/components/SplitterLayout/useConcatSplitterElements.tsx index 73c757c027b..a06e5b5eabe 100644 --- a/packages/main/src/components/SplitterLayout/useConcatSplitterElements.tsx +++ b/packages/main/src/components/SplitterLayout/useConcatSplitterElements.tsx @@ -9,6 +9,7 @@ interface ConcatSplitterElements { width: CSSProperties['width']; height: CSSProperties['height']; vertical: boolean; + onResize: SplitterLayoutPropTypes['onResize'] | undefined; } export const useConcatSplitterElements = (concatSplitterElements: ConcatSplitterElements) => { @@ -42,6 +43,7 @@ export const useConcatSplitterElements = (concatSplitterElements: ConcatSplitter height={concatSplitterElements?.height} width={concatSplitterElements?.width} vertical={concatSplitterElements?.vertical} + onResize={concatSplitterElements?.onResize} />, ); // -1 => prev element