diff --git a/packages/shared/src/components/CalendarHeatmap.tsx b/packages/shared/src/components/CalendarHeatmap.tsx index 0e54d3f2f4..2767f4fcc0 100644 --- a/packages/shared/src/components/CalendarHeatmap.tsx +++ b/packages/shared/src/components/CalendarHeatmap.tsx @@ -245,7 +245,7 @@ export function CalendarHeatmap({ return ( e.preventDefault()} > diff --git a/packages/shared/src/components/ExpandableContent.spec.tsx b/packages/shared/src/components/ExpandableContent.spec.tsx new file mode 100644 index 0000000000..425765a9bc --- /dev/null +++ b/packages/shared/src/components/ExpandableContent.spec.tsx @@ -0,0 +1,184 @@ +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { ExpandableContent } from './ExpandableContent'; +import clearAllMocks = jest.clearAllMocks; + +describe('ExpandableContent', () => { + const shortContent =
Short content that fits
; + const longContent = ( +
+

Long content paragraph 1

+

Long content paragraph 2

+

Long content paragraph 3

+

Long content paragraph 4

+

Long content paragraph 5

+

Long content paragraph 6

+

Long content paragraph 7

+

Long content paragraph 8

+

Long content paragraph 9

+

Long content paragraph 10

+
+ ); + + beforeEach(() => { + // Reset scrollHeight mock before each test + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + return 100; // Default value + }, + }); + }); + + afterEach(() => { + clearAllMocks(); + }); + + it('should render children content', () => { + render({shortContent}); + const element = screen.getByText('Short content that fits'); + expect(element).toBeInTheDocument(); + expect(element).toBeVisible(); + }); + + it('should not show "See More" button when content is short', async () => { + // Mock scrollHeight to be less than maxHeight + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + return 200; // Less than default 320px + }, + }); + + render({shortContent}); + + // Wait a bit for useEffect to run + await waitFor( + () => { + expect( + screen.queryByRole('button', { name: /see more/i }), + ).not.toBeInTheDocument(); + }, + { timeout: 200 }, + ); + }); + + it('should show "See More" button when content exceeds maxHeight', async () => { + // Mock scrollHeight to be more than maxHeight + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + return 500; // More than default 320px + }, + }); + + render({longContent}); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /see more/i }), + ).toBeInTheDocument(); + }); + }); + + it('should expand content when "See More" button is clicked', async () => { + // Mock scrollHeight to be more than maxHeight + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + return 500; + }, + }); + + render( + {longContent}, + ); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /see more/i }), + ).toBeInTheDocument(); + }); + + const seeMoreButton = screen.getByRole('button', { name: /see more/i }); + fireEvent.click(seeMoreButton); + + // Button should disappear after expansion + await waitFor(() => { + expect( + screen.queryByRole('button', { name: /see more/i }), + ).not.toBeInTheDocument(); + }); + }); + + it('should show gradient overlay when content is collapsed', async () => { + // Mock scrollHeight to be more than maxHeight + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + return 500; + }, + }); + + render( + {longContent}, + ); + + // Wait for See More button to appear, which indicates collapsed state + await waitFor(() => { + expect( + screen.getByRole('button', { name: /see more/i }), + ).toBeInTheDocument(); + }); + }); + + it('should hide "See More" button when content is expanded', async () => { + // Mock scrollHeight to be more than maxHeight + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + return 500; + }, + }); + + render( + {longContent}, + ); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /see more/i }), + ).toBeInTheDocument(); + }); + + const seeMoreButton = screen.getByRole('button', { name: /see more/i }); + fireEvent.click(seeMoreButton); + + // Both button and gradient should be hidden after expansion + await waitFor(() => { + expect( + screen.queryByRole('button', { name: /see more/i }), + ).not.toBeInTheDocument(); + }); + }); + + it('should apply custom maxHeight', async () => { + // Mock scrollHeight to exceed custom maxHeight + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get() { + return 200; // More than 150px + }, + }); + + render( + {longContent}, + ); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /see more/i }), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/shared/src/components/ExpandableContent.tsx b/packages/shared/src/components/ExpandableContent.tsx new file mode 100644 index 0000000000..518a7385e2 --- /dev/null +++ b/packages/shared/src/components/ExpandableContent.tsx @@ -0,0 +1,100 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonIconPosition, + ButtonSize, + ButtonVariant, +} from './buttons/Button'; +import { MoveToIcon } from './icons'; +import { IconSize } from './Icon'; + +export interface ExpandableContentProps { + children: ReactNode; + maxHeight?: number; // in pixels + className?: string; +} + +const DEFAULT_MAX_HEIGHT = 320; // pixels + +export function ExpandableContent({ + children, + maxHeight = DEFAULT_MAX_HEIGHT, + className, +}: ExpandableContentProps): ReactElement { + const [isExpanded, setIsExpanded] = useState(false); + const [showSeeMore, setShowSeeMore] = useState(false); + const contentRef = useRef(null); + + useEffect(() => { + const element = contentRef.current; + if (!element) { + return undefined; + } + + const checkHeight = () => { + const contentHeight = element.scrollHeight; + setShowSeeMore(contentHeight > maxHeight); + }; + + // Wait for browser to complete layout before checking height + // Using double RAF ensures the layout is fully calculated + const rafId = requestAnimationFrame(() => { + requestAnimationFrame(() => { + checkHeight(); + }); + }); + + // Only use ResizeObserver if there are images (for async loading) + const hasImages = element.querySelector('img') !== null; + if (!hasImages) { + return () => cancelAnimationFrame(rafId); + } + + const resizeObserver = new ResizeObserver(checkHeight); + resizeObserver.observe(element); + + return () => { + cancelAnimationFrame(rafId); + resizeObserver.disconnect(); + }; + }, [maxHeight, children]); + + return ( + <> +
+ {children} + {!isExpanded && showSeeMore && ( +
+ )} +
+ + {showSeeMore && !isExpanded && ( +
+ +
+ )} + + ); +} diff --git a/packages/shared/src/components/HorizontalScroll/HorizontalScroll.tsx b/packages/shared/src/components/HorizontalScroll/HorizontalScroll.tsx index d64a48d3c6..c793d03f08 100644 --- a/packages/shared/src/components/HorizontalScroll/HorizontalScroll.tsx +++ b/packages/shared/src/components/HorizontalScroll/HorizontalScroll.tsx @@ -23,7 +23,12 @@ function HorizontalScrollComponent( const titleId = `horizontal-scroll-title-${id}`; const { ref, header } = useHorizontalScrollHeader({ ...scrollProps, - title: { ...scrollProps?.title, id: titleId }, + title: + scrollProps.title && + typeof scrollProps.title === 'object' && + 'copy' in scrollProps.title + ? { ...scrollProps.title, id: titleId } + : scrollProps.title, }); return ( diff --git a/packages/shared/src/components/HorizontalScroll/HorizontalScrollHeader.tsx b/packages/shared/src/components/HorizontalScroll/HorizontalScrollHeader.tsx index ba14c7f308..188959f46a 100644 --- a/packages/shared/src/components/HorizontalScroll/HorizontalScrollHeader.tsx +++ b/packages/shared/src/components/HorizontalScroll/HorizontalScrollHeader.tsx @@ -1,8 +1,9 @@ import type { MouseEventHandler, ReactElement, ReactNode } from 'react'; import React from 'react'; +import classNames from 'classnames'; import Link from '../utilities/Link'; import { Button } from '../buttons/Button'; -import { ButtonVariant } from '../buttons/common'; +import { ButtonSize, ButtonVariant } from '../buttons/common'; import ConditionalWrapper from '../ConditionalWrapper'; import { ArrowIcon } from '../icons'; import { Typography, TypographyType } from '../typography/Typography'; @@ -15,7 +16,7 @@ export interface HorizontalScrollTitleProps { } export interface HorizontalScrollHeaderProps { - title: HorizontalScrollTitleProps; + title?: HorizontalScrollTitleProps | ReactNode; isAtEnd: boolean; isAtStart: boolean; onClickNext: MouseEventHandler; @@ -23,6 +24,8 @@ export interface HorizontalScrollHeaderProps { onClickSeeAll?: MouseEventHandler; linkToSeeAll?: string; canScroll: boolean; + className?: string; + buttonSize?: ButtonSize; } export const HorizontalScrollTitle = ({ @@ -50,10 +53,25 @@ export function HorizontalScrollHeader({ onClickSeeAll, linkToSeeAll, canScroll, + className, + buttonSize = ButtonSize.Medium, }: HorizontalScrollHeaderProps): ReactElement { + // Check if title is props object or custom ReactNode + const isCustomTitle = + title && typeof title === 'object' && !('copy' in title); + return ( -
- +
+ {isCustomTitle + ? title + : title && ( + + )} {canScroll && (
+ )) + ) : ( +
+ No results +
+ )} +
+ ) : ( + + )} + + +
+ ); +}; + +export default Autocomplete; diff --git a/packages/shared/src/components/fields/ControlledSwitch.tsx b/packages/shared/src/components/fields/ControlledSwitch.tsx new file mode 100644 index 0000000000..f0721f6ed4 --- /dev/null +++ b/packages/shared/src/components/fields/ControlledSwitch.tsx @@ -0,0 +1,63 @@ +import { Controller, useFormContext } from 'react-hook-form'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { Switch } from './Switch'; + +type ControlledSwitchProps = { + name: string; + label: string; + description?: string | React.ReactNode; + disabled?: boolean; + onChange?: (value: boolean) => void; +}; + +const ControlledSwitch = ({ + name, + label, + description, + disabled, + onChange, +}: ControlledSwitchProps) => { + const { control } = useFormContext(); + + return ( + ( +
+
+ + {label} + + { + field.onChange(!field.value); + onChange?.(!field.value); + }} + compact={false} + disabled={disabled} + /> +
+ {description && ( + + {description} + + )} +
+ )} + /> + ); +}; + +export default ControlledSwitch; diff --git a/packages/shared/src/components/fields/ControlledTextField.tsx b/packages/shared/src/components/fields/ControlledTextField.tsx new file mode 100644 index 0000000000..b07493c322 --- /dev/null +++ b/packages/shared/src/components/fields/ControlledTextField.tsx @@ -0,0 +1,40 @@ +import { Controller, useFormContext } from 'react-hook-form'; +import React from 'react'; +import type { TextFieldProps } from './TextField'; +import { TextField } from './TextField'; + +type ControlledTextFieldProps = Pick< + TextFieldProps, + | 'name' + | 'label' + | 'leftIcon' + | 'placeholder' + | 'hint' + | 'fieldType' + | 'className' +>; + +const ControlledTextField = ({ + name, + hint, + ...restProps +}: ControlledTextFieldProps) => { + const { control } = useFormContext(); + + return ( + ( + + )} + /> + ); +}; +export default ControlledTextField; diff --git a/packages/shared/src/components/fields/ControlledTextarea.tsx b/packages/shared/src/components/fields/ControlledTextarea.tsx new file mode 100644 index 0000000000..c0275e0047 --- /dev/null +++ b/packages/shared/src/components/fields/ControlledTextarea.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import Textarea from './Textarea'; +import type { BaseFieldProps } from './BaseFieldContainer'; + +const ControlledTextarea = ({ + name, + ...restProps +}: Pick< + BaseFieldProps, + 'name' | 'label' | 'maxLength' +>) => { + const { control } = useFormContext(); + + return ( + ( +