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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
Expand All @@ -12,12 +13,18 @@ import { createPortal } from 'react-dom';
import { MaskedScrollArea } from '../../masked-scrollarea';
import { useMacosVersion } from '@/utils/use-utils-bridge';
import { commitGlobalCSSVar } from '@/embedded/chat/global-css-var';
import {
resolveActivityCenterAnchorHeight,
resolveActivityCenterExitY,
shouldPadActivityCenterSafeArea,
} from './layout';

export const ActivityCenterBanner = ({
header,
children,
visible,
className,
style,
...props
}: HTMLMotionProps<'div'> & {
header: (expanded: boolean) => ReactNode;
Expand All @@ -27,34 +34,64 @@ export const ActivityCenterBanner = ({
const ref = useRef<HTMLDivElement>(null);
const macosVersion = useMacosVersion();
const [open, setOpen] = useState(true);
const [anchorHeight, setAnchorHeight] = useState(0);
const [measuredVisibleHeight, setMeasuredVisibleHeight] = useState(0);

// banner enter with a spring animation,
// add extra space to avoid bottom side bounce into viewport
const safeArea = 60;
const headerHeight = 36;

const commitHeight = useCallback((height: number) => {
commitGlobalCSSVar(
'activityCenterHeight',
`${(height ? height - safeArea : 0).toFixed(0)}px`
);
}, []);
const usesSafeAreaPadding = shouldPadActivityCenterSafeArea({
anchorHeight,
measuredVisibleHeight,
});

const detectHeight = useCallback(() => {
const el = ref.current;
if (!el || !visible) {
commitHeight(0);
setMeasuredVisibleHeight(0);
setAnchorHeight(0);
return;
}

const paddingBottom =
parseFloat(window.getComputedStyle(el).paddingBottom) || 0;
const height = el.getBoundingClientRect().height;
commitHeight(height);
}, [visible, commitHeight]);
const nextMeasuredVisibleHeight = Math.max(0, height - paddingBottom);

setMeasuredVisibleHeight(nextMeasuredVisibleHeight);
setAnchorHeight(previousAnchorHeight =>
resolveActivityCenterAnchorHeight({
previousAnchorHeight,
measuredVisibleHeight: nextMeasuredVisibleHeight,
isOpen: open,
})
);
}, [open, visible]);

// const debouncedDetectHeight = useMemo(
// () => debounce(detectHeight, 100),
// [detectHeight]
// );

useLayoutEffect(() => {
detectHeight();
}, [detectHeight]);

useEffect(() => {
commitGlobalCSSVar(
'activityCenterHeight',
`${(visible ? anchorHeight : 0).toFixed(0)}px`
);
}, [anchorHeight, visible]);

useEffect(() => {
return () => {
commitGlobalCSSVar('activityCenterHeight', '0px');
};
}, []);

useEffect(() => {
if (!visible) {
detectHeight();
Expand All @@ -64,18 +101,17 @@ export const ActivityCenterBanner = ({
if (!el) return;

const dispose = observeResize(el, detectHeight);
return () => {
detectHeight();
dispose();
};
return dispose;
}, [visible, detectHeight]);

return createPortal(
<AnimatePresence>
{visible && (
<motion.div
initial={{ y: 'calc(100% + 20px)' }}
exit={{ y: 'calc(100% + 20px)' }}
exit={{
y: resolveActivityCenterExitY({ anchorHeight, safeArea }),
}}
animate={{ y: 0 }}
transition={{ type: 'spring', stiffness: 100, damping: 15 }}
className={cn(
Expand All @@ -91,7 +127,13 @@ export const ActivityCenterBanner = ({
: 'bg-surface-card',
''
)}
style={{ paddingBottom: safeArea, bottom: -safeArea }}
style={{
paddingBottom: usesSafeAreaPadding ? safeArea : 0,
...(anchorHeight > 0
? { top: `calc(100dvh - ${anchorHeight.toFixed(0)}px)` }
: { bottom: -safeArea }),
...style,
}}
{...props}
ref={ref}
>
Expand Down Expand Up @@ -126,9 +168,12 @@ export const ActivityCenterBanner = ({
{/* Shadow below the banner */}
<div
className={cn(
'absolute top-0 left-0 w-full h-[calc(100%-60px)] -z-1 rounded-t-2xl',
'absolute top-0 left-0 w-full -z-1 rounded-t-2xl',
'shadow-[0_-24px_24px_rgba(0,0,0,0.1)]'
)}
style={{
height: `calc(100% - ${usesSafeAreaPadding ? safeArea : 0}px)`,
}}
/>
</motion.div>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export const resolveActivityCenterAnchorHeight = ({
previousAnchorHeight,
measuredVisibleHeight,
isOpen,
}: {
previousAnchorHeight: number;
measuredVisibleHeight: number;
isOpen: boolean;
}) => {
if (measuredVisibleHeight <= 0) {
return 0;
}

if (isOpen) {
return measuredVisibleHeight;
}

if (!isOpen && previousAnchorHeight > 0) {
return previousAnchorHeight;
}

return measuredVisibleHeight;
};

export const shouldPadActivityCenterSafeArea = ({
anchorHeight,
measuredVisibleHeight,
}: {
anchorHeight: number;
measuredVisibleHeight: number;
}) => anchorHeight <= 0 || measuredVisibleHeight >= anchorHeight - 0.5;

export const resolveActivityCenterExitY = ({
anchorHeight,
safeArea,
exitOffset = 20,
}: {
anchorHeight: number;
safeArea: number;
exitOffset?: number;
}) => {
if (anchorHeight <= 0) {
return `calc(100% + ${exitOffset}px)`;
}

return anchorHeight + safeArea + exitOffset;
};
53 changes: 53 additions & 0 deletions web/tests/embedded/activity-center-banner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
resolveActivityCenterAnchorHeight,
resolveActivityCenterExitY,
shouldPadActivityCenterSafeArea,
} from '../../src/embedded/chat/components/messages/activity-center/layout';

describe('ActivityCenterBanner layout anchoring', () => {
it('keeps the expanded anchor height while the banner is collapsing', () => {
expect(
resolveActivityCenterAnchorHeight({
previousAnchorHeight: 240,
measuredVisibleHeight: 36,
isOpen: false,
})
).toBe(240);
});

it('grows the anchor when open content becomes taller', () => {
expect(
resolveActivityCenterAnchorHeight({
previousAnchorHeight: 120,
measuredVisibleHeight: 180,
isOpen: true,
})
).toBe(180);
});

it('shrinks the anchor when open content becomes shorter', () => {
expect(
resolveActivityCenterAnchorHeight({
previousAnchorHeight: 240,
measuredVisibleHeight: 120,
isOpen: true,
})
).toBe(120);
});

it('does not expose bottom safe-area padding after top-anchored collapse', () => {
expect(
shouldPadActivityCenterSafeArea({
anchorHeight: 240,
measuredVisibleHeight: 36,
})
).toBe(false);
});

it('pushes a top-anchored collapsed banner below the viewport on exit', () => {
expect(
resolveActivityCenterExitY({ anchorHeight: 240, safeArea: 60 })
).toBe(320);
});
});
Loading