pricing-comparison-table
fandhe-frontend-pre-styled-ui の heading / text / table / button / icon / card / native-select の 7 部品を合成した、カテゴリ見出し行付きのプラン比較表です。主参照は対応表 ID R0601 です。Blocks セクションは新規部品を追加するものではなく、既存の Themes 部品を組み合わせた実例集であることに注意してください。
幅の広い画面ではプラン列 × 機能行の比較表(カテゴリごとに見出し行で区切ります)を表示します。48rem 未満の狭い画面では、この表の代わりに「プランごとのカード」と「native-select で 1 プランを選ぶ 2 列表」の両方を縦に並べて表示します。後者は select の選択状態が異なる 2 例(初期状態と切替後の例)を並記した静的表示であり、無 JS のため select を操作しても表・CTA は連動しません(連動の配線は利用者の Rust/wasm コードの責務です)。
本 Demo は静的な表示例です。<form> は出力せず、送信・決済処理を持ちません。プラン名・機能名・価格はすべて架空のものであり、実在の企業名・PII は含みません。
Demo
プランを比較する
機能ごとの違いを一目で確認できます。
| 機能 | Starter $9 | Growth $29 | Scale $79 |
|---|---|---|---|
| 基本機能 | |||
| プロジェクト数 | 3 件 | 無制限 | 無制限 |
| ストレージ容量 | 5GB | 100GB | 1TB |
| カスタムドメイン | |||
| サポート | |||
| 優先サポート窓口 | |||
| 専任担当者 | |||
表示 A: プラン別カード
Starter
$9
基本機能
- プロジェクト数: 3 件
- ストレージ容量: 5GB
- カスタムドメイン
サポート
- 優先サポート窓口
- 専任担当者
Growth
$29
基本機能
- プロジェクト数: 無制限
- ストレージ容量: 100GB
- カスタムドメイン
サポート
- 優先サポート窓口
- 専任担当者
Scale
$79
基本機能
- プロジェクト数: 無制限
- ストレージ容量: 1TB
- カスタムドメイン
サポート
- 優先サポート窓口
- 専任担当者
表示 B-1: Growth を選択(初期状態)
| 機能 | Growth $29 |
|---|---|
| 基本機能 | |
| プロジェクト数 | 無制限 |
| ストレージ容量 | 100GB |
| カスタムドメイン | |
| サポート | |
| 優先サポート窓口 | |
| 専任担当者 | |
表示 B-2: Scale を選択
| 機能 | Scale $79 |
|---|---|
| 基本機能 | |
| プロジェクト数 | 無制限 |
| ストレージ容量 | 1TB |
| カスタムドメイン | |
| サポート | |
| 優先サポート窓口 | |
| 専任担当者 | |
使用部品
Rust コード
Rust
use fandhe_frontend_core::{div, el, span, text, Node};
use fandhe_frontend_pre_styled_ui::button::{self, ButtonProps, ButtonVariant};
use fandhe_frontend_pre_styled_ui::card::{self, CardProps, CardVariant};
use fandhe_frontend_pre_styled_ui::heading::{
heading, HeadingLevel, HeadingProps, HeadingSize, HeadingWeight,
};
use fandhe_frontend_pre_styled_ui::icon::{icon, IconProps};
use fandhe_frontend_pre_styled_ui::native_select::{self, FieldIds, FieldProps, NativeSelectProps};
use fandhe_frontend_pre_styled_ui::table::{self, TableProps, TableVariant};
use fandhe_frontend_pre_styled_ui::text::{self as styled_text, TextProps, TextVariant};
use fandhe_frontend_pre_styled_ui::Size;
/// 表の列数(機能名列 + プラン数)を文字列表記した定数。プラン数が
/// [`PLANS`] 固定のため、`colspan` 属性値としてリテラルで持つ(ファイル内
/// ユニットテストで `PLANS.len() + 1` との一致を固定)。
const COL_COUNT_STR: &str = "4";
/// 機能可否・値の表現。
enum FeatureValue {
/// 含まれる(チェックアイコン)。
Included,
/// 含まれない(バツアイコン)。
Excluded,
/// 文字表示(例: 上限件数)。
Text(&'static str),
}
/// 比較表の列見出しに置くプラン 1 件分(架空データ)。
struct Plan {
name: &'static str,
price: &'static str,
cta_label: &'static str,
/// 強調表示するプランかどうか(`accent-subtle` 背景、[`LAYOUT_CSS`])。
featured: bool,
}
/// 機能比較 1 行分。`values` の長さは [`PLANS`] の件数と一致させる
/// (ファイル内ユニットテストで固定する不変条件)。
struct FeatureRow {
label: &'static str,
values: &'static [FeatureValue],
}
/// カテゴリ見出し行 1 件分(機能行のグルーピング単位、`table::body` 1 個に
/// 対応する)。
struct Category {
label: &'static str,
rows: &'static [FeatureRow],
}
/// プラン列 3 件。価格は `crate::blocks::dummy_assets::SAMPLE_PRICE_TIERS`
/// (Starter $9 / Growth $29 / Scale $79)と同じ値を直書きする(`const` の
/// 配列添字アクセスより明快なため)。
const PLANS: [Plan; 3] = [
Plan {
name: "Starter",
price: "$9",
cta_label: "Starter を選ぶ",
featured: false,
},
Plan {
name: "Growth",
price: "$29",
cta_label: "Growth を選ぶ",
featured: true,
},
Plan {
name: "Scale",
price: "$79",
cta_label: "Scale を選ぶ",
featured: false,
},
];
/// カテゴリ 2 件・機能行合計 5 件(架空データ)。
const CATEGORIES: [Category; 2] = [
Category {
label: "基本機能",
rows: &[
FeatureRow {
label: "プロジェクト数",
values: &[
FeatureValue::Text("3 件"),
FeatureValue::Text("無制限"),
FeatureValue::Text("無制限"),
],
},
FeatureRow {
label: "ストレージ容量",
values: &[
FeatureValue::Text("5GB"),
FeatureValue::Text("100GB"),
FeatureValue::Text("1TB"),
],
},
FeatureRow {
label: "カスタムドメイン",
values: &[
FeatureValue::Excluded,
FeatureValue::Included,
FeatureValue::Included,
],
},
],
},
Category {
label: "サポート",
rows: &[
FeatureRow {
label: "優先サポート窓口",
values: &[
FeatureValue::Excluded,
FeatureValue::Included,
FeatureValue::Included,
],
},
FeatureRow {
label: "専任担当者",
values: &[
FeatureValue::Excluded,
FeatureValue::Excluded,
FeatureValue::Included,
],
},
],
},
];
/// 可否を表す自作の抽象チェック/バツ図形。意味を持つアイコンのため
/// `label` を指定する(参照元のアイコン形状・内部識別子は持ち込まない)。
fn feature_value_icon(value: &FeatureValue) -> Node {
match value {
FeatureValue::Included => icon(
&IconProps {
size: Size::Sm,
label: Some("含まれる"),
..IconProps::default()
},
vec![("data-blocks-pricing-comparison-table-value", "included")],
vec![
el(
"circle",
vec![
("cx", "12"),
("cy", "12"),
("r", "9"),
("fill", "none"),
("stroke", "currentColor"),
("stroke-width", "1.5"),
],
vec![],
),
el(
"path",
vec![
("d", "M8 12.5l2.5 2.5L16 9"),
("fill", "none"),
("stroke", "currentColor"),
("stroke-width", "1.5"),
("stroke-linecap", "round"),
("stroke-linejoin", "round"),
],
vec![],
),
],
),
FeatureValue::Excluded => icon(
&IconProps {
size: Size::Sm,
label: Some("含まれない"),
..IconProps::default()
},
vec![("data-blocks-pricing-comparison-table-value", "excluded")],
vec![
el(
"path",
vec![
("d", "M8 8l8 8"),
("stroke", "currentColor"),
("stroke-width", "1.5"),
("stroke-linecap", "round"),
],
vec![],
),
el(
"path",
vec![
("d", "M16 8l-8 8"),
("stroke", "currentColor"),
("stroke-width", "1.5"),
("stroke-linecap", "round"),
],
vec![],
),
],
),
FeatureValue::Text(_) => span(vec![], vec![]),
}
}
/// プラン 1 列分の列見出し(`<th scope="col">`)。プラン名・価格・CTA を
/// 縦に並べる(親 issue のレイアウト仕様)。
fn plan_column_header(plan: &Plan) -> Node {
let col = if plan.featured {
"featured"
} else {
"standard"
};
let cta_variant = if plan.featured {
ButtonVariant::Solid
} else {
ButtonVariant::Outline
};
table::column_header(
vec![("data-blocks-pricing-comparison-table-col", col)],
vec![div(
vec![("class", "blocks-pricing-comparison-table-plan-head")],
vec![
span(vec![], vec![text(plan.name)]),
styled_text::text(
&TextProps {
variant: TextVariant::Muted,
..TextProps::default()
},
vec![("data-blocks-pricing-comparison-table-price", "")],
vec![text(plan.price)],
),
button::button(
&ButtonProps {
variant: cta_variant,
..ButtonProps::default()
},
vec![("data-blocks-pricing-comparison-table-cta", "")],
vec![text(plan.cta_label)],
),
],
)],
)
}
/// カテゴリ見出し行(`colspan` でプラン列をまたぐ 1 セル行)。`colspan` を
/// 引数化し、幅広表([`COL_COUNT_STR`])と select 2 列表(`"2"`)の双方で
/// 共用する(最小差分の方針、モジュール doc 参照)。
fn category_row(category: &Category, colspan: &str) -> Node {
table::row(
vec![],
vec![table::row_header(
vec![
("colspan", colspan),
("data-blocks-pricing-comparison-table-category", ""),
],
vec![text(category.label)],
)],
)
}
/// 機能 1 件分の値表現(可否アイコン or 文字列)を組み立てる。幅広表・
/// select 2 列表の双方から呼ばれる共通ヘルパ(最小差分の方針)。
fn value_node(value: &FeatureValue) -> Node {
match value {
FeatureValue::Text(label) => span(
vec![("data-blocks-pricing-comparison-table-value", "text")],
vec![text(*label)],
),
included_or_excluded => feature_value_icon(included_or_excluded),
}
}
/// 機能比較 1 行分(`table::row`)。
fn feature_table_row(row: &FeatureRow) -> Node {
let mut cells: Vec<Node> = vec![table::row_header(
vec![("data-blocks-pricing-comparison-table-feature", "")],
vec![text(row.label)],
)];
for (value, plan) in row.values.iter().zip(PLANS.iter()) {
let col = if plan.featured {
"featured"
} else {
"standard"
};
cells.push(table::cell(
vec![("data-blocks-pricing-comparison-table-col", col)],
vec![value_node(value)],
));
}
table::row(vec![], cells)
}
/// 幅広表示(R0601): カテゴリ見出し行付きの比較表。
fn table_view() -> Node {
let mut header_cells: Vec<Node> = vec![table::column_header(vec![], vec![text("機能")])];
header_cells.extend(PLANS.iter().map(plan_column_header));
let bodies: Vec<Node> = CATEGORIES
.iter()
.map(|category| {
let mut rows = vec![category_row(category, COL_COUNT_STR)];
rows.extend(category.rows.iter().map(feature_table_row));
table::body(vec![], rows)
})
.collect();
let mut table_children = vec![
table::caption(vec![], vec![text("プラン別の機能比較")]),
table::header(vec![], vec![table::row(vec![], header_cells)]),
];
table_children.extend(bodies);
let table_node = table::root(
TableProps {
variant: TableVariant::Outline,
..TableProps::default()
},
vec![],
table_children,
);
div(
vec![("data-blocks-pricing-comparison-table-view", "table")],
vec![table::scroll_area(
vec![
("role", "region"),
("aria-label", "プラン比較表"),
("tabindex", "0"),
],
vec![table_node],
)],
)
}
/// 狭幅表示(R1150): プランごとのカード。カテゴリごとに小見出し(`H4`)+
/// 機能一覧を並べる。
fn plan_card(plan_index: usize, plan: &Plan) -> Node {
let cta_variant = if plan.featured {
ButtonVariant::Solid
} else {
ButtonVariant::Outline
};
let mut body_children: Vec<Node> = Vec::new();
for category in CATEGORIES.iter() {
body_children.push(heading(
HeadingLevel::H4,
&HeadingProps {
size: HeadingSize::Sm,
weight: HeadingWeight::Bold,
},
vec![],
vec![text(category.label)],
));
let items: Vec<Node> = category
.rows
.iter()
.map(|row| {
let value = &row.values[plan_index];
let value_node = match value {
FeatureValue::Text(label) => span(
vec![("data-blocks-pricing-comparison-table-value", "text")],
vec![text(format!("{}: {label}", row.label))],
),
included_or_excluded => div(
vec![("class", "blocks-pricing-comparison-table-card-item")],
vec![feature_value_icon(included_or_excluded), text(row.label)],
),
};
el("li", vec![], vec![value_node])
})
.collect();
body_children.push(el(
"ul",
vec![("class", "blocks-pricing-comparison-table-card-list")],
items,
));
}
card::root(
CardProps {
variant: CardVariant::Outline,
..CardProps::default()
},
vec![("data-blocks-pricing-comparison-table-card", "")],
vec![
card::header(
vec![],
vec![
card::title(vec![], vec![text(plan.name)]),
styled_text::text(
&TextProps {
variant: TextVariant::Muted,
..TextProps::default()
},
vec![("data-blocks-pricing-comparison-table-price", "")],
vec![text(plan.price)],
),
],
),
card::body(vec![], body_children),
card::footer(
vec![],
vec![button::button(
&ButtonProps {
variant: cta_variant,
..ButtonProps::default()
},
vec![("data-blocks-pricing-comparison-table-cta", "")],
vec![text(plan.cta_label)],
)],
),
],
)
}
/// 狭幅表示(R1150)本体。プラン数分のカードを並べる。
fn cards_view() -> Node {
div(
vec![("data-blocks-pricing-comparison-table-view", "cards")],
PLANS
.iter()
.enumerate()
.map(|(i, plan)| plan_card(i, plan))
.collect(),
)
}
/// select 2 列表インスタンスの id(`<label for>` と select 実 `id` を同じ
/// リテラルで揃えるための固定値。モジュール doc「select のラベルと id
/// 一意性」節参照)。
const SELECT_ID_GROWTH: &str = "blocks-pricing-comparison-table-plan-select-growth";
const SELECT_ID_SCALE: &str = "blocks-pricing-comparison-table-plan-select-scale";
/// select 列見出し(`<th scope="col">`)。プラン名・価格を縦に並べる
/// ([`plan_column_header`] と異なり CTA は列見出しへ含めない。CTA は
/// [`select_view`] が表の外へ独立して配置する)。
fn select_column_header(plan: &Plan) -> Node {
table::column_header(
vec![],
vec![
span(vec![], vec![text(plan.name)]),
styled_text::text(
&TextProps {
variant: TextVariant::Muted,
..TextProps::default()
},
vec![("data-blocks-pricing-comparison-table-price", "")],
vec![text(plan.price)],
),
],
)
}
/// 狭幅表示(R0201): native-select で 1 プランを選び、機能一覧を「機能 /
/// 値」の 2 列表で示す。`selected` は [`PLANS`] の添字(状態違いの並記は
/// [`narrow_view`] が呼び出しを 2 回行うことで表現する)。`select_id` は
/// `<label for>` と select の実 `id` を揃えるための固定リテラル
/// ([`SELECT_ID_GROWTH`]/[`SELECT_ID_SCALE`])。無 JS のため select を
/// 操作しても表・CTA は連動しない(利用者コードの責務、モジュール doc
/// 参照)。連動しないことを見た目からも判別できるよう `disabled` にする
/// (操作できるように見えて実は状態表示専用、という誤認を避けるための
/// 判断。モジュール doc「狭幅表示 A/B と状態の並記」節参照)。
fn select_view(selected: usize, select_id: &'static str) -> Node {
let plan = &PLANS[selected];
let field = FieldProps {
id: select_id,
ids: FieldIds {
control: Some(select_id),
..FieldIds::default()
},
disabled: true,
invalid: false,
required: false,
readonly: false,
has_helper_text: false,
};
let options: Vec<Node> = PLANS
.iter()
.enumerate()
.map(|(i, plan)| {
let mut attrs: Vec<(&str, &str)> = vec![("value", plan.name)];
if i == selected {
attrs.push(("selected", ""));
}
el("option", attrs, vec![text(plan.name)])
})
.collect();
let select_bar = div(
vec![("class", "blocks-pricing-comparison-table-select-bar")],
vec![
el(
"label",
vec![("for", select_id)],
vec![text("プランを選択")],
),
native_select::native_select(&NativeSelectProps::default(), &field, vec![], options),
],
);
let bodies: Vec<Node> = CATEGORIES
.iter()
.map(|category| {
let mut rows = vec![category_row(category, "2")];
rows.extend(category.rows.iter().map(|row| {
table::row(
vec![],
vec![
table::row_header(
vec![("data-blocks-pricing-comparison-table-feature", "")],
vec![text(row.label)],
),
table::cell(vec![], vec![value_node(&row.values[selected])]),
],
)
}));
table::body(vec![], rows)
})
.collect();
let mut table_children = vec![
table::caption(vec![], vec![text(format!("{}の機能一覧", plan.name))]),
table::header(
vec![],
vec![table::row(
vec![],
vec![
table::column_header(vec![], vec![text("機能")]),
select_column_header(plan),
],
)],
),
];
table_children.extend(bodies);
let cta_variant = if plan.featured {
ButtonVariant::Solid
} else {
ButtonVariant::Outline
};
div(
vec![("data-blocks-pricing-comparison-table-view", "select")],
vec![
select_bar,
table::root(
TableProps {
variant: TableVariant::Outline,
..TableProps::default()
},
vec![],
table_children,
),
button::button(
&ButtonProps {
variant: cta_variant,
..ButtonProps::default()
},
vec![("data-blocks-pricing-comparison-table-cta", "")],
vec![text(plan.cta_label)],
),
],
)
}
/// 狭幅(48rem 未満)で表の代わりに置く 2 領域: 表示 A([`cards_view`]、
/// R1150)と表示 B([`select_view`]、R0201)。表示 B は選択状態違い
/// (Growth を選択=初期状態 / Scale を選択)を 2 インスタンス並べる
/// (モジュール doc「狭幅表示 A/B と状態の並記」節参照)。
fn narrow_view() -> Node {
let section_label = |label: &'static str| {
styled_text::text(
&TextProps {
variant: TextVariant::Muted,
..TextProps::default()
},
vec![],
vec![text(label)],
)
};
div(
vec![("class", "blocks-pricing-comparison-table-narrow")],
vec![
div(
vec![],
vec![section_label("表示 A: プラン別カード"), cards_view()],
),
div(
vec![],
vec![
section_label("表示 B-1: Growth を選択(初期状態)"),
select_view(1, SELECT_ID_GROWTH),
],
),
div(
vec![],
vec![
section_label("表示 B-2: Scale を選択"),
select_view(2, SELECT_ID_SCALE),
],
),
],
)
}
/// `pricing-comparison-table` の Demo 本体。呼び出しごとに同一の `Node` を
/// 返す純関数。intro 領域 + 幅広表 + 狭幅表示(表示 A/B 状態違い並記)の
/// 3 領域を縦に並べる(表示切替は [`LAYOUT_CSS`] の `@media` が担う)。
pub fn demo() -> Node {
let intro = div(
vec![("class", "blocks-pricing-comparison-table-intro")],
vec![
heading(
HeadingLevel::H3,
&HeadingProps {
size: HeadingSize::Xl2,
weight: HeadingWeight::Bold,
},
vec![],
vec![text("プランを比較する")],
),
styled_text::text(
&TextProps {
variant: TextVariant::Muted,
..TextProps::default()
},
vec![("data-blocks-pricing-comparison-table-lead", "")],
vec![text("機能ごとの違いを一目で確認できます。")],
),
],
);
div(
vec![("class", "blocks-pricing-comparison-table-layout")],
vec![intro, table_view(), narrow_view()],
)
}原案差分メモ
対応表 ID R0601(幅広の比較表)・R1150(狭幅ティアカード)・R0201(狭幅 select 切替)を集約する際、原案から次の差分を設けています。
- 狭幅(48rem 未満)は R1150 と R0201 を切替式にせず、両方を縦に並記しています。
- R0201 の select 状態は、初期状態(Growth 選択)と切替後の例(Scale 選択)の 2 例を並記しています。
- select の可視ラベルは
field::labelではなく素の<label for>を使い、id/forを明示的な固定値で一致させています。 - select の
idはインスタンスごとに一意な値にしています。 - 可否アイコンは自作の抽象図形(チェック/バツ)で、参照元のアイコン形状は持ち込んでいません。
- CTA ボタンはすべて
type="button"で、送信先やhrefを持ちません。 - 見出しレベルは
H3/H4を使い、ページ本文のH2を飛ばさないようにしています。 - 配色・余白はすべて既存の
--fandhe-color-*/--fandhe-space-*トークンのみを使っています。 - プラン名・価格・機能名・文言はすべて架空のものです。