Created
March 11, 2025 02:56
-
-
Save manabuyasuda/b68e7777ac85695c22cd123ffb48ded0 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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