0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

HeroUIでレスポンシブなトースト位置に制御

Posted at

背景

HeroUIのトースト通知について、デフォルトだと右下に出ます。これがモバイルだと下部のナビゲーションやボタンと被って使いづらい場合があります。

今回、モバイルとデスクトップで表示位置を切り替える実装についてまとめです。

解消したい問題

  • モバイル画面だと下部のUIとトーストが重なってタップできない
  • デスクトップ画面だと右下で表示だけど、モバイルと同じ位置表示にはしたくない
  • 画面サイズ変更時に動的に切り替えたい

実装方針

HeroUIのToastProviderはplacementプロパティで位置を制御するため動的に切り替える必要がありました。

  1. Context APIで位置情報をグローバル管理
  2. MediaQueryListでデバイスサイズを監視
  3. カスタムフックでロジックをまとめる
  4. ToastProviderに動的に値を渡す

実装

トースト位置管理用のContext

contexts/toast-position-context.tsx
'use client';

import { createContext, useContext, useState, type ReactNode } from 'react';

export type ToastPosition = 
  | 'top-left'
  | 'top-center'
  | 'top-right'
  | 'bottom-left'
  | 'bottom-center'
  | 'bottom-right';

interface ToastPositionContextValue {
  position: ToastPosition;
  setPosition: (position: ToastPosition) => void;
  offset: number;
  setOffset: (offset: number) => void;
}

const ToastPositionContext = createContext<ToastPositionContextValue | undefined>(undefined);

export function ToastPositionProvider({ children }: { children: ReactNode }) {
  const [position, setPosition] = useState<ToastPosition>('bottom-right');
  const [offset, setOffset] = useState(0);

  return (
    <ToastPositionContext.Provider value={{ position, setPosition, offset, setOffset }}>
      {children}
    </ToastPositionContext.Provider>
  );
}

export function useToastPosition() {
  const context = useContext(ToastPositionContext);
  if (!context) {
    throw new Error('useToastPosition must be used within ToastPositionProvider');
  }
  return context;
}

レスポンシブ対応のカスタムフック

hooks/use-toast-position.ts
'use client';

import { useEffect } from 'react';
import { useToastPosition, type ToastPosition } from '@/contexts/toast-position-context';

interface UseToastPositionOptions {
  mobilePosition?: ToastPosition;
  desktopPosition?: ToastPosition;
  mobileOffset?: number;
  desktopOffset?: number;
}

export function useToastPositionControl(options: UseToastPositionOptions = {}) {
  const {
    mobilePosition = 'top-center',
    desktopPosition = 'bottom-right',
    mobileOffset = 64, // ヘッダー高さ分のオフセット
    desktopOffset = 0,
  } = options;

  const { setPosition, setOffset } = useToastPosition();

  useEffect(() => {
    const mediaQuery = window.matchMedia('(max-width: 768px)');

    const handleMediaChange = (e: MediaQueryListEvent | MediaQueryList) => {
      if (e.matches) {
        // モバイル
        setPosition(mobilePosition);
        setOffset(mobileOffset);
      } else {
        // デスクトップ
        setPosition(desktopPosition);
        setOffset(desktopOffset);
      }
    };

    // 初期設定
    handleMediaChange(mediaQuery);

    // リスナー登録
    mediaQuery.addEventListener('change', handleMediaChange);

    // クリーンアップ
    return () => {
      mediaQuery.removeEventListener('change', handleMediaChange);
      // デフォルトに戻す
      setPosition('bottom-right');
      setOffset(0);
    };
  }, [mobilePosition, desktopPosition, mobileOffset, desktopOffset, setPosition, setOffset]);
}

ProviderでHeroUIに繋げる

components/providers.tsx
'use client';

import { ToastProvider } from '@heroui/react';
import { ToastPositionProvider, useToastPosition } from '@/contexts/toast-position-context';

function ToastProviderWithPosition() {
  const { position, offset } = useToastPosition();
  
  return (
    <ToastProvider 
      placement={position} 
      toastOffset={offset}
    />
  );
}

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ToastPositionProvider>
      <ToastProviderWithPosition />
      {children}
    </ToastPositionProvider>
  );
}

呼び出し側

app/example/page.tsx
'use client';

import { useToastPositionControl } from '@/hooks/use-toast-position';
import { toast } from '@heroui/react';

export default function ExamplePage() {
  useToastPositionControl({
    mobilePosition: 'top-center',
    desktopPosition: 'bottom-right',
    mobileOffset: 64, // ヘッダー分ずらす
    desktopOffset: 0,
  });

  const showToast = () => {
    toast.success('位置が切り替わるトースト');
  };

  return (
    <button onClick={showToast}>
      トースト表示
    </button>
  );
}

実装の際

MediaQueryList

const mediaQuery = window.matchMedia('(max-width: 768px)');

CSSのメディアクエリと同じ。

クリーンアップ

return () => {
  mediaQuery.removeEventListener('change', handleMediaChange);
  setPosition('bottom-right');
  setOffset(0);
};

コンポーネントがアンマウントされたときにデフォルトに戻さないと、次のページで変な位置に出ることがあります。

型定義

export type ToastPosition = 
  | 'top-left'
  | 'top-center'
  | 'top-right'
  | 'bottom-left'
  | 'bottom-center'
  | 'bottom-right';

HeroUIのドキュメントより。使える位置を全部列挙

デフォルト値

const {
  mobilePosition = 'top-center',
  desktopPosition = 'bottom-right',
  mobileOffset = 64,
  desktopOffset = 0,
} = options;

一応オプション渡さなくても動くようにしておきます

その他

ユーザー設定反映

// ユーザーが位置を選べる場合
useToastPositionControl({
  mobilePosition: userPreferences.toastPosition || 'top-center',
  desktopPosition: 'bottom-right',
});

ヘッダーの高さを動的に取得

const headerHeight = document.querySelector('header')?.offsetHeight || 64;

useToastPositionControl({
  mobilePosition: 'top-center',
  mobileOffset: headerHeight + 16, // ヘッダー分の余白など
});

フォームページだけ位置を変える

if (isFormPage) {
  useToastPositionControl({
    mobilePosition: 'top-center',
    desktopPosition: 'top-right', // 入力の邪魔にならない位置
  });
}

最後に

HeroUIのトースト位置をレスポンシブに制御するために Context API + MediaQueryList + カスタムフックの組み合わせで実装してみました。
上記のサンプルコードでは、トーストがPC画面の場合にデフォルトの画面右下表示、SP画面の場合に画面上部で表示されるようになります。

PCとモバイルでトースト表示位置をデフォルトではない状態に設定したい場合はたまにあると思います。参考にしてみてください。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?