feat: add gradient indicator for scrollable event descriptions (#25460)

* feat: add gradient indicator for scrollable event descriptions

- Create EventDescription component with dynamic gradient overlay
- Show gradient only when description content overflows (180px max-height)
- Use Tailwind v4 gradient utilities for optimal styling
- Replace inline description rendering in EventMeta with new component
- Minimal implementation: 28 lines, no useEffect, single state variable

* add top gradient and optimize ScrollableWithGradients component

* add top gradient and optimize ScrollableWithGradients component

* refactor: extract reusable ScrollableWithGradients component with top/bottom gradients

* adjust gradient fades
This commit is contained in:
Dhairyashil Shinde authored and GitHub committed 2025-12-15 14:14:28 +01:00
1 parent 9050f9b61f
commit e312d1c044
2 files changed
+86 -9

No files matched your search

@@ -20,6 +20,7 @@ import { EventTypeAutoTranslatedField } from "@calcom/prisma/enums";
import i18nConfigration from "../../../../../i18n.json";
import { fadeInUp } from "../config";
import { FromToTime } from "../utils/dates";
import { ScrollableWithGradients } from "./ScrollableWithGradients";
import { useBookerTime } from "./hooks/useBookerTime";
const WebTimezoneSelect = dynamic(
@@ -174,15 +175,16 @@ export const EventMeta = ({
{translatedTitle ?? event?.title}
</EventTitle>
{(event.description || translatedDescription) && (
<EventMetaBlock
data-testid="event-meta-description"
contentClassName="mb-8 wrap-break-word max-w-full max-h-[180px] scroll-bar pr-4 overflow-y-auto">
<div
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: markdownToSafeHTMLClient(translatedDescription ?? event.description),
}}
/>
<EventMetaBlock data-testid="event-meta-description" contentClassName="mb-8">
<ScrollableWithGradients
className="wrap-break-word scroll-bar max-h-[180px] max-w-full overflow-y-auto pr-4"
ariaLabel={t("description")}>
<div
dangerouslySetInnerHTML={{
__html: markdownToSafeHTMLClient(translatedDescription ?? event.description),
}}
/>
</ScrollableWithGradients>
</EventMetaBlock>
)}
<div className="stack-y-4 font-medium rtl:-mr-2">
@@ -0,0 +1,75 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
interface ScrollGradients {
top: boolean;
bottom: boolean;
}
interface ScrollableWithGradientsProps {
children: ReactNode;
className?: string;
ariaLabel?: string;
}
const SCROLL_THRESHOLD = 5; // Pixels before showing gradient
export function ScrollableWithGradients({ children, className, ariaLabel }: ScrollableWithGradientsProps) {
const [gradients, setGradients] = useState<ScrollGradients>({ top: false, bottom: false });
const scrollRef = useRef<HTMLDivElement | null>(null);
const updateGradients = useCallback(() => {
const node = scrollRef.current;
if (!node) return;
const { scrollTop, scrollHeight, clientHeight } = node;
const top = scrollTop > SCROLL_THRESHOLD;
const bottom = scrollTop + clientHeight < scrollHeight - SCROLL_THRESHOLD;
setGradients((prev) => (prev.top === top && prev.bottom === bottom ? prev : { top, bottom }));
}, []);
const setRef = useCallback(
(node: HTMLDivElement | null) => {
scrollRef.current = node;
updateGradients();
},
[updateGradients]
);
// Handle resize events (window resize, font load, etc.)
useEffect(() => {
const node = scrollRef.current;
if (!node) return;
const resizeObserver = new ResizeObserver(updateGradients);
resizeObserver.observe(node);
return () => resizeObserver.disconnect();
}, [updateGradients]);
return (
<div className="relative">
<div
ref={setRef}
onScroll={updateGradients}
className={className}
tabIndex={0}
role="region"
aria-label={ariaLabel}>
{children}
</div>
{gradients.top && (
<div
className="from-default pointer-events-none absolute left-0 right-0 top-0 h-8 bg-gradient-to-b to-transparent dark:from-[var(--cal-bg-muted)]"
aria-hidden="true"
/>
)}
{gradients.bottom && (
<div
className="from-default pointer-events-none absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t to-transparent dark:from-[var(--cal-bg-muted)]"
aria-hidden="true"
/>
)}
</div>
);
}