電脳麻将UI 〜 selector

電脳麻将ver.2.0.0 からキーボードで打牌選択できるようになりました。これを実現しているのが selector です。リストを表すDOMノードに selector を適用すると、

  • 矢印キーなどで1つ1つ選択し、Enterキーで確定
  • マウスを合わせることで選択し、クリックで確定
  • タッチデバイスではタップで選択し、再度タップで確定

という選択UIが実現できます。

マウスを想定したUIでは、ボタンにマウスを合わせたときに「選択」状態*1にし、クリックで「確定」するというUIが使われることがありますが、これをタッチデバイスで使うと「選択」と「確定」が同時に発生してしまいます。タッチデバイスではタップすると、touchstart → mouseover → click と連続してイベントが発生するためです。タップの際にclickイベントの発生を抑止するには、それ以前のイベントハンドラで preventDefault() を呼び出す必要があります。

つまり、touchstart のイベントハンドラで、

  • touchstart のハンドラをキャンセル*2
  • preventDefault() の呼び出し*3

を行います。これで選択後のタップで click イベントが発生するようになります。

次に「選択」の動作を focus に集約します。キーボードでは keydown、マウスでは mouseover、タッチデバイスでは touchstart とバラバラなので、それぞれのイベントハンドラから focus イベントを発生させれば「フォーカスされたら選択」と統一できます。

最後に「選択解除」の動作を blur に集約します。他の要素にフォーカスが移れば自然と blur イベントが発生しますが、mouseout の際にも blur イベントを発生させるのがよいでしょう。blur イベントのハンドラでは touchstart のいったんキャンセルしたイベントハンドラを再登録する必要があります。

これらを考慮した @kobalab/majiang-ui のクラス Majiang.UI.Util の selector の実装は以下の通りです。

/*
 *  selector.js
 */
"use strict";

const selectors = {};

function setSelector(list, namespace, param = {}) {

    clearSelector(namespace);

    let opt = {
        confirm: 'Enter', prev: 'ArrowLeft', next: 'ArrowRight',
        tabindex: 0, focus: 0, touch: true
    };
    Object.assign(opt, param);

    if (namespace[0] != '.') namespace = '.' + namespace;
    selectors[namespace] = list;

    let i   = null;
    let len = list.length

    function touchstart(ev) {
        if (opt.touch) {
            $(ev.target).off('touchstart' + namespace).trigger('focus');
            return false;
        }
        else {
            $(ev.target).trigger('focus');
        }
    }

    list.attr('tabindex', opt.tabindex).attr('role','button')
        .on('touchstart' + namespace, touchstart)
        .on('focus'      + namespace, (ev)=>{ i = list.index($(ev.target)) })
        .on('blur'       + namespace, (ev)=>{ i = null;
                        $(ev.target).on('touchstart' + namespace, touchstart)})
        .on('mouseover'  + namespace, (ev)=>$(ev.target).trigger('focus'))
        .on('mouseout'   + namespace, (ev)=>$(ev.target).trigger('blur'));

    if (opt.confirm) {
        $(window).on('keyup' + namespace, (ev)=>{
            if (ev.key == opt.confirm && i != null) {
                list.eq(i).trigger('click');
                return false;
            }
        });
    }
    if (opt.prev && opt.next) {
        $(window).on('keydown' + namespace, (ev)=>{
            if (ev.key == opt.prev) {
                i = (i == null) ? len - 1 :
                    (i <=    0) ?       0 : i - 1;
                list.eq(i).trigger('touchstart');
                return false;
            }
            else if (ev.key == opt.next) {
                i = (i ==    null) ?       0 :
                    (i >= len - 1) ? len - 1 : i + 1;
                list.eq(i).trigger('touchstart');
                return false;
            }
        });
    }
    if (opt.focus != null) {
        list.eq(opt.focus).trigger('touchstart');
    }
    return list;
}

function clearSelector(namespace) {
    if (namespace[0] != '.') namespace = '.' + namespace;
    if (! selectors[namespace]) return;
    selectors[namespace].removeAttr('tabindex role').off(namespace);
    $(window).off(namespace);
    delete selectors[namespace];
}

module.exports = {
    setSelector:    setSelector,
    clearSelector:  clearSelector
}

list で操作対象となるDOMノードのリストをjQueryオブジェクトとして指定します。namespace にはこの操作のネームスペースを指定します。この値は selector を解除するときに使用します。param には以下のオプションを指定できます。

confirm
「確定」の動作に使用するキーの キー名。デフォルト値は Enter
prev
「前を選択」の動作に使用するキーのキー名。デフォルト値は ArrowLeft
next
「次を選択」の動作に使用するキーのキー名。デフォルト値は ArrowRight
tabindex
要素の tabindex 属性に設定する値。タブ移動の対象外としたい場合は -1 を指定する。デフォルト値は 0
focus
最初に選択状態にする要素のインデックス。負の値を指定すると末尾からのインデックスとして解釈する。初期状態で選択なしとする場合は null を指定する。デフォルト値は 0
touch
真の場合、タッチを選択と解釈する。偽の場合はタッチで即確定する。デフォルト値は true

confirm、prev / next に null を指定すると、それぞれのキー操作が無効になります。

以下で実際の動作を確認できます。

*1:CSSの hover を使って実装することが多い

*2:こうしないと永遠にclickが発生しない

*3:実際のコードでは return false しています