product-overview-featured-split
fandhe-frontend-pre-styled-ui の breadcrumb / heading / text / image / radio-card / rating-group / button / icon 部品を合成した、単一の大きな商品画像と特徴説明を持つ商品詳細の実例です。Blocks セクションは新規部品を追加するものではなく、既存の Themes/Primitives 部品を組み合わせた実例集であることに注意してください(主参照・集約元ともに対応表 ID R1177 の 1 件のみ。出典の固有名・ファイル名は記載しません)。
広い画面では左に商品情報(パンくず・商品名・価格・評価・説明・サイズ選択・在庫表示・カート追加ボタン・保証の補足)、右に単一の大きな商品画像を 2 カラムで並べます。48rem 未満の狭い画面では 1 列になり、商品画像を情報の間(パンくず〜説明の下・サイズ選択より上)へ挟み込みます(CSS Grid の grid-template-areas のみで実現しており、display: none も DOM の重複も使いません。DOM 順は常に「商品情報(上)→ 画像 → 商品情報(下)」で、狭い画面の視覚順と一致します)。
本 Demo は静的な表示例であり、<form> 要素は一切持たず、データの取得・送信・状態管理を行いません。カート追加ボタンは type="button" のまま送信先を持たず、押しても何も起きないボタンを操作可能なまま残さないため disabled で固定表示します。サイズの選択欄はネイティブ disabled のまま固定表示し、現在の選択を本文テキストで明文化しています。在庫表示・保証の補足に添えるアイコンは独自の単純な幾何形状(チェック線・盾形)の装飾であり、実在のアイコンセット・ブランドの意匠は持ち込んでいません。パンくずのリンクは href="#" を避け、実在する相対パスへ向けています。商品名・価格・評価件数・サイズ・説明文・在庫・保証の文言はすべて独自に書いた架空のものであり、実企業名・実クレデンシャル・PII を含みません。
Demo
保温ステンレスボトル
¥3,980
二重構造の真空断熱で保温・保冷が長時間続くステンレスボトルです。シンプルな見た目で普段使いしやすく、オフィスにもアウトドアにも馴染みます。
現在の選択: 350 mL
在庫あり・2〜3 営業日で発送
購入から 1 年間の品質保証付き
使用部品
Rust コード
use crate::blocks::dummy_assets;
use fandhe_frontend_core::{div, el, text, Node};
use fandhe_frontend_pre_styled_ui::breadcrumb::{self, BreadcrumbVariant};
use fandhe_frontend_pre_styled_ui::button::{self, ButtonProps};
use fandhe_frontend_pre_styled_ui::heading::{self, HeadingLevel, HeadingProps, HeadingSize};
use fandhe_frontend_pre_styled_ui::icon::{self, IconProps};
use fandhe_frontend_pre_styled_ui::image::{self, AspectRatio, ImageProps};
use fandhe_frontend_pre_styled_ui::radio_card::{self, Orientation as RadioCardOrientation};
use fandhe_frontend_pre_styled_ui::rating_group::{
self, RatingGroup, RatingGroupProps, RatingItemFlags,
};
use fandhe_frontend_pre_styled_ui::text::{self as styled_text, TextProps, TextSize, TextVariant};
use fandhe_frontend_pre_styled_ui::{ColorPalette, Size};
/// 評価ラベル(`rating_group::label` の `id`)。他 block との重複を避ける
/// ため本 block 名を含む(モジュール doc「id を block 固有の定数にする
/// 理由」節参照)。
const RATING_LABEL_ID: &str = "blocks-product-overview-featured-split-rating-label";
/// サイズ選択見出し(`radio_card::label` の `id`)。
const SIZE_LABEL_ID: &str = "blocks-product-overview-featured-split-size-label";
/// サイズ選択 radio card のネイティブ `name`(フォーム未送信のため排他
/// 選択の実効はないが、[`radio_card::item_hidden_input`] の契約上必須)。
const SIZE_NAME: &str = "blocks-product-overview-featured-split-size";
/// パンくず。`href="#"` は使わず実在する相対パスへ向ける
/// (モジュール doc「`<form>` を持たない」節参照)。
fn product_breadcrumb() -> Node {
breadcrumb::root(
Size::Sm,
BreadcrumbVariant::default(),
Some("パンくず"),
vec![],
vec![breadcrumb::list(
vec![],
vec![
breadcrumb::item(
vec![],
vec![breadcrumb::link("../../", vec![], vec![text("ホーム")])],
),
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("保温ステンレスボトル")],
)],
),
],
)],
)
}
/// 評価行(`rating-group`、readonly。現在の評価をラベルで明文化する、
/// `product_overview_gallery_split.rs::rating_row` と同型の判断)。
fn rating_row() -> Node {
let rating_props = RatingGroupProps {
disabled: false,
readonly: true,
required: false,
};
let rating_group_state = RatingGroup::new(5, Some(4), true);
let rating_label = rating_group::label(
&rating_props,
Some(RATING_LABEL_ID),
vec![],
vec![text("評価 4.0(52 件)")],
);
let rating_items: Vec<Node> = (1..=rating_group_state.count())
.map(|i| {
rating_group::item(
i,
RatingItemFlags {
checked: rating_group_state.is_checked(i),
highlighted: rating_group_state.is_highlighted(i),
disabled: false,
readonly: true,
},
&format!("{i} star{}", if i == 1 { "" } else { "s" }),
vec![],
vec![],
)
})
.collect();
let rating_control =
rating_group::control(&rating_props, Some(RATING_LABEL_ID), vec![], rating_items);
div(
vec![("class", "blocks-product-overview-featured-split-rating")],
vec![rating_group::root(
Size::Sm,
ColorPalette::Accent,
&rating_props,
vec![],
vec![rating_label, rating_control],
)],
)
}
/// 価格(素の `<p>`。モジュール doc「価格を素の `<p>` で組み立てる理由」
/// 節参照)。
fn price_line() -> Node {
el(
"p",
vec![("class", "blocks-product-overview-featured-split-price")],
vec![text("¥3,980")],
)
}
/// サイズ選択の radio card 1 件(説明文付き)。ネイティブ disabled の
/// まま用いる(モジュール doc「サイズ選択を radio card + ネイティブ
/// disabled にする理由」節参照)。
fn size_item(checked: bool, value: &'static str, label: &str, description: &str) -> Node {
radio_card::item(
checked,
true,
value,
vec![],
vec![
radio_card::item_hidden_input(checked, true, Some(SIZE_NAME), value, vec![]),
radio_card::item_control(
checked,
true,
vec![],
vec![
radio_card::item_indicator(checked, true, false, vec![]),
radio_card::item_content(
vec![],
vec![
radio_card::item_text(vec![], vec![text(label)]),
radio_card::item_description(vec![], vec![text(description)]),
],
),
],
),
],
)
}
/// サイズ選択欄(2 択、説明文付き。350 mL を選択済みで固定)。
fn size_options() -> Node {
let items = vec![
size_item(true, "350ml", "350 mL", "通勤バッグに収まる軽量サイズ"),
size_item(
false,
"500ml",
"500 mL",
"一日の水分補給に十分な大容量サイズ",
),
];
div(
vec![("class", "blocks-product-overview-featured-split-option")],
vec![
radio_card::label(Some(SIZE_LABEL_ID), vec![], vec![text("サイズ")]),
radio_card::root(
Size::Sm,
ColorPalette::Accent,
true,
None::<RadioCardOrientation>,
Some(SIZE_LABEL_ID),
vec![("aria-disabled", "true")],
items,
),
styled_text::text(
&TextProps {
size: TextSize::Sm,
variant: TextVariant::Muted,
..TextProps::default()
},
vec![],
vec![text("現在の選択: 350 mL")],
),
],
)
}
/// 在庫行・保証行で共有するチェック線アイコン(独自の単純な幾何形状。
/// 実在アイコンセット・ブランドの意匠は持ち込まない)。装飾用途のため
/// `aria-hidden` を付与する([`IconProps::label`] を `None` のまま使う)。
///
/// `icon::icon` は呼び出し側が渡した `class` 属性を `drop_class_attr` で
/// 無条件に除去するため、独自クラスでは [`LAYOUT_CSS`] の色規則が
/// 当たらない(レビュー指摘対応)。`class` の代わりに `data-*` 属性を渡す
/// (`drop_class_attr` の除去対象ではない)ことで属性セレクタから当て、
/// `[data-scope="icon"][data-part="root"][data-blocks-*]` の形は
/// `testimonial_quote_stats.rs`・`comparison_table.rs`・
/// `auth_split_accent_panel.rs` 等の既存 icon 色付け block と同じ判断。
/// `d="M4 12l5 5L20 6"` はオープンポリライン(チェックマーク)のため、
/// `icon::icon` の既定 `fill="currentColor"` のままでは塗りつぶされて
/// 三角形に見える(レビュー指摘対応)。`fill="none"` + `stroke="currentColor"`
/// へ明示的に切り替える(`feed_upvote_cards.rs::vote_icon` と同じ判断)。
fn check_icon() -> Node {
icon::icon(
&IconProps {
size: Size::Sm,
..IconProps::default()
},
vec![("data-blocks-product-overview-featured-split-icon-stock", "")],
vec![el(
"path",
vec![
("d", "M4 12l5 5L20 6"),
("fill", "none"),
("stroke", "currentColor"),
("stroke-width", "2"),
("stroke-linecap", "round"),
("stroke-linejoin", "round"),
],
vec![],
)],
)
}
/// 在庫行・保証行で共有する盾形アイコン(独自の単純な幾何形状)。
/// `d` は `z` で閉じた単一パスのため、[`check_icon`] と異なり既定の
/// `fill="currentColor"` のままでよい(塗りつぶし形状として意図通り)。
fn shield_icon() -> Node {
icon::icon(
&IconProps {
size: Size::Sm,
..IconProps::default()
},
vec![],
vec![el(
"path",
vec![("d", "M12 3l7 3v6c0 5-3.5 8-7 9-3.5-1-7-4-7-9V6z")],
vec![],
)],
)
}
/// 在庫表示行(icon + text)。
fn stock_row() -> Node {
div(
vec![("class", "blocks-product-overview-featured-split-stock")],
vec![
check_icon(),
styled_text::text(
&TextProps::default(),
vec![],
vec![text("在庫あり・2〜3 営業日で発送")],
),
],
)
}
/// カート追加ボタン(全幅、既定 `type="button"`。モジュール doc「購入
/// ボタンを disabled の静的表示にする理由」節参照)。
fn add_to_cart_button() -> Node {
button::button(
&ButtonProps {
size: Size::Lg,
disabled: true,
..ButtonProps::default()
},
vec![("data-blocks-product-overview-featured-split-add", "")],
vec![text("カートに追加")],
)
}
/// 保証の補足行(icon + text、Muted・Sm)。
fn warranty_row() -> Node {
div(
vec![("class", "blocks-product-overview-featured-split-warranty")],
vec![
shield_icon(),
styled_text::text(
&TextProps {
size: TextSize::Sm,
variant: TextVariant::Muted,
..TextProps::default()
},
vec![],
vec![text("購入から 1 年間の品質保証付き")],
),
],
)
}
/// 左上(summary 領域): パンくず → 商品名 → 価格 → 評価 → 説明。
fn summary() -> Node {
div(
vec![("class", "blocks-product-overview-featured-split-summary")],
vec![
product_breadcrumb(),
heading::heading(
HeadingLevel::H2,
&HeadingProps {
size: HeadingSize::Xl,
..HeadingProps::default()
},
vec![],
vec![text("保温ステンレスボトル")],
),
price_line(),
rating_row(),
styled_text::text(
&TextProps {
variant: TextVariant::Muted,
..TextProps::default()
},
vec![],
vec![text(
"二重構造の真空断熱で保温・保冷が長時間続くステンレスボトルです。シンプルな見た目で普段使いしやすく、オフィスにもアウトドアにも馴染みます。",
)],
),
],
)
}
/// 右(media 領域): 単一の大きな商品画像。
fn media() -> Node {
div(
vec![("class", "blocks-product-overview-featured-split-media")],
vec![image::image(
&ImageProps {
aspect_ratio: AspectRatio::Square,
..ImageProps::new(
dummy_assets::PRODUCT_SRC,
"保温ステンレスボトル の商品画像(プレースホルダー)",
)
},
vec![("data-blocks-product-overview-featured-split-image", "")],
)],
)
}
/// 左下(details 領域): サイズ選択 → 在庫表示 → カート追加ボタン →
/// 保証の補足。
fn details() -> Node {
div(
vec![("class", "blocks-product-overview-featured-split-details")],
vec![
size_options(),
stock_row(),
add_to_cart_button(),
warranty_row(),
],
)
}
/// `product-overview-featured-split` の Demo 本体。呼び出しごとに同一の
/// `Node` を返す純関数(`crate::blocks` モジュール doc「静的表示」節)。
/// DOM 順は `summary → media → details`(モジュール doc「レイアウト」
/// 節参照)。
#[must_use]
pub fn demo() -> Node {
div(
vec![("class", "blocks-product-overview-featured-split-layout")],
vec![summary(), media(), details()],
)
}原案差分メモ
- 主参照・集約元ともに対応表 ID R1177 の 1 件のみのため、状態違い(在庫切れ版等)の併記は行っていません。
48remのブレークポイントは CSS メディアクエリのため、同一ページ内で狭い幅のレイアウトを静的に再現することはできません。実機確認はサンドボックス制約により未実施で、cargo testによる出力検証のみで代替しました。- 狭い幅での画像の挟み込みは
display: noneや DOM の重複ではなく、CSS Grid のgrid-template-areasの切り替えのみで実現しています(DOM 順は常にsummary → media → details)。 _/blocks-intake/の参照ファイルは本イシュー着手時点で worktree に存在しないため、対応表 ID のみを記して実装しました。
関連情報: Breadcrumb / Heading / Text / Image / Radio Card / Rating Group / Button / Icon