Skip to content
fandhe-frontendcore v0.4.3
GitHub

navbar-app-links

fandhe-frontend-pre-styled-ui の navigation-menu / button / avatar / menu / badge / icon を合成した 1 段のアプリ用ナビバーです(左にロゴと主ナビ、右に主操作・通知・アバターメニュー)。collapsible は指定外ですが、狭幅到達性の確立済みパターン(ハンバーガー + 常時展開パネル)を再利用するために使います。既存の Themes/Primitives 部品の合成例です(主参照は対応表 ID R1087。出典の固有名は記載しません)。

現在地リンクは「ピル型」「下線型」の 2 種類があり、Demo は 3 インスタンスを並記します: ピル型・主操作ボタン・受信箱の件数バッジ付き、下線型・ナビのみ、狭幅でハンバーガーパネルを常時展開した状態(ピル型と同じ構成)。

狭い幅(Demo 枠基準の container query、48rem 未満)ではデスクトップ用ナビを隠し、ハンバーガーのみ表示します。パネルは aria-controls で常時展開のナビ複製のみを持ち、id を持つアバターメニューはバー内へ常時表示のまま複製しません。

本 Demo は静的表示例です。docs サイトは JS ハイドレーションを行わないため操作系ボタンはすべて無効化しています。<form> は使わず、ボタンは type="button" で送信先を持ちません。文言はすべて架空のものです。

Demo

使用部品

Rust コード

Rust
use fandhe_frontend_core::{div, el, header, span, text, Node};
use fandhe_frontend_pre_styled_ui::avatar::{self, AvatarProps, ImageStatus};
use fandhe_frontend_pre_styled_ui::badge::{self, BadgeProps};
use fandhe_frontend_pre_styled_ui::button::{self, ButtonProps};
use fandhe_frontend_pre_styled_ui::collapsible;
use fandhe_frontend_pre_styled_ui::icon::{icon, IconProps};
use fandhe_frontend_pre_styled_ui::menu::{self, OpenState};
use fandhe_frontend_pre_styled_ui::navigation_menu::{self, NavigationMenuProps};
use fandhe_frontend_pre_styled_ui::Size;

/// 実在の自リポジトリ URL(`href` の方針、モジュール doc 参照)。
const REPO: &str = "https://github.com/Fandhe-AI/fandhe-frontend";
/// 実在の自組織 URL。
const ORG: &str = "https://github.com/Fandhe-AI";

/// 装飾用の自作幾何アイコン(実在ブランドのロゴを模さない、`label: None`)。
fn geo_icon(d: &'static str) -> Node {
    icon(
        &IconProps {
            label: None,
            ..IconProps::default()
        },
        vec![],
        vec![el("path", vec![("d", d)], vec![])],
    )
}

/// ロゴ(幾何図形 + ブランド名テキスト)。
fn logo() -> Node {
    span(
        vec![("data-blocks-navbar-app-links-logo", "")],
        vec![
            geo_icon("M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z"),
            span(vec![], vec![text("Fandhe Console")]),
        ],
    )
}

/// メインナビ本体(`aria_label` は variant ごとに一意にする。デスクトップ
/// 用と [`hamburger_panel`] への clone とで 2 回出るが、非表示側は
/// `display: none` で a11y ツリーから除外されるため実害を持たない)。
/// `with_badge` は「受信箱」リンクへ件数 [`badge`] を付けるかどうか
/// (R0578 の集約対応、対応表参照)。
fn nav(aria_label: &str, with_badge: bool) -> Node {
    let props = NavigationMenuProps::default();
    let mut inbox_children: Vec<Node> = vec![text("受信箱")];
    if with_badge {
        inbox_children.push(badge::badge(
            &BadgeProps::default(),
            vec![("data-blocks-navbar-app-links-count", "")],
            vec![text("3")],
        ));
    }
    navigation_menu::root(
        &props,
        aria_label,
        vec![("data-blocks-navbar-app-links-nav", "")],
        vec![navigation_menu::list(
            &props,
            vec![],
            vec![
                navigation_menu::item(
                    navigation_menu::OpenState::Closed,
                    false,
                    &props,
                    "dashboard",
                    vec![],
                    vec![navigation_menu::link(
                        "./",
                        true,
                        vec![],
                        vec![text("ダッシュボード")],
                    )],
                ),
                navigation_menu::item(
                    navigation_menu::OpenState::Closed,
                    false,
                    &props,
                    "projects",
                    vec![],
                    vec![navigation_menu::link(
                        REPO,
                        false,
                        vec![],
                        vec![text("プロジェクト")],
                    )],
                ),
                navigation_menu::item(
                    navigation_menu::OpenState::Closed,
                    false,
                    &props,
                    "inbox",
                    vec![],
                    vec![navigation_menu::link(ORG, false, vec![], inbox_children)],
                ),
            ],
        )],
    )
}

/// 主操作ボタン(`pill`/`narrow` のみ、無 JS のため `disabled: true` 固定)。
fn primary_action() -> Node {
    button::button(
        &ButtonProps {
            disabled: true,
            ..ButtonProps::default()
        },
        vec![("data-blocks-navbar-app-links-cta", "")],
        vec![text("新規作成")],
    )
}

/// 通知ボタン(無 JS のため `disabled: true` 固定、アクセシブルネーム付き)。
fn notification_button() -> Node {
    button::icon_button(
        &ButtonProps {
            disabled: true,
            ..ButtonProps::default()
        },
        "Notifications",
        vec![("data-blocks-navbar-app-links-notify", "")],
        vec![geo_icon("M6 8a6 6 0 0 1 12 0v4l2 4H4l2-4z")],
    )
}

/// アバターメニュー(`menu::trigger` を `disabled: true` 固定にし、中に
/// [`avatar`] を入れる。`content_id` は variant ごとに一意にする)。
fn user_menu(variant: &str) -> Node {
    let content_id = format!("blocks-navbar-app-links-user-menu-{variant}");
    let trigger = menu::trigger(
        OpenState::Closed,
        true,
        Some(content_id.as_str()),
        vec![
            ("aria-label", "Open user menu"),
            ("data-blocks-navbar-app-links-user-trigger", ""),
        ],
        vec![avatar::root(
            &AvatarProps::default(),
            vec![],
            vec![avatar::fallback(
                ImageStatus::Error,
                vec![],
                vec![text("YK")],
            )],
        )],
    );
    let content = menu::content(
        OpenState::Closed,
        Some(content_id.as_str()),
        None,
        vec![],
        vec![
            menu::item("account", false, false, vec![], vec![text("アカウント")]),
            menu::item("settings", false, false, vec![], vec![text("設定")]),
            menu::separator(vec![], vec![]),
            menu::item("logout", false, false, vec![], vec![text("ログアウト")]),
        ],
    );
    let positioner = menu::positioner(OpenState::Closed, vec![], vec![content]);
    menu::root(
        Size::Sm,
        OpenState::Closed,
        vec![],
        vec![trigger, positioner],
    )
}

/// ハンバーガートリガー(狭い幅専用、[`hamburger_panel`] を
/// `aria-controls` で指す。押しても何も起きないため `disabled: true`
/// 固定だが、`OpenState::Open` によりパネル自体は常に到達可能)。
fn hamburger(panel_id: &str) -> Node {
    collapsible::trigger(
        collapsible::OpenState::Open,
        true,
        Some(panel_id),
        vec![
            ("aria-label", "Open main menu"),
            ("data-blocks-navbar-app-links-toggle", ""),
        ],
        vec![geo_icon("M3 6h18v2H3zM3 11h18v2H3zM3 16h18v2H3z")],
    )
}

/// 常時展開のハンバーガーパネル(ナビの clone のみを持つ、モジュール doc
/// 「id と ARIA の一意性」節参照)。
fn hamburger_panel(panel_id: &str, nav_node: Node) -> Node {
    collapsible::content(
        collapsible::OpenState::Open,
        true,
        Some(panel_id),
        vec![("data-blocks-navbar-app-links-panel", "")],
        vec![nav_node],
    )
}

/// 1 variant 分のナビバー一式(バー + 常時展開パネル)を組み立てる。
/// `current_style` は `"pill"`/`"underline"`(見せ方の切り替え、[`LAYOUT_CSS`]
/// が data 属性で分岐する)、`with_cta` は主操作ボタンの有無、`with_badge`
/// は受信箱の件数バッジの有無、`narrow` はラッパーを狭幅に固定するか。
fn bar(
    variant: &'static str,
    current_style: &'static str,
    with_cta: bool,
    with_badge: bool,
    narrow: bool,
) -> Node {
    let panel_id = format!("nal-panel-{variant}");
    let aria_label = format!("メインナビゲーション({variant})");
    let nav_node = nav(&aria_label, with_badge);
    let nav_wrap = div(
        vec![("data-blocks-navbar-app-links-nav-wrap", "")],
        vec![nav_node.clone()],
    );
    let mut actions: Vec<Node> = Vec::new();
    if with_cta {
        actions.push(primary_action());
    }
    actions.push(notification_button());
    actions.push(user_menu(variant));

    let mut frame_attrs = vec![
        ("data-blocks-navbar-app-links-shell", ""),
        // 狭幅ではハンバーガーパネルが root(header)の外側(兄弟)に置かれる
        // ため、current-style は root ではなくパネルとの共通祖先である shell
        // へ付ける。LAYOUT_CSS の current-style セレクタは shell を祖先に
        // 見るため、この属性位置がパネル内リンクへも下線/ピルを届かせる
        // 前提になる(イシュー #2926 レビュー指摘: root 限定だと狭幅表示で
        // 現在地スタイルがパネル内に反映されない)。
        ("data-blocks-navbar-app-links-current-style", current_style),
    ];
    if narrow {
        frame_attrs.push(("data-blocks-navbar-app-links-frame", "narrow"));
    }

    div(
        frame_attrs,
        vec![
            header(
                vec![
                    ("data-blocks-navbar-app-links-root", ""),
                    ("data-blocks-navbar-app-links-variant", variant),
                ],
                vec![
                    logo(),
                    nav_wrap,
                    div(vec![("data-blocks-navbar-app-links-actions", "")], actions),
                    hamburger(&panel_id),
                ],
            ),
            hamburger_panel(&panel_id, nav_node),
        ],
    )
}

/// caption(並記された各 variant の見出し)。
fn caption(label: &'static str) -> Node {
    fandhe_frontend_core::p(
        vec![("class", "blocks-navbar-app-links-caption")],
        vec![text(label)],
    )
}

/// `navbar-app-links` の Demo 本体。3 variant を縦に並記する純関数。
pub fn demo() -> Node {
    div(
        vec![("class", "blocks-navbar-app-links-stack")],
        vec![
            caption("現在地をピル型で示す・主操作ボタン付き(pill)"),
            bar("pill", "pill", true, true, false),
            caption("現在地を下線で示す・ナビのみ(underline)"),
            bar("underline", "underline", false, false, false),
            caption("狭幅でハンバーガーパネルを常時展開した状態(narrow)"),
            bar("narrow", "pill", true, true, true),
        ],
    )
}

集約元との差分メモ

  • 主参照 R1087(ピル型代表構成)を基準に、色調違いのみの集約元(暗色系 R1084/R1085/R1086、明色系 R1088/R1089)はテーマの明暗切り替えへ統一し独立インスタンスにしていません。
  • R1084/R1089(主操作ボタン付き)は「ピル型」に、R1086/R1088(ナビのみ)は「下線型」に対応します。
  • R0578(件数バッジ付きリンク)はピル型・narrow のみが持つ受信箱の件数 badge に、R0579(現在地リンクの見せ方違い)はピル型・下線型の並記に対応します。
  • ハンバーガーパネルはナビの複製のみを持ち、id を持つアバターメニューは複製せずバー内へ常時表示します。操作系ボタンはすべて無効化しています。

関連情報: Navigation Menu / Button / Avatar / Menu / Badge / Icon / Collapsible