cart-dialog
カートを開くボタンと、画面中央に出るダイアログ(タイトル・閉じるボタン・数量選択/削除付き商品行・淡い面の集計・右寄せの次へボタン)で構成するカートダイアログのブロックです。dialog / button / image / text / native-select / separator / data-list の 7 部品を合成します。Blocks は既存部品の合成例であり、新しい UI 部品は追加しません。
主参照は対応表 ID R1251(集約元 R0680)です。商品名・属性・価格はすべて架空のデータであり、実在のブランド・商品・PII は含みません。商品画像はビルド時生成の同梱プレースホルダー SVG です。
本 Demo は無 JS の静的表示のみであり、<form> を含みません。ダイアログは常に開いた状態で表示します。
Demo
ショッピングカート
エルゴノミック メッシュチェア
カラー: グレー / サイズ: M
¥24,800
ノイズキャンセリング ヘッドホン
カラー: ブラック
¥18,200
- 小計
- ¥43,000
- 送料
- ¥600
- 合計
- ¥43,600
使用部品
Rust コード
Rust
use crate::blocks::dummy_assets;
use fandhe_frontend_core::{div, el, text, Node};
use fandhe_frontend_pre_styled_ui::button::{button, ButtonProps, ButtonVariant};
use fandhe_frontend_pre_styled_ui::data_list::{self, DataListOrientation, DataListProps};
use fandhe_frontend_pre_styled_ui::dialog::{self, ContentIds, DialogRole, OpenState};
use fandhe_frontend_pre_styled_ui::image::{image, ImageProps, ImageShape};
use fandhe_frontend_pre_styled_ui::native_select::{
native_select, FieldIds, FieldProps, NativeSelectProps,
};
use fandhe_frontend_pre_styled_ui::recipe::Size;
use fandhe_frontend_pre_styled_ui::separator::{separator, SeparatorProps};
use fandhe_frontend_pre_styled_ui::text::{
self as styled_text, TextProps, TextSize, TextVariant, TextWeight,
};
/// [`dialog::content`] の `id`([`dialog::trigger`] の `controls` と対)。
const CONTENT_ID: &str = "blocks-cart-dialog-content";
/// [`dialog::title`] の `id`([`dialog::content`] の `labelledby` と対)。
const TITLE_ID: &str = "blocks-cart-dialog-title";
/// 架空の商品行データ(商品名, 属性表示, 価格表示, 初期選択数量)。
/// 実在のブランド・商品・PII は含まない。
const CART_ITEMS: &[(&str, &str, &str, u8)] = &[
(
"エルゴノミック メッシュチェア",
"カラー: グレー / サイズ: M",
"¥24,800",
1,
),
(
"ノイズキャンセリング ヘッドホン",
"カラー: ブラック",
"¥18,200",
1,
),
];
/// 集計行(ラベル, 値)。最終行(合計)だけ [`summary`] 側で強調用の
/// `data-*` を追加する。
const SUMMARY_ROWS: &[(&str, &str)] = &[("小計", "¥43,000"), ("送料", "¥600"), ("合計", "¥43,600")];
/// 指定した初期選択数量 `selected` の 1〜5 の `<option>` 列を組み立てる
/// (`cart_two_column_summary.rs::qty_options` と同型)。
fn qty_options(selected: u8) -> Vec<Node> {
(1..=5u8)
.map(|n| {
let mut attrs = vec![("value", n.to_string())];
if n == selected {
attrs.push(("selected", String::new()));
}
el(
"option",
attrs.iter().map(|(k, v)| (*k, v.as_str())).collect(),
vec![text(n.to_string())],
)
})
.collect()
}
/// 商品行 1 件(サムネイル + 名称/属性 + 価格/数量選択/削除)。`index` は
/// 0 始まりで、数量 `select` の一意な `id` の派生に使う
/// (`cart_two_column_summary.rs::item_row` と同型)。
fn item_row(index: usize, name: &str, attrs: &str, price: &str, qty: u8) -> Node {
let field_id = format!("blocks-cart-dialog-qty-{}", index + 1);
let qty_aria_label = format!("{name} の数量");
let field = FieldProps {
id: &field_id,
ids: FieldIds::default(),
disabled: true,
invalid: false,
required: false,
readonly: false,
has_helper_text: false,
};
div(
vec![("class", "blocks-cart-dialog-item")],
vec![
image(
&ImageProps {
shape: ImageShape::Rounded,
..ImageProps::new(dummy_assets::PRODUCT_SRC, "")
},
vec![("data-blocks-cart-dialog-thumb", "")],
),
div(
vec![("class", "blocks-cart-dialog-item-body")],
vec![
styled_text::text(
&TextProps {
weight: TextWeight::Medium,
..TextProps::default()
},
vec![],
vec![text(name)],
),
styled_text::text(
&TextProps {
variant: TextVariant::Muted,
size: TextSize::Sm,
..TextProps::default()
},
vec![],
vec![text(attrs)],
),
],
),
div(
vec![("class", "blocks-cart-dialog-item-controls")],
vec![
styled_text::text(
&TextProps {
weight: TextWeight::Medium,
..TextProps::default()
},
vec![],
vec![text(price)],
),
native_select(
&NativeSelectProps::default(),
&field,
vec![("aria-label", qty_aria_label.as_str())],
qty_options(qty),
),
button(
&ButtonProps {
variant: ButtonVariant::Ghost,
size: Size::Sm,
..ButtonProps::default()
},
vec![("disabled", ""), ("data-disabled", "")],
vec![text("削除")],
),
],
),
],
)
}
/// 商品行と行間の `separator` を束ねたリスト。
fn item_list() -> Node {
let mut children = Vec::new();
for (index, (name, attrs, price, qty)) in CART_ITEMS.iter().enumerate() {
if index > 0 {
children.push(separator(&SeparatorProps::default(), vec![]));
}
children.push(item_row(index, name, attrs, price, *qty));
}
div(vec![("class", "blocks-cart-dialog-items")], children)
}
/// 集計行 1 件(`data_list::item` + `item-label` + `item-value`)。最終行
/// (合計)だけ強調用の `data-blocks-cart-dialog-total` を付与する
/// (`cart_two_column_summary.rs::summary_row` と同型)。
fn summary_row(label: &str, value: &str, emphasize: bool) -> Node {
let attrs = if emphasize {
vec![("data-blocks-cart-dialog-total", "")]
} else {
vec![]
};
data_list::item(
attrs,
vec![
data_list::item_label(vec![], vec![text(label)]),
data_list::item_value(vec![], vec![text(value)]),
],
)
}
/// 淡い背景面の集計(小計・送料・合計)。
fn summary() -> Node {
let last = SUMMARY_ROWS.len() - 1;
let rows = SUMMARY_ROWS
.iter()
.enumerate()
.map(|(i, (label, value))| summary_row(label, value, i == last))
.collect();
div(
vec![("data-blocks-cart-dialog-summary", "")],
vec![data_list::root(
DataListProps {
orientation: DataListOrientation::Horizontal,
..DataListProps::default()
},
vec![],
rows,
)],
)
}
/// `cart-dialog` の Demo 本体。カートを開くボタン(右寄せ)+ 中央に開いた
/// 静的なダイアログで構成する。呼び出しごとに同一の `Node` を返す純関数。
pub fn demo() -> Node {
div(
vec![("class", "blocks-cart-dialog-stack")],
vec![
div(
vec![("class", "blocks-cart-dialog-bar")],
vec![dialog::trigger(
OpenState::Open,
Some(CONTENT_ID),
vec![
("disabled", ""),
("data-disabled", ""),
("data-blocks-cart-dialog-open", ""),
],
vec![text("カート(2)")],
)],
),
dialog::root(
Size::Lg,
OpenState::Open,
vec![("data-blocks-cart-dialog-root", "")],
vec![
dialog::backdrop(OpenState::Open, vec![], vec![]),
dialog::positioner(
OpenState::Open,
vec![],
vec![dialog::content(
OpenState::Open,
DialogRole::Dialog,
false,
ContentIds {
id: Some(CONTENT_ID),
labelledby: Some(TITLE_ID),
describedby: None,
},
vec![],
vec![
dialog::close_trigger(
vec![
("aria-label", "閉じる"),
("disabled", ""),
("data-disabled", ""),
],
vec![text("×")],
),
dialog::title(
Some(TITLE_ID),
vec![],
vec![text("ショッピングカート")],
),
dialog::body(
vec![("data-blocks-cart-dialog-body", "")],
vec![item_list()],
),
summary(),
dialog::footer(
vec![],
vec![button(
&ButtonProps::default(),
vec![
("data-blocks-cart-dialog-next", ""),
("disabled", ""),
("data-disabled", ""),
],
vec![text("レジに進む")],
)],
),
],
)],
),
],
),
],
)
}原案差分メモ
- 開くボタン・閉じるボタンは docs サイトが JS ハイドレーションを行わない設計のため無 JS 下では開閉を切り替えられません。「開くボタン」「閉じるボタン」を明示する issue のレイアウト仕様に合わせて両方を配置したうえで、ネイティブ
disabled属性 +data-disabledでフォーカス・クリック不能を明示しています(store-nav-centered-logoと同型の判断)。同じ理由で数量 select・削除ボタン・レジに進むボタンもdisabledにしています(数量 select は集計再計算を持たない静的デモとの矛盾を避けるため)。 - 静的なデモは閉じる機構を実際には持たず、ダイアログの外側に説明・コード・ナビゲーションがあるため、表示の実態と一致させて
aria-modalは false にしています(contact-dialog-formと同じ判断)。 - 主参照(対応表 ID R1251)の中央モーダル + 集計 + 次へボタンという構成を主体とし、集約元(R0680、中央ダイアログ型カート)の商品行構成(画像・名称/属性・価格/数量選択/削除)を統合しています。R0680 単体にはない淡い背景面の集計(小計・送料・合計)と右寄せの次へボタンを主参照側から採用した点が差分です。
- コンテナ幅(Demo 枠の幅、ビューポート幅ではありません)が 36rem 未満になると
@containerによってダイアログの横幅が全幅に近づき、商品行のコントロール・次へボタンが縦積み・全幅になります。
関連情報: Dialog / Button / Image / Text / Native Select / Separator / Data List