Skip to content

Instantly share code, notes, and snippets.

@manabuyasuda
Created March 11, 2025 02:56
Show Gist options
  • Save manabuyasuda/b68e7777ac85695c22cd123ffb48ded0 to your computer and use it in GitHub Desktop.
Save manabuyasuda/b68e7777ac85695c22cd123ffb48ded0 to your computer and use it in GitHub Desktop.
import { usePathname, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useRef, useMemo } from 'react';
type ScrollIntoViewOptions = {
behavior?: ScrollBehavior;
block?: ScrollLogicalPosition;
inline?: ScrollLogicalPosition;
};
interface UseLinkScrollOptions {
/**
* フォーカス設定後に呼び出されるコールバック関数
*/
onFocus?: () => void;
/**
* スクロール設定のオプション
*/
scrollOptions?: ScrollIntoViewOptions;
/**
* フォーカスを設定するかどうか
* @default true
*/
shouldFocus?: boolean;
}
/**
* ページネーションのリンククリック時にスクロール処理を管理するカスタムフックです。
* Next.jsにあるLinkコンポーネントのscrollを拡張して、CSSセレクタを指定した場合はその要素へスクロールできます。
* ロード時やsessionStorageが使用できないプライベートモードなどではスクロールは実行されません。
* @param scrollTarget スクロール対象の指定
* - `true`: ページトップにスクロールする - Next.jsのLinkコンポーネントのデフォルト挙動
* - `false`: スクロールしない(デフォルト)- Next.jsのLinkコンポーネントのデフォルト挙動
* - `string`: CSSセレクタとして解釈して、その要素にスクロールする
* @param options スクロール処理のオプション(scrollTargetがstringの場合のみ有効)
* - `onFocus`: フォーカス設定後に呼び出されるコールバック関数
* - `scrollOptions`: スクロール設定のオプション(behavior, block, inline)
* - `shouldFocus`: フォーカスを設定するかどうか(デフォルト: true)
* @returns {Object} Linkコンポーネントに適用するプロパティを生成する関数
* - `getLinkScrollProps`: Linkコンポーネントに渡すプロパティを生成する関数
* - boolean型のscrollTargetの場合: `{ scroll: scrollTarget }`
* - 文字列型のscrollTargetの場合: `{ scroll: false, onClick: handleLinkClick }`
* @example
* // 基本的な使用方法(Paginationコンポーネント内)
* function Pagination({ scroll = true, ...props }) {
* const { getLinkScrollProps } = useLinkScroll(scroll);
*
* return (
* <Link href={createPageUrl(pageNo + 1)} {...getLinkScrollProps()}>次のページ</Link>
* );
* }
*
*/
export function useLinkScroll(
scrollTarget: boolean | string,
options: UseLinkScrollOptions = {},
) {
// オプションの初期設定
const { onFocus, scrollOptions = {}, shouldFocus = true } = options;
// パスとクエリパラメータの変更を監視する
const pathname = usePathname();
const searchParams = useSearchParams();
// スクロールターゲットを保持するref
const scrollTargetRef = useRef<string | null>(
typeof scrollTarget === 'string' ? scrollTarget : null,
);
// スクロールオプションをマージする
const mergedScrollOptions = useMemo(
() => ({
behavior: 'instant' as ScrollBehavior,
block: 'start' as ScrollLogicalPosition,
inline: 'nearest' as ScrollLogicalPosition,
...scrollOptions,
}),
[scrollOptions],
);
/**
* 要素にフォーカスを設定します。
*/
const setElementFocus = useCallback(
(selector: string) => {
try {
const element = document.querySelector(selector);
if (element) {
// 通常フォーカスを受け取らない要素(div, h1, h2など)でもフォーカスできるように
// tabIndex: -1を設定(既存の値は上書きしない)
if (!element.hasAttribute('tabindex')) {
element.setAttribute('tabindex', '-1');
}
// フォーカスを設定する
if ('focus' in element) {
(element as HTMLElement).focus();
// コールバックがある場合は実行する
if (onFocus) {
onFocus();
}
}
}
} catch (error) {
// フォーカス設定に失敗した場合はログを出力
console.warn('[useLinkScroll] フォーカスの設定に失敗しました。', {
selector,
error,
});
}
},
[onFocus],
);
/**
* 指定された要素にスクロールします。
*/
const scrollToElement = useCallback(
(selector: string): void => {
try {
// 文字列の場合はセレクタとして解釈する
const element = document.querySelector(selector);
if (element) {
// 要素が存在する場合はスクロールする
element.scrollIntoView({
behavior: mergedScrollOptions.behavior,
block: mergedScrollOptions.block,
inline: mergedScrollOptions.inline,
});
// スクロール後にフォーカスを設定する
if (shouldFocus) {
setElementFocus(selector);
}
} else {
// 要素が見つからない場合はログを出力する
console.warn(
'[useLinkScroll] スクロール対象の要素が見つかりませんでした。',
{ selector },
);
}
} catch (error) {
// スクロール処理に失敗した場合はログを出力する
console.warn('[useLinkScroll] スクロール処理に失敗しました。', {
selector,
error,
});
}
},
[mergedScrollOptions, setElementFocus, shouldFocus],
);
/**
* 要素へのスクロールを実行する。
*/
const handleLinkClick = useCallback(() => {
// クリック時にスクロール先の要素を保存する
if (typeof scrollTarget === 'string') {
scrollTargetRef.current = scrollTarget;
// クリックによる遷移フラグをsessionStorageに保存する
try {
// スクロール先の要素を保存する
sessionStorage.setItem('scrollTarget', scrollTarget);
// クリックであることを保存する
sessionStorage.setItem('isClickNavigation', 'true');
} catch (error) {
// sessionStorageが使用できない場合はログを出力する
console.warn(
'[useLinkScroll] sessionStorageへの保存に失敗しました。プライベートブラウジングモードや、ストレージの使用が制限されている環境では、スクロール機能が正常に動作しない場合があります。',
{ error },
);
}
}
}, [scrollTarget]);
// パスまたはクエリパラメータが変更されたときにスクロール処理を実行する
useEffect(() => {
// scrollTargetがbooleanの場合は何もしない
if (typeof scrollTarget === 'boolean') {
return;
}
// sessionStorageからスクロール情報を取得する
try {
const savedScrollTarget = sessionStorage.getItem('scrollTarget');
const isClickNavigation =
sessionStorage.getItem('isClickNavigation') === 'true';
// クリックによる遷移でない場合、またはスクロール先の要素がない場合は処理をスキップする
if (!isClickNavigation || !savedScrollTarget) {
return;
}
// DOMContentLoadedイベントが発生した後にスクロール処理を実行する
const executeScroll = () => {
// レンダリングサイクルに合わせてスクロール処理を実行する
requestAnimationFrame(() => {
// 指定された要素にスクロールする
scrollToElement(savedScrollTarget);
// スクロール実行後にフラグを削除する
sessionStorage.removeItem('isClickNavigation');
sessionStorage.removeItem('scrollTarget');
});
};
// 念のため、DOMが完全に読み込まれた段階でスクロール処理を実行する
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', executeScroll, {
once: true,
});
} else {
executeScroll();
}
} catch (error) {
// sessionStorageが使用できない場合は無視する
console.warn('[useLinkScroll] sessionStorageが使用できません。', {
error,
});
}
}, [pathname, searchParams, scrollToElement, scrollTarget]);
return {
getLinkScrollProps: useCallback(() => {
// scrollTargetがbooleanの場合は、Next.jsにあるLinkコンポーネントのデフォルト挙動を使用する
if (typeof scrollTarget === 'boolean') {
return {
scroll: scrollTarget,
};
}
// scrollTargetが文字列の場合は、カスタムのクリックハンドラを使用する
return {
scroll: false, // Next.jsのデフォルトスクロールを無効化
onClick: handleLinkClick,
};
}, [scrollTarget, handleLinkClick]),
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment