type TabElement = HTMLElement & { disabled?: boolean };
interface PitaTabsResolvedOptions {
defaultTab: number;
enableKeyboard: boolean;
enableAnimation: boolean;
onTabChange?: (
currentIndex: number,
previousIndex: number,
context: {
activeTab: TabElement;
activePanel: HTMLElement;
previousTab?: TabElement;
previousPanel?: HTMLElement;
}
) => void;
onInit?: (instance: PitaTabs) => void;
}
export interface PitaTabsOptions {
defaultTab?: number;
enableKeyboard?: boolean;
enableAnimation?: boolean;
onTabChange?: (
currentIndex: number,
previousIndex: number,
context: {
activeTab: TabElement;
activePanel: HTMLElement;
previousTab?: TabElement;
previousPanel?: HTMLElement;
}
) => void;
onInit?: (instance: PitaTabs) => void;
}
// グローバル拡張: 要素とウィンドウにカスタムプロパティを付与
declare global {
interface HTMLElement {
pitaTabs?: PitaTabs;
}
interface Window {
pitaTabs?: object;
}
}
class PitaTabs {
private element: HTMLElement | null;
private config: PitaTabsResolvedOptions;
private tabs: TabElement[] = [];
private panels: HTMLElement[] = [];
private activeIndex: number;
constructor(element: string | HTMLElement, options: PitaTabsOptions = {}) {
// ブラウザ環境でない場合は何もしない
if (typeof window === 'undefined') {
this.element = null;
// デフォルト構成を最低限用意
this.config = {
defaultTab: 0,
enableKeyboard: true,
enableAnimation: true
};
this.activeIndex = this.config.defaultTab;
return;
}
this.element = typeof element === 'string' ? document.querySelector(element) : element;
if (!this.element) {
// 要素がない場合でも内部状態を整えて終了
this.config = {
defaultTab: 0,
enableKeyboard: true,
enableAnimation: true
};
this.activeIndex = this.config.defaultTab;
return;
}
this.config = {
defaultTab: 0,
enableKeyboard: true,
enableAnimation: true,
...options
};
this.activeIndex = this.config.defaultTab;
this.init();
}
private init(): void {
if (!this.element) return;
// タブボタンとパネルを取得
this.tabs = Array.from(this.element.querySelectorAll('.tab-button'));
this.panels = Array.from(this.element.querySelectorAll('.tab-panel'));
if (this.tabs.length === 0 || this.panels.length === 0) {
console.warn('PitaTabs: タブボタンまたはパネルが見つかりません');
return;
}
// イベントリスナーを設定
this.setupEventListeners();
// 初期状態を設定
this.setActiveTab(this.activeIndex, false);
// 初期化コールバックを実行
if (typeof this.config.onInit === 'function') {
this.config.onInit(this);
}
}
private setupEventListeners(): void {
if (!this.element) return;
// タブボタンのクリックイベント
this.tabs.forEach((tab, index) => {
tab.addEventListener('click', (e: MouseEvent) => {
e.preventDefault();
if (!tab.disabled) {
this.setActiveTab(index);
}
});
});
// キーボードナビゲーション
if (this.config.enableKeyboard) {
this.element.addEventListener('keydown', (e: KeyboardEvent) => {
this.handleKeyDown(e);
});
}
}
private handleKeyDown(e: KeyboardEvent): void {
const focusedTab = document.activeElement;
const focusedIndex = this.tabs.findIndex(t => t === focusedTab);
if (focusedIndex === -1) return;
let newIndex = focusedIndex;
switch (e.key) {
case 'ArrowLeft':
case 'ArrowUp':
newIndex = focusedIndex > 0 ? focusedIndex - 1 : this.tabs.length - 1;
break;
case 'ArrowRight':
case 'ArrowDown':
newIndex = focusedIndex < this.tabs.length - 1 ? focusedIndex + 1 : 0;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = this.tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
this.setActiveTab(newIndex);
this.tabs[newIndex].focus();
}
public setActiveTab(index: number, triggerCallback: boolean = true): void {
if (index < 0 || index >= this.tabs.length) return;
const previousIndex = this.activeIndex;
this.activeIndex = index;
// 全てのタブとパネルを非アクティブ化
this.tabs.forEach((tab, i) => {
tab.classList.toggle('active', i === index);
tab.setAttribute('aria-selected', i === index ? 'true' : 'false');
tab.tabIndex = i === index ? 0 : -1;
});
this.panels.forEach((panel, i) => {
const isActive = i === index;
panel.classList.toggle('active', isActive);
panel.setAttribute('aria-hidden', isActive ? 'false' : 'true');
});
// アニメーション対応
if (this.config.enableAnimation && this.panels[index]) {
this.panels[index].style.animation = 'none';
// フォースリフロー
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
this.panels[index].offsetHeight;
this.panels[index].style.animation = '';
}
// コールバックを実行
if (triggerCallback && typeof this.config.onTabChange === 'function') {
this.config.onTabChange(index, previousIndex, {
activeTab: this.tabs[index],
activePanel: this.panels[index],
previousTab: this.tabs[previousIndex],
previousPanel: this.panels[previousIndex]
});
}
}
// パブリックメソッド
public getActiveIndex(): number {
return this.activeIndex;
}
public getActiveTab(): TabElement | undefined {
return this.tabs[this.activeIndex];
}
public getActivePanel(): HTMLElement | undefined {
return this.panels[this.activeIndex];
}
// 特定のタブを有効/無効にする
public setTabDisabled(index: number, disabled: boolean = true): void {
if (index < 0 || index >= this.tabs.length) return;
const tab = this.tabs[index];
tab.disabled = disabled;
tab.setAttribute('aria-disabled', disabled ? 'true' : 'false');
// アクティブなタブが無効化された場合、次のタブに移動
if (disabled && index === this.activeIndex) {
const nextIndex = this.tabs.findIndex((t, i) => i !== index && !t.disabled);
if (nextIndex !== -1) {
this.setActiveTab(nextIndex);
}
}
}
// タブにバッジを追加
public addBadge(index: number, text: string): void {
if (index < 0 || index >= this.tabs.length) return;
const tab = this.tabs[index];
let badge = tab.querySelector('.tab-badge');
if (!badge) {
badge = document.createElement('span');
badge.className = 'tab-badge';
tab.appendChild(badge);
}
badge.textContent = text;
}
// タブのバッジを削除
public removeBadge(index: number): void {
if (index < 0 || index >= this.tabs.length) return;
const badge = this.tabs[index].querySelector('.tab-badge');
if (badge) {
badge.remove();
}
}
// インスタンスを破棄
public destroy(): void {
// イベントリスナーは自動的にクリーンアップされる
this.element = null;
this.tabs = [];
this.panels = [];
}
}
// 自動初期化
const initTabs = (): void => {
if (typeof document === 'undefined') return;
const tabElements = document.querySelectorAll('.tabs');
tabElements.forEach((element) => {
if (!element.pitaTabs) {
element.pitaTabs = new PitaTabs(element);
}
});
};
// 自動初期化(バニラJS用・ブラウザ環境のみ)
// window.pitaTabs = { disabled: true } で無効化可能
if (typeof window !== 'undefined' && typeof document !== 'undefined' && !window.pitaTabs) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
window.pitaTabs = new Proxy({}, {
get: () => 'PitaTabs initialized'
});
initTabs();
});
} else {
window.pitaTabs = new Proxy({}, {
get: () => 'PitaTabs initialized'
});
initTabs();
}
}
export default PitaTabs;