单选框和复选框组

This commit is contained in:
tommy
2025-11-06 09:54:40 +08:00
parent f551a41a5b
commit c1ae0eb401
5 changed files with 325 additions and 39 deletions

View File

@@ -2,6 +2,9 @@ You are an expert [0.7 Dioxus](https://dioxuslabs.com/learn/0.7) assistant. Diox
Provide concise code examples with detailed descriptions
You can query documents through context7 mcp.
# Dioxus Dependency
You can add Dioxus to your `Cargo.toml` like this:

View File

@@ -432,6 +432,89 @@
gap: 0.75rem;
}
.ui-radio-chip-group,
.ui-checkbox-chip-group {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.ui-choice-group-label {
font-size: 0.9rem;
font-weight: 500;
color: hsl(var(--foreground));
white-space: nowrap;
}
.ui-choice-group-options {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.ui-radio-chip,
.ui-checkbox-chip {
appearance: none;
background-color: hsl(var(--background));
border: 1px dashed hsl(var(--border));
border-radius: 0.65rem;
color: hsl(var(--muted-foreground));
cursor: pointer;
display: inline-flex;
flex-direction: column;
gap: 0.15rem;
justify-content: center;
min-height: 2.2rem;
min-width: 3.5rem;
padding: 0.45rem 1.1rem;
position: relative;
text-align: center;
transition:
border-color 0.2s ease,
color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
}
.ui-radio-chip:hover:not([data-disabled="true"]),
.ui-checkbox-chip:hover:not([data-disabled="true"]) {
border-color: hsl(var(--foreground) / 0.6);
color: hsl(var(--foreground));
}
.ui-radio-chip[data-state="selected"],
.ui-checkbox-chip[data-state="selected"] {
border: 1px solid hsl(var(--foreground));
color: hsl(var(--foreground));
background-color: hsl(var(--background));
box-shadow: 0 1px 2px hsl(var(--foreground) / 0.2);
}
.ui-radio-chip[data-disabled="true"],
.ui-checkbox-chip[data-disabled="true"] {
cursor: not-allowed;
opacity: 0.55;
}
.ui-radio-chip:focus-visible,
.ui-checkbox-chip:focus-visible {
outline: none;
box-shadow:
0 0 0 2px hsl(var(--background)),
0 0 0 4px hsl(var(--ring) / 0.45);
}
.ui-chip-label {
font-size: 0.95rem;
font-weight: 500;
}
.ui-chip-description {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.ui-switch {
appearance: none;
background-color: hsl(var(--muted));

View File

@@ -56,3 +56,112 @@ pub fn Checkbox(
}
}
}
#[derive(Clone, PartialEq)]
pub struct CheckboxChipOption {
pub label: String,
pub value: String,
pub description: Option<String>,
pub disabled: bool,
}
impl CheckboxChipOption {
pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
Self {
label: label.into(),
value: value.into(),
description: None,
disabled: false,
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn disabled(mut self) -> Self {
self.disabled = true;
self
}
}
#[component]
pub fn CheckboxChipGroup(
mut values: Signal<Vec<String>>,
#[props(into)] options: Vec<CheckboxChipOption>,
#[props(into, default)] label: Option<String>,
#[props(default)] disabled: bool,
#[props(into, default)] class: Option<String>,
#[props(optional)] on_values_change: Option<EventHandler<Vec<String>>>,
) -> Element {
let classes = merge_class("ui-checkbox-chip-group", class);
let current_values = values();
let group_disabled = disabled;
rsx! {
div {
class: classes,
role: "group",
"aria-disabled": group_disabled,
if let Some(label_text) = label.clone() {
span {
class: "ui-choice-group-label",
"{label_text}"
}
}
div {
class: "ui-choice-group-options",
for option in options.iter().cloned() {
{
let option_label = option.label.clone();
let option_value = option.value.clone();
let option_description = option.description.clone();
let is_disabled = group_disabled || option.disabled;
let is_selected = current_values.iter().any(|item| item == &option_value);
let mut values_signal = values.clone();
let handler = on_values_change.clone();
rsx! {
button {
class: "ui-checkbox-chip",
"data-state": if is_selected { "selected" } else { "idle" },
"data-disabled": is_disabled,
role: "checkbox",
"aria-checked": if is_selected { "true" } else { "false" },
"aria-disabled": if is_disabled { "true" } else { "false" },
r#type: "button",
disabled: is_disabled,
onclick: move |_| {
if is_disabled {
return;
}
values_signal.with_mut(|items| {
if let Some(index) = items.iter().position(|item| item == &option_value) {
items.remove(index);
} else {
items.push(option_value.clone());
}
});
if let Some(callback) = handler.clone() {
callback.call(values_signal());
}
},
span {
class: "ui-chip-label",
"{option_label}"
}
if let Some(description) = option_description.clone() {
span {
class: "ui-chip-description",
"{description}"
}
}
}
}
}
}
}
}
}
}

View File

@@ -103,3 +103,115 @@ pub fn RadioGroupItem(
}
}
}
#[derive(Clone, PartialEq)]
pub struct RadioChipOption {
pub label: String,
pub value: String,
pub description: Option<String>,
pub disabled: bool,
}
impl RadioChipOption {
pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
Self {
label: label.into(),
value: value.into(),
description: None,
disabled: false,
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn disabled(mut self) -> Self {
self.disabled = true;
self
}
}
#[component]
pub fn RadioChipGroup(
mut value: Signal<Option<String>>,
#[props(into)] options: Vec<RadioChipOption>,
#[props(into, default)] label: Option<String>,
#[props(default)] disabled: bool,
#[props(into, default)] class: Option<String>,
#[props(optional)] on_value_change: Option<EventHandler<String>>,
) -> Element {
let classes = merge_class("ui-radio-chip-group", class);
let current_value = value();
let group_disabled = disabled;
rsx! {
div {
class: classes,
role: "radiogroup",
"aria-disabled": group_disabled,
if let Some(label_text) = label.clone() {
span {
class: "ui-choice-group-label",
"{label_text}"
}
}
div {
class: "ui-choice-group-options",
for option in options.iter().cloned() {
{
let option_label = option.label.clone();
let option_value = option.value.clone();
let option_description = option.description.clone();
let is_disabled = group_disabled || option.disabled;
let is_selected = current_value
.as_ref()
.map(|selected| selected == &option_value)
.unwrap_or(false);
let mut value_signal = value.clone();
let handler = on_value_change.clone();
rsx! {
button {
class: "ui-radio-chip",
"data-state": if is_selected { "selected" } else { "idle" },
"data-disabled": is_disabled,
role: "radio",
"aria-checked": if is_selected { "true" } else { "false" },
"aria-disabled": if is_disabled { "true" } else { "false" },
r#type: "button",
disabled: is_disabled,
onclick: move |_| {
if is_disabled {
return;
}
let already_selected = value_signal()
.as_ref()
.map(|selected| selected == &option_value)
.unwrap_or(false);
if !already_selected {
value_signal.set(Some(option_value.clone()));
if let Some(callback) = handler.clone() {
callback.call(option_value.clone());
}
}
},
span {
class: "ui-chip-label",
"{option_label}"
}
if let Some(description) = option_description.clone() {
span {
class: "ui-chip-description",
"{description}"
}
}
}
}
}
}
}
}
}
}

View File

@@ -1,12 +1,12 @@
use crate::components::ui::{
Avatar, Badge, BadgeVariant, Button, ButtonSize, ButtonVariant, Card, CardContent,
CardDescription, CardFooter, CardHeader, CardTitle, Checkbox, DateRange, DateRangePicker,
Input, Label, Pagination, Popover, Select, SelectOption, Slider, Switch, Table, TableBody,
TableCell, TableHead, TableHeader, TableRow, ToggleGroup, ToggleGroupItem, ToggleGroupMode,
CardDescription, CardFooter, CardHeader, CardTitle, CheckboxChipGroup, CheckboxChipOption,
DateRange, DateRangePicker, Input, Label, Pagination, Popover, Select, SelectOption, Slider,
Switch, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, ToggleGroup,
ToggleGroupItem, ToggleGroupMode,
};
use chrono::NaiveDate;
use dioxus::prelude::*;
use std::collections::HashSet;
const PAGE_SIZE: usize = 8;
const AVAILABLE_TAGS: &[&str] = &["加急", "赠品", "VIP", "缺货", "重复下单", "需回访"];
@@ -499,7 +499,7 @@ pub fn Orders() -> Element {
let fulfillment_filter = use_signal(|| None::<FulfillmentStatus>);
let channel_filter = use_signal(|| None::<SalesChannel>);
let method_filter = use_signal(|| None::<PaymentMethod>);
let tags_filter = use_signal(|| HashSet::<String>::new());
let tags_filter = use_signal(|| Vec::<String>::new());
let min_total = use_signal(|| 0.0f32);
let flagged_only = use_signal(|| false);
let date_range = use_signal(|| None::<DateRange>);
@@ -546,8 +546,8 @@ pub fn Orders() -> Element {
let fulfillment_selected = fulfillment_filter();
let channel_selected = channel_filter();
let method_selected = method_filter();
let tag_filter_set = tags_filter();
let active_tag_count = tag_filter_set.len();
let selected_tags = tags_filter();
let active_tag_count = selected_tags.len();
let min_total_value = min_total();
let flagged_only_value = flagged_only();
let date_range_selected = date_range();
@@ -573,6 +573,10 @@ pub fn Orders() -> Element {
.first()
.cloned()
.unwrap_or_else(|| "all".to_string());
let tag_chip_options: Vec<CheckboxChipOption> = AVAILABLE_TAGS
.iter()
.map(|tag| CheckboxChipOption::new(*tag, *tag))
.collect();
let filtered: Vec<Order> = all_orders
.iter()
@@ -609,10 +613,10 @@ pub fn Orders() -> Element {
.map(|expected| order.payment_method == expected)
.unwrap_or(true);
let matches_tags = if tag_filter_set.is_empty() {
let matches_tags = if selected_tags.is_empty() {
true
} else {
tag_filter_set
selected_tags
.iter()
.all(|tag| order.tags.iter().any(|candidate| candidate == tag))
};
@@ -889,35 +893,10 @@ pub fn Orders() -> Element {
span { class: "ui-field-helper", {format!("最低金额:¥{}", min_total_value as i32)} }
}
div { class: "ui-stack orders-filter-wide", style: "gap: 0.5rem;",
Label { "标签" }
div { class: "orders-tag-cloud",
for tag in AVAILABLE_TAGS.iter() {
{
let tag_value = tag.to_string();
let is_checked = tag_filter_set.contains(*tag);
rsx! {
label { style: "display: inline-flex; align-items: center; gap: 0.4rem;",
Checkbox {
checked: is_checked,
on_checked_change: {
let mut setter = tags_filter.clone();
let tag_value = tag_value.clone();
move |checked: bool| {
setter.with_mut(|tags| {
if checked {
tags.insert(tag_value.clone());
} else {
tags.remove(&tag_value);
}
});
}
},
}
span { "{tag}" }
}
}
}
}
CheckboxChipGroup {
label: "标签",
values: tags_filter.clone(),
options: tag_chip_options.clone(),
}
span { class: "ui-field-helper", {format!("已选标签:{}", active_tag_count)} }
}
@@ -961,7 +940,7 @@ pub fn Orders() -> Element {
setter_fulfillment.set(None);
setter_channel.set(None);
setter_method.set(None);
setter_tags.set(HashSet::new());
setter_tags.set(Vec::new());
setter_range.set(None);
setter_min_total.set(0.0);
setter_flagged.set(false);