Skip to content
fandhe-frontendcore v0.4.3
GitHub

app-shell-stacked

fandhe-frontend-pre-styled-ui の navigation-menu / breadcrumb / avatar / menu / button / heading / separator / card / icon / collapsible 部品を合成した、縦 3 段のアプリケーションシェルです(上から水平ナビバー・見出し帯・メイン領域)。Blocks セクションは新規部品を追加するものではなく、既存の Themes/Primitives 部品を組み合わせた実例集であることに注意してください(主参照は対応表 ID R1274。出典の固有名・ファイル名は記載しません)。

見出し帯には「ナビバーと一体化した版」「独立した白帯の版」の 2 系統があり、違いは面色・境界線・余白のみです。Demo は次の 4 variant を並記します: 一体型見出し帯(見出し + アクションボタン)、独立白帯・パンくず付き(breadcrumb + 見出し + アクション)、独立白帯・タブ風ナビ(見出しの下に navigation-menu を並べ、先頭タブを aria-current="page" にする)、見出し帯なし・フッター付き(メイン下部に区切り線とフッター文言)です。

狭い幅(Demo 枠の幅基準の container query、48rem 未満)ではデスクトップ用のメインナビを隠し、ハンバーガートリガーのみを表示します。ハンバーガーは常時展開のパネルへ aria-controls で関連付けられており、パネルにはナビの複製だけを入れます(プロフィールメニューは id を持つため複製すると重複 id になるので、通知ボタン・プロフィールメニューはバー内へ全幅で常時表示します)。

本 Demo は静的な表示例であり、docs サイトは JS ハイドレーションを行わないため、押しても何も起きないボタン(通知・プロフィールメニュー・見出しのアクション・ハンバーガートリガー)はすべて無効化して操作不能であることを明示しています。<form> 要素は一切持たず、データの取得・送信・状態管理を行いません。ボタンは type="button" のまま送信先を持ちません。文言はすべて独自に書いた架空のものであり、実企業名・実クレデンシャル・PII を含みません。実際に使うときはリンク先・文言を差し替えてください。

Demo

一体型見出し帯(unified)

Fandhe Console

プロジェクト

最近のアクティビティ

ここにページ固有のコンテンツが表示されます(架空のダミー本文)。

独立した白帯・パンくず付き(separate-breadcrumb)

Fandhe Console

プロジェクト

最近のアクティビティ

ここにページ固有のコンテンツが表示されます(架空のダミー本文)。

独立した白帯・タブ風ナビ(separate-tabs)

Fandhe Console

プロジェクト

最近のアクティビティ

ここにページ固有のコンテンツが表示されます(架空のダミー本文)。

見出し帯なし・フッター付き(no-heading-footer)

Fandhe Console

最近のアクティビティ

ここにページ固有のコンテンツが表示されます(架空のダミー本文)。

© 2026 Fandhe Console. 架空のダミーフッターです。

使用部品

Rust コード

Rust
use fandhe_frontend_core::{div, el, header, p, section, span, text, Node};
use fandhe_frontend_pre_styled_ui::avatar::{self, AvatarProps, ImageStatus};
use fandhe_frontend_pre_styled_ui::breadcrumb::{self, BreadcrumbVariant};
use fandhe_frontend_pre_styled_ui::button::{self, ButtonProps};
use fandhe_frontend_pre_styled_ui::card;
use fandhe_frontend_pre_styled_ui::collapsible;
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::menu::{self, OpenState};
use fandhe_frontend_pre_styled_ui::navigation_menu::{self, NavigationMenuProps};
use fandhe_frontend_pre_styled_ui::separator::{self, SeparatorProps};
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)。
const NAV_ITEMS: &[(&str, &str, &str)] = &[
    ("overview", "概要", REPO),
    ("projects", "プロジェクト", REPO),
    ("members", "メンバー", ORG),
];

/// 装飾用の自作幾何アイコン(実在ブランドのロゴを模さない、`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-app-shell-stacked-logo", "")],
        vec![
            geo_icon("M4 4h16v6H4zM4 14h16v6H4z"),
            span(vec![], vec![text("Fandhe Console")]),
        ],
    )
}

/// メインナビ本体(`aria_label` は variant ごとに一意にする。デスクトップ
/// 用と [`hamburger_panel`] への clone とで 2 回出るが、非表示側は
/// `display: none` で a11y ツリーから除外されるため実害を持たない)。
fn nav(aria_label: &str) -> Node {
    let props = NavigationMenuProps::default();
    navigation_menu::root(
        &props,
        aria_label,
        vec![("data-blocks-app-shell-stacked-nav", "")],
        vec![navigation_menu::list(
            &props,
            vec![],
            NAV_ITEMS
                .iter()
                .map(|(value, label, href)| {
                    navigation_menu::item(
                        navigation_menu::OpenState::Closed,
                        false,
                        &props,
                        value,
                        vec![],
                        vec![navigation_menu::link(
                            href,
                            false,
                            vec![],
                            vec![text(*label)],
                        )],
                    )
                })
                .collect(),
        )],
    )
}

/// 通知ボタン(無 JS のため `disabled: true` 固定、アクセシブルネーム付き)。
fn notification_button() -> Node {
    button::icon_button(
        &ButtonProps {
            disabled: true,
            ..ButtonProps::default()
        },
        "Notifications",
        vec![("data-blocks-app-shell-stacked-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-app-shell-stacked-user-menu-{variant}");
    let trigger = menu::trigger(
        OpenState::Closed,
        true,
        Some(content_id.as_str()),
        vec![
            ("aria-label", "Open user menu"),
            ("data-blocks-app-shell-stacked-user-trigger", ""),
        ],
        vec![avatar::root(
            &AvatarProps::default(),
            vec![],
            vec![avatar::fallback(
                ImageStatus::Error,
                vec![],
                vec![text("AR")],
            )],
        )],
    );
    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-app-shell-stacked-toggle", ""),
        ],
        vec![geo_icon("M3 6h18v2H3zM3 11h18v2H3zM3 16h18v2H3z")],
    )
}

/// 常時展開のハンバーガーパネル(ナビの clone のみを持つ、モジュール doc
/// 「パネルへ clone するのはナビだけ」節参照)。
fn hamburger_panel(panel_id: &str, nav_node: Node) -> Node {
    collapsible::content(
        collapsible::OpenState::Open,
        true,
        Some(panel_id),
        vec![("data-blocks-app-shell-stacked-panel", "")],
        vec![nav_node],
    )
}

/// 上部の水平ナビバー(ロゴ・デスクトップ用ナビ・通知・プロフィール・
/// ハンバーガー、常時展開パネルを併設する)。
fn top_bar(variant: &str) -> Node {
    let panel_id = format!("ass-panel-{variant}");
    let aria_label = format!("メインナビゲーション({variant})");
    let nav_node = nav(&aria_label);
    let nav_wrap = div(
        vec![("data-blocks-app-shell-stacked-nav-wrap", "")],
        vec![nav_node.clone()],
    );
    div(
        vec![("data-blocks-app-shell-stacked-bar", "")],
        vec![
            header(
                vec![
                    ("data-blocks-app-shell-stacked-root", ""),
                    ("data-blocks-app-shell-stacked-variant", variant),
                ],
                vec![
                    logo(),
                    nav_wrap,
                    div(
                        vec![("data-blocks-app-shell-stacked-actions", "")],
                        vec![notification_button(), user_menu(variant)],
                    ),
                    hamburger(&panel_id),
                ],
            ),
            hamburger_panel(&panel_id, nav_node),
        ],
    )
}

/// アクションボタン(見出し帯の右側、無 JS のため `disabled: true` 固定)。
fn heading_action() -> Node {
    button::button(
        &ButtonProps {
            disabled: true,
            ..ButtonProps::default()
        },
        vec![("data-blocks-app-shell-stacked-heading-action", "")],
        vec![text("新規作成")],
    )
}

/// D/F と同型のパンくず(Home → Blocks → 現在ページ)。
fn breadcrumb_nav() -> Node {
    breadcrumb::root(
        Size::Md,
        BreadcrumbVariant::default(),
        Some("Breadcrumb example"),
        vec![],
        vec![breadcrumb::list(
            vec![],
            vec![
                breadcrumb::item(
                    vec![],
                    vec![breadcrumb::link("../../", vec![], vec![text("Home")])],
                ),
                breadcrumb::separator(vec![], vec![text("/")]),
                breadcrumb::item(
                    vec![],
                    vec![breadcrumb::link("../", vec![], vec![text("Blocks")])],
                ),
                breadcrumb::separator(vec![], vec![text("/")]),
                breadcrumb::item(
                    vec![],
                    vec![breadcrumb::current_link(vec![], vec![text("プロジェクト")])],
                ),
            ],
        )],
    )
}

/// `separate-tabs` variant のタブ風ナビ(先頭を `aria-current="page"`)。
/// 現在ページタブ(概要)はサイト内相対パス `./`(自ページ)へリンクし、
/// 他タブは表示内容に対応する実在 URL(アクティビティ→コミット履歴、
/// 設定→組織ページ、`app_shell_navbar_columns.rs` の「設定」→ORG と同じ
/// 対応)を持つ(codex レビュー是正、`href` が全タブ同一の REPO 固定で
/// 現在ページタブをクリックすると別ページへ遷移してしまう問題を修正)。
fn tabs_nav() -> Node {
    let props = NavigationMenuProps::default();
    let activity_href = format!("{REPO}/commits");
    let items: [(&str, &str, bool, &str); 3] = [
        ("overview", "概要", true, "./"),
        ("activity", "アクティビティ", false, activity_href.as_str()),
        ("settings", "設定", false, ORG),
    ];
    navigation_menu::root(
        &props,
        "セクションタブ",
        vec![("data-blocks-app-shell-stacked-tabs", "")],
        vec![navigation_menu::list(
            &props,
            vec![],
            items
                .into_iter()
                .map(|(value, label, current, href)| {
                    navigation_menu::item(
                        navigation_menu::OpenState::Closed,
                        false,
                        &props,
                        value,
                        vec![],
                        vec![navigation_menu::link(
                            href,
                            current,
                            vec![],
                            vec![text(label)],
                        )],
                    )
                })
                .collect(),
        )],
    )
}

/// 見出し帯(`heading_kind` で lead 要素を切り替える。`None` は
/// `no-heading-footer` 用で帯自体を出さない)。
fn heading_band(variant: &str, heading_kind: Option<&str>, title: &'static str) -> Option<Node> {
    // `None` は `no-heading-footer` 用で見出し帯そのものを出さない
    // (`Some("plain")` は unified 用で lead 要素なし)。
    let heading_kind = heading_kind?;
    let lead = match heading_kind {
        "breadcrumb" => Some(breadcrumb_nav()),
        _ => None,
    };
    // 上段(見出し + アクション。`breadcrumb` kind はここへパンくずも含める)
    // と下段(`tabs` kind のみのタブ風ナビ)の 2 行へ分ける。狭幅で
    // タブナビが見出しと同一行に押し込まれて overflow: hidden により
    // 切り取られるのを避けるため(Bugbot Medium/codex P1 是正)。
    let mut top_row: Vec<Node> = Vec::new();
    if let Some(lead) = lead {
        top_row.push(lead);
    }
    top_row.push(heading(
        HeadingLevel::H3,
        &HeadingProps::default(),
        vec![],
        vec![text(title)],
    ));
    top_row.push(heading_action());
    let mut children = vec![div(
        vec![("data-blocks-app-shell-stacked-heading-row", "")],
        top_row,
    )];
    if heading_kind == "tabs" {
        children.push(tabs_nav());
    }
    Some(div(
        vec![
            ("data-blocks-app-shell-stacked-heading", ""),
            ("data-blocks-app-shell-stacked-variant", variant),
        ],
        children,
    ))
}

/// メイン領域(`card` 1 枚。`no-heading-footer` のみ下部にフッターを持つ)。
/// `aria-label` は variant ごとに一意にする(4 variant すべてが同一の
/// 「メインコンテンツ」のままだとランドマークが区別できない、Cursor
/// Bugbot 指摘の是正。`app-shell-navbar-columns` は単一インスタンスの
/// ため同種の問題を持たない)。
fn main_section(variant: &str, with_footer: bool) -> Node {
    let mut children = vec![card::root(
        card::CardVariant::Outline,
        vec![],
        vec![
            card::header(
                vec![],
                vec![card::title(vec![], vec![text("最近のアクティビティ")])],
            ),
            card::body(
                vec![],
                vec![p(
                    vec![],
                    vec![text(
                        "ここにページ固有のコンテンツが表示されます(架空のダミー本文)。",
                    )],
                )],
            ),
        ],
    )];
    if with_footer {
        children.push(separator::separator(&SeparatorProps::default(), vec![]));
        children.push(p(
            vec![("data-blocks-app-shell-stacked-footer", "")],
            vec![text("© 2026 Fandhe Console. 架空のダミーフッターです。")],
        ));
    }
    let aria_label = format!("メインコンテンツ({variant})");
    section(
        vec![
            ("aria-label", aria_label.as_str()),
            ("data-blocks-app-shell-stacked-main", ""),
            ("data-blocks-app-shell-stacked-variant", variant),
        ],
        children,
    )
}

/// 1 variant 分のシェル一式(バー + 見出し帯 + メイン)を組み立てる。
fn shell(
    variant: &'static str,
    heading_kind: Option<&'static str>,
    title: &'static str,
    with_footer: bool,
) -> Node {
    let mut children = vec![top_bar(variant)];
    if let Some(band) = heading_band(variant, heading_kind, title) {
        children.push(band);
    }
    children.push(main_section(variant, with_footer));
    div(
        vec![
            ("data-blocks-app-shell-stacked-shell", ""),
            ("data-blocks-app-shell-stacked-variant", variant),
        ],
        children,
    )
}

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

/// `app-shell-stacked` の Demo 本体。4 variant を縦に並記する純関数。
pub fn demo() -> Node {
    div(
        vec![("class", "blocks-app-shell-stacked-stack")],
        vec![
            caption("一体型見出し帯(unified)"),
            shell("unified", Some("plain"), "プロジェクト", false),
            caption("独立した白帯・パンくず付き(separate-breadcrumb)"),
            shell(
                "separate-breadcrumb",
                Some("breadcrumb"),
                "プロジェクト",
                false,
            ),
            caption("独立した白帯・タブ風ナビ(separate-tabs)"),
            shell("separate-tabs", Some("tabs"), "プロジェクト", false),
            caption("見出し帯なし・フッター付き(no-heading-footer)"),
            shell("no-heading-footer", None, "プロジェクト", true),
        ],
    )
}

集約元との差分メモ

  • 主参照 R1274(一体型見出し帯)を代表形とし、配色・余白違いのみの R1275/R1276/R1277/R1280/R1281 の 5 件は独立したインスタンスにせず、既存のテーマトークンへ統一しました。
  • R0389(パンくず + アクション)は独立白帯・パンくず付き variant として、R0390(タブ風ナビ)は独立白帯・タブ風ナビ variant として、それぞれ実装しています。
  • R0138(見出しなし + フッター付き骨格)は見出し帯なし・フッター付き variant に対応します。
  • R0391(パンくず + 検索欄)は独立の variant にしていません。input は本 block が使う部品一覧に含まれておらず、無 JS デモに送信先のない検索入力を置くのを避けるためです。見た目としては、独立白帯・パンくず付き variant の右側アクションを検索欄へ差し替えたものに相当します。
  • 狭い幅(Demo 枠基準の container query、48rem 未満)ではデスクトップ用のメインナビを隠し、常時展開のハンバーガーパネル(ナビの複製)へ委ねます。プロフィールメニューは id を持つため複製の対象にせず、バー内へ全幅で常時表示しています。
  • 通知・プロフィールメニュー・見出しのアクション・ハンバーガートリガーはすべて無効化して押しても何も起きないことを明示しています。
  • 文言・アイコンはすべて独自に書いた架空のものです。配色・余白・角丸は既存のテーマトークンに従っています。

関連情報: Navigation Menu / Breadcrumb / Avatar / Menu / Button / Heading / Separator / Card / Icon / Collapsible