Skip to content
fandhe-frontendcore v0.4.3
GitHub

settings-team-table

検索欄・並び替え・招待ボタンを持つツールバーと、アバター付き氏名・メール・ロールバッジ・追加日・行末の操作メニューを持つメンバー一覧テーブルを組み合わせた設定ページ向けブロックです。table / avatar / badge / input-group / input / button / menu / heading の 8 部品を合成します。Blocks は既存部品の合成例であり、新しい UI 部品は追加しません。

版 A「検索欄あり」(対応表 ID R0269、主参照)と版 B「検索欄なし基本版」(R0267)の 2 版を縦に並記します。テーブル構成(氏名・メール・ロール・追加日・操作の 5 列)は両版で共有し、ツールバーの検索欄の有無のみが差分です。各版には版見出し(H3)を置き、表のアクセシブルネームもその見出しから与えるため、画面上でも支援技術でも 2 つの表を区別できます。氏名・メールアドレス・追加日はすべて架空のデータであり、実在の人物・組織は含みません。

本 Demo は無 JS の静的表示のみであり、<form> を含みません。並び替え menu・行末の操作 menu はいずれも disabled: true で固定した閉じた menu です(操作しても開閉が追従できないため)。招待ボタンは送信先を持たず、検索欄は絞り込み処理を持たないため、いずれも無効化(disabled)して表示します。

コンテナ幅が 47.99rem 未満になると、メール列と追加日列が隠れます。氏名・ロール・操作の 3 列は常に到達可能なまま残ります。

Demo

版 A: 検索欄あり

検索
氏名メールロール追加日操作
Haruto Fujimaki
Haruto Fujimaki
haruto.fujimaki@example.comオーナー
Elena Vasquez
Elena Vasquez
elena.vasquez@example.com管理者
Kwame Boateng
Kwame Boateng
kwame.boateng@example.comメンバー
Mei Lindqvist
Mei Lindqvist
mei.lindqvist@example.comメンバー
Noor Al-Sayed
Noor Al-Sayed
noor.alsayed@example.com管理者

版 B: 検索欄なし基本版

氏名メールロール追加日操作
Ola Bergström
Ola Bergström
ola.bergström@example.comオーナー
Priya Chandran
Priya Chandran
priya.chandran@example.comメンバー
Théo Marchetti
Théo Marchetti
théo.marchetti@example.comメンバー

使用部品

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::{self, ButtonProps};
use fandhe_frontend_pre_styled_ui::heading::{heading, HeadingLevel, HeadingProps};
use fandhe_frontend_pre_styled_ui::input::{self, FieldIds, FieldProps, 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::table::{self, TableProps};
use fandhe_frontend_pre_styled_ui::{ColorPalette, Size};

/// メンバー 1 件分の架空データ。
struct Member {
    /// [`dummy_assets::PERSON_NAMES`] への添字。
    name_index: usize,
    role: Role,
    joined_iso: &'static str,
    joined_label: &'static str,
}

/// ロール(権限)。バッジ variant をロールごとに変える。
#[derive(Clone, Copy)]
enum Role {
    Owner,
    Admin,
    Member,
}

impl Role {
    fn label(self) -> &'static str {
        match self {
            Role::Owner => "オーナー",
            Role::Admin => "管理者",
            Role::Member => "メンバー",
        }
    }

    fn variant(self) -> BadgeVariant {
        match self {
            Role::Owner => BadgeVariant::Solid,
            Role::Admin => BadgeVariant::Subtle,
            Role::Member => BadgeVariant::Outline,
        }
    }
}

/// 版 A(検索欄あり、R0269 主参照)のメンバー 5 件。
const MEMBERS_A: [Member; 5] = [
    Member {
        name_index: 0,
        role: Role::Owner,
        joined_iso: "2025-04-01",
        joined_label: "2025 年 4 月 1 日",
    },
    Member {
        name_index: 1,
        role: Role::Admin,
        joined_iso: "2025-07-14",
        joined_label: "2025 年 7 月 14 日",
    },
    Member {
        name_index: 2,
        role: Role::Member,
        joined_iso: "2025-11-02",
        joined_label: "2025 年 11 月 2 日",
    },
    Member {
        name_index: 3,
        role: Role::Member,
        joined_iso: "2026-02-20",
        joined_label: "2026 年 2 月 20 日",
    },
    Member {
        name_index: 4,
        role: Role::Admin,
        joined_iso: "2026-06-09",
        joined_label: "2026 年 6 月 9 日",
    },
];

/// 版 B(検索欄なし基本版、R0267)のメンバー 3 件。
const MEMBERS_B: [Member; 3] = [
    Member {
        name_index: 5,
        role: Role::Owner,
        joined_iso: "2025-05-18",
        joined_label: "2025 年 5 月 18 日",
    },
    Member {
        name_index: 6,
        role: Role::Member,
        joined_iso: "2025-09-30",
        joined_label: "2025 年 9 月 30 日",
    },
    Member {
        name_index: 7,
        role: Role::Member,
        joined_iso: "2026-03-11",
        joined_label: "2026 年 3 月 11 日",
    },
];

/// 氏名からイニシャル(先頭文字)を組み立てる(`avatar::fallback` 用)。
fn initials(name: &str) -> String {
    name.chars().take(1).collect()
}

/// 氏名から架空メールアドレスを組み立てる(`example.com` 固定、
/// `settings_share_members.rs::members_section` と同型)。
fn email_for(name: &str) -> String {
    format!(
        "{}@example.com",
        name.to_lowercase()
            .replace(' ', ".")
            .replace(['\'', '-'], "")
    )
}

/// 検索欄(`input-group` + `input type="search"`、`aria-label` のみで
/// ラベル付け、モジュール doc「使用部品」節参照)。絞り込み処理を持たない
/// 静的 Demo のため `disabled: true` 固定(モジュール doc「無 JS のため...
/// disabled 固定」節参照)。
fn search_field(version: &str) -> Node {
    let field_id = format!("blocks-settings-team-table-{version}-search");
    let field = FieldProps {
        id: &field_id,
        ids: FieldIds::default(),
        disabled: true,
        invalid: false,
        required: false,
        readonly: false,
        has_helper_text: false,
    };
    let group_props = InputGroupProps {
        disabled: true,
        invalid: false,
    };
    input_group::root(
        &group_props,
        vec![("data-blocks-settings-team-table-search", "")],
        vec![
            input_group::addon(
                InputGroupAlign::InlineStart,
                &group_props,
                vec![],
                vec![text("検索")],
            ),
            input::input(
                &InputProps::default(),
                &field,
                vec![
                    ("type", "search"),
                    ("autocomplete", "off"),
                    ("placeholder", "名前またはメールで検索"),
                    ("aria-label", "メンバーを検索"),
                ],
            ),
        ],
    )
}

/// 並び替え menu(無 JS のため `disabled: true` 固定、モジュール doc
/// 「無 JS のため...disabled 固定」節参照)。
fn sort_menu(version: &str) -> Node {
    let content_id = format!("blocks-settings-team-table-{version}-sort-content");
    let trigger_id = format!("blocks-settings-team-table-{version}-sort-trigger");
    menu::root(
        Size::Sm,
        OpenState::Closed,
        vec![],
        vec![
            menu::trigger(
                OpenState::Closed,
                true,
                Some(content_id.as_str()),
                vec![("id", trigger_id.as_str())],
                vec![text("並び替え: 名前")],
            ),
            menu::positioner(
                OpenState::Closed,
                vec![],
                vec![menu::content(
                    OpenState::Closed,
                    Some(content_id.as_str()),
                    Some(trigger_id.as_str()),
                    vec![],
                    vec![
                        menu::item("name", false, false, vec![], vec![text("名前")]),
                        menu::item("joined", false, false, vec![], vec![text("追加日")]),
                        menu::item("role", false, false, vec![], vec![text("ロール")]),
                    ],
                )],
            ),
        ],
    )
}

/// ツールバー(検索欄〔`with_search` が `true` の版のみ〕+ 並び替え +
/// 招待ボタン)。招待ボタンは送信先・遷移先を持たない Demo のため
/// `disabled: true`(`settings_share_members.rs::invite_section` と同じ
/// 判断軸)。
fn toolbar(version: &str, with_search: bool) -> Node {
    let mut leading: Vec<Node> = Vec::new();
    if with_search {
        leading.push(search_field(version));
    }
    div(
        vec![("class", "blocks-settings-team-table-toolbar")],
        vec![
            div(
                vec![("class", "blocks-settings-team-table-toolbar-leading")],
                leading,
            ),
            div(
                vec![("class", "blocks-settings-team-table-toolbar-actions")],
                vec![
                    sort_menu(version),
                    button::button(
                        &ButtonProps {
                            disabled: true,
                            ..ButtonProps::default()
                        },
                        vec![],
                        vec![text("メンバーを招待")],
                    ),
                ],
            ),
        ],
    )
}

/// 行末の操作 menu(`list_people.rs::example_inline_link_menu` と同型、
/// `aria-label` に氏名を含めて一意化する)。
fn row_menu(version: &str, index: usize, name: &str) -> Node {
    let content_id = format!("blocks-settings-team-table-{version}-menu-{index}");
    let trigger_id = format!("blocks-settings-team-table-{version}-menu-trigger-{index}");
    let trigger_label = format!("その他の操作、{name}");
    menu::root(
        Size::Sm,
        OpenState::Closed,
        vec![],
        vec![
            menu::trigger(
                OpenState::Closed,
                true,
                Some(content_id.as_str()),
                vec![
                    ("id", trigger_id.as_str()),
                    ("aria-label", trigger_label.as_str()),
                ],
                vec![text("\u{2026}")],
            ),
            menu::positioner(
                OpenState::Closed,
                vec![],
                vec![menu::content(
                    OpenState::Closed,
                    Some(content_id.as_str()),
                    Some(trigger_id.as_str()),
                    vec![],
                    vec![
                        menu::item(
                            "change-role",
                            false,
                            false,
                            vec![],
                            vec![text("ロールを変更")],
                        ),
                        menu::item("reinvite", false, false, vec![], vec![text("再招待")]),
                        menu::separator(vec![], vec![]),
                        menu::item("remove", false, false, vec![], vec![text("削除")]),
                    ],
                )],
            ),
        ],
    )
}

/// 氏名セル(アバター + 氏名、`table::row_header`)。
fn name_cell(name: &str) -> Node {
    table::row_header(
        vec![],
        vec![div(
            vec![("class", "blocks-settings-team-table-person")],
            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(initials(name))]),
                    ],
                ),
                text(name),
            ],
        )],
    )
}

/// メンバー一覧テーブルの 1 行。
fn member_row(version: &str, index: usize, member: &Member) -> Node {
    let name = dummy_assets::PERSON_NAMES[member.name_index];
    let email = email_for(name);
    table::row(
        vec![],
        vec![
            name_cell(name),
            table::cell(
                vec![("data-blocks-settings-team-table-secondary", "")],
                vec![text(email)],
            ),
            table::cell(
                vec![],
                vec![badge::badge(
                    &BadgeProps {
                        variant: member.role.variant(),
                        palette: ColorPalette::Accent,
                        ..BadgeProps::default()
                    },
                    vec![],
                    vec![text(member.role.label())],
                )],
            ),
            table::cell(
                vec![("data-blocks-settings-team-table-secondary", "")],
                vec![el(
                    "time",
                    vec![("datetime", member.joined_iso)],
                    vec![text(member.joined_label)],
                )],
            ),
            table::cell(vec![], vec![row_menu(version, index, name)]),
        ],
    )
}

/// メンバー一覧テーブル(ヘッダー + 行群)。
fn members_table(version: &str, title_id: &str, members: &[Member]) -> Node {
    let rows: Vec<Node> = members
        .iter()
        .enumerate()
        .map(|(i, member)| member_row(version, i, member))
        .collect();
    table::root(
        TableProps {
            size: Size::Sm,
            interactive: true,
            ..TableProps::default()
        },
        vec![("aria-labelledby", title_id)],
        vec![
            table::header(
                vec![],
                vec![table::row(
                    vec![],
                    vec![
                        table::column_header(vec![], vec![text("氏名")]),
                        table::column_header(
                            vec![("data-blocks-settings-team-table-secondary", "")],
                            vec![text("メール")],
                        ),
                        table::column_header(vec![], vec![text("ロール")]),
                        table::column_header(
                            vec![("data-blocks-settings-team-table-secondary", "")],
                            vec![text("追加日")],
                        ),
                        table::column_header(vec![], vec![text("操作")]),
                    ],
                )],
            ),
            table::body(vec![], rows),
        ],
    )
}

/// 版 1 件(版見出し H3 + ツールバー + テーブル)。`version` は `id` の
/// 一意化に使う。2 版は列見出しが同一のため、版見出しを可視で置き、表の
/// アクセシブルネームも `aria-labelledby` で同じ見出しから与えて、画面上でも
/// 支援技術でも版を区別できるようにする(`settings_integrations_grid.rs`
/// の版見出しと同じ流儀。`heading` は `data-scope` を持つためページ目次には
/// 載らない)。
fn version_panel(version: &str, title: &str, with_search: bool, members: &[Member]) -> Node {
    let title_id = format!("blocks-settings-team-table-{version}-title");
    div(
        vec![("data-blocks-settings-team-table-version", version)],
        vec![
            heading(
                HeadingLevel::H3,
                &HeadingProps::default(),
                vec![("id", &title_id)],
                vec![text(title)],
            ),
            toolbar(version, with_search),
            members_table(version, &title_id, members),
        ],
    )
}

/// `settings-team-table` の Demo 本体(呼び出しごとに同一の `Node` を
/// 返す純関数)。版 A(検索欄あり、R0269)→ 版 B(検索欄なし、R0267)の
/// 順に縦へ静的併記する(モジュール doc「2 版を縦に併記する」節参照)。
#[must_use]
pub fn demo() -> Node {
    div(
        vec![("class", "blocks-settings-team-table-stack")],
        vec![
            version_panel("a", "版 A: 検索欄あり", true, &MEMBERS_A),
            version_panel("b", "版 B: 検索欄なし基本版", false, &MEMBERS_B),
        ],
    )
}

原案差分メモ

  • R0269 → 版 A(検索欄あり、主参照): ツールバーに検索欄(input-group と input type="search" の組)・並び替え menu・招待ボタンを持つ代表構成です。メンバー 5 件のテーブル(氏名・メール・ロール・追加日・操作)を持ちます。
  • R0267 → 版 B(検索欄なし基本版): ツールバーから検索欄を省き、並び替え menu・招待ボタンのみにした基本版です。テーブル構成(列・行末メニュー)は版 A と共有し、別データ(3 件)を表示します。
  • 狭幅での列非表示: イシュー本文の要件(「狭幅ではメール列と追加日列を隠す」)をコンテナクエリ(47.99rem 未満)で実装しました。氏名・ロール・操作の 3 列は常に到達可能なまま残します。
  • _/blocks-intake/ の対応表 ID R0267/R0269 の参照ファイルは、本イシュー着手時点で本 worktree に存在しないため参照ファイルは未閲覧です。取り込んだのはイシュー本文のレイアウト仕様のみであり、文言・配色・アイコンは独自に書いています。

関連情報: Table / Avatar / Badge / Input Group / Input / Button / Menu