2014年5月26日月曜日

Using Bootstrap dropdown from Dart

自分で何かウェブサイトをでっち上げる場合、最近はもっぱらBootstrapを使うことが増えてる。普段はCSSで出来る範囲しかやらないんだけど、今回dropdownも含めてDartからやろうとして少しひっかかったのでここにメモを残しておく。

Bootstrap

Bootstrapは使うだけでなんとなくそれっぽいウェブサイトがデザインできる素敵CSSとJavascriptのライブラリで最近はなにかと良く使ってる。

ただBootstrapはDartと組み合わせようとすると少し面倒なことになってしまう。CSSだけを使ってる分にはいいけど、dropdownみたいに何らかのJavascriptが走るようなのは途端にうまく動かなくなるからだ。というか実際色々試してみたところ動かなかった。

たとえばBootstrapのdropdownに関するドキュメントではdata-toggleにdropdownって書けばいいよーと書いてあるけど、polymer.dart使って定義したWeb Component内でそれをやっても動かない。jQueryとdart:jsを駆使してむりくりJavascript側のdropdown関数を呼んでみても何か動かない。

しょうがないからBootjackとか使ってみるか…? いやでも最新のBootstrap使えないんじゃちょっとイケてないしな…という感じで困って色々探したところ、公式のDart API ReferenceってDartで書かれてるけどdropdown使ってる(Optionsのところクリックするとdropdownする)じゃん、と気づいた。

それでソースをちらっと見たところDart API ReferenceもまさにBootstrap使ってて完全に渡りに船だった。

Javascriptは不要

結論から言うと、dropdownでもjavascriptを使う必要はなかった。Dart API Referenceのソースはここで見られて、dropdownに関する部分を転載すると以下のようになる。

  <li class="dropdown">
    <a class="dropdown-toggle" on-click="{{toggleOptionsMenu}}">
      Options <b class="caret"></b>
    </a>
    <ul class="dropdown-menu">
      <li class="visible-lg"><a on-click="{{togglePanel}}">
        {{showOrHideLibraries}} Libraries
      </a></li>
      <li class="visible-lg"><a on-click="{{toggleMinimap}}">
        {{showOrHideMinimap}} Minimap
      </a></li>
      <li><a on-click="{{toggleInherited}}">
        {{showOrHideInherited}} Inherited
      </a></li>
      <li><a on-click="{{toggleObjectMembers}}">
        {{showOrHideObjectMembers}} Object Members
      </a></li>
      <li><a on-click="{{togglePkg}}">
        {{showOrHidePackages}} Packages
      </a></li>
    </ul>
  </li>

上記に対応するコードが次の部分。

  void enteredView() {
    super.enteredView();
    ...
    registerObserver('onclick',
        onClick.listen(hideOptionsMenuWhenClickedOutside));

    ...
  }

  void toggleOptionsMenu(MouseEvent event, detail, target) {
    var list = shadowRoot.querySelector(".dropdown-menu").parent;
    if (list.classes.contains("open")) {
      list.classes.remove("open");
    } else {
      _openedAt = event.timeStamp;
      list.classes.add("open");
    }
  }

  void hideOptionsMenuWhenClickedOutside(MouseEvent e) {
    if (_openedAt != null && _openedAt == e.timeStamp) {
      _openedAt == null;
      return;
    }
    hideOptionsMenu();
  }

  void hideOptionsMenu() {
    var list = shadowRoot.querySelector(".dropdown-menu").parent;
    list.classes.remove("open");
  }
  

内容としてはdropdownクラスなliに対してopenっていうクラスを付加してやればdropdownするし、openを取り除けば閉じる、っていう極めて単純なものだった。

よりPolymerらしく

上のコードをそのまま流用しても使えるんだけど、せっかくなのでよりPolymerらしくしたのが次のバージョン。自分用に書き換えてるのでさっきのとは少し内容が違うけど、雰囲気は大体一緒。

  <li class="{{ {'dropdown':true, 'open':userDropdownOpen} }}">
    <a class="dropdown-toggle" on-click="{{toggleUserDropdown}}" >
    <span class="glyphicon glyphicon-user"></span> {{authState.user.email}} <b class="caret"></b>
    </a>
    <ul class="dropdown-menu">
      <li><a href="setting/"><span class="glyphicon glyphicon-wrench"></span> {{setting}}</a></li>
      <li class="divider"></li>
      <li><a href="{{authState.url}}"><span class="glyphicon glyphicon-log-out"></span> {{logout}}</a></li>
    </ul>
  </li>

どの辺がPolymerっぽいかというとclassのenable/disableをコードからいじるんでなくバインディングで解決してるところ。このhtmlで言えばopenクラスの有効・無効がuserDropdownOpenフラグにバインドされているので、あとはフラグの値を書き換えるだけで自動的にopenが付与される。

なお、上のhtmlに対応するコードがこちら。

  @observable bool userDropdownOpen = false;

  @override
  void enteredView() {
    super.enteredView();

    // Apply style from parent in order to use bootstrap
    shadowRoot.applyAuthorStyles = true;

    // Register to the root window so we can receive the onClick
    // even if it was clicked outside of this component
    window.onClick.listen((e) => hideOptionsMenu(e.timeStamp));
  }

  void toggleUserDropdown(MouseEvent e, var detail, Node target) {
    if (userDropdownOpen) {
      userDropdownOpen = false;
    } else {
      _dropdownOpenTime = e.timeStamp;
      userDropdownOpen = true;
    }
  }

  void hideOptionsMenu(int timestamp) {
    if (_dropdownOpenTime != null && _dropdownOpenTime == timestamp) {
      _dropdownOpenTime == null;
      return;
    }
    userDropdownOpen = false;
  }

元のコードではregisterObserverでonclickイベントに接続していたんだけど、こっちではwindowのonClickに直接つないでる。

こういう風にした理由はregisterObserverだと同一Component内のonclickしか拾えなかったから。たとえば全く別のcomponentの上でマウスクリックした場合、onclickが検知できなくてdropdownが閉じない現象が発生していた。そのため、全componentの親に相当するwindowのonClickに直接つないでどこをクリックしても適切にdropdownが閉じられるようにした。