Skip to content

Instantly share code, notes, and snippets.

@colelawrence
Last active February 4, 2025 20:39
Show Gist options
  • Save colelawrence/028930161b586dcc393018ee7df3a19b to your computer and use it in GitHub Desktop.
Save colelawrence/028930161b586dcc393018ee7df3a19b to your computer and use it in GitHub Desktop.
React helper for when you want to enable "clicking" a button without changing the document active element/focus
import { useEffect } from "react";
import { listen } from "./zlisten";
/** For when you want to do something without breaking the focus of the active element */
export const useClickByHoverToAvoidChangingFocus = (element: HTMLElement | null | undefined, callback: null | undefined | (() => void)) => {
useEffect(() => {
if (!element || !callback) return;
let openTimeout: (Animation | any)[] = [];
const DURATION = 1000;
// Create an anticipation animation that builds up to opening
const anticipateOpen = () => element.animate([
{ transform: 'scale(1)', offset: 0 },
{ transform: 'scale(1.02)', offset: 0.4 },
{ transform: 'scale(1.04)', offset: 0.6 },
{ transform: 'scale(1.05)', offset: 0.95 },
{ transform: 'scale(1)', offset: 1 }
], {
duration: DURATION,
easing: 'ease-in',
fill: 'forwards'
});
const unsub = listen(element, "mouseenter", () => {
// we need anticipated animation if our click would otherwise change the focus
if (document.activeElement == null || document.activeElement === document.body) return;
const animation = anticipateOpen();
openTimeout = [setTimeout(callback, DURATION), animation];
});
const unsub2 = listen(element, "mouseleave", () => {
openTimeout.forEach(t => {
if (t instanceof Animation) t.cancel();
else clearTimeout(t);
});
});
return () => {
unsub();
unsub2();
};
}, [element, callback]);
};
import type { DevString } from "#devstrings";
import { memo, useCallback, useState } from "react";
import { openInDevEditor } from "./openInDevEditor";
import { useClickByHoverToAvoidChangingFocus } from "./useClickByHoverToAvoidChangingFocus";
export const DevStringLink = memo(({ reason }: { reason: DevString }) => {
const {
context: { loc } = {},
} = reason.toJSON();
const useLoc = loc && String(loc).split("/").slice(-2).join("/");
const open = useCallback(() => openInDevEditor(String(loc)), [loc]);
const [btn, setBtn] = useState<HTMLButtonElement | null>(null);
useClickByHoverToAvoidChangingFocus(btn, open);
return useLoc ? (
<button $="bg-transparent text-xs hover:bg-neutral-100" onClick={open} title={useLoc} ref={setBtn}>
<span $="underline">{reason.toMessageString()}</span> ↗︎
</button>
) : (
reason.toMessageString()
);
});
interface listen {
<K extends keyof HTMLElementEventMap>(
target: HTMLElement,
type: K,
listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
): () => void;
<K extends keyof WindowEventHandlersEventMap>(
target: Window,
type: K,
listener: WindowEventHandlersEventMap[K],
options?: boolean | AddEventListenerOptions,
): () => void;
(
target: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): () => void;
}
/** Listen to events on the target and return an unsubscribe function */
export const listen: listen = (
target: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
) => {
target.addEventListener(type, listener, options);
return () => target.removeEventListener(type, listener, options);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment