Popover

Popoverは、ユーザーのアクションに応じて一時的にコンテンツを表示するコンポーネントです。
アイコンやボタンなどの要素をトリガーとして使用し、追加情報や操作オプションを提供します。

プロパティ

Popoverは、以下のプロパティをサポートしています。

名前 デフォルト値 説明
placement string top 表示する位置を指定します。top, bottom, left, right のいずれかを指定できます。
align string start 吹き出しの位置を指定できます。start, center, end のいずれかを指定できます。
show boolean false 表示するかどうかを制御します。
hideArrow boolean false 矢印を非表示にするかどうかを指定します。
offset {x: number; y: number} {x: 0, y: 0} 表示位置を任意の方向にずらすための値です。
duration number 380 表示・非表示の速さを指定できます。
noFollow boolean false 追従させるかどうかを制御できます。

インストールの手順

以下のコンポーネントのコードを、使いたいプロジェクトにコピー&ペーストします。
パスは実際のプロジェクトの構成にあわせて更新します。

modules/Popover.svelte
        <!--
@component
## 概要
- ユーザーのアクションに応じて一時的にコンテンツを表示するコンポーネントです

## 機能
- 指定した方向にポップオーバーを表示できる
- 指定した位置に吹き出しを移動できる
- 表示したポップオーバーを追従させるさせないを指定できる

## Props
- show: ポップオーバーを表示するかどうか
- placement: ポップオーバーの表示方向
- align: 吹き出しの表示位置
- offset: ポップオーバー表示位置のオフセット
- noFollow: ポップオーバーを追従させるかどうか
- hideArrow: 吹き出しの矢印を非表示にするかどうか
- trigger: ポップオーバーを表示させるためのコンテンツ

## Usage
```svelte
  <Popover placement="top">
    <div class="w-45">
      <div>ここにコンテンツが入ります</div>
      <div>コンテンツ説明が入ります</div>
    </div>

    {#snippet trigger(toggle)}
      <Button size="large" onclick={toggle}>表示</Button>
    {/snippet}
  </Popover>
```
-->

<script module lang="ts">
  import type { ClassValue } from 'svelte/elements';
  import { cva, type VariantProps } from 'class-variance-authority';
  import { type Snippet, tick, untrack } from 'svelte';

  export const popoverVariants = cva('relative z-10 bg-base-container-default border border-base-stroke-default rounded-lg shadow-lg', {
    variants: {
      /** どの位置にpopoverを表示するか */
      placement: {
        top: '',
        bottom: '',
        left: '',
        right: '',
      },
      /** どの位置に吹き出しを表示するか */
      align: {
        start: '',
        center: '',
        end: '',
      },
    },
    compoundVariants: [
      {
        placement: 'top',
        align: 'start',
        class: 'origin-bottom-left',
      },
      {
        placement: 'top',
        align: 'center',
        class: 'origin-[bottom_center]',
      },
      {
        placement: 'top',
        align: 'end',
        class: 'origin-bottom-right',
      },
      {
        placement: 'bottom',
        align: 'start',
        class: 'origin-top-left',
      },
      {
        placement: 'bottom',
        align: 'center',
        class: 'origin-[top_center]',
      },
      {
        placement: 'bottom',
        align: 'end',
        class: 'origin-top-right',
      },
      {
        placement: 'left',
        align: 'start',
        class: 'origin-[right_top]',
      },
      {
        placement: 'left',
        align: 'center',
        class: 'origin-[right_center]',
      },
      {
        placement: 'left',
        align: 'end',
        class: 'origin-[right_bottom]',
      },
      {
        placement: 'right',
        align: 'start',
        class: 'origin-[left_top]',
      },
      {
        placement: 'right',
        align: 'center',
        class: 'origin-[left_center]',
      },
      {
        placement: 'right',
        align: 'end',
        class: 'origin-[left_bottom]',
      },
    ],
    defaultVariants: {
      placement: 'top',
      align: 'start',
    },
  });

  export const arrowContainerVariants = cva('absolute z-10 size-3', {
    variants: {
      placement: {
        top: '',
        bottom: '',
        left: '',
        right: '',
      },
      align: {
        start: '',
        center: '',
        end: '',
      },
    },
    compoundVariants: [
      {
        placement: 'top',
        align: 'start',
        class: 'left-6 -bottom-1.5',
      },
      {
        placement: 'top',
        align: 'center',
        class: 'left-1/2 -bottom-1.5 -translate-x-1/2',
      },
      {
        placement: 'top',
        align: 'end',
        class: 'right-6 -bottom-1.5',
      },
      {
        placement: 'bottom',
        align: 'start',
        class: 'left-6 -top-1.5',
      },
      {
        placement: 'bottom',
        align: 'center',
        class: 'left-1/2 -top-1.5 -translate-x-1/2',
      },
      {
        placement: 'bottom',
        align: 'end',
        class: 'right-6 -top-1.5',
      },
      {
        placement: 'left',
        align: 'start',
        class: 'top-6 -right-1.5',
      },
      {
        placement: 'left',
        align: 'center',
        class: 'top-1/2 -right-1.5 -translate-y-1/2',
      },
      {
        placement: 'left',
        align: 'end',
        class: 'bottom-6 -right-1.5',
      },
      {
        placement: 'right',
        align: 'start',
        class: 'top-6 -left-1.5',
      },
      {
        placement: 'right',
        align: 'center',
        class: 'top-1/2 -left-1.5 -translate-y-1/2',
      },
      {
        placement: 'right',
        align: 'end',
        class: 'bottom-6 -left-1.5',
      },
    ],
    defaultVariants: {
      placement: 'top',
      align: 'start',
    },
  });
  export const arrowVariants = cva('size-3 bg-base-container-default border-base-stroke-default origin-center', {
    variants: {
      placement: {
        top: 'border-b border-r rotate-45',
        bottom: 'border-r border-t -rotate-45',
        left: 'border-r border-t rotate-45',
        right: 'border-l border-t -rotate-45',
      },
      align: {
        start: '',
        center: '',
        end: '',
      },
    },
    defaultVariants: {
      placement: 'top',
      align: 'start',
    },
  });

  export type PopoverVariants = VariantProps<typeof popoverVariants>;

  export interface PopoverProps extends PopoverVariants {
    /** popoverを表示するか */
    show?: boolean;
    /** popoverの表示位置のオフセット距離 */
    offset?: { x?: number; y?: number };
    /** popover表示/非表示の速さ */
    duration?: number;
    /** popoverを追従させるかどうか */
    noFollow?: boolean;
    /** 吹き出しの矢印を非表示にするかどうか */
    hideArrow?: boolean;
    /** popover の中身 */
    children: Snippet<[]>;
    /**
     * popover を開くための snippet。引数に toggle 関数を受け取ります。
     */
    triggerContent: Snippet<[() => void]>;
    class?: ClassValue;
  }

  /** 吹き出しの大きさ */
  const ARROW_PX = 12;
  /** ポップオーバーの端ギリギリに矢印が来ないようにするマージン */
  const EDGE_PAD = 12;
  /** 6rem 相当の距離(6 * 4px = 24px と仮定) */
  const BASE_ARROW_POSITION = 24;
</script>

<script lang="ts">
  import { scale } from 'svelte/transition';

  let { show = $bindable(false), placement = 'top', align = 'start', offset = { x: 0, y: 0 }, duration = 300, noFollow = false, hideArrow = false, children, triggerContent, class: className }: PopoverProps = $props();

  /** popoverとtrigger要素のベースのオフセット距離 */
  const BASE_OFFSET = $derived(hideArrow === false ? 9 : 0);

  /** viewportサイズ */
  let vw = $state(0);
  let vh = $state(0);

  /** popover 座標 */
  let top = $state(0);
  let left = $state(0);

  /** popover 見切れ解消補正値 */
  let popoverAdjustmentTop = $state(0);
  let popoverAdjustmentLeft = $state(0);

  let displayPlacement = $derived(placement);

  let triggerElement = $state<HTMLElement>();
  let popoverElement = $state<HTMLElement>();

  let popoverWidth = $state(0);
  let popoverHeight = $state(0);

  let triggerWidth = $state(0);
  let triggerHeight = $state(0);

  let popoverVariantsClass = $derived(popoverVariants({ placement: displayPlacement, align }));
  let arrowContainerVariantsClass = $derived(arrowContainerVariants({ placement: displayPlacement, align }));
  let arrowVariantsClass = $derived(arrowVariants({ placement: displayPlacement, align }));

  let arrowAdjustmentX = $derived.by(() => {
    // 上下のときだけ横方向を調整
    if (displayPlacement !== 'top' && displayPlacement !== 'bottom') return 0;
    if (!popoverWidth) return 0;

    const base_left =
      align === 'start'
        ? BASE_ARROW_POSITION
        : align === 'center'
          ? popoverWidth / 2 - ARROW_PX / 2
          : popoverWidth - BASE_ARROW_POSITION - ARROW_PX;

    const lo = EDGE_PAD;
    const hi = popoverWidth - EDGE_PAD - ARROW_PX;

    const ideal_left = base_left - popoverAdjustmentLeft;

    const clamped_left = clamp(ideal_left, lo, hi);

    return Math.round(clamped_left - base_left);
  });

  let arrowAdjustmentY = $derived.by(() => {
    // 左右のときだけ縦方向を調整
    if (displayPlacement !== 'left' && displayPlacement !== 'right') return 0;
    if (!popoverHeight) return 0;

    const base_top =
      align === 'start'
        ? BASE_ARROW_POSITION
        : align === 'center'
          ? popoverHeight / 2 - ARROW_PX / 2
          : popoverHeight - BASE_ARROW_POSITION - ARROW_PX;

    const lo = EDGE_PAD;
    const hi = popoverHeight - EDGE_PAD - ARROW_PX;

    const ideal_top = base_top - popoverAdjustmentTop;
    const clamped_top = clamp(ideal_top, lo, hi);

    return Math.round(clamped_top - base_top);
  });

  // 座標更新
  $effect(() => {
    updatePopoverPosition();
  });

  // pinch でのリサイズ
  $effect(() => {
    window.visualViewport?.addEventListener('resize', updatePopoverPosition);
    return () => {
      window.visualViewport?.removeEventListener('resize', updatePopoverPosition);
    };
  });

  $effect(() => {
    if (noFollow) return;
    let raf_id: number | null = null;
    const update = () => {
      updatePopoverPosition();
      raf_id = requestAnimationFrame(update);
    };

    update();

    return () => {
      if (raf_id !== null) {
        cancelAnimationFrame(raf_id);
        raf_id = null;
      }
    };
  });

  /** popover を非表示にした時の初期化 */
  $effect(() => {
    if (!show) {
      displayPlacement = placement;
      top = 0;
      left = 0;
      popoverAdjustmentLeft = 0;
      popoverAdjustmentTop = 0;
    }
  });

  function clamp(v: number, lo: number, hi: number) {
    return Math.min(Math.max(v, lo), hi);
  }

  function toggleShow() {
    show = !show;
  }

  /** ポップオーバーの座標を更新する */
  async function updatePopoverPosition() {
    if (!show || !triggerElement || !popoverElement) return;
    const trigger_rect = triggerElement.getBoundingClientRect();

    // 本来の位置に表示する
    updatePopoverPositionByRect(placement, trigger_rect);

    await tick();
    requestAnimationFrame(() => {
      if (!popoverElement) return;
      // 反転のチェック
      adjustDisplayPlacement(popoverElement.getBoundingClientRect());
      if (displayPlacement !== placement) {
        updatePopoverPositionByRect(displayPlacement, trigger_rect);
      }
    });
    // 別軸のずらしのチェック
    adjustPopoverPosition(trigger_rect);
  }

  /** rect に応じてポップオーバーの座標を更新する関数 */
  function updatePopoverPositionByRect(placement: PopoverProps['placement'], rect: DOMRect) {
    if (!triggerElement || !popoverElement) return;

    // placement に基づく座標計算
    switch (placement) {
      case 'top':
        top = rect.top - popoverHeight - BASE_OFFSET + (offset.y ?? 0);
        break;
      case 'bottom':
        top = rect.bottom + BASE_OFFSET + (offset.y ?? 0);
        break;
      case 'left':
        left = rect.left - popoverWidth - BASE_OFFSET + (offset.x ?? 0);
        break;
      case 'right':
        left = rect.right + BASE_OFFSET + (offset.x ?? 0);
        break;
    }

    // align に基づく座標計算
    switch (align) {
      case 'start':
        if (placement === 'top' || placement === 'bottom') {
          left = rect.left + (offset.x ?? 0);
        }
        else {
          top = rect.top + (offset.y ?? 0);
        }
        break;
      case 'center':
        if (placement === 'top' || placement === 'bottom') {
          left = rect.left + rect.width / 2 - popoverWidth / 2 + (offset.x ?? 0);
        }
        else {
          top = rect.top + rect.height / 2 - popoverHeight / 2 + (offset.y ?? 0);
        }
        break;
      case 'end':
        if (placement === 'top' || placement === 'bottom') {
          left = rect.right - popoverWidth + (offset.x ?? 0);
        }
        else {
          top = rect.bottom - popoverHeight + (offset.y ?? 0);
        }
        break;
    }

    untrack(() => {
      // Safari 対応
      if (Math.abs((window.visualViewport?.width ?? 0) - window.innerWidth) < 1) {
        top += window.visualViewport?.offsetTop ?? 0;
        left += window.visualViewport?.offsetLeft ?? 0;
      }
    });
  }

  /**
   * 必要であれば displayPlacement を調整する
   * @param rect PopoverElement の DOMRect
   */
  function adjustDisplayPlacement(rect: DOMRect) {
    let display_placement = placement;

    switch (placement) {
      case 'top':
        // 上にはみ出ている
        if (rect.top < 0) {
          const bottom_space = vh - (rect.bottom + triggerHeight + BASE_OFFSET);
          // 下の方が領域が広い場合
          if (Math.abs(rect.top) < bottom_space) {
            display_placement = 'bottom';
          }
        }
        break;
      case 'bottom':
        if (rect.bottom > vh) {
          const top_space = rect.top - triggerHeight - BASE_OFFSET;
          if (Math.abs(rect.bottom - vh) < top_space) {
            display_placement = 'top';
          }
        }
        break;
      case 'left':
        if (rect.left < 0) {
          const right_space = vw - (rect.right + triggerWidth + BASE_OFFSET);
          if (Math.abs(rect.left) < right_space) {
            display_placement = 'right';
          }
        }
        break;
      case 'right':
        if (rect.right > vw) {
          const left_space = rect.left - triggerWidth - BASE_OFFSET;
          if (Math.abs(rect.right - vw) < left_space) {
            display_placement = 'left';
          }
        }
        break;
    }

    displayPlacement = display_placement;
  }

  /** popoverの別軸での見切れを補正する値を求める関数 */
  function adjustPopoverPosition(rect: DOMRect) {
    // trigger が画面内に見えているかどうか
    const is_trigger_visible = rect.bottom > 0 && rect.top < vh && rect.right > 0 && rect.left < vw;

    // trigger が画面内でかつ popover がplacementの別軸でみきれている場合に位置を補正する
    if (is_trigger_visible) {
      if (displayPlacement === 'top' || displayPlacement === 'bottom') {
        const overflow_left = -left;
        const overflow_right = left + popoverWidth - vw;
        popoverAdjustmentLeft = overflow_left > 0 ? overflow_left : overflow_right > 0 ? -overflow_right : 0;
        popoverAdjustmentTop = 0;
      }
      else {
        const overflow_top = -top;
        const overflow_bottom = top + popoverHeight - vh;
        popoverAdjustmentTop = overflow_top > 0 ? overflow_top : overflow_bottom > 0 ? -overflow_bottom : 0;
        popoverAdjustmentLeft = 0;
      }
    }
  }

  function close(e) {
    if (triggerElement?.contains(e.target)) return;
    show = false;
  }
</script>

<svelte:window onclickcapture={close} bind:innerWidth={vw} bind:innerHeight={vh} />

<div class={className}>
  <div bind:this={triggerElement} bind:clientHeight={triggerHeight} bind:clientWidth={triggerWidth}>
    {@render triggerContent(toggleShow)}
  </div>

  {#if show}
    <div class="fixed top-0 left-0 z-50" style="transform: translate({left}px, {top}px);" bind:this={popoverElement} bind:clientHeight={popoverHeight} bind:clientWidth={popoverWidth}>
      <div style="transform: translate({popoverAdjustmentLeft}px, {popoverAdjustmentTop}px);">
        <div class={[popoverVariantsClass]} in:scale={{ duration, opacity: 0, start: 0.9 }} out:scale={{ duration, opacity: 0, start: 0.9 }}>
          {@render children?.()}
        </div>
        <!-- 吹き出し -->
        {#if hideArrow === false}
          <div class={arrowContainerVariantsClass} style="transform: translate({arrowAdjustmentX}px, {arrowAdjustmentY}px);">
            <div class={arrowVariantsClass} in:scale|global={{ duration, opacity: 0, start: 0.9 }} out:scale|global={{ duration, opacity: 0, start: 1 }}></div>
          </div>
        {/if}

      </div>
    </div>
  {/if}
</div>

      

使い方


サンプル

Default

Defaultでの表示です。

Placement / Align

PlacementとAlignの組み合わせによる表示例です。

Hide Arrow

矢印を非表示にすることができます。

No Follow

noFollow を指定すると、一度表示されたポップオーバーが対象要素に追従しなくなります。 そのため、スクロールしてもポップオーバーの表示位置は固定され、対象要素と一緒に移動しません。

スクロール追従が不要なケースや、パフォーマンスを重視したい場合に活用できます。

Offset

Offsetを指定することで、表示位置を調整することができます。

With DropdownMenu

DropdownMenuと組み合わせた例です。