authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-04 13:50:30-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-04 20:50:30+00:00
log0576820755fe12e32e50f03259035c863913b29f
tree9cfce14628d9a6ab4ded9dde493437d755d92c93
parentcb3211c8f3705cc3cb68460b392e95750bc66554
signature Signed by PGP key B5690EEEBB952194

Prevent focus-ring clipping in AnimatedHeight (#2503)


1 files changed, 37 insertions(+), 5 deletions(-)

apps/web/src/components/AnimatedHeight.tsx+37-5
...@@ -1,10 +1,25 @@...@@ -1,10 +1,25 @@
1"use client";1"use client";
22
3import { type ReactNode, useLayoutEffect, useRef, useState } from "react";3import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
4
5const HEIGHT_TRANSITION_FALLBACK_MS = 250;
46
5export function AnimatedHeight({ children }: { readonly children: ReactNode }) {7export function AnimatedHeight({ children }: { readonly children: ReactNode }) {
6 const contentRef = useRef<HTMLDivElement>(null);8 const contentRef = useRef<HTMLDivElement>(null);
7 const [height, setHeight] = useState<number | null>(null);9 const [heightState, setHeightState] = useState<{
10 readonly height: number | null;
11 readonly isClipping: boolean;
12 }>({ height: null, isClipping: false });
13
14 useEffect(() => {
15 if (!heightState.isClipping) return;
16 const timeoutId = window.setTimeout(() => {
17 setHeightState((currentState) =>
18 currentState.isClipping ? { ...currentState, isClipping: false } : currentState,
19 );
20 }, HEIGHT_TRANSITION_FALLBACK_MS);
21 return () => window.clearTimeout(timeoutId);
22 }, [heightState.height, heightState.isClipping]);
823
9 useLayoutEffect(() => {24 useLayoutEffect(() => {
10 const element = contentRef.current;25 const element = contentRef.current;
...@@ -14,7 +29,13 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) {...@@ -14,7 +29,13 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) {
1429
15 const updateHeight = () => {30 const updateHeight = () => {
16 const nextHeight = Math.ceil(element.scrollHeight || element.getBoundingClientRect().height);31 const nextHeight = Math.ceil(element.scrollHeight || element.getBoundingClientRect().height);
17 setHeight((currentHeight) => (currentHeight === nextHeight ? currentHeight : nextHeight));32 setHeightState((currentState) => {
33 if (currentState.height === nextHeight) return currentState;
34 return {
35 height: nextHeight,
36 isClipping: currentState.height !== null,
37 };
38 });
18 };39 };
19 const cancelPendingFrames = () => {40 const cancelPendingFrames = () => {
20 if (firstFrameId !== null) {41 if (firstFrameId !== null) {
...@@ -50,8 +71,19 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) {...@@ -50,8 +71,19 @@ export function AnimatedHeight({ children }: { readonly children: ReactNode }) {
5071
51 return (72 return (
52 <div73 <div
53 className="overflow-hidden transition-[height] duration-200 ease-out motion-reduce:transition-none"74 data-slot="animated-height"
54 style={height === null ? undefined : { height }}75 className="transition-[height] duration-200 ease-out motion-reduce:transition-none"
76 style={
77 heightState.height === null
78 ? undefined
79 : { height: heightState.height, overflow: heightState.isClipping ? "hidden" : "visible" }
80 }
81 onTransitionEnd={(event) => {
82 if (event.target !== event.currentTarget || event.propertyName !== "height") return;
83 setHeightState((currentState) =>
84 currentState.isClipping ? { ...currentState, isClipping: false } : currentState,
85 );
86 }}
55 >87 >
56 <div ref={contentRef}>{children}</div>88 <div ref={contentRef}>{children}</div>
57 </div>89 </div>