GODRICH

通知 ui 9選 — サイドバーに置ける活動フィードウィジェット

通知 ui とは、一瞬だけ現れて消えるトーストではなく、画面の片隅に居座り、積み上がり続けるリストのことです。サイドバーに置けるこの9個のウィジェットは、既読、修正前の値、ログレベル、今日の件数まで、すべて実際の状態を書き換えて動きます。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

順番は、作る人の都合ではなくリストがたどる順です。まずチームの作業が上から積まれ(01)、修正の前後が履歴に残り(02)、既読と未読が分かれます(03)。外からの言及が転がり込み(04)、返信がひとまとまりに折りたたまれ(05)、書類が提出から承認へと段階を進みます(06)。ここまでが積む仕事です。次は選ぶ仕事です。ログをレベルで絞り込み(07)、開設したばかりのフィードが最初の活動で目を覚まします(08)。最後に、積まれた一日をサマリーカウンターが数に畳みます(09)。

01新しい活動が上に現れるチームのストリーム

誰が何をいつしたかを、アバター・一文・時刻の一行で積むリストです。リスト全体に role="log"aria-live を付けてあり、活動を追加すると新しい行が折りたたまれた状態から広がって上に載り、残りが一行ずつ下がります。

role="log"aria-livetranslateY
document.getElementById('ts-add').addEventListener('click', function () {
  ts.classList.remove('is-demo');
  var row = document.getElementById('ts-tpl').content.firstElementChild.cloneNode(true);
  row.classList.add('is-shut');
  tsList.insertBefore(row, tsList.firstChild);
  void row.offsetWidth; // 2段階に分けないと grid の展開がアニメーションしない
  row.classList.remove('is-shut');
  tsCountUp();
});

02修正した値を戻せる変更履歴 diff

修正前の値には del で打ち消し線が、修正後の値には ins で下線が引かれ、前後が一目で分かります。各行の元に戻すボタンを押すと、その行だけが前の値に戻り、残った修正数も書き換わります。

delinssteps(1, end)
b.addEventListener('click', function () {
  dh.classList.remove('is-demo');
  var row = b.closest('.dh__row');
  var back = row.classList.toggle('is-back');
  b.setAttribute('aria-pressed', back ? 'true' : 'false');
  dhLeft += back ? -1 : 1;
  dhCount();
});

03既読と未読が分かれる通知リスト

未読の行では、白いカードの上で青い点が脈打ち、開くと点が消えて既読の色へ1フレームで切り替わります。すべて既読を押すと残りの行が全部既読になり、残数が読み上げられます。

scalesteps(1, end)aria-live
function ncSync() {
  var left = ncRows.filter(function (r) {
    return !r.classList.contains('is-read');
  }).length;
  ncBadge.textContent = ncBadge.getAttribute('data-t')
    .replace('{n}', left);
}
document.getElementById('nc-all').addEventListener('click', function () {
  nc.classList.remove('is-demo');
  ncRows.forEach(function (r) { r.classList.add('is-read'); });
  ncSync();
});

04横から転がり込むソーシャル言及カード

誰かが言及するたびに、カードが右端から転がり込み、少し傾いてからまっすぐに収まります。言及は現れるだけで消えることはなく、リストが role="log" なので新しい順に読み上げられます。

translateXrotaterole="log"
document.getElementById('mn-more').addEventListener('click', function () {
  mn.classList.remove('is-demo');
  var row = document.getElementById('mn-tpl').content.firstElementChild.cloneNode(true);
  mnList.insertBefore(row, mnList.firstChild);
  row.addEventListener('animationend', function () {
    row.classList.remove('is-in');
  }, { once: true });
});

05返信が折りたたまれるミニコメントスレッド

返信のまとまりは grid-template-rows0fr1fr の間で折りたたまれたり広がったりし、切り替えには aria-expanded が付きます。コメントするを押すと、新しい返信が下から浮かび上がります。

grid-template-rowsaria-expandedtranslateY
function ctOpen(on) {
  ctRe.classList.toggle('is-open', on);
  ctTg.setAttribute('aria-expanded', on ? 'true' : 'false');
  ctTg.classList.toggle('is-open', on);
}
ctTg.addEventListener('click', function () {
  ct.classList.remove('is-demo');
  ctOpen(!ctRe.classList.contains('is-open'));
});

06段階ごとに灯が入る状態バッジのタイムライン

提出・審査・承認の3段階が縦に並び、つなぎ線が現在の段階まで満ちていきます。次の段階を押すと、今の段階だけが aria-current を受け、段階名が案内の言葉にも反映されます。

scaleYsteps(1, end)aria-current
function stGo(i) {
  stAt = (i + stRows.length) % stRows.length;
  stRows.forEach(function (r, n) {
    r.classList.toggle('is-now', n === stAt);
    r.classList.toggle('is-done', n < stAt);
    if (n === stAt) { r.setAttribute('aria-current', 'step'); }
    else { r.removeAttribute('aria-current'); }
  });
  stPhase.setAttribute('data-at', String(stAt));
}

07レベルチップで絞り込むシステムログ

情報・警告・エラーのチップが aria-pressed で押された状態を覚え、押されたレベルの行だけが残り、他は hidden で画面から外れます。チップを全部オフにすると絞り込みがなくなるため、行が全部戻ります。

aria-pressedhiddensteps(1, end)
function lgFilter() {
  var on = lgChips.filter(function (c) {
    return c.getAttribute('aria-pressed') === 'true';
  }).map(function (c) { return c.getAttribute('data-k'); });
  var showAll = on.length === 0 || on.length === lgChips.length;
  lgRows.forEach(function (r) {
    r.hidden = !(showAll || on.indexOf(r.getAttribute('data-k')) >= 0);
  });
}

08空のフィードが最初の活動に変わる瞬間

開設したばかりのフィードは、空の時間のほうが長いものです。空状態の案内はカット1回で退き、最初の活動が下から浮かび上がります。その領域を aria-live にしてあるので、最初の活動はスクリーンリーダーにも伝わります。

steps(1, end)translateYaria-live
function efSync(on) {
  ef.classList.toggle('has-act', on);
  efPhase.textContent = on
    ? efPhase.getAttribute('data-t1').replace('{n}', efCount)
    : efPhase.getAttribute('data-t2');
}
document.getElementById('ef-add').addEventListener('click', function () {
  ef.classList.remove('is-demo');
  var row = document.getElementById('ef-tpl').content.firstElementChild.cloneNode(true);
  efList.insertBefore(row, efList.firstChild);
  efCount++;
  efSync(true);
});

09今日の件数と時間帯バーを数えるサマリーカウンター

今日の件数が増えると数字が書き換わって読み上げられ、時間帯ごとのバーは同じ目盛りの grid の上で根元から伸びていきます。再集計を押すと数字が実際に上がり、最後のバーが実際に伸びます。

aria-livescaleYtransform-origin
function tick(ts) {
  if (!t0) { t0 = ts; }
  var p = Math.min(1, (ts - t0) / 400);
  var v = Math.round(from + (to - from) * p);
  scN.textContent = String(v);
  if (p < 1) { requestAnimationFrame(tick); }
}
requestAnimationFrame(tick);
w.style.setProperty('--f', '.85'); // バーそのものが実際に伸びる

どこで壊れるか — 落とし穴

最初にぶつかるのは、1つの要素に同じプロパティを動かすアニメーションを2つ掛けることです。03 の未読の点には、消えるカットと脈打ちの両方が transform を使っていたのですが、同じプロパティを2つのアニメーションが触ると、animation-name の並びで後ろに書いた方が勝ってカットが一切起こらなくなりました。そこでカットは opacity に移し、脈打ちだけが transform に残りました。09 のバーも、伸びるアニメーションと畳むアニメーションを並べて掛けた途端、畳む側が永遠に勝ってバーが伸びなくなったため、1周の中で伸びて畳む1つのアニメーションに統合しました。

2番目は文を断片に切り分けることです。活動の一文を名前・助詞・目的語に切り分けると韓国語では見栄えが良いのですが、英語版と日本語版では語順が崩れます。そこで文は名前のノード1つと残りの一文のノード1つだけにまとめ、目的語の強調は行の背景と左のバーに移しました。言語が変わっても文が生きているのは、このためです。

3番目は配置用の transform をリセットしてしまう失敗です。06 の段階の点はレールの中央に置くために translate(-50%, -50%) を使っていますが、動きを止めるブロックで transform をまとめて初期化すると、点がレールの隅に飛んでしまいます。点はアニメーションだけを止め、位置を決めた transform はそのまま残すのがこの部品の規則です。

さらに、つなぎ線のような細い要素は幅が狭いため、累積面積は小さく出ます。06 のつなぎ線は満ちていく間も累積面積は 1.62% と9個の中で最も小さいままでしたが、変わった画素の中の平均差は 92.6 に達しました。実際、02 も取り消しのカットが中心のため累積面積は 2.81% と低いままですが、強度は 72.3 を保ちました。細い要素は面積ではなく強度で語るため、測定値を見るときはこの2つを分けて読む必要があります。片方だけを見ると、細くてもはっきり動いている要素を落としてしまいます。だから測定のたびに面積と強度の両方を確かめるのが、この部品集の作法です。

最後は狭い画面です。スマホでは 320px の枠の縦が 174px しかなく、9個全部はそのまま入りません。そこで行を減らすウィジェットは最も古い行から画面の外へ出し(01・03・04・05)、ログは最後の行から外し(07)、残る4つは文字と間隔だけを詰めます。狭い画面の検査は 320×200 の枠で縦のあふれを測るもので、1px でもあふれると枠にスクロールバーが出て部品がよく見えなくなります。この9つもその余白の中に収まるよう、行数と文字を一緒に詰めて組み直してあります。バニラ版と React 版を同じ値でまとめた圧縮ファイルのパスワードは sub453jm で、この記事のデモがすべて入っています。

アクセシビリティ

動きを減らす設定がオンの画面では、9つ全部のループが止まり、結果の状態だけが残ります。新しい行は広がったまま、既読の行は既読の色のまま、承認のタイムラインは審査を過ぎた姿で止まり、動きが消えても何が確定したのかは画面に残ります。

フィード系のアクセシビリティは、結局新しい項目をどう知らせるかに集約されます。積むリストは role="log"aria-live="polite" で括り(01・04・05)、書き換わる数字と案内も同じ値で読み上げられます(03・08・09)。押された状態は aria-pressed(02・07)、広がりは aria-expanded(05)、今の段階は aria-current(06) で伝えます。

項目 画面で スクリーンリーダーで 操作
01 チームのストリーム 新しい行が上に現れる role="log" で行を読み上げ 活動を追加
02 変更履歴 diff 打ち消し線 → 下線 取り消し aria-pressed 元に戻す
03 通知リスト 点の脈打ち → 既読の色 残数 aria-live 開く · すべて既読
04 ソーシャル言及 カードが横から転がり込む role="log" で言及を読み上げ 言及を読み込む
05 コメントスレッド 返信のまとまりが折りたたまる aria-expanded 切り替え · コメントする
06 状態タイムライン つなぎ線が満ちる aria-current="step" 次の段階
07 ログ絞り込み チップのオンオフ · 行が外れる チップ aria-pressed チップ切替
08 空フィード切替 空状態 → 最初の活動 最初の活動 aria-live 新しい活動 · 空にする
09 サマリーカウンター バーが時間差で伸びる 件数 aria-live 再集計

role="log" をいつトーストの代わりに使うかの基準は、MDN の log role 文書にまとまっています。このウィジェットを使う画面は通知カテゴリーダッシュボードカテゴリーに、JavaScript が実際に動く部品は JS カテゴリーに集まっています。

FAQ

トーストと通知リストはどう使い分けるのですか?

3秒で消えても構わない知らせならトースト、遡って読む記録ならリストです。トーストは自分で消えるので戻れませんが、この記事のリストは行が画面に残り、過ぎた活動を読み直せます。役割も同じように分かれ、トーストは role="status"、積むリストは role="log" が適切です。

項目が次々届くとスクリーンリーダーがうるさくなりませんか?

だからこそ aria-live はリスト全体に付け、更新のたびに行を1つだけ足しています。読み上げられるのは届いた行だけで、一度読んだ行は再度読まれません。件数が変わるときは小さな案内が数字だけを伝えるため、リスト全体を繰り返しません。

フィードが数百行に伸びたらどうすればいいですか?

全部を見せようとせず、3つの動きで畳みます。古い行は折りたたみ(05)、見たいレベルだけを残し(07)、一日の量はバーに要約します(09)。この3つをサイドバーに並べれば、長いリストを広げなくても流れが読めます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。