Skip to content
fandhe-frontendcore v0.4.3
GitHub

app-shell-stacked-overlap

濃色のヒーロー帯(ナビバー + ページ見出し)の下端へ本文カードが重なるアプリシェルの合成例です。帯にはロゴ・主要ナビ・通知・プロフィールをまとめ、狭い幅ではメニューボタンで開く常時展開パネルへナビを畳みます。新しい UI 部品は作らず、既存部品(navigation-menu/input-group/input/ avatar/menu/button/card/heading/collapsible/icon)のみで構成しています。無 JS の静的な表示で <form> は出力しません。

主参照は対応表 ID R1278(代表構成)、集約元は対応表 ID R1279(帯の配色だけがブランド色になる版)・R1282(ナビが 2 段になり 2 行目に検索欄と主要リンクが入る版)です(出典の固有名・ファイル名は記載しません)。ブランド名・ユーザー名・本文はすべて架空のサンプルです。

Demo

1 段ナビ・中立配色

Fandhe Frontend

ダッシュボード

本日の概況をまとめています。

今月の進捗

先週比 12% 増。目標まで残り 3 タスクです。

次のマイルストーン

10 月 3 日にレビュー予定です。

2 段ナビ・ブランド配色 + 検索

Fandhe Frontend

ダッシュボード(検索対応)

検索欄付きのレイアウト例です(送信先を持たない静的な入力です)。

今月の進捗

先週比 12% 増。目標まで残り 3 タスクです。

次のマイルストーン

10 月 3 日にレビュー予定です。

使用部品

Rust コード

Rust
use fandhe_frontend_core::{a, div, el, header, nav as nav_el, p, span, text, Node};
use fandhe_frontend_pre_styled_ui::avatar::{self, AvatarProps, ImageStatus};
use fandhe_frontend_pre_styled_ui::button::{self, ButtonProps};
use fandhe_frontend_pre_styled_ui::card::{self, CardProps};
use fandhe_frontend_pre_styled_ui::collapsible;
use fandhe_frontend_pre_styled_ui::field::{
    self, FieldIds, FieldOrientation, FieldProps, FieldRootProps,
};
use fandhe_frontend_pre_styled_ui::heading::{heading, HeadingLevel, HeadingProps};
use fandhe_frontend_pre_styled_ui::icon::{icon, IconProps};
use fandhe_frontend_pre_styled_ui::input::{self, InputProps};
use fandhe_frontend_pre_styled_ui::input_group::{self, InputGroupAlign, InputGroupProps};
use fandhe_frontend_pre_styled_ui::menu::{self, OpenState as MenuOpenState};
use fandhe_frontend_pre_styled_ui::navigation_menu::{self, NavigationMenuProps, OpenState};
use fandhe_frontend_pre_styled_ui::visually_hidden;
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";

/// メインナビの項目一覧(value, label, href)。両 variant で共有する。
const NAV_ITEMS: &[(&str, &str, &str)] = &[
    ("overview", "概要", REPO),
    ("projects", "プロジェクト", REPO),
    ("reports", "レポート", REPO),
    ("team", "メンバー", ORG),
];

/// 2 段版の 2 行目(クイックリンク、静的な素の `<a>`)。
const QUICK_LINKS: &[(&str, &str)] = &[("最近の更新", REPO), ("ヘルプ", ORG)];

/// 重なるカードのダミー本文行。
const CARD_ROWS: &[(&str, &str)] = &[
    ("今月の進捗", "先週比 12% 増。目標まで残り 3 タスクです。"),
    ("次のマイルストーン", "10 月 3 日にレビュー予定です。"),
];

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

/// ベル(通知)の幾何図形アイコン。
fn bell_icon() -> Node {
    geo_icon(
        Size::Sm,
        "M12 3a5 5 0 0 0-5 5v3l-2 4h14l-2-4V8a5 5 0 0 0-5-5zM10 18a2 2 0 0 0 4 0h-4z",
    )
}

/// ハンバーガー(3 本線)アイコン。
fn hamburger_icon() -> Node {
    geo_icon(Size::Md, "M3 6h18v2H3zM3 11h18v2H3zM3 16h18v2H3z")
}

/// 検索(虫眼鏡)アイコン。円 + 持ち手のストローク図形のため、`geo_icon`
/// の塗り面(`fill="currentColor"` 既定)ではなく `fill="none"` +
/// `stroke="currentColor"` を明示する(塗りつぶすと円が埋まり持ち手が
/// 消えて検索アイコンに見えなくなるため。`footer_inline_nav::geo_icon`
/// と同型の判断)。
fn search_icon() -> Node {
    icon(
        &IconProps {
            size: Size::Sm,
            label: None,
            ..IconProps::default()
        },
        vec![],
        vec![el(
            "path",
            vec![
                ("d", "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zm9 17-5.2-5.2"),
                ("fill", "none"),
                ("stroke", "currentColor"),
                ("stroke-width", "2"),
                ("stroke-linecap", "round"),
                ("stroke-linejoin", "round"),
            ],
            vec![],
        )],
    )
}

/// ロゴ(幾何図形アイコン + ブランド名テキスト、リンクにしない。
/// `app_shell_navbar_columns::logo` と同型)。
fn logo() -> Node {
    div(
        vec![("data-blocks-app-shell-stacked-overlap-logo", "")],
        vec![
            geo_icon(Size::Md, "M4 4h16v6H4zM4 14h16v6H4z"),
            span(vec![], vec![text("Fandhe Frontend")]),
        ],
    )
}

/// メインナビ本体(`aria-label` は variant ごとに一意にする)。
fn nav(aria_label: &str) -> Node {
    let props = NavigationMenuProps::default();
    navigation_menu::root(
        &props,
        aria_label,
        vec![],
        vec![navigation_menu::list(
            &props,
            vec![],
            NAV_ITEMS
                .iter()
                .map(|(value, label, href)| {
                    navigation_menu::item(
                        OpenState::Closed,
                        false,
                        &props,
                        value,
                        vec![],
                        vec![navigation_menu::link(
                            href,
                            false,
                            vec![],
                            vec![text(*label)],
                        )],
                    )
                })
                .collect(),
        )],
    )
}

/// 2 段版の 2 行目クイックリンク(部品化しない静的なダミーナビ、`href="#"`
/// は使わない)。
fn quick_links(aria_label: &str) -> Node {
    nav_el(
        vec![("aria-label", aria_label)],
        vec![div(
            vec![("data-blocks-app-shell-stacked-overlap-quick-links", "")],
            QUICK_LINKS
                .iter()
                .map(|(label, href)| a(vec![("href", href)], vec![text(*label)]))
                .collect(),
        )],
    )
}

/// 検索欄(可視ラベルの代わりに `visually_hidden` + `<label for>`、
/// `hero_search::search_group` と同型)。送信ボタンは置かない(`<form>`
/// を持たないデモのため)。
fn search_field(suffix: &str) -> Node {
    let field_id = format!("blocks-app-shell-stacked-overlap-search-{suffix}");
    let query_field = FieldProps {
        id: &field_id,
        ids: FieldIds::default(),
        disabled: false,
        invalid: false,
        required: false,
        readonly: false,
        has_helper_text: false,
    };
    let group_props = InputGroupProps {
        disabled: false,
        invalid: false,
    };
    field::root(
        &FieldRootProps {
            orientation: FieldOrientation::Vertical,
        },
        &query_field,
        vec![("data-blocks-app-shell-stacked-overlap-search-field", "")],
        vec![
            visually_hidden::root(
                vec![],
                vec![field::label(
                    &query_field,
                    vec![],
                    vec![text("サイト内検索")],
                )],
            ),
            input_group::root(
                &group_props,
                vec![],
                vec![
                    input_group::addon(
                        InputGroupAlign::InlineStart,
                        &group_props,
                        vec![],
                        vec![search_icon()],
                    ),
                    input::input(
                        &InputProps::default(),
                        &query_field,
                        vec![
                            ("type", "search"),
                            ("autocomplete", "off"),
                            ("placeholder", "検索"),
                        ],
                    ),
                ],
            ),
        ],
    )
}

/// 通知ボタン(無 JS デモのため `disabled: true` 固定)。
fn notify_button() -> Node {
    button::icon_button(
        &ButtonProps {
            disabled: true,
            ..ButtonProps::default()
        },
        "通知を表示",
        vec![("data-blocks-app-shell-stacked-overlap-notify", "")],
        vec![bell_icon()],
    )
}

/// プロフィールメニュー(閉じた `menu`、avatar フォールバック +
/// visually-hidden のアクセシブルネーム。`suffix` で variant 間の id 衝突を
/// 避ける。`sidebar_07::team_switcher`/`user_menu` と同型)。
fn profile_menu(suffix: &str) -> Node {
    let content_id = format!("blocks-app-shell-stacked-overlap-profile-{suffix}");
    let trigger = menu::trigger(
        MenuOpenState::Closed,
        true,
        Some(content_id.as_str()),
        vec![("data-blocks-app-shell-stacked-overlap-profile", "")],
        vec![
            avatar::root(
                &AvatarProps::default(),
                vec![],
                vec![avatar::fallback(
                    ImageStatus::Error,
                    vec![],
                    vec![text("YT")],
                )],
            ),
            visually_hidden::root(vec![], vec![text("アカウントメニューを開く")]),
        ],
    );
    let content = menu::content(
        MenuOpenState::Closed,
        Some(content_id.as_str()),
        None,
        vec![],
        vec![
            menu::item("profile", 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(MenuOpenState::Closed, vec![], vec![content]);
    menu::root(
        Size::Sm,
        MenuOpenState::Closed,
        vec![],
        vec![trigger, positioner],
    )
}

/// アクション行(通知 + プロフィールメニュー)。
fn actions(suffix: &str) -> Node {
    div(
        vec![("data-blocks-app-shell-stacked-overlap-actions", "")],
        vec![notify_button(), profile_menu(suffix)],
    )
}

/// ハンバーガートリガー(狭い幅専用、常時展開の [`mobile_panel`] を
/// `aria-controls` で指す。`header_simple_bar::hamburger` と同型で
/// `disabled: true` 固定 — 押しても何も起きない静的表示のため、`aria-label`
/// は「開く」等の実行可能な操作を示唆しない名称にする(イシュー #2897
/// codex レビュー指摘)。
fn hamburger(panel_id: &str) -> Node {
    collapsible::trigger(
        OpenState::Open,
        true,
        Some(panel_id),
        vec![
            ("aria-label", "メニュー"),
            ("data-blocks-app-shell-stacked-overlap-toggle", ""),
        ],
        vec![hamburger_icon()],
    )
}

/// 常時展開のドロップダウンパネル(狭い幅専用、[`shell`] の呼び出し元が
/// ナビ(clone)と、`id` 重複を避けて別 suffix で組み直したアクション・
/// 検索欄・行 2 を渡す)。
fn mobile_panel(panel_id: &str, children: Vec<Node>) -> Node {
    collapsible::content(
        OpenState::Open,
        true,
        Some(panel_id),
        vec![("data-blocks-app-shell-stacked-overlap-panel", "")],
        children,
    )
}

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

/// 重なる本文カード群。
fn body_cards() -> Node {
    div(
        vec![("data-blocks-app-shell-stacked-overlap-body", "")],
        CARD_ROWS
            .iter()
            .map(|(title, body)| {
                card::root(
                    CardProps::default(),
                    vec![],
                    vec![
                        card::header(vec![], vec![card::title(vec![], vec![text(*title)])]),
                        card::body(vec![], vec![p(vec![], vec![text(*body)])]),
                    ],
                )
            })
            .collect(),
    )
}

/// 1 本の shell(モジュール doc「2 variant の並記」節参照)。
fn shell(
    variant: &str,
    root_label: &str,
    nav_label: &str,
    quick_links_label: Option<&str>,
    page_title: &str,
    page_summary: &str,
) -> Node {
    let panel_suffix = format!("{variant}-panel");
    let panel_id = format!("blocks-app-shell-stacked-overlap-panel-{variant}");
    // nav・クイックリンクは `id` を持たないため clone で共有できるが、
    // プロフィールメニュー(`menu::content` の `id`)・検索欄(`field` の
    // `id`)はそれぞれ固有 `id` を出力するため、パネル側は clone せず
    // 別 suffix(`panel_suffix`)で独立して組み立てる(重複 `id` の禁止、
    // `blocks_contract.rs::demo_output_has_no_dangling_aria_references_or_duplicate_ids`
    // 参照)。
    let nav_node = nav(nav_label);
    let nav_wrap = div(
        vec![("data-blocks-app-shell-stacked-overlap-nav-wrap", "")],
        vec![nav_node.clone()],
    );
    let actions_wrap = div(
        vec![("data-blocks-app-shell-stacked-overlap-actions-wrap", "")],
        vec![actions(variant)],
    );

    let mut panel_children = vec![nav_node, actions(&panel_suffix)];

    let bar = header(
        vec![("data-blocks-app-shell-stacked-overlap-bar", "")],
        vec![logo(), nav_wrap, actions_wrap, hamburger(&panel_id)],
    );

    let mut band_children = vec![bar];

    if let Some(quick_links_label) = quick_links_label {
        let quick_links_node = quick_links(quick_links_label);
        let row2_wrap = div(
            vec![("data-blocks-app-shell-stacked-overlap-row2", "")],
            vec![search_field(variant), quick_links_node.clone()],
        );
        band_children.push(row2_wrap);
        panel_children.push(search_field(&panel_suffix));
        panel_children.push(quick_links_node);
    }

    band_children.push(mobile_panel(&panel_id, panel_children));

    band_children.push(div(
        vec![("data-blocks-app-shell-stacked-overlap-heading-row", "")],
        vec![
            heading(
                HeadingLevel::H3,
                &HeadingProps::default(),
                vec![],
                vec![text(page_title)],
            ),
            p(vec![], vec![text(page_summary)]),
        ],
    ));

    div(
        vec![
            ("tabindex", "0"),
            ("role", "region"),
            ("aria-label", root_label),
            ("data-blocks-app-shell-stacked-overlap-root", ""),
            ("data-blocks-app-shell-stacked-overlap-variant", variant),
        ],
        vec![
            div(
                vec![("data-blocks-app-shell-stacked-overlap-band", "")],
                band_children,
            ),
            body_cards(),
        ],
    )
}

/// `app-shell-stacked-overlap` の Demo 本体。2 variant を縦に並記する
/// 純関数。
pub fn demo() -> Node {
    div(
        vec![("class", "blocks-app-shell-stacked-overlap-stack")],
        vec![
            caption("1 段ナビ・中立配色"),
            shell(
                "single-row",
                "アプリシェル(1 段ナビ)",
                "アプリ(1 段)",
                None,
                "ダッシュボード",
                "本日の概況をまとめています。",
            ),
            caption("2 段ナビ・ブランド配色 + 検索"),
            shell(
                "two-row-search",
                "アプリシェル(2 段ナビ + 検索)",
                "アプリ(2 段)",
                Some("クイックリンク"),
                "ダッシュボード(検索対応)",
                "検索欄付きのレイアウト例です(送信先を持たない静的な入力です)。",
            ),
        ],
    )
}

集約元との差分メモ

  • 集約元 2 件を 2 variant(1 段ナビ・中立配色 / 2 段ナビ・ブランド配色 + 検索)へ集約しました。1 段目が R1278(主参照)、2 段目が R1279(帯の配色だけがブランド色になる版)・R1282(ナビが 2 段になり 2 行目に検索欄と主要リンクが入る版)に対応します。
  • 帯の下端には常に本文カードが重なります。重なり量は Demo 枠の幅(コンテナクエリ)が広いほど大きくなります。
  • 集約元の配色・文言・アイコン・ハンバーガーメニュー・ドロップダウン開閉は持ち込んでいません。ナビバーは常に主要リンクが並んだ状態を表示し、狭い幅では常時展開のメニューパネルへ切り替わります。
  • 通知ボタン・プロフィールメニューのトリガーは押しても何も起きないため、いずれも disabled にして固定し、フォーカス・操作不能であることを明示しています。
  • 検索欄は送信先を持たない静的な入力で、送信ボタンは置いていません。
  • 列切り替え・重なり量の判定はビューポート幅ではなく Demo 枠自体の幅(コンテナクエリ)を基準にしています。Demo 枠の幅を 48rem(768px 相当)以上に広げると、重なりが大きくなりデスクトップ用ナビが表示されます。
  • 文言・アイコン・ユーザー名はすべて独自に書いた架空のものです。配色・余白・角丸は既存のテーマトークンに従っています。

関連情報: Navigation Menu / Input Group / Input / Avatar / Menu / Button / Card / Heading / Collapsible / Icon