APG Patterns
English
English

Window Splitter

2つのペイン間で移動可能なセパレーター。ユーザーが各ペインの相対的なサイズを変更できます。IDE、ファイルブラウザ、リサイズ可能なレイアウトで使用されます。

デモ

矢印キーでスプリッターを移動します。Enter キーで折りたたみ/展開します。Shift+矢印キーで大きなステップで移動します。Home/End キーで最小/最大位置に移動します。

Keyboard Navigation
← / →
Move horizontal splitter
↑ / ↓
Move vertical splitter
Shift + Arrow
Move by large step
Home / End
Move to min/max
Enter
Collapse/Expand

Horizontal Splitter

Primary Pane
Secondary Pane

Vertical Splitter

Primary Pane
Secondary Pane

Disabled Splitter

Primary Pane
Secondary Pane

Readonly Splitter

Primary Pane
Secondary Pane

Initially Collapsed

Primary Pane
Secondary Pane

デモのみ表示 →

アクセシビリティ

WAI-ARIA ロール

ロール 対象要素 説明
separator スプリッター要素 ペインサイズを制御するフォーカス可能なセパレーター

WAI-ARIA プロパティ

aria-valuenow

プライマリペインのサイズ(パーセンテージ)

値
0-100
必須
はい

aria-valuemin

最小値(デフォルト: 10)

値
number
必須
はい

aria-valuemax

最大値(デフォルト: 90)

値
number
必須
はい

aria-controls

プライマリペインのID(+ セカンダリペインのIDは任意)

値
ID reference(s)
必須
はい

aria-label

アクセシブルな名前

値
string
必須
条件付き(aria-labelledbyがない場合は必須)

aria-labelledby

表示されるラベル要素への参照

値
ID reference
必須
条件付き(aria-labelがない場合は必須)

aria-orientation

デフォルト: horizontal(左右分割)

値
horizontal | vertical
必須
いいえ

aria-disabled

無効状態

値
true | false
必須
いいえ

WAI-ARIA ステート

aria-valuenow

対象要素
separator要素
値
0-100(0 = 折り畳み、50 = 半分、100 = 完全展開)
必須
はい
変更トリガー

矢印キー、Home/End、Enter(折り畳み/展開)、ポインタードラッグ

キーボードサポート

キー アクション
Arrow Right / Arrow Left 水平スプリッターを移動(増加/減少)
Arrow Up / Arrow Down 垂直スプリッターを移動(増加/減少)
Shift + Arrow 大きなステップで移動(デフォルト: 10%)
Home 最小位置に移動
End 最大位置に移動
Enter プライマリペインの折り畳み/展開を切り替え
  • 矢印キーは向きに基づいて方向が制限されます。水平スプリッターはLeft/Rightのみに、垂直スプリッターはUp/Downのみに応答します。
  • RTLモードでは、水平スプリッターでArrowLeftが増加、ArrowRightが減少になります。
  • aria-readonlyはrole=“separator”には有効ではありません。読み取り専用の動作はJavaScriptのみで強制する必要があります。

フォーカス管理

イベント 振る舞い
Tab スプリッターは通常のタブ順序でフォーカスを受け取る
無効時 スプリッターはフォーカス不可(tabindex="-1")
読み取り専用時 スプリッターはフォーカス可能だが操作不可
折り畳み/展開後 フォーカスはスプリッターに残る

参考資料

ソースコード

WindowSplitter.astro
---
/**
 * APG Window Splitter Pattern - Astro Implementation
 *
 * A movable separator between two panes that allows users to resize
 * the relative size of each pane.
 * Uses Web Components for interactive behavior.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/
 */

export interface Props {
  /** Primary pane ID (required for aria-controls) */
  primaryPaneId: string;
  /** Secondary pane ID (optional, added to aria-controls) */
  secondaryPaneId?: string;
  /** Initial position as % (0-100, default: 50) */
  defaultPosition?: number;
  /** Initial collapsed state (default: false) */
  defaultCollapsed?: boolean;
  /** Position when expanding from initial collapse */
  expandedPosition?: number;
  /** Minimum position as % (default: 10) */
  min?: number;
  /** Maximum position as % (default: 90) */
  max?: number;
  /** Keyboard step as % (default: 5) */
  step?: number;
  /** Shift+Arrow step as % (default: 10) */
  largeStep?: number;
  /** Splitter orientation (default: horizontal = left-right split) */
  orientation?: 'horizontal' | 'vertical';
  /** Text direction for RTL support */
  dir?: 'ltr' | 'rtl';
  /** Whether pane can be collapsed (default: true) */
  collapsible?: boolean;
  /** Disabled state (not focusable, not operable) */
  disabled?: boolean;
  /** Readonly state (focusable but not operable) */
  readonly?: boolean;
  /** Splitter id */
  id?: string;
  /** Additional CSS class */
  class?: string;
  /** Accessible label when no visible label */
  'aria-label'?: string;
  /** Reference to external label element */
  'aria-labelledby'?: string;
  /** Reference to description element */
  'aria-describedby'?: string;
  /** Test id for testing */
  'data-testid'?: string;
}

const {
  primaryPaneId,
  secondaryPaneId,
  defaultPosition = 50,
  defaultCollapsed = false,
  expandedPosition,
  min = 10,
  max = 90,
  step = 5,
  largeStep = 10,
  orientation = 'horizontal',
  dir,
  collapsible = true,
  disabled = false,
  readonly = false,
  id,
  class: className = '',
  'aria-label': ariaLabel,
  'aria-labelledby': ariaLabelledby,
  'aria-describedby': ariaDescribedby,
  'data-testid': dataTestid,
} = Astro.props;

// Utility function
const clamp = (value: number, minVal: number, maxVal: number): number => {
  return Math.min(maxVal, Math.max(minVal, value));
};

// Calculate initial position
const initialPosition = defaultCollapsed ? 0 : clamp(defaultPosition, min, max);

// Compute aria-controls
const ariaControls = secondaryPaneId ? `${primaryPaneId} ${secondaryPaneId}` : primaryPaneId;

const isVertical = orientation === 'vertical';
---

<apg-window-splitter
  data-min={min}
  data-max={max}
  data-step={step}
  data-large-step={largeStep}
  data-orientation={orientation}
  data-dir={dir}
  data-collapsible={collapsible}
  data-disabled={disabled}
  data-readonly={readonly}
  data-default-position={defaultPosition}
  data-expanded-position={expandedPosition}
  data-collapsed={defaultCollapsed}
>
  <div
    class={`apg-window-splitter ${isVertical ? 'apg-window-splitter--vertical' : ''} ${disabled ? 'apg-window-splitter--disabled' : ''} ${className}`.trim()}
    style={`--splitter-position: ${initialPosition}%`}
  >
    <div
      role="separator"
      id={id}
      tabindex={disabled ? -1 : 0}
      aria-valuenow={initialPosition}
      aria-valuemin={min}
      aria-valuemax={max}
      aria-controls={ariaControls}
      aria-orientation={isVertical ? 'vertical' : undefined}
      aria-disabled={disabled ? true : undefined}
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledby}
      aria-describedby={ariaDescribedby}
      data-testid={dataTestid}
      class="apg-window-splitter__separator"
    >
    </div>
    {
      !disabled && !readonly && (
        <div
          class="apg-window-splitter__popup"
          role="group"
          aria-label={`Adjust ${ariaLabel || ''}`}
        >
          <button
            type="button"
            class="apg-window-splitter__popup-button"
            tabindex="-1"
            aria-label={`Collapse ${ariaLabel || ''}`}
            data-adjust="collapse"
          >
            <svg
              width="12"
              height="12"
              viewBox="0 0 12 12"
              fill="none"
              stroke="currentColor"
              stroke-width="1.5"
              stroke-linecap="round"
              stroke-linejoin="round"
              aria-hidden="true"
              style={`transform: translate(${isVertical ? '0px, -1px' : '-1px, 0px'})`}
            >
              <path d={isVertical ? 'M2 9L6 5L10 9M2 5L6 1L10 5' : 'M5 2L1 6L5 10M9 2L5 6L9 10'} />
            </svg>
          </button>
          <button
            type="button"
            class="apg-window-splitter__popup-button"
            tabindex="-1"
            aria-label={`Shrink ${ariaLabel || ''}`}
            data-adjust="decrease"
          >
            <svg
              width="12"
              height="12"
              viewBox="0 0 12 12"
              fill="none"
              stroke="currentColor"
              stroke-width="1.5"
              stroke-linecap="round"
              stroke-linejoin="round"
              aria-hidden="true"
              style={`transform: translate(${isVertical ? '0px, -1px' : '-1px, 0px'})`}
            >
              <path d={isVertical ? 'M2 8L6 4L10 8' : 'M8 2L4 6L8 10'} />
            </svg>
          </button>
          <button
            type="button"
            class="apg-window-splitter__popup-button"
            tabindex="-1"
            aria-label={`Expand ${ariaLabel || ''}`}
            data-adjust="increase"
          >
            <svg
              width="12"
              height="12"
              viewBox="0 0 12 12"
              fill="none"
              stroke="currentColor"
              stroke-width="1.5"
              stroke-linecap="round"
              stroke-linejoin="round"
              aria-hidden="true"
              style={`transform: translate(${isVertical ? '0px, 1px' : '1px, 0px'})`}
            >
              <path d={isVertical ? 'M2 4L6 8L10 4' : 'M4 2L8 6L4 10'} />
            </svg>
          </button>
          <button
            type="button"
            class="apg-window-splitter__popup-button"
            tabindex="-1"
            aria-label={`Expand ${ariaLabel || ''} to maximum`}
            data-adjust="maximize"
          >
            <svg
              width="12"
              height="12"
              viewBox="0 0 12 12"
              fill="none"
              stroke="currentColor"
              stroke-width="1.5"
              stroke-linecap="round"
              stroke-linejoin="round"
              aria-hidden="true"
              style={`transform: translate(${isVertical ? '0px, 1px' : '1px, 0px'})`}
            >
              <path
                d={isVertical ? 'M2 3L6 7L10 3M2 7L6 11L10 7' : 'M3 2L7 6L3 10M7 2L11 6L7 10'}
              />
            </svg>
          </button>
        </div>
      )
    }
  </div>
</apg-window-splitter>

<script>
  const HOVER_DELAY = 300;
  const DISMISS_DELAY = 300;
  const ACTIVE_SETTLE_DELAY = 500;
  const TAP_DISTANCE_THRESHOLD = 5;
  const POPUP_OFFSET = 8;

  class ApgWindowSplitter extends HTMLElement {
    private separator: HTMLElement | null = null;
    private container: HTMLElement | null = null;
    private popup: HTMLElement | null = null;
    private isDragging = false;
    private previousPosition: number | null = null;
    private collapsed = false;

    private popupState: 'hidden' | 'showing' | 'active' = 'hidden';
    private popupPos: { x: number; y: number } | null = null;
    private hoverTimer: ReturnType<typeof setTimeout> | null = null;
    private dismissTimer: ReturnType<typeof setTimeout> | null = null;
    private activeSettleTimer: ReturnType<typeof setTimeout> | null = null;
    private pointerStart: { x: number; y: number } | null = null;
    private isMouseOverPopup = false;
    private hoverPos: { x: number; y: number } | null = null;

    // Bound event handlers (stored to properly remove listeners)
    private boundHandleKeyDown = this.handleKeyDown.bind(this);
    private boundHandlePointerDown = this.handlePointerDown.bind(this);
    private boundHandlePointerMove = this.handlePointerMove.bind(this);
    private boundHandlePointerUp = this.handlePointerUp.bind(this);
    private boundHandleSeparatorMouseEnter = this.handleSeparatorMouseEnter.bind(this);
    private boundHandleSeparatorMouseLeave = this.handleSeparatorMouseLeave.bind(this);
    private boundHandleSeparatorMouseMove = this.handleSeparatorMouseMove.bind(this);
    private boundHandleSeparatorFocus = this.handleSeparatorFocus.bind(this);
    private boundHandleSeparatorBlur = this.handleSeparatorBlur.bind(this);
    private boundHandlePopupMouseEnter = this.handlePopupMouseEnter.bind(this);
    private boundHandlePopupMouseLeave = this.handlePopupMouseLeave.bind(this);
    private boundHandlePopupKeyDown = this.handlePopupKeyDown.bind(this);
    private boundHandlePopupButtonClick = this.handlePopupButtonClick.bind(this);
    private boundHandleOutsidePointerDown = this.handleOutsidePointerDown.bind(this);

    connectedCallback() {
      this.separator = this.querySelector('[role="separator"]');
      this.container = this.querySelector('.apg-window-splitter');
      this.popup = this.querySelector('.apg-window-splitter__popup');

      // Initialize state from data attributes
      this.collapsed = this.dataset.collapsed === 'true';
      if (!this.collapsed) {
        this.previousPosition = this.currentPosition;
      }

      if (this.separator) {
        this.separator.addEventListener('keydown', this.boundHandleKeyDown);
        this.separator.addEventListener('pointerdown', this.boundHandlePointerDown);
        this.separator.addEventListener('pointermove', this.boundHandlePointerMove);
        this.separator.addEventListener('pointerup', this.boundHandlePointerUp);
        this.separator.addEventListener('mouseenter', this.boundHandleSeparatorMouseEnter);
        this.separator.addEventListener('mouseleave', this.boundHandleSeparatorMouseLeave);
        this.separator.addEventListener('mousemove', this.boundHandleSeparatorMouseMove);
        this.separator.addEventListener('focus', this.boundHandleSeparatorFocus);
        this.separator.addEventListener('blur', this.boundHandleSeparatorBlur);
      }

      if (this.popup) {
        this.popup.addEventListener('mouseenter', this.boundHandlePopupMouseEnter);
        this.popup.addEventListener('mouseleave', this.boundHandlePopupMouseLeave);
        this.popup.addEventListener('keydown', this.boundHandlePopupKeyDown);
        this.popup.addEventListener('click', this.boundHandlePopupButtonClick);
      }

      // Reflect the initial collapsed/min/max state on the popup buttons.
      this.updatePopupButtonStates();
    }

    disconnectedCallback() {
      if (this.separator) {
        this.separator.removeEventListener('keydown', this.boundHandleKeyDown);
        this.separator.removeEventListener('pointerdown', this.boundHandlePointerDown);
        this.separator.removeEventListener('pointermove', this.boundHandlePointerMove);
        this.separator.removeEventListener('pointerup', this.boundHandlePointerUp);
        this.separator.removeEventListener('mouseenter', this.boundHandleSeparatorMouseEnter);
        this.separator.removeEventListener('mouseleave', this.boundHandleSeparatorMouseLeave);
        this.separator.removeEventListener('mousemove', this.boundHandleSeparatorMouseMove);
        this.separator.removeEventListener('focus', this.boundHandleSeparatorFocus);
        this.separator.removeEventListener('blur', this.boundHandleSeparatorBlur);
      }

      if (this.popup) {
        this.popup.removeEventListener('mouseenter', this.boundHandlePopupMouseEnter);
        this.popup.removeEventListener('mouseleave', this.boundHandlePopupMouseLeave);
        this.popup.removeEventListener('keydown', this.boundHandlePopupKeyDown);
        this.popup.removeEventListener('click', this.boundHandlePopupButtonClick);
      }

      document.removeEventListener('pointerdown', this.boundHandleOutsidePointerDown);
      this.clearAllTimers();
    }

    private get min(): number {
      return Number(this.dataset.min) || 10;
    }

    private get max(): number {
      return Number(this.dataset.max) || 90;
    }

    private get step(): number {
      return Number(this.dataset.step) || 5;
    }

    private get largeStep(): number {
      return Number(this.dataset.largeStep) || 10;
    }

    private get orientation(): string {
      return this.dataset.orientation || 'horizontal';
    }

    private get isHorizontal(): boolean {
      return this.orientation === 'horizontal';
    }

    private get isVertical(): boolean {
      return this.orientation === 'vertical';
    }

    private get textDir(): string | undefined {
      return this.dataset.dir;
    }

    private get isRTL(): boolean {
      if (this.textDir === 'rtl') return true;
      if (this.textDir === 'ltr') return false;
      return document.dir === 'rtl';
    }

    private get isCollapsible(): boolean {
      return this.dataset.collapsible !== 'false';
    }

    private get isDisabled(): boolean {
      return this.dataset.disabled === 'true';
    }

    private get isReadonly(): boolean {
      return this.dataset.readonly === 'true';
    }

    private get defaultPosition(): number {
      return Number(this.dataset.defaultPosition) || 50;
    }

    private get expandedPosition(): number | undefined {
      const val = this.dataset.expandedPosition;
      return val !== undefined ? Number(val) : undefined;
    }

    private get currentPosition(): number {
      return Number(this.separator?.getAttribute('aria-valuenow')) || 0;
    }

    private clamp(value: number): number {
      return Math.min(this.max, Math.max(this.min, value));
    }

    private clearAllTimers() {
      if (this.hoverTimer) clearTimeout(this.hoverTimer);
      if (this.dismissTimer) clearTimeout(this.dismissTimer);
      if (this.activeSettleTimer) clearTimeout(this.activeSettleTimer);
      this.hoverTimer = null;
      this.dismissTimer = null;
      this.activeSettleTimer = null;
    }

    private updatePopupButtonStates() {
      if (!this.popup) return;
      const pos = this.currentPosition;
      const collapseBtn = this.popup.querySelector<HTMLButtonElement>('[data-adjust="collapse"]');
      const decreaseBtn = this.popup.querySelector<HTMLButtonElement>('[data-adjust="decrease"]');
      const increaseBtn = this.popup.querySelector<HTMLButtonElement>('[data-adjust="increase"]');
      const maximizeBtn = this.popup.querySelector<HTMLButtonElement>('[data-adjust="maximize"]');
      // While collapsed the splitter sits below `min`, so the shrink direction is
      // disabled (already minimal) and the expand direction stays enabled.
      const shrinkDisabled = this.collapsed || pos <= this.min;
      const expandDisabled = !this.collapsed && pos >= this.max;
      if (collapseBtn) {
        collapseBtn.setAttribute('aria-disabled', String(shrinkDisabled));
      }
      if (decreaseBtn) {
        decreaseBtn.setAttribute('aria-disabled', String(shrinkDisabled));
      }
      if (increaseBtn) {
        increaseBtn.setAttribute('aria-disabled', String(expandDisabled));
      }
      if (maximizeBtn) {
        maximizeBtn.setAttribute('aria-disabled', String(expandDisabled));
      }
    }

    private updatePosition(newPosition: number) {
      if (!this.separator || this.isDisabled) return;

      const clampedPosition = this.clamp(newPosition);
      const currentPosition = this.currentPosition;

      if (clampedPosition === currentPosition) return;

      // Update ARIA
      this.separator.setAttribute('aria-valuenow', String(clampedPosition));

      // Update visual via CSS custom property
      if (this.container) {
        this.container.style.setProperty('--splitter-position', `${clampedPosition}%`);
      }

      // Calculate size in px
      let sizeInPx = 0;
      if (this.container) {
        sizeInPx =
          (clampedPosition / 100) *
          (this.isHorizontal ? this.container.offsetWidth : this.container.offsetHeight);
      }

      // Update popup button disabled states
      this.updatePopupButtonStates();

      // Dispatch event
      this.dispatchEvent(
        new CustomEvent('positionchange', {
          detail: { position: clampedPosition, sizeInPx },
          bubbles: true,
        })
      );
    }

    // Expand from the collapsed state, restoring to the previous/expanded position.
    // Shared by the collapse toggle, popup buttons and keyboard handlers.
    private expandFromCollapsed() {
      if (!this.separator) return;

      const restorePosition =
        this.previousPosition ?? this.expandedPosition ?? this.defaultPosition ?? 50;
      const clampedRestore = this.clamp(restorePosition);

      this.dispatchEvent(
        new CustomEvent('collapsedchange', {
          detail: { collapsed: false, previousPosition: this.currentPosition },
          bubbles: true,
        })
      );

      this.collapsed = false;
      this.separator.setAttribute('aria-valuenow', String(clampedRestore));

      if (this.container) {
        this.container.style.setProperty('--splitter-position', `${clampedRestore}%`);
      }

      let sizeInPx = 0;
      if (this.container) {
        sizeInPx =
          (clampedRestore / 100) *
          (this.isHorizontal ? this.container.offsetWidth : this.container.offsetHeight);
      }

      this.updatePopupButtonStates();

      this.dispatchEvent(
        new CustomEvent('positionchange', {
          detail: { position: clampedRestore, sizeInPx },
          bubbles: true,
        })
      );
    }

    private handleToggleCollapse() {
      if (!this.isCollapsible || this.isDisabled || this.isReadonly) return;
      if (!this.separator) return;

      if (this.collapsed) {
        this.expandFromCollapsed();
        return;
      }

      // Collapse
      this.previousPosition = this.currentPosition;

      this.dispatchEvent(
        new CustomEvent('collapsedchange', {
          detail: { collapsed: true, previousPosition: this.currentPosition },
          bubbles: true,
        })
      );

      this.collapsed = true;
      this.separator.setAttribute('aria-valuenow', '0');

      if (this.container) {
        this.container.style.setProperty('--splitter-position', '0%');
      }

      this.updatePopupButtonStates();

      this.dispatchEvent(
        new CustomEvent('positionchange', {
          detail: { position: 0, sizeInPx: 0 },
          bubbles: true,
        })
      );
    }

    private calcPopupPosition(clientX: number, clientY: number): { x: number; y: number } | null {
      if (!this.separator) return null;
      const popupWidth = this.popup?.offsetWidth || (this.isVertical ? 34 : 120);
      const popupHeight = this.popup?.offsetHeight || (this.isVertical ? 120 : 34);
      const vw = window.innerWidth;
      const vh = window.innerHeight;

      let x: number;
      let y: number;

      if (this.isHorizontal) {
        x = clientX - popupWidth / 2;
        const belowY = clientY + POPUP_OFFSET;
        const aboveY = clientY - POPUP_OFFSET - popupHeight;
        y = belowY + popupHeight <= vh ? belowY : aboveY;
      } else {
        y = clientY - popupHeight / 2;
        const rightX = clientX + POPUP_OFFSET;
        const leftX = clientX - POPUP_OFFSET - popupWidth;
        x = rightX + popupWidth <= vw ? rightX : leftX;
      }

      x = Math.min(vw - popupWidth, Math.max(0, x));
      y = Math.min(vh - popupHeight, Math.max(0, y));

      return { x, y };
    }

    private showPopup(clientX: number, clientY: number) {
      if (this.isDisabled || this.isReadonly || !this.popup) return;

      if (this.dismissTimer) {
        clearTimeout(this.dismissTimer);
        this.dismissTimer = null;
      }

      const pos = this.calcPopupPosition(clientX, clientY);
      if (pos) {
        this.popupPos = pos;
        this.popup.style.left = `${pos.x}px`;
        this.popup.style.top = `${pos.y}px`;
        this.popup.style.flexDirection = this.isVertical ? 'column' : 'row';
        this.popup.classList.add('apg-window-splitter__popup--visible');
        this.popupState = 'showing';
        this.updatePopupButtonStates();
      }
    }

    private hidePopup() {
      this.clearAllTimers();
      document.removeEventListener('pointerdown', this.boundHandleOutsidePointerDown);
      this.popupState = 'hidden';
      this.popupPos = null;
      if (this.popup) {
        this.popup.classList.remove('apg-window-splitter__popup--visible');
      }
    }

    private handleOutsidePointerDown(event: PointerEvent) {
      if (this.popup && !this.popup.contains(event.target as Node)) {
        this.hidePopup();
      }
    }

    private scheduleDismiss() {
      if (this.dismissTimer) clearTimeout(this.dismissTimer);
      this.dismissTimer = setTimeout(() => {
        const hasFocusInside =
          this.popup?.contains(document.activeElement) || this.separator === document.activeElement;
        if (!hasFocusInside) {
          this.hidePopup();
        }
      }, DISMISS_DELAY);
    }

    private handleKeyDown(event: KeyboardEvent) {
      if (this.isDisabled || this.isReadonly) return;

      // Tab to popup buttons when popup is visible
      if (event.key === 'Tab' && !event.shiftKey && this.popupState !== 'hidden') {
        const firstBtn = this.popup?.querySelector<HTMLButtonElement>('button');
        if (firstBtn) {
          event.preventDefault();
          firstBtn.focus();
          return;
        }
      }

      const hasShift = event.shiftKey;
      const currentStep = hasShift ? this.largeStep : this.step;

      let delta = 0;
      let handled = false;

      switch (event.key) {
        case 'ArrowRight':
          if (!this.isHorizontal) break;
          delta = this.isRTL ? -currentStep : currentStep;
          handled = true;
          break;

        case 'ArrowLeft':
          if (!this.isHorizontal) break;
          delta = this.isRTL ? currentStep : -currentStep;
          handled = true;
          break;

        case 'ArrowUp':
          if (!this.isVertical) break;
          delta = -currentStep;
          handled = true;
          break;

        case 'ArrowDown':
          if (!this.isVertical) break;
          delta = currentStep;
          handled = true;
          break;

        case 'Enter':
          this.handleToggleCollapse();
          handled = true;
          break;

        case 'Home':
          // Shrink direction is a no-op while collapsed (already at minimum).
          if (!this.collapsed) this.updatePosition(this.min);
          handled = true;
          break;

        case 'End':
          if (this.collapsed) {
            this.expandFromCollapsed();
          } else {
            this.updatePosition(this.max);
          }
          handled = true;
          break;
      }

      if (handled) {
        event.preventDefault();
        if (delta !== 0) {
          if (this.collapsed) {
            // Only the expand direction wakes a collapsed splitter; shrink is a no-op.
            if (delta > 0) this.expandFromCollapsed();
          } else {
            this.updatePosition(this.currentPosition + delta);
          }
        }
      }
    }

    private handlePointerDown(event: PointerEvent) {
      if (this.isDisabled || this.isReadonly || !this.separator) return;

      event.preventDefault();

      this.pointerStart = { x: event.clientX, y: event.clientY };
      this.isDragging = false;

      if (typeof this.separator.setPointerCapture === 'function') {
        this.separator.setPointerCapture(event.pointerId);
      }

      if (this.hoverTimer) {
        clearTimeout(this.hoverTimer);
        this.hoverTimer = null;
      }

      this.separator.focus();
    }

    private handlePointerMove(event: PointerEvent) {
      const start = this.pointerStart;
      if (!start) return;

      if (!this.isDragging) {
        const dx = event.clientX - start.x;
        const dy = event.clientY - start.y;
        const distance = Math.sqrt(dx * dx + dy * dy);
        if (distance >= TAP_DISTANCE_THRESHOLD) {
          this.isDragging = true;
          if (this.popupState !== 'hidden') this.hidePopup();
        } else {
          return;
        }
      }

      if (!this.container) return;

      // Measure the containing layout (the consumer positions the panes there).
      // The visual update is driven solely by the positionchange event, keeping
      // the component agnostic of how the consumer renders its panes.
      const measureElement = this.container.parentElement || this.container;
      const rect = measureElement.getBoundingClientRect();

      let percent: number;
      if (this.isHorizontal) {
        const x = event.clientX - rect.left;
        percent = (x / rect.width) * 100;
      } else {
        const y = event.clientY - rect.top;
        percent = (y / rect.height) * 100;
      }

      this.updatePosition(percent);
    }

    private handlePointerUp(event: PointerEvent) {
      if (this.separator && typeof this.separator.releasePointerCapture === 'function') {
        try {
          this.separator.releasePointerCapture(event.pointerId);
        } catch {
          // Ignore
        }
      }

      const start = this.pointerStart;
      if (start && !this.isDragging) {
        this.showPopup(start.x, start.y);
      }

      this.isDragging = false;
      this.pointerStart = null;
    }

    private handleSeparatorMouseEnter(event: MouseEvent) {
      if (this.isDisabled || this.isReadonly || this.isDragging) return;
      this.hoverPos = { x: event.clientX, y: event.clientY };
      if (this.popupState === 'hidden') {
        if (this.hoverTimer) clearTimeout(this.hoverTimer);
        this.hoverTimer = setTimeout(() => {
          const pos = this.hoverPos;
          if (pos) this.showPopup(pos.x, pos.y);
        }, HOVER_DELAY);
      }
      if (this.dismissTimer) {
        clearTimeout(this.dismissTimer);
        this.dismissTimer = null;
      }
    }

    private handleSeparatorMouseLeave() {
      if (this.hoverTimer) {
        clearTimeout(this.hoverTimer);
        this.hoverTimer = null;
      }
      if (this.popupState !== 'hidden') this.scheduleDismiss();
    }

    private handleSeparatorMouseMove(event: MouseEvent) {
      this.hoverPos = { x: event.clientX, y: event.clientY };
    }

    private handleSeparatorFocus() {
      if (this.isDisabled || this.isReadonly) return;
      if (this.popupState === 'hidden') {
        if (this.separator) {
          const rect = this.separator.getBoundingClientRect();
          this.showPopup(rect.left + rect.width / 2, rect.top + rect.height / 2);
        }
      }
      if (this.dismissTimer) {
        clearTimeout(this.dismissTimer);
        this.dismissTimer = null;
      }
    }

    private handleSeparatorBlur() {
      if (this.popupState !== 'hidden') {
        setTimeout(() => {
          if (
            !this.popup?.contains(document.activeElement) &&
            this.separator !== document.activeElement
          ) {
            this.scheduleDismiss();
          }
        }, 0);
      }
    }

    private handlePopupMouseEnter() {
      this.isMouseOverPopup = true;
      if (this.dismissTimer) {
        clearTimeout(this.dismissTimer);
        this.dismissTimer = null;
      }
    }

    private handlePopupMouseLeave() {
      this.isMouseOverPopup = false;
      if (this.popupState !== 'hidden') this.scheduleDismiss();
    }

    private handlePopupKeyDown(event: KeyboardEvent) {
      if (event.key === 'Escape') {
        event.preventDefault();
        this.hidePopup();
        this.separator?.focus();
      }
      if (event.key === 'Tab' && event.shiftKey) {
        event.preventDefault();
        this.hidePopup();
        this.separator?.focus();
      }
    }

    // Mark the popup as actively adjusting, then settle back to the showing state.
    private markPopupActive() {
      if (this.popupState !== 'active') {
        this.popupState = 'active';
        document.addEventListener('pointerdown', this.boundHandleOutsidePointerDown);
      }

      if (this.activeSettleTimer) clearTimeout(this.activeSettleTimer);
      this.activeSettleTimer = setTimeout(() => {
        if (this.popupState === 'active') {
          if (!this.isMouseOverPopup) {
            const pos = this.hoverPos;
            if (pos && this.popup) {
              const newPopupPos = this.calcPopupPosition(pos.x, pos.y);
              if (newPopupPos) {
                this.popup.style.left = `${newPopupPos.x}px`;
                this.popup.style.top = `${newPopupPos.y}px`;
              }
            }
          }
          this.popupState = 'showing';
        }
      }, ACTIVE_SETTLE_DELAY);
    }

    private handlePopupButtonClick(event: Event) {
      const target = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-adjust]');
      if (!target) return;
      if (this.isDisabled || this.isReadonly) return;
      if (target.getAttribute('aria-disabled') === 'true') return;

      const adjustType = target.dataset.adjust;

      if (this.collapsed) {
        // Shrink direction is a no-op while collapsed (already at minimum).
        if (adjustType !== 'increase' && adjustType !== 'maximize') return;
        this.expandFromCollapsed();
        if (adjustType === 'maximize') this.updatePosition(this.max);
        this.markPopupActive();
        return;
      }

      let targetPos: number;
      switch (adjustType) {
        case 'collapse':
          targetPos = this.min;
          break;
        case 'decrease':
          targetPos = this.currentPosition - this.step;
          break;
        case 'increase':
          targetPos = this.currentPosition + this.step;
          break;
        case 'maximize':
          targetPos = this.max;
          break;
        default:
          return;
      }

      this.updatePosition(targetPos);
      this.markPopupActive();
    }

    // Public method to update position programmatically
    setPosition(newPosition: number) {
      this.updatePosition(newPosition);
    }

    // Public method to toggle collapse
    toggleCollapse() {
      this.handleToggleCollapse();
    }
  }

  if (!customElements.get('apg-window-splitter')) {
    customElements.define('apg-window-splitter', ApgWindowSplitter);
  }
</script>

使い方

Example
---
import WindowSplitter from './WindowSplitter.astro';
---

<div class="layout">
  <div id="primary-pane">
    Primary Content
  </div>
  <WindowSplitter
    primaryPaneId="primary-pane"
    secondaryPaneId="secondary-pane"
    position={50}
    min={20}
    max={80}
    step={5}
    aria-label="Resize panels"
  />
  <div id="secondary-pane">
    Secondary Content
  </div>
</div>

API

プロパティ型デフォルト説明
primaryPaneIdstringrequiredプライマリペインのID(aria-controls用)
secondaryPaneIdstring-セカンダリペインのID(任意)
positionnumber50初期位置(パーセンテージ 0-100)
collapsedbooleanfalse折り畳み状態で開始
orientation'horizontal' | 'vertical''horizontal'スプリッターの向き
disabledbooleanfalse無効状態
readonlybooleanfalse読み取り専用状態
Astroコンポーネントは <apg-window-splitter> Web Componentをレンダリングします。設定は data-* 属性を通じて渡され、コンポーネントはすべてのキーボードとポインターのインタラクションをクライアントサイドで処理します。

Custom Events

イベントDetail説明
windowsplitter:position-change{ position: number, sizeInPx: number }位置変更時に発火
windowsplitter:collapsed-change{ collapsed: boolean, previousPosition: number }折り畳み状態変更時に発火

テスト

テストは、ARIA構造、キーボードナビゲーション、フォーカス管理、ポインターインタラクションのAPG準拠を検証します。Window Splitterコンポーネントは2層のテスト戦略を使用します。

テスト戦略

ユニットテスト(Container API / Testing Library)

コンポーネントのHTML出力と基本的なインタラクションを検証します。これらのテストは正しいテンプレートレンダリングとARIA属性を確認します。

  • ARIA構造(role、aria-valuenow、aria-controls)
  • キーボードインタラクション(矢印キー、Home/End、Enter)
  • 折り畳み/展開機能
  • RTLサポート
  • 無効/読み取り専用状態

E2Eテスト(Playwright)

ポインターインタラクションを含む実際のブラウザ環境でコンポーネントの動作を検証します。

  • ポインタードラッグでリサイズ
  • Tabナビゲーション全体のフォーカス管理
  • フレームワーク間の一貫性
  • 視覚状態(CSSカスタムプロパティの更新)

テストカテゴリ

高優先度: ARIA構造

テスト説明
role="separator"スプリッターにseparatorロールが設定されている
aria-valuenowプライマリペインのサイズ(パーセンテージ 0-100)
aria-valuemin/max最小値と最大値が設定されている
aria-controlsプライマリ(および任意のセカンダリ)ペインを参照
aria-orientation垂直スプリッターの場合は"vertical"に設定
aria-disabled無効時は"true"に設定

高優先度: キーボードインタラクション

テスト説明
ArrowRight/Left水平スプリッターを移動(増加/減少)
ArrowUp/Down垂直スプリッターを移動(増加/減少)
Direction restriction誤った方向のキーは効果なし
Shift+Arrow大きなステップで移動
Home/End最小/最大位置に移動
Enter (collapse)0に折り畳み
Enter (expand)以前の位置を復元
RTL supportRTLモードでArrowLeft/Rightが反転

高優先度: フォーカス管理

テスト説明
tabindex="0"スプリッターがフォーカス可能
tabindex="-1"無効なスプリッターはフォーカス不可
readonly focusable読み取り専用スプリッターはフォーカス可能だが操作不可
Focus after collapseフォーカスはスプリッターに残る

中優先度: ポインターインタラクション

テスト説明
Drag to resizeドラッグ中に位置が更新される
Focus on clickクリックでスプリッターにフォーカス
Disabled no response無効なスプリッターはポインターを無視
Readonly no response読み取り専用スプリッターはポインターを無視

中優先度: アクセシビリティ

テスト説明
axe violationsWCAG 2.1 AA違反なし
Collapsed state折り畳み時に違反なし
Disabled state無効時に違反なし

テストの実行

ユニットテスト

# Run all Window Splitter unit tests
npx vitest run src/patterns/windowsplitter/

# Run framework-specific tests
npm run test:react -- WindowSplitter.test.tsx
npm run test:vue -- WindowSplitter.test.vue.ts
npm run test:svelte -- WindowSplitter.test.svelte.ts
npm run test:astro

E2Eテスト

# Run all Window Splitter E2E tests
npm run test:e2e -- windowsplitter.spec.ts

# Run in UI mode
npm run test:e2e:ui -- windowsplitter.spec.ts

テストツール

詳細はテスト戦略ガイドを参照してください。

WindowSplitter.test.astro.ts
/**
 * WindowSplitter Astro Component Tests using Container API
 *
 * These tests verify the initial server-rendered HTML of WindowSplitter.astro.
 * Web Component behavior (keyboard, collapse/expand, RTL, pointer, popup,
 * positionchange/collapsedchange events and the setPosition/toggleCollapse
 * methods) is covered by E2E tests in e2e/windowsplitter.spec.ts.
 *
 * @see https://docs.astro.build/en/reference/container-reference/
 */
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import WindowSplitter from './WindowSplitter.astro';

describe('WindowSplitter (Astro Container API)', () => {
  let container: AstroContainer;

  beforeEach(async () => {
    container = await AstroContainer.create();
  });

  async function renderSplitter(props: Record<string, unknown>): Promise<Document> {
    const html = await container.renderToString(WindowSplitter, { props });
    return new JSDOM(html).window.document;
  }

  // 🔴 High Priority: HTML Structure
  describe('HTML Structure', () => {
    it('renders apg-window-splitter custom element wrapper', async () => {
      const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Resize' });
      expect(doc.querySelector('apg-window-splitter')).not.toBeNull();
    });

    it('renders an element with role="separator"', async () => {
      const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Resize' });
      expect(doc.querySelector('[role="separator"]')).not.toBeNull();
    });

    it('sets the initial position as --splitter-position on the container', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        defaultPosition: 30,
        'aria-label': 'Resize',
      });
      const splitterContainer = doc.querySelector<HTMLElement>('.apg-window-splitter');
      expect(splitterContainer?.getAttribute('style')).toBe('--splitter-position: 30%');
    });

    it('adds the vertical modifier class for a vertical splitter', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        orientation: 'vertical',
        'aria-label': 'Resize',
      });
      const splitterContainer = doc.querySelector('.apg-window-splitter');
      expect(splitterContainer?.classList.contains('apg-window-splitter--vertical')).toBe(true);
    });

    it('adds the disabled modifier class when disabled', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        disabled: true,
        'aria-label': 'Resize',
      });
      const splitterContainer = doc.querySelector('.apg-window-splitter');
      expect(splitterContainer?.classList.contains('apg-window-splitter--disabled')).toBe(true);
    });
  });

  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('has aria-valuenow set to defaultPosition', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        defaultPosition: 30,
        'aria-label': 'Resize',
      });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.getAttribute('aria-valuenow')).toBe('30');
    });

    it('has aria-valuenow="50" when no defaultPosition is given', async () => {
      const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Resize' });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.getAttribute('aria-valuenow')).toBe('50');
    });

    it('has aria-valuenow="0" when defaultCollapsed is true', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        defaultPosition: 30,
        defaultCollapsed: true,
        'aria-label': 'Resize',
      });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.getAttribute('aria-valuenow')).toBe('0');
    });

    it('has aria-valuemin="10" and aria-valuemax="90" by default', async () => {
      const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Resize' });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.getAttribute('aria-valuemin')).toBe('10');
      expect(separator?.getAttribute('aria-valuemax')).toBe('90');
    });

    it('has aria-valuemin set to min', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        min: 5,
        'aria-label': 'Resize',
      });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.getAttribute('aria-valuemin')).toBe('5');
    });

    it('has aria-valuemax set to max', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        max: 95,
        'aria-label': 'Resize',
      });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.getAttribute('aria-valuemax')).toBe('95');
    });

    it('has aria-controls referencing the primary pane', async () => {
      const doc = await renderSplitter({ primaryPaneId: 'main-panel', 'aria-label': 'Resize' });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.getAttribute('aria-controls')).toBe('main-panel');
    });

    it('has aria-controls referencing both panes when secondaryPaneId is provided', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        secondaryPaneId: 'secondary',
        'aria-label': 'Resize',
      });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.getAttribute('aria-controls')).toBe('primary secondary');
    });

    it('has aria-orientation="vertical" for a vertical splitter', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        orientation: 'vertical',
        'aria-label': 'Resize',
      });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.getAttribute('aria-orientation')).toBe('vertical');
    });

    it('does not have aria-orientation for a horizontal splitter', async () => {
      const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Resize' });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.hasAttribute('aria-orientation')).toBe(false);
    });

    it('has aria-disabled="true" when disabled', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        disabled: true,
        'aria-label': 'Resize',
      });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.getAttribute('aria-disabled')).toBe('true');
    });

    it('does not have aria-disabled when not disabled', async () => {
      const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Resize' });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.hasAttribute('aria-disabled')).toBe(false);
    });

    // aria-readonly is not a valid attribute for role="separator";
    // readonly behavior is enforced by the Web Component only.
    it('does not have aria-readonly when readonly', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        readonly: true,
        'aria-label': 'Resize',
      });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.hasAttribute('aria-readonly')).toBe(false);
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('Accessible Name', () => {
    it('has aria-label when provided', async () => {
      const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Resize panels' });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.getAttribute('aria-label')).toBe('Resize panels');
    });

    it('has aria-labelledby when provided', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        'aria-labelledby': 'splitter-label',
      });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.getAttribute('aria-labelledby')).toBe('splitter-label');
    });

    it('has aria-describedby when provided', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        'aria-label': 'Resize',
        'aria-describedby': 'splitter-help',
      });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.getAttribute('aria-describedby')).toBe('splitter-help');
    });
  });

  // 🔴 High Priority: Focus Management
  describe('Focus Management', () => {
    it('has tabindex="0" on the separator', async () => {
      const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Resize' });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.getAttribute('tabindex')).toBe('0');
    });

    it('has tabindex="-1" on the separator when disabled', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        disabled: true,
        'aria-label': 'Resize',
      });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.getAttribute('tabindex')).toBe('-1');
    });

    it('has tabindex="0" on the separator when readonly', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        readonly: true,
        'aria-label': 'Resize',
      });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.getAttribute('tabindex')).toBe('0');
    });
  });

  // 🟡 Medium Priority: Popup Buttons
  describe('Popup Buttons', () => {
    it('renders a labelled group of adjust buttons', async () => {
      const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Sidebar' });
      const group = doc.querySelector('[role="group"]');
      const labels = Array.from(group?.querySelectorAll('button') ?? []).map((button) =>
        button.getAttribute('aria-label')
      );
      expect(group?.getAttribute('aria-label')).toBe('Adjust Sidebar');
      expect(labels).toEqual([
        'Collapse Sidebar',
        'Shrink Sidebar',
        'Expand Sidebar',
        'Expand Sidebar to maximum',
      ]);
    });

    it('has tabindex="-1" on the adjust buttons', async () => {
      const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Sidebar' });
      const tabindexes = Array.from(doc.querySelectorAll('[role="group"] button')).map((button) =>
        button.getAttribute('tabindex')
      );
      expect(tabindexes).toEqual(['-1', '-1', '-1', '-1']);
    });

    it('does not render the adjust buttons when disabled', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        disabled: true,
        'aria-label': 'Sidebar',
      });
      expect(doc.querySelector('[role="group"]')).toBeNull();
    });

    it('does not render the adjust buttons when readonly', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        readonly: true,
        'aria-label': 'Sidebar',
      });
      expect(doc.querySelector('[role="group"]')).toBeNull();
    });
  });

  // 🟡 Medium Priority: Initial Position Normalization
  describe('Initial Position Normalization', () => {
    it('clamps defaultPosition below min to min', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        defaultPosition: 5,
        min: 10,
        'aria-label': 'Resize',
      });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.getAttribute('aria-valuenow')).toBe('10');
    });

    it('clamps defaultPosition above max to max', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        defaultPosition: 95,
        max: 90,
        'aria-label': 'Resize',
      });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.getAttribute('aria-valuenow')).toBe('90');
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attributes', () => {
    it('sets the id attribute on the separator', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        id: 'sidebar-splitter',
        'aria-label': 'Resize',
      });
      expect(doc.querySelector('[role="separator"]')?.id).toBe('sidebar-splitter');
    });

    it('applies a custom class to the container', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        class: 'custom-class',
        'aria-label': 'Resize',
      });
      const splitterContainer = doc.querySelector('.apg-window-splitter');
      expect(splitterContainer?.classList.contains('custom-class')).toBe(true);
    });

    it('sets data-testid on the separator', async () => {
      const doc = await renderSplitter({
        primaryPaneId: 'primary',
        'data-testid': 'splitter',
        'aria-label': 'Resize',
      });
      const separator = doc.querySelector('[role="separator"]');
      expect(separator?.getAttribute('data-testid')).toBe('splitter');
    });
  });
});

リソース