Skip to content

Instantly share code, notes, and snippets.

@manabuyasuda
Created June 16, 2025 05:51
Show Gist options
  • Save manabuyasuda/4ff610b65639319862f157637e95b207 to your computer and use it in GitHub Desktop.
Save manabuyasuda/4ff610b65639319862f157637e95b207 to your computer and use it in GitHub Desktop.
selectタグの幅を動的に計算するカスタムフック
'use client';
import { useLayoutEffect, useRef, useSyncExternalStore, useCallback } from 'react';
/**
* selectタグの幅を動的に計算するカスタムフックです。
* デフォルトではoptionタグにある最長のテキストに基づいて幅を計算しますが、これを上書きします。
* @param selectRef - selectタグへの参照
* @param currentValue - 現在選択されている値
* @returns 計算された幅(ピクセル単位)または null(SSR時)
*
* @example
* ```
* function MySelect() {
* const selectRef = useRef<HTMLSelectElement>(null);
* const [value, setValue] = useState('');
*
* // フックを使用して幅を取得
* const width = useSelectWidth(selectRef, value);
*
* return (
* <select
* ref={selectRef}
* value={value}
* onChange={(e) => setValue(e.target.value)}
* style={width ? { width: `${width}px` } : undefined}
* >
* <option value="">選択してください</option>
* <option value="1">短いテキスト</option>
* <option value="2">とても長いテキストが入る場合</option>
* </select>
* );
* }
* ```
*/
export function useSelectWidth(
selectRef: React.RefObject<HTMLSelectElement | null>,
currentValue: string,
) {
const widthRef = useRef<number | null>(null);
// ハイドレーションエラーを防止する
const selectWidth = useSyncExternalStore(
// ストアの変更を監視する関数
// この実装では幅の変更を外部から通知する必要がないため、空の関数を返す
() => () => {},
// 現在の幅を取得する関数
// クライアントサイドでのみ実行され、現在の幅を返す
() => widthRef.current,
// サーバーサイドでの初期値を返す関数
// SSR時は常にnullを返し、ハイドレーション時の不一致を防ぐ
() => null,
);
// 幅を計算する関数をメモ化
const calculateWidth = useCallback(() => {
if (!selectRef.current) return;
const select = selectRef.current;
const selectedOption = select.options[select.selectedIndex];
if (selectedOption) {
const computedStyle = window.getComputedStyle(select);
// 不可視なspanタグを生成する
const tempSpan = document.createElement('span');
tempSpan.style.visibility = 'hidden';
tempSpan.style.position = 'absolute';
tempSpan.style.whiteSpace = 'nowrap';
// selectタグのスタイルを継承する
tempSpan.style.fontSize = computedStyle.fontSize;
tempSpan.style.fontFamily = computedStyle.fontFamily;
tempSpan.textContent = selectedOption.text;
// ページに出力して幅を計測する
document.body.appendChild(tempSpan);
const textWidth = tempSpan.offsetWidth;
document.body.removeChild(tempSpan);
// テキストだけの幅とパディング、ボーダーを加算して幅を最終計算する
const paddingLeft = parseFloat(computedStyle.paddingLeft);
const paddingRight = parseFloat(computedStyle.paddingRight);
const borderLeftWidth = parseFloat(computedStyle.borderLeftWidth);
const borderRightWidth = parseFloat(computedStyle.borderRightWidth);
const totalWidth =
textWidth + paddingLeft + paddingRight + borderLeftWidth + borderRightWidth;
widthRef.current = totalWidth;
}
}, [selectRef]);
// 初期レンダリング時と値が変更された時に幅を計算する
useLayoutEffect(() => {
calculateWidth();
}, [calculateWidth, currentValue]);
// リサイズ時にも幅を再計算する
useLayoutEffect(() => {
const handleResize = () => {
calculateWidth();
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [calculateWidth]);
return selectWidth;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment