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
79 changes: 79 additions & 0 deletions src/web/components/folding/Foldable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/* SPDX-FileCopyrightText: 2026 Greenbone AG
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import React from 'react';
import styled, {css, keyframes} from 'styled-components';
import {FoldState, type FoldStateType} from 'web/components/folding/Folding';

interface FoldableStyleProps {
$foldState: FoldStateType;
}

export interface FoldableProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
'onAnimationEnd' | 'onTransitionEnd'
> {
children?: React.ReactNode;
foldState?: FoldStateType;
onFoldStepEnd?: () => void;
}

const foldDelay = keyframes`
0% {
min-width: 0px;
}
100% {
min-width: 1px;
}
`;

const FoldableDiv = styled.div<FoldableStyleProps>`
overflow: hidden;
transition: height 0.4s;

display: ${({$foldState}) =>
$foldState === FoldState.FOLDED ? 'none' : 'block'};

height: ${({$foldState}) => {
switch ($foldState) {
case FoldState.FOLDED:
case FoldState.FOLDING:
return '0px';
case FoldState.FOLDING_START:
case FoldState.UNFOLDING:
return `${Math.ceil(window.innerHeight * 1.2)}px`;
case FoldState.UNFOLDING_START:
return '1px';
default:
return 'auto';
}
}};

animation: ${({$foldState}) =>
$foldState === FoldState.UNFOLDING_START ||
$foldState === FoldState.FOLDING_START
? css`
${foldDelay} 0.01s
`
: 'none'};
`;

const Foldable = ({
children,
foldState = FoldState.UNFOLDED,
onFoldStepEnd,
...props
}: FoldableProps) => (
<FoldableDiv
{...props}
$foldState={foldState}
onAnimationEnd={onFoldStepEnd}
onTransitionEnd={onFoldStepEnd}
>
{children}
</FoldableDiv>
);

export default Foldable;
192 changes: 0 additions & 192 deletions src/web/components/folding/Folding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import React from 'react';
import styled, {keyframes, css} from 'styled-components';
import {updateDisplayName} from 'web/utils/display-name';

interface FoldStatefulProps {
foldState?: FoldStateType;
onFoldStepEnd?: () => void;
onFoldToggle?: () => void;
style?: React.CSSProperties;
}

interface FoldToggleProps {
initialFoldState?: FoldStateType;
}

export interface FoldToggleComponentProps {
foldState: FoldStateType;
onFoldStepEnd: () => void;
onFoldToggle: () => void;
}

interface FoldableDivProps {
$foldState: FoldStateType;
}

export type FoldStateType = (typeof FoldState)[keyof typeof FoldState];

/**
Expand All @@ -41,170 +16,3 @@ export const FoldState = {
FOLDING: 'FOLDING',
UNFOLDING: 'UNFOLDING',
} as const;

const foldDelay = keyframes`
0% {
min-width: 0px;
}
100% {
min-width: 1px;
}
`;

const FoldableDiv = styled.div<FoldableDivProps>`
overflow: hidden;
transition: height 0.4s;

display: ${({$foldState}) =>
$foldState === FoldState.FOLDED ? 'none' : 'block'};

height: ${({$foldState}) => {
switch ($foldState) {
case FoldState.FOLDED:
case FoldState.FOLDING:
return '0px';
case FoldState.FOLDING_START:
case FoldState.UNFOLDING:
return `${Math.ceil(window.innerHeight * 1.2)}px`;
case FoldState.UNFOLDING_START:
return '1px';
default:
return 'auto';
}
}};

animation: ${({$foldState}) =>
$foldState === FoldState.UNFOLDING_START ||
$foldState === FoldState.FOLDING_START
? css`
${foldDelay} 0.01s
`
: 'none'};
`;

/**
* HOC for making a container content component foldable
*/
export const withFolding = <TProps extends {}>(
Component: React.ComponentType<TProps>,
) => {
const FoldingWrapper = ({
foldState = FoldState.UNFOLDED,
onFoldStepEnd,
...props
}: TProps & FoldStatefulProps) => (
<FoldableDiv
$foldState={foldState}
onAnimationEnd={onFoldStepEnd}
onTransitionEnd={onFoldStepEnd}
>
<Component {...(props as TProps)} />
</FoldableDiv>
);

return updateDisplayName(FoldingWrapper, Component, 'withFolding');
};

/**
* HOC to add fold parent functionality to a component.
*/

export const withFoldToggle = <TProps extends FoldToggleComponentProps>(
Component: React.ComponentType<TProps>,
) => {
type PublicProps = Omit<TProps, keyof FoldToggleComponentProps> &
FoldToggleProps;

class FoldToggleWrapper extends React.Component<
PublicProps,
{foldState: FoldStateType}
> {
constructor(props: PublicProps) {
super(props);

const {initialFoldState = FoldState.UNFOLDED} = props;

this.state = {
foldState: initialFoldState,
};

this.handleFoldStepEnd = this.handleFoldStepEnd.bind(this);
this.handleFoldToggle = this.handleFoldToggle.bind(this);
}

handleFoldToggle() {
this.setState(({foldState}) => {
let newFoldState: FoldStateType;

switch (foldState) {
case FoldState.FOLDED:
newFoldState = FoldState.UNFOLDING_START;
break;
case FoldState.UNFOLDED:
newFoldState = FoldState.FOLDING_START;
break;
case FoldState.UNFOLDING_START:
newFoldState = FoldState.FOLDED;
break;
case FoldState.FOLDING_START:
newFoldState = FoldState.UNFOLDED;
break;
case FoldState.UNFOLDING:
newFoldState = FoldState.FOLDING;
break;
case FoldState.FOLDING:
newFoldState = FoldState.UNFOLDING;
break;
default:
newFoldState = FoldState.UNFOLDED;
}
return {foldState: newFoldState};
});
}

handleFoldStepEnd() {
this.setState(({foldState}) => {
let newFoldState: FoldStateType;

switch (foldState) {
case FoldState.FOLDED:
newFoldState = FoldState.FOLDED;
break;
case FoldState.UNFOLDED:
newFoldState = FoldState.UNFOLDED;
break;
case FoldState.UNFOLDING_START:
newFoldState = FoldState.UNFOLDING;
break;
case FoldState.FOLDING_START:
newFoldState = FoldState.FOLDING;
break;
case FoldState.UNFOLDING:
newFoldState = FoldState.UNFOLDED;
break;
case FoldState.FOLDING:
newFoldState = FoldState.FOLDED;
break;
default:
newFoldState = FoldState.UNFOLDED;
}
return {foldState: newFoldState};
});
}

render() {
const {...other} = this.props;
const {foldState} = this.state;
const componentProps = {
...(other as Omit<TProps, keyof FoldToggleComponentProps>),
foldState,
onFoldStepEnd: this.handleFoldStepEnd,
onFoldToggle: this.handleFoldToggle,
} as TProps;

return <Component {...componentProps} />;
}
}

return FoldToggleWrapper;
};
68 changes: 68 additions & 0 deletions src/web/components/folding/__tests__/Foldable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/* SPDX-FileCopyrightText: 2026 Greenbone AG
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import {describe, test, expect, testing} from '@gsa/testing';
import {fireEvent, render, screen} from 'web/testing';
import Foldable from 'web/components/folding/Foldable';
import {FoldState} from 'web/components/folding/Folding';

describe('Foldable', () => {
test('should use the default fold state and forward props', () => {
const {element} = render(
<Foldable className="dummy-component" data-testid="dummy">
Dummy Component
</Foldable>,
);

expect(element).toHaveStyle({display: 'block', height: 'auto'});
expect(screen.getByTestId('dummy')).toHaveClass('dummy-component');
expect(screen.getByText('Dummy Component')).toBeVisible();
});

test.each([
[FoldState.FOLDED, 'none', '0px'],
[FoldState.FOLDING, 'block', '0px'],
[
FoldState.FOLDING_START,
'block',
`${Math.ceil(window.innerHeight * 1.2)}px`,
],
[FoldState.UNFOLDING, 'block', `${Math.ceil(window.innerHeight * 1.2)}px`],
[FoldState.UNFOLDING_START, 'block', '1px'],
[FoldState.UNFOLDED, 'block', 'auto'],
])('should apply %s folding styles', (foldState, display, height) => {
const {element, rerender} = render(<Foldable foldState={foldState} />);

expect(element).toHaveStyle({display, height});

rerender(<Foldable foldState={FoldState.UNFOLDED} />);
});

test('should hide folded content and show it when unfolded', () => {
const {rerender} = render(
<Foldable foldState={FoldState.FOLDED}>Dummy Component</Foldable>,
);
expect(screen.getByText('Dummy Component')).not.toBeVisible();

rerender(
<Foldable foldState={FoldState.UNFOLDED}>Dummy Component</Foldable>,
);
expect(screen.getByText('Dummy Component')).toBeVisible();
});

test('should call onFoldStepEnd after a transition', () => {
const onFoldStepEnd = testing.fn();
const {element} = render(
<Foldable
foldState={FoldState.FOLDING_START}
onFoldStepEnd={onFoldStepEnd}
/>,
);

fireEvent.transitionEnd(element);

expect(onFoldStepEnd).toHaveBeenCalledTimes(1);
});
});
Loading