testimonial-split-image
fandhe-frontend-pre-styled-ui の blockquote / image / avatar / rating-group / button / link / icon / separator 部品を合成した、人物写真と推薦文を横並びにするセクションです。Blocks セクションは新規部品を追加するものではなく、既存の Themes/Primitives 部品を組み合わせた実例集であることに注意してください(主参照は対応表 ID R1360、集約元は R0356・R0357・R0362・R0726・R0729・R1361。出典の固有名・ファイル名は記載しません)。
md(48rem)以上では片側に人物写真、反対側に引用文・著者情報・任意のロゴ/リンクを置く 2 カラム構成です。狭い幅では写真(または著者列)→ 引用文の順に縦積みになります。3 つの構成(縦長写真 + 星評価 + CTA ボタン / 暗色帯からはみ出す写真 / 写真の代わりに著者列)を静的インスタンスとして並べています。
本 Demo は静的な表示例であり、<form> 要素は一切持たず、データの取得・送信・状態管理を行いません。CTA ボタンは type="button" のまま送信先を持ちません。文言・氏名・社名はすべて独自に書いた架空のものであり、実企業名・実クレデンシャル・PII を含みません。ロゴは実在ブランドを模さない自作の幾何図形です。星評価は他ユーザーの平均評価を表す readonly の静的表示です。
Demo
縦長写真・ロゴ・星評価・CTA
導入からわずか数週間で、チーム全体の作業が驚くほど整理されました。
暗色帯からはみ出す写真
細部まで作り込まれた操作感で、初めて触ったメンバーもすぐに馴染めました。
写真の代わりに著者列
サポートの対応が丁寧で、困ったときにいつも助けてもらっています。
使用部品
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::blockquote::{self, BlockquoteVariant};
use fandhe_frontend_pre_styled_ui::button::{self, ButtonProps};
use fandhe_frontend_pre_styled_ui::icon::{icon, IconProps};
use fandhe_frontend_pre_styled_ui::image::{self, AspectRatio, ImageProps};
use fandhe_frontend_pre_styled_ui::link::{self, LinkProps};
use fandhe_frontend_pre_styled_ui::rating_group::{
self, RatingGroup, RatingGroupProps, RatingItemFlags,
};
use fandhe_frontend_pre_styled_ui::separator::{self, SeparatorProps};
use fandhe_frontend_pre_styled_ui::visually_hidden;
use fandhe_frontend_pre_styled_ui::{ColorPalette, Orientation, Size};
/// 実在の自リポジトリ URL(`href` の方針、モジュール doc 参照)。
const REPO: &str = "https://github.com/Fandhe-AI/fandhe-frontend";
/// [`REPO`] の accessible name(遷移先と食い違わない固定文字列)。
const REPO_LABEL: &str = "fandhe-frontend の GitHub リポジトリ";
/// 抽象図形ロゴ(六角形の輪郭。実在の企業ロゴを模さない、
/// `testimonial_background_image` と同じ形状)。
fn logo_icon(company: &str) -> Node {
icon(
&IconProps {
size: Size::Lg,
label: Some(company),
..IconProps::default()
},
vec![],
vec![el(
"path",
vec![("d", "M12 2L21 7V17L12 22L3 17V7Z")],
vec![],
)],
)
}
/// 星評価(readonly。他ユーザーの平均評価を表す静的表示、
/// `hero_social_proof.rs::rating` と同型)。`label_id` は variant ごとに
/// 一意化する(モジュール doc「星評価は readonly」節)。
fn rating(label_id: &str) -> Node {
let g = RatingGroup::new(5, Some(5), true);
let props = RatingGroupProps {
disabled: false,
readonly: true,
required: false,
};
let label = rating_group::label(
&props,
Some(label_id),
vec![],
vec![visually_hidden::root(vec![], vec![text("Average rating")])],
);
let items: Vec<Node> = (1..=g.count())
.map(|i| {
rating_group::item(
i,
RatingItemFlags {
checked: g.is_checked(i),
highlighted: g.is_highlighted(i),
disabled: false,
readonly: true,
},
&format!("{i} star{}", if i == 1 { "" } else { "s" }),
vec![],
vec![],
)
})
.collect();
let control = rating_group::control(&props, Some(label_id), vec![], items);
rating_group::root(
Size::Sm,
ColorPalette::Accent,
&props,
vec![],
vec![label, control],
)
}
/// 引用文 + 著者(氏名・役職)を持つ [`blockquote::root`]。
fn quote_block(quote_index: usize, person_index: usize) -> Node {
blockquote::root(
BlockquoteVariant::Plain,
ColorPalette::default(),
vec![],
vec![
blockquote::content(
vec![],
vec![text(dummy_assets::TESTIMONIAL_QUOTES[quote_index])],
),
blockquote::caption(
vec![],
vec![
div(vec![], vec![text(dummy_assets::PERSON_NAMES[person_index])]),
div(
vec![],
vec![text(
dummy_assets::JOB_TITLES[person_index % dummy_assets::JOB_TITLES.len()],
)],
),
],
),
],
)
}
/// `portrait` variant(主参照 R1360。縦長写真 + ロゴ + 星評価 + 引用文 + CTA)。
fn instance_portrait() -> Node {
let photo = image::image(
&ImageProps {
aspect_ratio: AspectRatio::Portrait,
..ImageProps::new(dummy_assets::AVATAR_SRC, "")
},
vec![("data-blocks-testimonial-split-image-photo", "")],
);
let content = div(
vec![("class", "blocks-testimonial-split-image-content")],
vec![
logo_icon(dummy_assets::COMPANY_NAMES[0]),
rating("blocks-testimonial-split-image-rating-portrait"),
quote_block(0, 0),
button::button(&ButtonProps::default(), vec![], vec![text("詳しく見る")]),
],
);
div(
vec![
("class", "blocks-testimonial-split-image-layout"),
("data-blocks-testimonial-split-image-variant", "portrait"),
],
vec![photo, content],
)
}
/// `band` variant(集約元 R1361・R0356。暗色帯からはみ出す写真 + 引用文 +
/// ロゴ + 外部リンク)。
fn instance_band() -> Node {
let photo = image::image(
&ImageProps {
aspect_ratio: AspectRatio::Portrait,
..ImageProps::new(dummy_assets::AVATAR_SRC, "")
},
vec![("data-blocks-testimonial-split-image-photo", "")],
);
let content = div(
vec![("class", "blocks-testimonial-split-image-content")],
vec![
logo_icon(dummy_assets::COMPANY_NAMES[1]),
quote_block(1, 1),
link::root(
REPO,
&LinkProps {
external: true,
..LinkProps::default()
},
vec![],
vec![text(REPO_LABEL)],
),
],
);
div(
vec![
("class", "blocks-testimonial-split-image-layout"),
("data-blocks-testimonial-split-image-band", ""),
("data-blocks-testimonial-split-image-variant", "band"),
],
vec![photo, content],
)
}
/// `author-column` variant(集約元 R0357・R0362。写真の代わりに円形
/// アバター + 縦罫線の著者列 + 星評価 + 引用文)。
fn instance_author_column() -> Node {
let author_column = div(
vec![("class", "blocks-testimonial-split-image-author")],
vec![
avatar::root(
&AvatarProps {
size: Size::Xl,
..AvatarProps::default()
},
vec![],
vec![avatar::image(
ImageStatus::Loaded,
dummy_assets::AVATAR_SRC,
"",
vec![],
)],
),
div(vec![], vec![text(dummy_assets::PERSON_NAMES[2])]),
div(
vec![],
vec![text(
dummy_assets::JOB_TITLES[2 % dummy_assets::JOB_TITLES.len()],
)],
),
],
);
// `separator::separator` の垂直バリアントは `align-self: stretch` で高さを
// 得る契約(`fandhe_frontend_pre_styled_ui::separator` 参照)。`.author`
// (column flex)の子に置くと stretch が横方向に働き高さ 0 になるため、
// `.layout` 直下(md 以上で row flex)の兄弟として置き、row 親の高さへ
// 正しく stretch させる(Bugbot 指摘、PR #3316)。
let sep = separator::separator(
&SeparatorProps {
orientation: Orientation::Vertical,
..SeparatorProps::default()
},
vec![("data-blocks-testimonial-split-image-separator", "")],
);
let content = div(
vec![("class", "blocks-testimonial-split-image-content")],
vec![
logo_icon(dummy_assets::COMPANY_NAMES[2]),
rating("blocks-testimonial-split-image-rating-author"),
quote_block(2, 2),
],
);
div(
vec![
("class", "blocks-testimonial-split-image-layout"),
(
"data-blocks-testimonial-split-image-variant",
"author-column",
),
],
vec![author_column, sep, content],
)
}
/// caption(並記された各 variant の見出し)。
fn caption(label: &str) -> Node {
fandhe_frontend_core::p(
vec![("class", "blocks-testimonial-split-image-caption")],
vec![text(label)],
)
}
/// `testimonial-split-image` の Demo 本体(3 variant 併記)。呼び出しごとに
/// 同一の `Node` を返す純関数。
pub fn demo() -> Node {
div(
vec![("class", "blocks-testimonial-split-image-stack")],
vec![
caption("縦長写真・ロゴ・星評価・CTA"),
instance_portrait(),
caption("暗色帯からはみ出す写真"),
instance_band(),
caption("写真の代わりに著者列"),
instance_author_column(),
],
)
}原案差分メモ
- 主参照(対応表 ID R1360、大きな縦長写真)は
portraitインスタンスに対応します。縦長写真の隣に抽象図形ロゴ・星評価(readonly)・引用文・CTA ボタンを縦に並べています。 - 集約元(対応表 ID R1361・R0356、暗色帯からはみ出す写真)は
bandインスタンスに対応します。CSS の::beforeで暗色帯を敷き、md 以上では写真をorderで右側へ回して帯からはみ出す配置にしています(DOM 順・読み上げ順は変えていません)。引用文の下には実在の自リポジトリ URL への外部リンクを置いています。 - 集約元(対応表 ID R0357・R0362、写真の代わりに著者列)は
author-columnインスタンスに対応します。大きな写真の代わりに円形アバター・氏名・役職を縦に並べ、md 以上でのみ表示する縦罫線で本文と区切っています。 - 集約元(対応表 ID R0726・R0729)は基本レイアウト(写真と本文の 2 カラム、狭幅での縦積み)の参照として
portrait/bandインスタンスに反映しており、独立インスタンス化はしていません。 - 写真は隣に氏名見出しが常に出力されるため
alt=""(装飾扱い)にしています。ロゴは装飾用の自作幾何図形(六角形。実在ブランドを模さない)でaria-labelを持つ意味のある画像として扱っています。 - 文言・氏名・社名・ロゴ図形はすべて独自に書いた架空のものです。参照元の文言・配色・アイコン形状は持ち込んでいません。
関連情報: Blockquote / Image / Avatar / Rating Group / Button / Link / Icon / Separator