【jQuery】自作アコーディオン

# はじめに
アコーディオン（開閉メニュー）を実装するとき、jQuery標準APIの `slideUp()` / `slideDown()` だと融通がきかなかったりするので簡単なスニペットを作りました。jQuery依存です。

- GitHub: https://github.com/pakchandae/accordion
![accordion.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514398/b38d1a47-c044-fb72-288b-3203109c4c86.gif)




# 使い方
1. jQueryと[public/assets/js/app.js](https://github.com/pakchandae/accordion/blob/master/public/assets/js/app.js)を読み込む
2. 対象要素に下記を指定する

|class名|カスタム属性|
|---|---|
|js-accordion-target<br>→ 開閉する要素|data-accordion-height<br>→ 初期表示の高さを指定（初期値: 0）|
|js-accordion-trigger<br>→ クリックする要素|data-accordion-target<br>→ ターゲットとなる要素を指定|

## サンプルコード

```html:html
<!-- target -->
<div class="js-accordion-target" id="target" data-accordion-height="50">
  <p style="width: 300px;">Lorem Ipsum is simply dummy text of the printing and typesetting industry.Lorem Ipsum is simply dummy text of the printing and typesetting industry.Lorem Ipsum is simply dummy text of the printing and typesetting industry.</p>
</div>

<!-- trigger -->
<a class="js-accordion-trigger" href="#" data-accordion-target="#target">read more</a>

<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>
<script src="app.js"></script>
```

# コードの解説
以下、Accordionクラスのコードです。

```js:js

class Accordion{
    constructor() {
        this.targetClass = 'js-accordion-target';
        this.$target = $('.' + this.targetClass);
        this.triggerClass = 'js-accordion-trigger';
        this.$trigger = $('.' + this.triggerClass);
        this.dataTarget = 'data-accordion-target';
        this.dataInitialHeight = 'data-accordion-height';
        this.openClass = 'is-open';
        this.ease = 'swing';
        this.speed = 300;
    }
    
    /**
     * 初期化処理。下記の場合エラー
     * - トリガー要素に[data-accordion-target]属性が存在しない
     * - トリガー要素に指定した[data-accordion-target]要素が存在しない
     * - [data-accordion-target]属性を持った要素に.js-accordion-triggerが指定されていない
     */
    init(){
        try {
            if (!this.$target.length || !this.$trigger.length) return;
            
            this.$trigger.each((i, el) => {
                if ($(el).attr(this.dataTarget)) {
                    if (!$($(el).attr(this.dataTarget)).length || !$($(el).attr(this.dataTarget)).hasClass(this.targetClass)) {                        
                        throw new Error(this.targetClass + ' element does not exist');
                    }
                } else {
                    throw new Error(this.dataTarget + ' attribute does not exist');
                }
            });
            
            $('[' + this.dataTarget + ']').each((i, el) => {
                if ($($(el).attr(this.dataTarget)).length && !$(el).hasClass(this.triggerClass)) {                    
                    throw new Error(this.triggerClass + ' attribute does not exist');
                }
            });
            
        } catch (e) {
            console.error(e.message);
            return;
        }
        
        this.setTarget();
        this.setEvent();
    }
    
    /**
     * ターゲット要素の初期スタイル指定
     */
    setTarget(){
        this.$target.each((i, el) => {
            let $el = $(el),
                initialHeight = $el.attr(this.dataInitialHeight);
                
            $el.css({
                height: initialHeight ? Number(initialHeight) : 0,
                overflow: 'hidden',
            })
        });
    }
    
    /**
     * イベント定義
     */
    setEvent(){
        $(window).on('resize', () => {
            this.resize();
		});
        
        this.$trigger.on('click', (e) => {
            this.click(e);
            return false;
        });
    }
    
    /**
     * クリックイベント
     * @param {object} event クリックイベントオブジェクト
     */
    click(event){
        let $target = $($(event.currentTarget).attr(this.dataTarget)),
            initialHeight = $target.attr(this.dataInitialHeight),
            height = this.getHeight($target.contents()),
            h = 0;
        
        if ($target.hasClass(this.openClass)) {
            h = initialHeight ? Number(initialHeight) : 0;
            $target.removeClass(this.openClass);
        } else {
            h = height;
            $target.addClass(this.openClass);
        }
        
        this.setHeight({
            $target: $target,
            speed: this.speed,
            height: h,
        });
    }
    
    /**
     * リサイズイベント
     */
    resize(){
        this.$target.each((i, el) => {
            let $el = $(el),
                h = this.getHeight($el.contents());
                
            if (!$el.hasClass(this.openClass)) return;
            
            this.setHeight({
                $target: $el,
                speed: 0,
                height: h,
            });
        });
    }
    
    /**
     * ターゲット要素の高さ指定
     * @param {object} param 対象要素・スピード・高さ
     */
    setHeight(param){
        let $target = param.$target,
            s = param.speed,
            h = param.height;
            
        $target.stop().animate({ height: h }, s, this.ease);
    }   
    
    /**
     * 対象要素の高さを取得
     * @param {object} $el 対象要素
     * @return {number} 対象要素の高さ
     */
    getHeight($el){
        let h = 0;
        
        $el.each((i, el) => {
            if ($(el).get(0).nodeType === Node.TEXT_NODE) {            
                $(el).wrap('<span>');
                h += $(el).parent().outerHeight(true);
                $(el).unwrap('<span>');
            } else {
                h += $(el).outerHeight(true);
            }
        });
        
        return h;
    }   
}

let accordion = new Accordion();
accordion.init()
```
難しいことはしてないので、コメント読んでいただければなんとなく分かるかと思いますが、一応メソッドごとに解説します。

## initメソッド
initメソッドでは初期化を行ってます。
まず、トリガー要素かターゲット要素が存在しない場合は、その後の処理が実行されないようreturnします。

```js
if (!this.$target.length || !this.$trigger.length) return;
```

その後下記エラーをチェックし、該当すればエラーを投げます。

- トリガー要素に[data-accordion-target]属性が存在しない
- トリガー要素に指定した[data-accordion-target]要素が存在しない
- [data-accordion-target]属性を持った要素に.js-accordion-triggerが指定されていない

```js
this.$trigger.each((i, el) => {
	if ($(el).attr(this.dataTarget)) {
		if (!$($(el).attr(this.dataTarget)).length || !$($(el).attr(this.dataTarget)).hasClass(this.targetClass)) {
			// トリガー要素に指定した[data-accordion-target]要素が存在しない
			throw new Error(this.targetClass + ' element does not exist');
		}
	} else {
		// トリガー要素に[data-accordion-target]属性が存在しない
		throw new Error(this.dataTarget + ' attribute does not exist');
	}
});

$('[' + this.dataTarget + ']').each((i, el) => {
	if ($($(el).attr(this.dataTarget)).length && !$(el).hasClass(this.triggerClass)) {
		// [data-accordion-target]属性を持った要素に.js-accordion-triggerが指定されていない
		throw new Error(this.triggerClass + ' attribute does not exist');
	}
});
```

エラーチェックを無事抜けたら、最後に`setTarget()`と`setEvent()`を呼び出します。

```js
try {
	// エラーチェック処理
} catch (e) {
	console.error(e.message);
	return;
}

this.setTarget();
this.setEvent();
```


## setTargetメソッド
setTargetメソッドでは、ターゲット要素（ `.js-accordion-target` ）の初期スタイルを設定します。
`height` → ターゲット要素が[data-accordion-height]属性を持っていたらその値、なければ0
`overflow` → アコーディオンを閉じたとき子要素がはみ出さないように `overflow: hidden` してあげます

```js
this.$target.each((i, el) => {
	let $el = $(el),
		initialHeight = $el.attr(this.dataInitialHeight);

	$el.css({
		height: initialHeight ? Number(initialHeight) : 0,
		overflow: 'hidden',
	})
});
```

## setEventメソッド
setEventメソッドでは、下記のイベント定義を行います。

```js
// トリガーのクリックイベント
this.$trigger.on('click', (e) => {
	this.click(e);
	return false;
});
```

```js
// リサイズイベント
$(window).on('resize', () => {
	this.resize();
});
```

## clickメソッド
clickメソッドは、トリガー要素がクリックされた際に実行される処理です。
ざっくり以下のような処理を行ってます。
1. ターゲット要素が閉じたとき、開いたときの高さを取得
2. ターゲット要素の開閉状態を見て、値を設定
3. アニメーション

```js
// 1. ターゲット要素が閉じたとき、開いたときの高さを取得
let $target = $($(event.currentTarget).attr(this.dataTarget)),
	initialHeight = $target.attr(this.dataInitialHeight),
	height = this.getHeight($target.contents()),
	h = 0;

// 2. ターゲット要素の開閉状態を見て、値を設定
if ($target.hasClass(this.openClass)) {
	h = initialHeight ? Number(initialHeight) : 0;
	$target.removeClass(this.openClass);
} else {
	h = height;
	$target.addClass(this.openClass);
}

// 3. アニメーション
this.setHeight({
	$target: $target,
	speed: this.speed,
	height: h,
});
```

## resizeメソッド
resizeメソッドは、リサイズ時にターゲット要素に対して行われる処理です。
リサイズ時に子要素の高さが変わるとレイアウトが崩れてしまうので、常にターゲット要素の高さを再計算してあげます。
流れとしては以下です。
1. ターゲット要素が開いたときの高さを取得
2. ターゲット要素が閉じてるときはreturn
3. 高さ調整


```js
resize(){
	this.$target.each((i, el) => {
	
		// 1. ターゲット要素が開いたときの高さを取得
		let $el = $(el),
			h = this.getHeight($el.contents());

		// 2. ターゲット要素が閉じてるときはreturn
		if (!$el.hasClass(this.openClass)) return;

		// 3. 高さ調整
		this.setHeight({
			$target: $el,
			speed: 0,
			height: h,
		});
	});
}
```

## setHeightメソッド
setHeightメソッドは、ターゲット要素の高さを設定します。
なお、引数で speed / height を渡せるようにしてるので、
`resize()` の高さ制御も、`click()`の開閉アニメーションもこの処理を実行してます。

```js
setHeight(param){
	let $target = param.$target,
		s = param.speed,
		h = param.height;
		
	$target.stop().animate({ height: h }, s, this.ease);
}  
```

## getHeightメソッド
getHeightメソッドは、引数に渡ってきた要素の高さの合計値を返します。
ターゲット要素が開いたときの高さ（＝子要素の高さの合計値）を取得するときに使用します。
なお、子要素がテキストノードの場合は高さが取れないので、一旦`<span>`でwrap → 高さ計算 → `<span>`をunwrap という荒業を行ってます。（絶対もっと良い方法ありますが）

```js
let h = 0;

$el.each((i, el) => {
	if ($(el).get(0).nodeType === Node.TEXT_NODE) {            
		$(el).wrap('<span>');
		h += $(el).parent().outerHeight(true);
		$(el).unwrap('<span>');
	} else {
		h += $(el).outerHeight(true);
	}
});

return h;
```

# まとめ
大したものでは無いのですが、勉強がてらアコーディオン機能を作ってみました。
小さな機能に関しては自作してしまったほうが早かったり融通がきいたりするので、どんどん自作コードで身を固めていきたいです。