Skip to content
fandhe-frontendcore v0.4.3
GitHub

app-shell-three-column

fandhe-frontend-pre-styled-ui の sidebar / avatar / button / icon / input-group / input / menu / visually-hidden の 8 部品を合成した、左の固定サイドバー・メイン・補助カラム(一覧/詳細用)の 3 列アプリシェルの合成例です。Blocks セクションは新規部品を追加するものではなく、既存の Themes/Primitives 部品を組み合わせた実例集であることに注意してください。本 Demo は認証・送信処理・データ取得を一切行わない静的な表示例です(対応表 ID: R1078・R1079・R1082・R1083・R0136)。

docs サイトは JS ハイドレーションを行わないため、次の 3 つの状態を静的に固定して縦に並べて掲示します。

  • 幅広(サイドバー展開 + 補助カラム): サイドバー(展開)+ メイン + 補助カラム(一覧用、右)。ヘッダーバーなし
  • アイコンのみの狭サイドバー + ヘッダー: サイドバーをアイコンのみへ折りたたみ、上端に検索欄・通知ボタン・プロフィールメニューを持つヘッダーバーを追加。メイン + 補助カラム(詳細用、右)
  • 狭幅(サイドバー折りたたみ、補助カラムなし): サイドバーを Collapsed + Offcanvas で隠し、ヘッダー左端に sidebar::trigger(メニューボタン)のみを出したもの。補助カラムは表示しません

補助カラムは「幅広」「アイコン」の 2 状態でのみ表示し、「狭幅」では表示しません(広い画面でのみ補助情報を出す設計を、状態を固定した静的 variant で表現しています)。「幅広」と「アイコン」の差分はヘッダーバーの有無だけでなく、サイドバーも展開(Expanded)からアイコンのみの折りたたみ(Collapsed + Icon)へ変化します。検索・通知・プロフィールを持つヘッダー付き版は、この折りたたみ済みサイドバーへヘッダーバーを足した骨格であることを示します。

本 Demo は静的な表示例であり、<form> 要素を持たず、値の送信・検証・認証処理・データ取得を一切行いません。検索欄・通知ボタン・プロフィールメニュー・サイドバーの各項目はいずれも初期状態を固定して掲示するのみで、押しても何も起きません(無 JS 制約、docs/policy/intentional-non-adoption.md §3.25 の責務境界: UI コンポーネント層はアプリケーションロジックを内包しません)。ブランド名(サンプル社)・ナビゲーション項目・ユーザーの頭文字はすべて架空のものであり、実企業名・実在人物・実クレデンシャル・PII を含みません。アイコンは自作の単純な幾何図形です。

Demo

幅広(サイドバー展開 + 補助カラム)

プレースホルダー行 1
プレースホルダー行 2
プレースホルダー行 3
プレースホルダー行 4
プレースホルダー行 5
プレースホルダー行 6

アイコンのみの狭サイドバー + ヘッダー

プレースホルダー行 1
プレースホルダー行 2
プレースホルダー行 3
プレースホルダー行 4
プレースホルダー行 5
プレースホルダー行 6

狭幅(サイドバー折りたたみ、補助カラムなし)

プレースホルダー行 1
プレースホルダー行 2
プレースホルダー行 3
プレースホルダー行 4
プレースホルダー行 5
プレースホルダー行 6

使用部品

Rust コード

Rust
use fandhe_frontend_core::{aside, div, el, label, li, p, section, span, text, ul, 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::field::FieldProps;
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};
use fandhe_frontend_pre_styled_ui::sidebar;
use fandhe_frontend_pre_styled_ui::sidebar::{
    Sidebar, SidebarCollapsible, SidebarMenuButtonProps, SidebarProps, SidebarState,
};
use fandhe_frontend_pre_styled_ui::visually_hidden;
use fandhe_frontend_pre_styled_ui::Size;

/// 自作の単純な矩形アイコン(装飾用途のため `label: None`)。
fn geo_icon(path_d: &'static str) -> Node {
    icon(
        &IconProps {
            label: None,
            ..IconProps::default()
        },
        vec![],
        vec![el("path", vec![("d", path_d)], vec![])],
    )
}

/// ブランド(ロゴ用の幾何アイコン + 社名、静的表示のみ)。
fn brand() -> Node {
    sidebar::header(
        vec![],
        vec![div(
            vec![("data-blocks-app-shell-three-column-brand", "")],
            vec![
                geo_icon("M4 4h16v16H4z"),
                span(vec![], vec![text("サンプル社")]),
            ],
        )],
    )
}

/// nav 項目一覧(1 件だけ active)。
fn nav_menu() -> Node {
    let items: &[(&str, &str)] = &[
        ("M4 4h16v16H4z", "ダッシュボード"),
        (
            "M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z",
            "プロジェクト",
        ),
        ("M12 2 3 7v10l9 5 9-5V7z", "カレンダー"),
        ("M5 3h14v18H5z", "レポート"),
        ("M12 2a10 10 0 1 0 .001 20.001A10 10 0 0 0 12 2z", "設定"),
    ];
    sidebar::group(
        None,
        vec![],
        vec![sidebar::group_content(
            vec![],
            vec![sidebar::menu(
                vec![],
                items
                    .iter()
                    .map(|(icon_path, label_text)| {
                        sidebar::menu_item(
                            vec![],
                            vec![sidebar::menu_button(
                                &SidebarMenuButtonProps {
                                    href: None,
                                    active: *label_text == "ダッシュボード",
                                    ..Default::default()
                                },
                                Some(geo_icon(icon_path)),
                                vec![],
                                vec![text(*label_text)],
                            )],
                        )
                    })
                    .collect(),
            )],
        )],
    )
}

/// 左サイドバー本体(`provider` の直接の子として置く)。
fn app_sidebar(state: &Sidebar, props: &SidebarProps, root_id: &str, aria_label: &str) -> Node {
    sidebar::root(
        state,
        props,
        aria_label,
        Some(root_id),
        vec![],
        vec![brand(), sidebar::content(vec![], vec![nav_menu()])],
    )
}

/// ヘッダーの検索欄(`input_group` + `input` + `visually_hidden` の可視
/// ラベル代替、`icon`/`narrow` variant のみが持つ)。
fn search_field(variant: &'static str) -> Node {
    let control_id = format!("blocks-app-shell-three-column-search-{variant}-control");
    let field_id = format!("blocks-app-shell-three-column-search-{variant}");
    let field = FieldProps {
        id: &field_id,
        ids: Default::default(),
        disabled: false,
        invalid: false,
        required: false,
        readonly: false,
        has_helper_text: false,
    };
    let group_props = InputGroupProps {
        disabled: false,
        invalid: false,
    };
    input_group::root(
        &group_props,
        vec![("data-blocks-app-shell-three-column-search", "")],
        vec![
            visually_hidden::root(
                vec![],
                vec![label(
                    vec![("for", control_id.as_str())],
                    vec![text("アプリ内を検索")],
                )],
            ),
            input_group::addon(
                InputGroupAlign::InlineStart,
                &group_props,
                vec![],
                vec![geo_icon(
                    "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zm9 17-5.2-5.2",
                )],
            ),
            input::input(
                &InputProps::default(),
                &field,
                vec![
                    ("type", "search"),
                    ("autocomplete", "off"),
                    ("placeholder", "検索..."),
                ],
            ),
        ],
    )
}

/// 通知ボタン(押しても何も起きない静的表示)。
fn notify_button() -> Node {
    button::icon_button(
        &ButtonProps::default(),
        "通知を表示",
        vec![("data-blocks-app-shell-three-column-notify", "")],
        vec![geo_icon(
            "M12 3a5 5 0 0 0-5 5v3l-2 4h14l-2-4V8a5 5 0 0 0-5-5zM10 19a2 2 0 0 0 4 0",
        )],
    )
}

/// プロフィールメニュー(閉じた `menu` + avatar トリガー)。
fn profile_menu(variant: &'static str) -> Node {
    let content_id = format!("blocks-app-shell-three-column-profile-menu-{variant}");
    let trigger = menu::trigger(
        OpenState::Closed,
        false,
        Some(content_id.as_str()),
        vec![
            ("aria-label", "プロフィールメニューを開く"),
            ("data-blocks-app-shell-three-column-profile-trigger", ""),
        ],
        vec![avatar::root(
            &AvatarProps::default(),
            vec![],
            vec![avatar::fallback(
                ImageStatus::Error,
                vec![],
                vec![text("サ")],
            )],
        )],
    );
    let content = menu::content(
        OpenState::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(OpenState::Closed, vec![], vec![content]);
    menu::root(
        Size::Sm,
        OpenState::Closed,
        vec![],
        vec![trigger, positioner],
    )
}

/// 常時表示のヘッダーバー(`icon`/`narrow` variant のみが持つ)。
/// `trigger` は `narrow` variant のみ `Some` を渡す。`with_actions` が
/// `false`(`narrow` variant)のときは通知ボタン・プロフィールメニューを
/// 出力しない(`narrow` はヘッダーバーへ trigger のみを置く狭幅到達状態
/// であり、モジュール doc の対応表と一致させる)。
fn header_bar(
    variant: &'static str,
    trigger: Option<Node>,
    with_search: bool,
    with_actions: bool,
) -> Node {
    let mut children: Vec<Node> = Vec::new();
    if let Some(trigger) = trigger {
        children.push(trigger);
    }
    if with_search {
        children.push(search_field(variant));
    }
    if with_actions {
        children.push(div(
            vec![("data-blocks-app-shell-three-column-actions", "")],
            vec![notify_button(), profile_menu(variant)],
        ));
    }
    div(
        vec![("data-blocks-app-shell-three-column-topbar", "")],
        children,
    )
}

/// メイン領域(中身なしのプレースホルダー行、R0136 の骨格を兼ねる)。
/// ランドマークとしての `role="region"`/`aria-label` は付けない
/// (スクロール枠である親の `sidebar::inset` が同じ `aria-label` で既に
/// region ランドマークを持つため、ここで重ねると landmark 名称が重複する。
/// `app_shell_sidebar_header::main_area` と同じ判断)。
fn main_area() -> Node {
    let rows: Vec<Node> = (1..=6)
        .map(|n| {
            div(
                vec![("data-blocks-app-shell-three-column-row", "")],
                vec![text(format!("プレースホルダー行 {n}"))],
            )
        })
        .collect();
    section(vec![("data-blocks-app-shell-three-column-main", "")], rows)
}

/// 補助カラム(一覧/詳細用、中身なしのプレースホルダー枠)。
fn secondary_column(aria_label: &str) -> Node {
    let items = ["項目 A", "項目 B", "項目 C"]
        .iter()
        .map(|label_text| {
            li(
                vec![("class", "blocks-app-shell-three-column-side-item")],
                vec![text(*label_text)],
            )
        })
        .collect();
    aside(
        vec![
            ("aria-label", aria_label),
            ("data-blocks-app-shell-three-column-aux", ""),
        ],
        vec![ul(
            vec![("class", "blocks-app-shell-three-column-side-list")],
            items,
        )],
    )
}

/// キャプション行。
fn caption(label_text: &'static str) -> Node {
    p(
        vec![("data-blocks-app-shell-three-column-caption", "")],
        vec![text(label_text)],
    )
}

/// variant 1 件分の骨格(`provider > (root, inset(topbar?, body(main, aux?)))`)。
#[allow(clippy::too_many_arguments)]
fn shell(
    variant: &'static str,
    state: SidebarState,
    collapsible: SidebarCollapsible,
    show_header: bool,
    with_search: bool,
    with_trigger: bool,
    with_actions: bool,
    with_aux: bool,
) -> Node {
    let sidebar_state = Sidebar::new(state);
    let props = SidebarProps {
        collapsible,
        ..SidebarProps::default()
    };
    let root_id = format!("blocks-app-shell-three-column-root-{variant}");
    let sidebar_label = format!("アプリのナビゲーション({variant})");
    let main_label = format!("メインコンテンツ({variant})");
    let aux_label = format!("補足情報({variant})");

    let trigger = if with_trigger {
        Some(sidebar::trigger(
            &sidebar_state,
            "サイドバーを開く",
            Some(root_id.as_str()),
            vec![],
            // `narrow` variant ではこの trigger がヘッダー内唯一の操作要素
            // かつ `trigger` 自体は透明背景・枠なしのため、子要素なしでは
            // 通常時にボタンの位置が視覚的に見えない(codex-review P1、
            // イシュー #2898)。`app_shell_stacked` と同じハンバーガー
            // アイコン(3 本線、装飾用途のため `label: None`)を可視の子
            // 要素として渡す。
            vec![geo_icon("M3 6h18v2H3zM3 11h18v2H3zM3 16h18v2H3z")],
        ))
    } else {
        None
    };

    let mut body_children = vec![main_area()];
    if with_aux {
        body_children.push(secondary_column(&aux_label));
    }
    let body = div(
        vec![("data-blocks-app-shell-three-column-body", "")],
        body_children,
    );

    let mut inset_children = Vec::new();
    if show_header {
        inset_children.push(header_bar(variant, trigger, with_search, with_actions));
    }
    inset_children.push(body);

    let inset = sidebar::inset(
        vec![
            ("tabindex", "0"),
            ("role", "region"),
            ("aria-label", main_label.as_str()),
            ("data-blocks-app-shell-three-column-inset", ""),
        ],
        inset_children,
    );

    sidebar::provider(
        &sidebar_state,
        &props,
        vec![
            ("data-blocks-app-shell-three-column-instance", ""),
            ("data-blocks-app-shell-three-column-variant", variant),
        ],
        vec![
            app_sidebar(&sidebar_state, &props, root_id.as_str(), &sidebar_label),
            inset,
        ],
    )
}

/// `app-shell-three-column` の Demo 本体。呼び出しごとに同一の `Node` を
/// 返す純関数。3 variant を縦に並べる(モジュール doc「3 variant を 1 つの
/// Demo に縦並記する」参照)。
pub fn demo() -> Node {
    div(
        vec![("data-blocks-app-shell-three-column-stack", "")],
        vec![
            caption("幅広(サイドバー展開 + 補助カラム)"),
            shell(
                "wide",
                SidebarState::Expanded,
                SidebarCollapsible::None,
                false,
                false,
                false,
                false,
                true,
            ),
            caption("アイコンのみの狭サイドバー + ヘッダー"),
            shell(
                "icon",
                SidebarState::Collapsed,
                SidebarCollapsible::Icon,
                true,
                true,
                false,
                true,
                true,
            ),
            caption("狭幅(サイドバー折りたたみ、補助カラムなし)"),
            shell(
                "narrow",
                SidebarState::Collapsed,
                SidebarCollapsible::Offcanvas,
                true,
                false,
                true,
                false,
                false,
            ),
        ],
    )
}

原案差分メモ

  • R1078(代表構成): サイドバー + メイン + 補助カラムの基本形は「幅広」variant がそのまま示しています
  • R1079(補助カラムの右配置): 本 Demo は全 variant を通じて補助カラムをメインの後ろ(視覚的には右)に固定しています。DOM 順をサイドバー → メイン → 補助カラムのまま保ち、order による視覚順の入れ替えは行っていません(読み上げ順・Tab 順を視覚順と一致させるため)
  • R1082(アイコンのみの狭サイドバー): 「アイコンのみの狭サイドバー + ヘッダー」variant がそのまま示しています
  • R1083(狭サイドバー + 上部ヘッダー): 「狭幅」variant がそのまま示しています
  • R0136(中身なしの 2 カラム骨格): メイン・補助カラムはいずれも中身なしのプレースホルダー枠であり、この骨格を兼ねています
  • 幅に連動した実際の折りたたみ切り替えは無 JS の静的 HTML では実演できないため、ビューポート幅にもリサイズにも連動しない 状態を固定した静的な variant として 3 つ並記しています(app-shell-sidebar-header と同じ判断)

関連情報: Sidebar / Avatar / Button / Icon / Input Group / Input / Menu / Visually Hidden