Skip to content

Commit e3446d0

Browse files
zzj3720Managed via Tart
andauthored
Fix activity center collapse anchoring (#33)
Co-authored-by: Managed via Tart <admin@Manageds-Virtual-Machine.local>
1 parent 591a1c3 commit e3446d0

3 files changed

Lines changed: 161 additions & 16 deletions

File tree

web/src/embedded/chat/components/messages/activity-center/banner.tsx

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
44
import {
55
useCallback,
66
useEffect,
7+
useLayoutEffect,
78
useRef,
89
useState,
910
type ReactNode,
@@ -12,12 +13,18 @@ import { createPortal } from 'react-dom';
1213
import { MaskedScrollArea } from '../../masked-scrollarea';
1314
import { useMacosVersion } from '@/utils/use-utils-bridge';
1415
import { commitGlobalCSSVar } from '@/embedded/chat/global-css-var';
16+
import {
17+
resolveActivityCenterAnchorHeight,
18+
resolveActivityCenterExitY,
19+
shouldPadActivityCenterSafeArea,
20+
} from './layout';
1521

1622
export const ActivityCenterBanner = ({
1723
header,
1824
children,
1925
visible,
2026
className,
27+
style,
2128
...props
2229
}: HTMLMotionProps<'div'> & {
2330
header: (expanded: boolean) => ReactNode;
@@ -27,34 +34,64 @@ export const ActivityCenterBanner = ({
2734
const ref = useRef<HTMLDivElement>(null);
2835
const macosVersion = useMacosVersion();
2936
const [open, setOpen] = useState(true);
37+
const [anchorHeight, setAnchorHeight] = useState(0);
38+
const [measuredVisibleHeight, setMeasuredVisibleHeight] = useState(0);
3039

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

36-
const commitHeight = useCallback((height: number) => {
37-
commitGlobalCSSVar(
38-
'activityCenterHeight',
39-
`${(height ? height - safeArea : 0).toFixed(0)}px`
40-
);
41-
}, []);
45+
const usesSafeAreaPadding = shouldPadActivityCenterSafeArea({
46+
anchorHeight,
47+
measuredVisibleHeight,
48+
});
4249

4350
const detectHeight = useCallback(() => {
4451
const el = ref.current;
4552
if (!el || !visible) {
46-
commitHeight(0);
53+
setMeasuredVisibleHeight(0);
54+
setAnchorHeight(0);
4755
return;
4856
}
57+
58+
const paddingBottom =
59+
parseFloat(window.getComputedStyle(el).paddingBottom) || 0;
4960
const height = el.getBoundingClientRect().height;
50-
commitHeight(height);
51-
}, [visible, commitHeight]);
61+
const nextMeasuredVisibleHeight = Math.max(0, height - paddingBottom);
62+
63+
setMeasuredVisibleHeight(nextMeasuredVisibleHeight);
64+
setAnchorHeight(previousAnchorHeight =>
65+
resolveActivityCenterAnchorHeight({
66+
previousAnchorHeight,
67+
measuredVisibleHeight: nextMeasuredVisibleHeight,
68+
isOpen: open,
69+
})
70+
);
71+
}, [open, visible]);
5272

5373
// const debouncedDetectHeight = useMemo(
5474
// () => debounce(detectHeight, 100),
5575
// [detectHeight]
5676
// );
5777

78+
useLayoutEffect(() => {
79+
detectHeight();
80+
}, [detectHeight]);
81+
82+
useEffect(() => {
83+
commitGlobalCSSVar(
84+
'activityCenterHeight',
85+
`${(visible ? anchorHeight : 0).toFixed(0)}px`
86+
);
87+
}, [anchorHeight, visible]);
88+
89+
useEffect(() => {
90+
return () => {
91+
commitGlobalCSSVar('activityCenterHeight', '0px');
92+
};
93+
}, []);
94+
5895
useEffect(() => {
5996
if (!visible) {
6097
detectHeight();
@@ -64,18 +101,17 @@ export const ActivityCenterBanner = ({
64101
if (!el) return;
65102

66103
const dispose = observeResize(el, detectHeight);
67-
return () => {
68-
detectHeight();
69-
dispose();
70-
};
104+
return dispose;
71105
}, [visible, detectHeight]);
72106

73107
return createPortal(
74108
<AnimatePresence>
75109
{visible && (
76110
<motion.div
77111
initial={{ y: 'calc(100% + 20px)' }}
78-
exit={{ y: 'calc(100% + 20px)' }}
112+
exit={{
113+
y: resolveActivityCenterExitY({ anchorHeight, safeArea }),
114+
}}
79115
animate={{ y: 0 }}
80116
transition={{ type: 'spring', stiffness: 100, damping: 15 }}
81117
className={cn(
@@ -91,7 +127,13 @@ export const ActivityCenterBanner = ({
91127
: 'bg-surface-card',
92128
''
93129
)}
94-
style={{ paddingBottom: safeArea, bottom: -safeArea }}
130+
style={{
131+
paddingBottom: usesSafeAreaPadding ? safeArea : 0,
132+
...(anchorHeight > 0
133+
? { top: `calc(100dvh - ${anchorHeight.toFixed(0)}px)` }
134+
: { bottom: -safeArea }),
135+
...style,
136+
}}
95137
{...props}
96138
ref={ref}
97139
>
@@ -126,9 +168,12 @@ export const ActivityCenterBanner = ({
126168
{/* Shadow below the banner */}
127169
<div
128170
className={cn(
129-
'absolute top-0 left-0 w-full h-[calc(100%-60px)] -z-1 rounded-t-2xl',
171+
'absolute top-0 left-0 w-full -z-1 rounded-t-2xl',
130172
'shadow-[0_-24px_24px_rgba(0,0,0,0.1)]'
131173
)}
174+
style={{
175+
height: `calc(100% - ${usesSafeAreaPadding ? safeArea : 0}px)`,
176+
}}
132177
/>
133178
</motion.div>
134179
)}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
export const resolveActivityCenterAnchorHeight = ({
2+
previousAnchorHeight,
3+
measuredVisibleHeight,
4+
isOpen,
5+
}: {
6+
previousAnchorHeight: number;
7+
measuredVisibleHeight: number;
8+
isOpen: boolean;
9+
}) => {
10+
if (measuredVisibleHeight <= 0) {
11+
return 0;
12+
}
13+
14+
if (isOpen) {
15+
return measuredVisibleHeight;
16+
}
17+
18+
if (!isOpen && previousAnchorHeight > 0) {
19+
return previousAnchorHeight;
20+
}
21+
22+
return measuredVisibleHeight;
23+
};
24+
25+
export const shouldPadActivityCenterSafeArea = ({
26+
anchorHeight,
27+
measuredVisibleHeight,
28+
}: {
29+
anchorHeight: number;
30+
measuredVisibleHeight: number;
31+
}) => anchorHeight <= 0 || measuredVisibleHeight >= anchorHeight - 0.5;
32+
33+
export const resolveActivityCenterExitY = ({
34+
anchorHeight,
35+
safeArea,
36+
exitOffset = 20,
37+
}: {
38+
anchorHeight: number;
39+
safeArea: number;
40+
exitOffset?: number;
41+
}) => {
42+
if (anchorHeight <= 0) {
43+
return `calc(100% + ${exitOffset}px)`;
44+
}
45+
46+
return anchorHeight + safeArea + exitOffset;
47+
};
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
resolveActivityCenterAnchorHeight,
4+
resolveActivityCenterExitY,
5+
shouldPadActivityCenterSafeArea,
6+
} from '../../src/embedded/chat/components/messages/activity-center/layout';
7+
8+
describe('ActivityCenterBanner layout anchoring', () => {
9+
it('keeps the expanded anchor height while the banner is collapsing', () => {
10+
expect(
11+
resolveActivityCenterAnchorHeight({
12+
previousAnchorHeight: 240,
13+
measuredVisibleHeight: 36,
14+
isOpen: false,
15+
})
16+
).toBe(240);
17+
});
18+
19+
it('grows the anchor when open content becomes taller', () => {
20+
expect(
21+
resolveActivityCenterAnchorHeight({
22+
previousAnchorHeight: 120,
23+
measuredVisibleHeight: 180,
24+
isOpen: true,
25+
})
26+
).toBe(180);
27+
});
28+
29+
it('shrinks the anchor when open content becomes shorter', () => {
30+
expect(
31+
resolveActivityCenterAnchorHeight({
32+
previousAnchorHeight: 240,
33+
measuredVisibleHeight: 120,
34+
isOpen: true,
35+
})
36+
).toBe(120);
37+
});
38+
39+
it('does not expose bottom safe-area padding after top-anchored collapse', () => {
40+
expect(
41+
shouldPadActivityCenterSafeArea({
42+
anchorHeight: 240,
43+
measuredVisibleHeight: 36,
44+
})
45+
).toBe(false);
46+
});
47+
48+
it('pushes a top-anchored collapsed banner below the viewport on exit', () => {
49+
expect(
50+
resolveActivityCenterExitY({ anchorHeight: 240, safeArea: 60 })
51+
).toBe(320);
52+
});
53+
});

0 commit comments

Comments
 (0)