fix(app): prevent desktop combobox initial 0,0 flash
This commit is contained in:
@@ -0,0 +1,130 @@
|
|||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { gotoHome } from "./helpers/app";
|
||||||
|
|
||||||
|
test("project filter dropdown never appears visibly at 0,0 on open", async ({ page }) => {
|
||||||
|
await gotoHome(page);
|
||||||
|
|
||||||
|
const trigger = page.getByText("Project", { exact: true }).first();
|
||||||
|
await expect(trigger).toBeVisible();
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(window as any).__projectFilterFlashProbe = new Promise<{
|
||||||
|
targetFound: boolean;
|
||||||
|
visibleAtOrigin: boolean;
|
||||||
|
records: Array<{ left: number; top: number; opacity: number }>;
|
||||||
|
}>((resolve) => {
|
||||||
|
let target: HTMLElement | null = null;
|
||||||
|
const records: Array<{ left: number; top: number; opacity: number }> = [];
|
||||||
|
|
||||||
|
const capture = () => {
|
||||||
|
if (!target) return;
|
||||||
|
const style = getComputedStyle(target);
|
||||||
|
records.push({
|
||||||
|
left: Number.parseFloat(style.left || "0"),
|
||||||
|
top: Number.parseFloat(style.top || "0"),
|
||||||
|
opacity: Number.parseFloat(style.opacity || "1"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContainer = (node: HTMLElement): HTMLElement | null => {
|
||||||
|
let current: HTMLElement | null = node;
|
||||||
|
while (current && current !== document.body) {
|
||||||
|
const style = getComputedStyle(current);
|
||||||
|
if (style.position === "absolute" && style.backgroundColor !== "rgba(0, 0, 0, 0)") {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryResolveTarget = (root: HTMLElement) => {
|
||||||
|
const stack = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
|
||||||
|
for (const element of stack) {
|
||||||
|
if (!element.textContent?.includes("No projects")) continue;
|
||||||
|
const container = getContainer(element);
|
||||||
|
if (!container) continue;
|
||||||
|
target = container;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
observer.disconnect();
|
||||||
|
if (!target) {
|
||||||
|
resolve({
|
||||||
|
targetFound: false,
|
||||||
|
visibleAtOrigin: false,
|
||||||
|
records: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleAtOrigin = records.some(
|
||||||
|
(entry) => entry.left <= 1 && entry.top <= 1 && entry.opacity > 0.01
|
||||||
|
);
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
targetFound: true,
|
||||||
|
visibleAtOrigin,
|
||||||
|
records,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sampleFrames = () => {
|
||||||
|
capture();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
capture();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
capture();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
capture();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
capture();
|
||||||
|
finish();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
for (const added of Array.from(mutation.addedNodes)) {
|
||||||
|
if (!(added instanceof HTMLElement)) continue;
|
||||||
|
if (tryResolveTarget(added)) {
|
||||||
|
sampleFrames();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
mutation.type === "attributes" &&
|
||||||
|
mutation.target instanceof HTMLElement &&
|
||||||
|
!target &&
|
||||||
|
tryResolveTarget(mutation.target)
|
||||||
|
) {
|
||||||
|
sampleFrames();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["style"],
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => finish(), 2500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await trigger.click();
|
||||||
|
const probe = await page.evaluate(() => (window as any).__projectFilterFlashProbe);
|
||||||
|
|
||||||
|
expect(probe.targetFound).toBe(true);
|
||||||
|
expect(probe.visibleAtOrigin).toBe(false);
|
||||||
|
});
|
||||||
@@ -47,10 +47,29 @@ export interface ComboboxProps {
|
|||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
desktopPlacement?: "top-start" | "bottom-start";
|
desktopPlacement?: "top-start" | "bottom-start";
|
||||||
|
/**
|
||||||
|
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
||||||
|
* coordinates resolve. This intentionally disables fade enter/exit animation
|
||||||
|
* for that combobox instance to avoid animation overriding hidden opacity.
|
||||||
|
*/
|
||||||
|
desktopPreventInitialFlash?: boolean;
|
||||||
anchorRef: React.RefObject<View | null>;
|
anchorRef: React.RefObject<View | null>;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toNumericStyleValue(value: unknown): number | null {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
|
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -171,6 +190,7 @@ export function Combobox({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
desktopPlacement = "top-start",
|
desktopPlacement = "top-start",
|
||||||
|
desktopPreventInitialFlash = true,
|
||||||
anchorRef,
|
anchorRef,
|
||||||
children,
|
children,
|
||||||
}: ComboboxProps): ReactElement {
|
}: ComboboxProps): ReactElement {
|
||||||
@@ -180,6 +200,7 @@ export function Combobox({
|
|||||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||||
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
|
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
|
||||||
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
|
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
|
||||||
|
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||||
|
|
||||||
@@ -263,10 +284,38 @@ export function Combobox({
|
|||||||
setReferenceWidth(null);
|
setReferenceWidth(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const raf = requestAnimationFrame(() => update());
|
const raf = requestAnimationFrame(() => void update());
|
||||||
return () => cancelAnimationFrame(raf);
|
return () => cancelAnimationFrame(raf);
|
||||||
}, [desktopPlacement, isMobile, update, isOpen]);
|
}, [desktopPlacement, isMobile, update, isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || isMobile) {
|
||||||
|
setReferenceAtOrigin(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceEl = anchorRef.current;
|
||||||
|
if (!referenceEl) {
|
||||||
|
setReferenceAtOrigin(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
referenceEl.measureInWindow((x, y) => {
|
||||||
|
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
|
||||||
|
});
|
||||||
|
}, [anchorRef, isMobile, isOpen]);
|
||||||
|
|
||||||
|
const floatingTop = toNumericStyleValue(floatingStyles.top);
|
||||||
|
const floatingLeft = toNumericStyleValue(floatingStyles.left);
|
||||||
|
const hasResolvedDesktopPosition =
|
||||||
|
referenceWidth !== null &&
|
||||||
|
floatingTop !== null &&
|
||||||
|
floatingLeft !== null &&
|
||||||
|
(floatingTop !== 0 || floatingLeft !== 0 || referenceAtOrigin);
|
||||||
|
const shouldHideDesktopContent =
|
||||||
|
desktopPreventInitialFlash && !hasResolvedDesktopPosition;
|
||||||
|
const shouldUseDesktopFade = !desktopPreventInitialFlash;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMobile) return;
|
if (!isMobile) return;
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -514,8 +563,8 @@ export function Combobox({
|
|||||||
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
|
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
|
||||||
<Pressable style={styles.desktopBackdrop} onPress={handleClose} />
|
<Pressable style={styles.desktopBackdrop} onPress={handleClose} />
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeIn.duration(100)}
|
entering={shouldUseDesktopFade ? FadeIn.duration(100) : undefined}
|
||||||
exiting={FadeOut.duration(100)}
|
exiting={shouldUseDesktopFade ? FadeOut.duration(100) : undefined}
|
||||||
style={[
|
style={[
|
||||||
styles.desktopContainer,
|
styles.desktopContainer,
|
||||||
{
|
{
|
||||||
@@ -524,7 +573,7 @@ export function Combobox({
|
|||||||
maxWidth: 400,
|
maxWidth: 400,
|
||||||
},
|
},
|
||||||
floatingStyles,
|
floatingStyles,
|
||||||
referenceWidth === null ? { opacity: 0 } : null,
|
shouldHideDesktopContent ? { opacity: 0 } : null,
|
||||||
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
|
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
|
||||||
]}
|
]}
|
||||||
ref={refs.setFloating}
|
ref={refs.setFloating}
|
||||||
|
|||||||
Reference in New Issue
Block a user