Skip to content

Commit ac9e836

Browse files
nensidosariAmarTrebinjacsshanzelilaswrebelchris
authored
feat: profile 4.0 (#5005)
Co-authored-by: Amar Trebinjac <36768584+AmarTrebinjac@users.noreply.github.com> Co-authored-by: Lee Hansel Solevilla <13744167+sshanzel@users.noreply.github.com> Co-authored-by: Lee Hansel Solevilla <sshanzel@yahoo.com> Co-authored-by: Luca Pagliaro <pagliaroluca@gmail.com> Co-authored-by: Amar Trebinjac <amartrebinjac@gmail.com> Co-authored-by: Chris Bongers <chrisbongers@gmail.com> Co-authored-by: capJavert <dev@kickass.website>
1 parent 0d8a052 commit ac9e836

File tree

172 files changed

+15286
-2812
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

172 files changed

+15286
-2812
lines changed

packages/shared/src/components/CalendarHeatmap.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ export function CalendarHeatmap<T extends { date: string }>({
245245

246246
return (
247247
<svg
248-
width={width}
248+
width="100%"
249249
viewBox={`0 0 ${width} ${height}`}
250250
onMouseDown={(e) => e.preventDefault()}
251251
>
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import React from 'react';
2+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
3+
import { ExpandableContent } from './ExpandableContent';
4+
import clearAllMocks = jest.clearAllMocks;
5+
6+
describe('ExpandableContent', () => {
7+
const shortContent = <div>Short content that fits</div>;
8+
const longContent = (
9+
<div>
10+
<p>Long content paragraph 1</p>
11+
<p>Long content paragraph 2</p>
12+
<p>Long content paragraph 3</p>
13+
<p>Long content paragraph 4</p>
14+
<p>Long content paragraph 5</p>
15+
<p>Long content paragraph 6</p>
16+
<p>Long content paragraph 7</p>
17+
<p>Long content paragraph 8</p>
18+
<p>Long content paragraph 9</p>
19+
<p>Long content paragraph 10</p>
20+
</div>
21+
);
22+
23+
beforeEach(() => {
24+
// Reset scrollHeight mock before each test
25+
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
26+
configurable: true,
27+
get() {
28+
return 100; // Default value
29+
},
30+
});
31+
});
32+
33+
afterEach(() => {
34+
clearAllMocks();
35+
});
36+
37+
it('should render children content', () => {
38+
render(<ExpandableContent>{shortContent}</ExpandableContent>);
39+
const element = screen.getByText('Short content that fits');
40+
expect(element).toBeInTheDocument();
41+
expect(element).toBeVisible();
42+
});
43+
44+
it('should not show "See More" button when content is short', async () => {
45+
// Mock scrollHeight to be less than maxHeight
46+
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
47+
configurable: true,
48+
get() {
49+
return 200; // Less than default 320px
50+
},
51+
});
52+
53+
render(<ExpandableContent>{shortContent}</ExpandableContent>);
54+
55+
// Wait a bit for useEffect to run
56+
await waitFor(
57+
() => {
58+
expect(
59+
screen.queryByRole('button', { name: /see more/i }),
60+
).not.toBeInTheDocument();
61+
},
62+
{ timeout: 200 },
63+
);
64+
});
65+
66+
it('should show "See More" button when content exceeds maxHeight', async () => {
67+
// Mock scrollHeight to be more than maxHeight
68+
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
69+
configurable: true,
70+
get() {
71+
return 500; // More than default 320px
72+
},
73+
});
74+
75+
render(<ExpandableContent>{longContent}</ExpandableContent>);
76+
77+
await waitFor(() => {
78+
expect(
79+
screen.getByRole('button', { name: /see more/i }),
80+
).toBeInTheDocument();
81+
});
82+
});
83+
84+
it('should expand content when "See More" button is clicked', async () => {
85+
// Mock scrollHeight to be more than maxHeight
86+
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
87+
configurable: true,
88+
get() {
89+
return 500;
90+
},
91+
});
92+
93+
render(
94+
<ExpandableContent maxHeight={320}>{longContent}</ExpandableContent>,
95+
);
96+
97+
await waitFor(() => {
98+
expect(
99+
screen.getByRole('button', { name: /see more/i }),
100+
).toBeInTheDocument();
101+
});
102+
103+
const seeMoreButton = screen.getByRole('button', { name: /see more/i });
104+
fireEvent.click(seeMoreButton);
105+
106+
// Button should disappear after expansion
107+
await waitFor(() => {
108+
expect(
109+
screen.queryByRole('button', { name: /see more/i }),
110+
).not.toBeInTheDocument();
111+
});
112+
});
113+
114+
it('should show gradient overlay when content is collapsed', async () => {
115+
// Mock scrollHeight to be more than maxHeight
116+
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
117+
configurable: true,
118+
get() {
119+
return 500;
120+
},
121+
});
122+
123+
render(
124+
<ExpandableContent maxHeight={320}>{longContent}</ExpandableContent>,
125+
);
126+
127+
// Wait for See More button to appear, which indicates collapsed state
128+
await waitFor(() => {
129+
expect(
130+
screen.getByRole('button', { name: /see more/i }),
131+
).toBeInTheDocument();
132+
});
133+
});
134+
135+
it('should hide "See More" button when content is expanded', async () => {
136+
// Mock scrollHeight to be more than maxHeight
137+
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
138+
configurable: true,
139+
get() {
140+
return 500;
141+
},
142+
});
143+
144+
render(
145+
<ExpandableContent maxHeight={320}>{longContent}</ExpandableContent>,
146+
);
147+
148+
await waitFor(() => {
149+
expect(
150+
screen.getByRole('button', { name: /see more/i }),
151+
).toBeInTheDocument();
152+
});
153+
154+
const seeMoreButton = screen.getByRole('button', { name: /see more/i });
155+
fireEvent.click(seeMoreButton);
156+
157+
// Both button and gradient should be hidden after expansion
158+
await waitFor(() => {
159+
expect(
160+
screen.queryByRole('button', { name: /see more/i }),
161+
).not.toBeInTheDocument();
162+
});
163+
});
164+
165+
it('should apply custom maxHeight', async () => {
166+
// Mock scrollHeight to exceed custom maxHeight
167+
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
168+
configurable: true,
169+
get() {
170+
return 200; // More than 150px
171+
},
172+
});
173+
174+
render(
175+
<ExpandableContent maxHeight={150}>{longContent}</ExpandableContent>,
176+
);
177+
178+
await waitFor(() => {
179+
expect(
180+
screen.getByRole('button', { name: /see more/i }),
181+
).toBeInTheDocument();
182+
});
183+
});
184+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { ReactElement, ReactNode } from 'react';
2+
import React, { useState, useRef, useEffect } from 'react';
3+
import classNames from 'classnames';
4+
import {
5+
Button,
6+
ButtonIconPosition,
7+
ButtonSize,
8+
ButtonVariant,
9+
} from './buttons/Button';
10+
import { MoveToIcon } from './icons';
11+
import { IconSize } from './Icon';
12+
13+
export interface ExpandableContentProps {
14+
children: ReactNode;
15+
maxHeight?: number; // in pixels
16+
className?: string;
17+
}
18+
19+
const DEFAULT_MAX_HEIGHT = 320; // pixels
20+
21+
export function ExpandableContent({
22+
children,
23+
maxHeight = DEFAULT_MAX_HEIGHT,
24+
className,
25+
}: ExpandableContentProps): ReactElement {
26+
const [isExpanded, setIsExpanded] = useState(false);
27+
const [showSeeMore, setShowSeeMore] = useState(false);
28+
const contentRef = useRef<HTMLDivElement>(null);
29+
30+
useEffect(() => {
31+
const element = contentRef.current;
32+
if (!element) {
33+
return undefined;
34+
}
35+
36+
const checkHeight = () => {
37+
const contentHeight = element.scrollHeight;
38+
setShowSeeMore(contentHeight > maxHeight);
39+
};
40+
41+
// Wait for browser to complete layout before checking height
42+
// Using double RAF ensures the layout is fully calculated
43+
const rafId = requestAnimationFrame(() => {
44+
requestAnimationFrame(() => {
45+
checkHeight();
46+
});
47+
});
48+
49+
// Only use ResizeObserver if there are images (for async loading)
50+
const hasImages = element.querySelector('img') !== null;
51+
if (!hasImages) {
52+
return () => cancelAnimationFrame(rafId);
53+
}
54+
55+
const resizeObserver = new ResizeObserver(checkHeight);
56+
resizeObserver.observe(element);
57+
58+
return () => {
59+
cancelAnimationFrame(rafId);
60+
resizeObserver.disconnect();
61+
};
62+
}, [maxHeight, children]);
63+
64+
return (
65+
<>
66+
<div
67+
ref={contentRef}
68+
className={classNames(
69+
'relative transition-all duration-500 ease-in-out',
70+
{
71+
'overflow-hidden': !isExpanded,
72+
},
73+
className,
74+
)}
75+
style={{
76+
maxHeight: !isExpanded ? `${maxHeight}px` : undefined,
77+
}}
78+
>
79+
{children}
80+
{!isExpanded && showSeeMore && (
81+
<div className="absolute inset-x-0 bottom-0 h-12 bg-gradient-to-b from-transparent to-background-default" />
82+
)}
83+
</div>
84+
85+
{showSeeMore && !isExpanded && (
86+
<div className="mt-4 flex w-full items-center justify-center">
87+
<Button
88+
variant={ButtonVariant.Subtle}
89+
size={ButtonSize.Medium}
90+
onClick={() => setIsExpanded(true)}
91+
icon={<MoveToIcon size={IconSize.XSmall} className="rotate-90" />}
92+
iconPosition={ButtonIconPosition.Right}
93+
>
94+
See More
95+
</Button>
96+
</div>
97+
)}
98+
</>
99+
);
100+
}

packages/shared/src/components/HorizontalScroll/HorizontalScroll.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@ function HorizontalScrollComponent(
2323
const titleId = `horizontal-scroll-title-${id}`;
2424
const { ref, header } = useHorizontalScrollHeader({
2525
...scrollProps,
26-
title: { ...scrollProps?.title, id: titleId },
26+
title:
27+
scrollProps.title &&
28+
typeof scrollProps.title === 'object' &&
29+
'copy' in scrollProps.title
30+
? { ...scrollProps.title, id: titleId }
31+
: scrollProps.title,
2732
});
2833

2934
return (

packages/shared/src/components/HorizontalScroll/HorizontalScrollHeader.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type { MouseEventHandler, ReactElement, ReactNode } from 'react';
22
import React from 'react';
3+
import classNames from 'classnames';
34
import Link from '../utilities/Link';
45
import { Button } from '../buttons/Button';
5-
import { ButtonVariant } from '../buttons/common';
6+
import { ButtonSize, ButtonVariant } from '../buttons/common';
67
import ConditionalWrapper from '../ConditionalWrapper';
78
import { ArrowIcon } from '../icons';
89
import { Typography, TypographyType } from '../typography/Typography';
@@ -15,14 +16,16 @@ export interface HorizontalScrollTitleProps {
1516
}
1617

1718
export interface HorizontalScrollHeaderProps {
18-
title: HorizontalScrollTitleProps;
19+
title?: HorizontalScrollTitleProps | ReactNode;
1920
isAtEnd: boolean;
2021
isAtStart: boolean;
2122
onClickNext: MouseEventHandler;
2223
onClickPrevious: MouseEventHandler;
2324
onClickSeeAll?: MouseEventHandler;
2425
linkToSeeAll?: string;
2526
canScroll: boolean;
27+
className?: string;
28+
buttonSize?: ButtonSize;
2629
}
2730

2831
export const HorizontalScrollTitle = ({
@@ -50,10 +53,25 @@ export function HorizontalScrollHeader({
5053
onClickSeeAll,
5154
linkToSeeAll,
5255
canScroll,
56+
className,
57+
buttonSize = ButtonSize.Medium,
5358
}: HorizontalScrollHeaderProps): ReactElement {
59+
// Check if title is props object or custom ReactNode
60+
const isCustomTitle =
61+
title && typeof title === 'object' && !('copy' in title);
62+
5463
return (
55-
<div className="mx-4 flex min-h-10 w-auto flex-row items-center justify-between laptop:mx-0 laptop:w-full">
56-
<HorizontalScrollTitle {...title} />
64+
<div
65+
className={classNames(
66+
'mx-4 flex min-h-10 w-auto flex-row items-center justify-between laptop:mx-0 laptop:w-full',
67+
className,
68+
)}
69+
>
70+
{isCustomTitle
71+
? title
72+
: title && (
73+
<HorizontalScrollTitle {...(title as HorizontalScrollTitleProps)} />
74+
)}
5775
{canScroll && (
5876
<div className="hidden flex-row items-center gap-3 tablet:flex">
5977
<Button
@@ -62,13 +80,15 @@ export function HorizontalScrollHeader({
6280
disabled={isAtStart}
6381
onClick={onClickPrevious}
6482
aria-label="Scroll left"
83+
size={buttonSize}
6584
/>
6685
<Button
6786
variant={ButtonVariant.Tertiary}
6887
icon={<ArrowIcon className="rotate-90" />}
6988
disabled={isAtEnd}
7089
onClick={onClickNext}
7190
aria-label="Scroll right"
91+
size={buttonSize}
7292
/>
7393
{(onClickSeeAll || linkToSeeAll) && (
7494
<ConditionalWrapper

0 commit comments

Comments
 (0)