notification-tray-tabs
ベルボタンから開くポップオーバーの中にタブ(すべて・未読、件数バッジ付き)を置き、タブごとの通知一覧を静的に並記した通知トレイのブロックです。popover / tabs / badge / button / avatar / menu / empty-state / visually-hidden / icon の 9 部品を合成します。Blocks は既存部品の合成例であり、新しい UI 部品は追加しません。
主参照は対応表 ID R0169(代表構成)で、R0168(空の状態)を Demo の計 3 インスタンス並記(空の状態版・代表構成版〔「すべて」選択〕・代表構成版〔「未読」選択〕)で集約しています。docs サイトは無 JS のためタブを切り替えられず、代表構成版は選択状態違いの 2 インスタンスで両パネルを可視化しています。人名は架空のデータであり、実在の人物・企業・PII は含みません。アバター画像はビルド時生成の同梱プレースホルダー SVG です。
本 Demo は無 JS の静的表示のみであり、<form> を含みません。ポップオーバーは開いた状態、操作メニューは閉じた状態で固定表示し、送信処理・送信先は一切持ちません。
Demo
通知
通知はありません
新しい通知が届くとここに表示されます。
通知はありません
新しい通知が届くとここに表示されます。
通知
Haruto Fujimaki がコメントしました
5 分前
Elena Vasquez があなたをレビュアーに追加しました
1 時間前
Kwame Boateng がタスクを完了にしました
3 時間前
Mei Lindqvist がドキュメントを更新しました
昨日
Haruto Fujimaki がコメントしました
5 分前
Elena Vasquez があなたをレビュアーに追加しました
1 時間前
Kwame Boateng がタスクを完了にしました
3 時間前
通知
Haruto Fujimaki がコメントしました
5 分前
Elena Vasquez があなたをレビュアーに追加しました
1 時間前
Kwame Boateng がタスクを完了にしました
3 時間前
Mei Lindqvist がドキュメントを更新しました
昨日
Haruto Fujimaki がコメントしました
5 分前
Elena Vasquez があなたをレビュアーに追加しました
1 時間前
Kwame Boateng がタスクを完了にしました
3 時間前
使用部品
Rust コード
Rust
use crate::blocks::dummy_assets;
use fandhe_frontend_core::{div, el, text, Node};
use fandhe_frontend_pre_styled_ui::avatar::{self, AvatarProps, ImageStatus};
use fandhe_frontend_pre_styled_ui::badge::{self, BadgeProps, BadgeVariant};
use fandhe_frontend_pre_styled_ui::button::{button, ButtonProps, ButtonVariant};
use fandhe_frontend_pre_styled_ui::empty_state::{self, EmptyStateProps};
use fandhe_frontend_pre_styled_ui::icon::{icon, IconProps};
use fandhe_frontend_pre_styled_ui::menu;
use fandhe_frontend_pre_styled_ui::popover::{self, OpenState};
use fandhe_frontend_pre_styled_ui::recipe::Size;
use fandhe_frontend_pre_styled_ui::tabs::{
self, ActivationMode, Orientation, TabItem, TabsProps, TabsVariant,
};
use fandhe_frontend_pre_styled_ui::visually_hidden;
/// 自作の幾何アイコン(線画。モジュール doc「アイコンは自作の単純図形」
/// 節参照)。`path` へ `fill="none"` + `stroke="currentColor"` を明示し、
/// `icon` の `<svg>` 側が固定で持つ `fill="currentColor"`(塗り面)を
/// 上書きして線画(ストローク)として描画する。
fn geo_icon(path_d: &'static str) -> Node {
icon(
&IconProps::default(),
vec![],
vec![el(
"path",
vec![
("d", path_d),
("fill", "none"),
("stroke", "currentColor"),
("stroke-width", "2"),
("stroke-linecap", "round"),
("stroke-linejoin", "round"),
],
vec![],
)],
)
}
/// ベルアイコン(釣鐘形 + 下端の房)。
fn bell_icon() -> Node {
geo_icon("M6 9a6 6 0 0 1 12 0v4l2 4H4l2-4z M10 20a2 2 0 0 0 4 0")
}
/// 縦 3 点アイコン(操作メニュー用)。
fn kebab_icon() -> Node {
geo_icon("M12 5v.01 M12 12v.01 M12 19v.01")
}
/// 1 件の通知行(アバター + 本文 + 相対時刻。`unread` のとき未読ドットを
/// `data-unread` 経由の CSS 疑似要素で表示する)。
fn notification_item(
name: &'static str,
message: &'static str,
when: &'static str,
unread: bool,
) -> Node {
let mut attrs: Vec<(&str, &str)> = vec![("class", "blocks-notification-tray-tabs-item")];
if unread {
attrs.push(("data-unread", ""));
}
div(
attrs,
vec![
avatar::root(
&AvatarProps {
size: Size::Sm,
..AvatarProps::default()
},
vec![],
vec![
avatar::image(ImageStatus::Loaded, dummy_assets::AVATAR_SRC, name, vec![]),
avatar::fallback(
ImageStatus::Loaded,
vec![],
vec![text(name.chars().take(1).collect::<String>())],
),
],
),
div(
vec![("class", "blocks-notification-tray-tabs-item-body")],
vec![
text(format!("{name} が{message}")),
div(
vec![("class", "blocks-notification-tray-tabs-item-time")],
vec![text(when)],
),
],
),
],
)
}
/// 通知一覧が空のときの表示(R0168)。
fn empty_list() -> Node {
div(
vec![],
vec![empty_state::root(
&EmptyStateProps::default(),
vec![],
vec![empty_state::content(
vec![],
vec![
empty_state::title(vec![], vec![text("通知はありません")]),
empty_state::description(
vec![],
vec![text("新しい通知が届くとここに表示されます。")],
),
],
)],
)],
)
}
/// ヘッダー行の操作メニュー(`navbar_two_row::profile_menu` と同型。
/// 無 JS のため `disabled: true` 固定、`content_id` は `variant` ごとに
/// 一意にする)。
fn header_menu(variant: &str) -> Node {
let content_id = format!("blocks-notification-tray-tabs-{variant}-menu");
let trigger = menu::trigger(
OpenState::Closed,
true,
Some(content_id.as_str()),
vec![("aria-label", "通知の操作")],
vec![kebab_icon()],
);
let content = menu::content(
OpenState::Closed,
Some(content_id.as_str()),
None,
vec![],
vec![
menu::item(
"mark-all-read",
false,
false,
vec![],
vec![text("すべて既読にする")],
),
menu::item("settings", false, false, vec![], vec![text("通知設定")]),
],
);
let positioner = menu::positioner(OpenState::Closed, vec![], vec![content]);
menu::root(
Size::Sm,
OpenState::Closed,
vec![],
vec![trigger, positioner],
)
}
/// タブ 2 個(すべて・未読)+ 各 content を組む。`total`/`unread` は
/// トリガーのバッジ数値、`all_list`/`unread_list` は各タブの一覧。
/// `selected` は SSR 時点の選択状態(`"all"`/`"unread"`)。
#[allow(clippy::too_many_arguments)]
fn tab_group(
variant: &str,
selected: &'static str,
total: u32,
unread: u32,
all_list: Node,
unread_list: Node,
) -> Node {
let tabs_id = format!("blocks-notification-tray-tabs-{variant}-tabs");
let props = TabsProps {
id: tabs_id.as_str(),
selected,
orientation: Orientation::Horizontal,
activation_mode: ActivationMode::Automatic,
loop_focus: true,
indicator: false,
};
let items = vec![
TabItem {
value: "all",
trigger: vec![
text("すべて "),
badge::badge(
&BadgeProps::default(),
vec![],
vec![text(total.to_string())],
),
],
content: vec![all_list],
disabled: selected != "all",
},
TabItem {
value: "unread",
trigger: vec![
text("未読 "),
badge::badge(
&BadgeProps {
variant: BadgeVariant::Solid,
..BadgeProps::default()
},
vec![],
vec![text(unread.to_string())],
),
],
content: vec![unread_list],
disabled: selected != "unread",
},
];
tabs::tabs(
TabsVariant::Line,
Size::Sm,
fandhe_frontend_pre_styled_ui::recipe::ColorPalette::default(),
&props,
items,
)
}
/// 1 版分(`variant`: `"empty"`/`"filled"`/`"filled-unread"`)のトレイ全体を
/// 組み立てる。`total`/`unread`/`all_list`/`unread_list` はタブ内訳
/// ([`tab_group`] へそのまま渡す)。`selected` は SSR 時点の選択状態
/// (`"all"`/`"unread"`)。
#[allow(clippy::too_many_arguments)]
fn tray(
variant: &str,
selected: &'static str,
total: u32,
unread: u32,
all_list: Node,
unread_list: Node,
) -> Node {
let content_id = format!("blocks-notification-tray-tabs-{variant}-content");
let title_id = format!("blocks-notification-tray-tabs-{variant}-title");
let mut bell_children = vec![bell_icon()];
if unread > 0 {
bell_children.push(badge::badge(
&BadgeProps {
variant: BadgeVariant::Solid,
size: Size::Sm,
..BadgeProps::default()
},
vec![("data-blocks-notification-tray-tabs-count", "")],
vec![text(unread.to_string())],
));
}
bell_children.push(visually_hidden::root(vec![], vec![text("通知を開く")]));
// ベルボタン自体は disabled 固定(無 JS で開閉できないため常に開状態
// を表示する。header_menu と同じ判断)。
let trigger = popover::trigger(
OpenState::Open,
true,
Some(content_id.as_str()),
vec![("class", "blocks-notification-tray-tabs-bell")],
bell_children,
);
let header = div(
vec![("class", "blocks-notification-tray-tabs-header")],
vec![
popover::title(Some(title_id.as_str()), vec![], vec![text("通知")]),
header_menu(variant),
],
);
// フッターボタンは遷移先を持たない(無 JS のため実際の一覧ページへの
// 導線がない)。有効な button のまま放置すると押しても何も起きない
// ため、bell trigger・header_menu と同じ判断で disabled 固定にする。
let footer = button(
&ButtonProps {
variant: ButtonVariant::Ghost,
size: Size::Sm,
disabled: true,
..ButtonProps::default()
},
vec![],
vec![text("すべての通知を見る")],
);
let content = popover::content(
OpenState::Open,
Some(content_id.as_str()),
Some(title_id.as_str()),
None,
vec![("data-blocks-notification-tray-tabs-content", "")],
vec![
header,
tab_group(variant, selected, total, unread, all_list, unread_list),
footer,
],
);
let positioner = popover::positioner(OpenState::Open, vec![], vec![content]);
popover::root(OpenState::Open, vec![], vec![trigger, positioner])
}
/// A: 空の状態(R0168)。「すべて」「未読」いずれのタブも空表示(両タブとも
/// 同一の空表示のため、選択状態を変えて 2 枚並記する意味がない)。
fn version_empty() -> Node {
tray("empty", "all", 0, 0, empty_list(), empty_list())
}
/// B: 代表構成(R0169)。通知 4 件(うち未読 3 件)。`variant`/`selected` は
/// 呼び出し側から受け取り、「すべて」選択版と「未読」選択版の 2 インスタンス
/// ([`demo`] 参照)で同じ通知データを使い回す。
fn version_filled(variant: &str, selected: &'static str) -> Node {
let names = dummy_assets::PERSON_NAMES;
let all_list = div(
vec![("class", "blocks-notification-tray-tabs-list")],
vec![
notification_item(names[0], "コメントしました", "5 分前", true),
notification_item(
names[1],
"あなたをレビュアーに追加しました",
"1 時間前",
true,
),
notification_item(names[2], "タスクを完了にしました", "3 時間前", true),
notification_item(names[3], "ドキュメントを更新しました", "昨日", false),
],
);
let unread_list = div(
vec![("class", "blocks-notification-tray-tabs-list")],
vec![
notification_item(names[0], "コメントしました", "5 分前", true),
notification_item(
names[1],
"あなたをレビュアーに追加しました",
"1 時間前",
true,
),
notification_item(names[2], "タスクを完了にしました", "3 時間前", true),
],
);
tray(variant, selected, 4, 3, all_list, unread_list)
}
/// `notification-tray-tabs` の Demo 本体。呼び出しごとに同一の `Node` を
/// 返す純関数。
///
/// docs サイトは無 JS のため `tabs::tabs` は非選択パネルへ `hidden` を
/// 付与し、閲覧者はタブを切り替えられない。「未読」パネルが一度も可視化
/// されないままでは代表構成の内容を確認できないため、`version_filled` を
/// `selected` 違い(`"all"`/`"unread"`)の 2 インスタンス併記にして両パネル
/// を可視化する(`pricing_tiers_comparison`/`pricing_tiers_morph` の
/// 「2 インスタンス併記」と同じ判断、レビュー指摘 PR #3429)。
pub fn demo() -> Node {
div(
vec![("class", "blocks-notification-tray-tabs-stack")],
vec![
version_empty(),
version_filled("filled", "all"),
version_filled("filled-unread", "unread"),
],
)
}原案差分メモ
- 版 A(空の状態、R0168): 「すべて」「未読」いずれのタブ内も
empty-stateを表示します。 - 版 B(代表構成、R0169): 「すべて」タブに通知 4 件(うち未読 3 件)、「未読」タブに未読分のみを表示します。未読件数はタブのバッジと、ベル右上のバッジの両方で示します。
popoverのpositionerは既定でオーバーレイ配置(position: absolute)ですが、本 Demo では掲示用にposition: staticへ中和し、常にフロー内へ表示しています。- 狭幅(コンテナ幅 30rem 未満)ではポップオーバーの中身が枠幅いっぱいに広がります(
@containerによるコンテナクエリ判定)。 - タブは実物の
tabs部品(トリガー 2 個 + パネル切替)を使用しています。無 JS のため選択状態は初期値で固定表示です。 - ベルアイコン・操作メニューのアイコンはいずれも自作の線画(SVG path)で、参照元由来のアイコンセットではありません。
関連情報: Popover / Tabs / Badge / Button / Avatar / Menu / Empty State / Visually Hidden / Icon