优化重构

This commit is contained in:
tommy
2025-11-07 15:34:18 +08:00
parent dddab92102
commit f3ec5a56ff
6 changed files with 407 additions and 335 deletions

View File

@@ -97,13 +97,12 @@ pub fn CheckboxChipGroup(
) -> Element { ) -> Element {
let classes = merge_class("ui-checkbox-chip-group", class); let classes = merge_class("ui-checkbox-chip-group", class);
let current_values = values(); let current_values = values();
let group_disabled = disabled;
rsx! { rsx! {
div { div {
class: classes, class: classes,
role: "group", role: "group",
"aria-disabled": group_disabled, "aria-disabled": disabled,
if let Some(label_text) = label.clone() { if let Some(label_text) = label.clone() {
span { span {
class: "ui-choice-group-label", class: "ui-choice-group-label",
@@ -112,49 +111,25 @@ pub fn CheckboxChipGroup(
} }
div { div {
class: "ui-choice-group-options", class: "ui-choice-group-options",
for option in options.iter().cloned() { for option in options {
{ {
let option_label = option.label.clone(); let is_selected = current_values.iter().any(|item| item == &option.value);
let option_value = option.value.clone(); let disabled_state = disabled || option.disabled;
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! { rsx! {
button { CheckboxChipItem {
class: "ui-checkbox-chip", option,
"data-state": if is_selected { "selected" } else { "idle" }, is_selected,
"data-disabled": is_disabled, disabled: disabled_state,
role: "checkbox", on_toggle: move |value: String| {
"aria-checked": if is_selected { "true" } else { "false" }, values.with_mut(|items| {
"aria-disabled": if is_disabled { "true" } else { "false" }, if let Some(index) = items.iter().position(|item| item == &value) {
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); items.remove(index);
} else { } else {
items.push(option_value.clone()); items.push(value);
} }
}); });
if let Some(callback) = handler.clone() { if let Some(callback) = on_values_change.as_ref() {
callback.call(values_signal()); callback.call(values());
}
},
span {
class: "ui-chip-label",
"{option_label}"
}
if let Some(description) = option_description.clone() {
span {
class: "ui-chip-description",
"{description}"
} }
} }
} }
@@ -165,3 +140,41 @@ pub fn CheckboxChipGroup(
} }
} }
} }
#[component]
fn CheckboxChipItem(
option: CheckboxChipOption,
is_selected: bool,
disabled: bool,
on_toggle: EventHandler<String>,
) -> Element {
let value = option.value.clone();
rsx! {
button {
class: "ui-checkbox-chip",
"data-state": if is_selected { "selected" } else { "idle" },
"data-disabled": disabled,
role: "checkbox",
"aria-checked": if is_selected { "true" } else { "false" },
"aria-disabled": if disabled { "true" } else { "false" },
r#type: "button",
disabled,
onclick: move |_| {
if !disabled {
on_toggle.call(value.clone());
}
},
span {
class: "ui-chip-label",
"{option.label}"
}
if let Some(description) = option.description {
span {
class: "ui-chip-description",
"{description}"
}
}
}
}
}

View File

@@ -43,11 +43,10 @@ pub fn Combobox(
) -> Element { ) -> Element {
let classes = merge_class("ui-combobox", class); let classes = merge_class("ui-combobox", class);
let trigger_id = id.unwrap_or_default(); let trigger_id = id.unwrap_or_default();
let search_placeholder = search_placeholder.unwrap_or_else(|| "Search...".to_string()); let search_placeholder_text = search_placeholder.unwrap_or_else(|| "Search...".to_string());
let open = use_signal(|| false); let mut open = use_signal(|| false);
let current_selection = use_signal(move || selected.clone()); let mut current_selection = use_signal(move || selected.clone());
let query = use_signal(|| String::new()); let mut query = use_signal(|| String::new());
let on_select_handler = on_select.clone();
let current_value = current_selection(); let current_value = current_selection();
let display_label = current_value let display_label = current_value
@@ -77,47 +76,84 @@ pub fn Combobox(
div { div {
class: classes, class: classes,
"data-disabled": disabled, "data-disabled": disabled,
onfocusout: { onfocusout: move |_| open.set(false),
let mut open_signal = open.clone(); ComboboxTrigger {
move |_| open_signal.set(false) id: trigger_id,
},
button {
class: "ui-combobox-trigger",
id: trigger_id.clone(),
"aria-haspopup": "dialog",
"aria-expanded": if open() { "true" } else { "false" },
disabled, disabled,
onclick: { open: open(),
let mut open_signal = open.clone(); display_label,
move |_| { on_toggle: move |_| {
if !disabled { if !disabled {
let next_state = !open_signal(); let next_state = !open();
open_signal.set(next_state); open.set(next_state);
if !next_state { if !next_state {
let mut query_signal = query.clone(); query.set(String::new());
query_signal.set(String::new());
} }
} }
} }
},
span { "{display_label}" }
span { class: "ui-combobox-caret", if open() { "" } else { "" } }
} }
if open() { if open() {
ComboboxContent {
search_placeholder: search_placeholder_text,
query,
filtered_options,
current_value,
on_select: move |value: String| {
current_selection.set(Some(value.clone()));
if let Some(callback) = on_select.as_ref() {
callback.call(value);
}
open.set(false);
query.set(String::new());
}
}
}
}
}
}
#[component]
fn ComboboxTrigger(
#[props(into)] id: String,
disabled: bool,
open: bool,
#[props(into)] display_label: String,
on_toggle: EventHandler<()>,
) -> Element {
rsx! {
button {
class: "ui-combobox-trigger",
id,
"aria-haspopup": "dialog",
"aria-expanded": if open { "true" } else { "false" },
disabled,
onclick: move |_| on_toggle.call(()),
span { "{display_label}" }
span { class: "ui-combobox-caret", if open { "" } else { "" } }
}
}
}
#[component]
fn ComboboxContent(
#[props(into)] search_placeholder: String,
mut query: Signal<String>,
filtered_options: Vec<ComboboxOption>,
current_value: Option<String>,
on_select: EventHandler<String>,
) -> Element {
rsx! {
div { div {
class: "ui-combobox-content", class: "ui-combobox-content",
div { div {
class: "ui-combobox-search", class: "ui-combobox-search",
input { input {
class: "ui-combobox-input", class: "ui-combobox-input",
placeholder: search_placeholder.clone(), placeholder: search_placeholder,
r#type: "text", r#type: "text",
autofocus: true, autofocus: true,
value: "{query()}", value: "{query()}",
oninput: { oninput: move |event| query.set(event.value()),
let mut query_signal = query.clone();
move |event| query_signal.set(event.value())
},
} }
} }
if filtered_options.is_empty() { if filtered_options.is_empty() {
@@ -130,41 +166,12 @@ pub fn Combobox(
class: "ui-combobox-list", class: "ui-combobox-list",
for option in filtered_options { for option in filtered_options {
{ {
let is_active = current_value let is_active = current_value.as_ref().map(|v| v == &option.value).unwrap_or(false);
.as_ref()
.map(|value| value == &option.value)
.unwrap_or(false);
let option_value = option.value.clone();
let option_label = option.label.clone();
let option_description = option.description.clone();
rsx! { rsx! {
li { ComboboxItem {
class: "ui-combobox-item", option,
"data-state": if is_active { "active" } else { "inactive" }, is_active,
button { on_select
r#type: "button",
onclick: {
let option_value = option_value.clone();
let handler = on_select_handler.clone();
let mut open_signal = open.clone();
let mut current_signal = current_selection.clone();
let mut query_signal = query.clone();
move |_| {
current_signal.set(Some(option_value.clone()));
if let Some(callback) = handler.clone() {
callback.call(option_value.clone());
}
open_signal.set(false);
query_signal.set(String::new());
}
},
span { class: "ui-combobox-label", "{option_label}" }
if let Some(description) = option_description {
span { class: "ui-combobox-description", "{description}" }
}
}
}
}
} }
} }
} }
@@ -174,3 +181,27 @@ pub fn Combobox(
} }
} }
} }
#[component]
fn ComboboxItem(
option: ComboboxOption,
is_active: bool,
on_select: EventHandler<String>,
) -> Element {
let value = option.value.clone();
rsx! {
li {
class: "ui-combobox-item",
"data-state": if is_active { "active" } else { "inactive" },
button {
r#type: "button",
onclick: move |_| on_select.call(value.clone()),
span { class: "ui-combobox-label", "{option.label}" }
if let Some(description) = option.description {
span { class: "ui-combobox-description", "{description}" }
}
}
}
}
}

View File

@@ -1,4 +1,5 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use super::button::{Button, ButtonVariant, ButtonSize};
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
pub enum DropdownItemVariant { pub enum DropdownItemVariant {
@@ -7,10 +8,10 @@ pub enum DropdownItemVariant {
} }
impl DropdownItemVariant { impl DropdownItemVariant {
fn as_str(&self) -> &'static str { fn to_button_variant(&self) -> ButtonVariant {
match self { match self {
DropdownItemVariant::Default => "default", DropdownItemVariant::Default => ButtonVariant::Ghost,
DropdownItemVariant::Destructive => "destructive", DropdownItemVariant::Destructive => ButtonVariant::Destructive,
} }
} }
} }
@@ -56,25 +57,19 @@ pub fn DropdownMenu(
#[props(into)] items: Vec<DropdownMenuItem>, #[props(into)] items: Vec<DropdownMenuItem>,
#[props(optional)] on_select: Option<EventHandler<String>>, #[props(optional)] on_select: Option<EventHandler<String>>,
) -> Element { ) -> Element {
let open = use_signal(|| false); let mut open = use_signal(|| false);
let on_select_handler = on_select.clone();
rsx! { rsx! {
div { div {
class: "ui-dropdown", class: "ui-dropdown",
button { Button {
variant: ButtonVariant::Ghost,
size: ButtonSize::Sm,
class: "ui-dropdown-trigger", class: "ui-dropdown-trigger",
"data-open": if open() { "true" } else { "false" }, on_click: move |_| open.set(!open()),
onclick: { "{label}"
let mut signal = open.clone();
move |_| {
let new_state = !signal();
signal.set(new_state);
}
},
span { "{label}" }
span { span {
style: "font-size: 0.85rem; opacity: 0.7;", style: "margin-left: 0.5rem; font-size: 0.85rem; opacity: 0.7;",
"" ""
} }
} }
@@ -83,33 +78,14 @@ pub fn DropdownMenu(
class: "ui-dropdown-content", class: "ui-dropdown-content",
div { div {
class: "ui-dropdown-list", class: "ui-dropdown-list",
for item in items.iter().cloned() { for item in items {
{ DropdownMenuItemButton {
let value = item.value.clone(); item,
let shortcut = item.shortcut.clone(); on_click: move |value: String| {
let variant = item.variant.as_str().to_string(); if let Some(callback) = on_select.as_ref() {
let mut open_signal = open.clone(); callback.call(value);
let handler = on_select_handler.clone();
rsx! {
button {
class: "ui-dropdown-item",
"data-variant": variant,
onclick: {
let value = value.clone();
let handler = handler.clone();
move |_| {
if let Some(callback) = handler.clone() {
callback.call(value.clone());
}
open_signal.set(false);
}
},
span { "{item.label}" }
if let Some(shortcut) = shortcut.clone() {
span { style: "font-size: 0.75rem; opacity: 0.6;", "{shortcut}" }
}
} }
open.set(false);
} }
} }
} }
@@ -119,3 +95,27 @@ pub fn DropdownMenu(
} }
} }
} }
#[component]
fn DropdownMenuItemButton(
item: DropdownMenuItem,
on_click: EventHandler<String>,
) -> Element {
let value = item.value.clone();
rsx! {
Button {
variant: item.variant.to_button_variant(),
size: ButtonSize::Sm,
class: "ui-dropdown-item",
on_click: move |_| on_click.call(value.clone()),
span { "{item.label}" }
if let Some(shortcut) = item.shortcut {
span {
style: "margin-left: auto; font-size: 0.75rem; opacity: 0.6;",
"{shortcut}"
}
}
}
}
}

View File

@@ -1,4 +1,5 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use super::button::{Button, ButtonVariant, ButtonSize};
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
pub struct MenubarItem { pub struct MenubarItem {
@@ -50,67 +51,90 @@ pub fn Menubar(
#[props(optional)] on_select: Option<EventHandler<String>>, #[props(optional)] on_select: Option<EventHandler<String>>,
) -> Element { ) -> Element {
let mut open = use_signal(|| None::<usize>); let mut open = use_signal(|| None::<usize>);
let handler = on_select.clone();
let menu_nodes: Vec<_> = menus
.iter()
.enumerate()
.map(|(index, menu)| {
let mut open_signal_hover = open.clone();
let mut open_signal_click = open.clone();
let is_open = open() == Some(index);
let item_nodes: Vec<_> = menu
.items
.iter()
.map(|item| {
let value = item.value.clone();
let shortcut = item.shortcut.clone();
let destructive = item.destructive;
let handler_clone = handler.clone();
let mut open_close = open.clone();
rsx! {
button {
class: "ui-menubar-item",
"data-variant": if destructive { "destructive" } else { "default" },
onclick: move |_| {
if let Some(cb) = handler_clone.clone() {
cb.call(value.clone());
}
open_close.set(None);
},
span { "{item.label}" }
if let Some(shortcut) = shortcut.clone() {
span { style: "font-size: 0.75rem; opacity: 0.6;", "{shortcut}" }
}
}
}
})
.collect();
rsx! {
span {
style: "position: relative;",
button {
class: "ui-menubar-trigger",
"data-open": if is_open { "true" } else { "false" },
onmouseenter: move |_| open_signal_hover.set(Some(index)),
onclick: move |_| open_signal_click.set(Some(index)),
"{menu.label}"
}
if is_open {
div { class: "ui-menubar-content", {item_nodes.into_iter()} }
}
}
}
})
.collect();
rsx! { rsx! {
div { div {
class: "ui-menubar", class: "ui-menubar",
onmouseleave: move |_| open.set(None), onmouseleave: move |_| open.set(None),
{menu_nodes.into_iter()} for (index, menu) in menus.into_iter().enumerate() {
MenubarMenuTrigger {
menu,
index,
is_open: open() == Some(index),
on_hover: move |idx| open.set(Some(idx)),
on_click: move |idx| open.set(Some(idx)),
on_item_select: move |value: String| {
if let Some(cb) = on_select.as_ref() {
cb.call(value);
}
open.set(None);
}
}
}
}
}
}
#[component]
fn MenubarMenuTrigger(
menu: MenubarMenu,
index: usize,
is_open: bool,
on_hover: EventHandler<usize>,
on_click: EventHandler<usize>,
on_item_select: EventHandler<String>,
) -> Element {
rsx! {
span {
style: "position: relative;",
onmouseenter: move |_| on_hover.call(index),
Button {
variant: if is_open { ButtonVariant::Ghost } else { ButtonVariant::Ghost },
size: ButtonSize::Sm,
class: "ui-menubar-trigger",
on_click: move |_| on_click.call(index),
"{menu.label}"
}
if is_open {
div {
class: "ui-menubar-content",
for item in menu.items {
MenubarItemButton {
item,
on_select: on_item_select
}
}
}
}
}
}
}
#[component]
fn MenubarItemButton(
item: MenubarItem,
on_select: EventHandler<String>,
) -> Element {
let value = item.value.clone();
let variant = if item.destructive {
ButtonVariant::Destructive
} else {
ButtonVariant::Ghost
};
rsx! {
Button {
variant,
size: ButtonSize::Sm,
class: "ui-menubar-item",
on_click: move |_| on_select.call(value.clone()),
span { "{item.label}" }
if let Some(shortcut) = item.shortcut {
span {
style: "margin-left: auto; font-size: 0.75rem; opacity: 0.6;",
"{shortcut}"
}
}
} }
} }
} }

View File

@@ -1,4 +1,5 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use super::button::{Button, ButtonVariant, ButtonSize};
#[component] #[component]
pub fn Pagination( pub fn Pagination(
@@ -13,13 +14,75 @@ pub fn Pagination(
} }
}); });
let on_change = on_page_change.clone(); let buttons = calculate_page_buttons(total_pages, current());
rsx! {
nav {
class: "ui-pagination",
aria_label: "Pagination",
Button {
variant: ButtonVariant::Outline,
size: ButtonSize::Sm,
disabled: current() <= 1,
on_click: move |_| {
let new_page = current().saturating_sub(1).max(1);
current.set(new_page);
if let Some(cb) = on_page_change.as_ref() {
cb.call(new_page);
}
},
"Prev"
}
for page in buttons {
{
if page == 0 {
rsx! {
span {
class: "ui-pagination-ellipsis",
""
}
}
} else {
let is_active = current() == page;
rsx! {
Button {
variant: if is_active { ButtonVariant::Default } else { ButtonVariant::Ghost },
size: ButtonSize::Sm,
on_click: move |_| {
let new_page = page.max(1).min(total_pages.max(1));
current.set(new_page);
if let Some(cb) = on_page_change.as_ref() {
cb.call(new_page);
}
},
"{page}"
}
}
}
}
}
Button {
variant: ButtonVariant::Outline,
size: ButtonSize::Sm,
disabled: current() >= total_pages.max(1),
on_click: move |_| {
let new_page = (current() + 1).min(total_pages.max(1));
current.set(new_page);
if let Some(cb) = on_page_change.as_ref() {
cb.call(new_page);
}
},
"Next"
}
}
}
}
fn calculate_page_buttons(total_pages: usize, active: usize) -> Vec<usize> {
let mut buttons = vec![]; let mut buttons = vec![];
if total_pages <= 7 { if total_pages <= 7 {
buttons.extend(1..=total_pages); buttons.extend(1..=total_pages);
} else { } else {
let active = current();
buttons.extend([1, 2]); buttons.extend([1, 2]);
if active > 4 { if active > 4 {
buttons.push(0); // ellipsis indicator buttons.push(0); // ellipsis indicator
@@ -34,68 +97,5 @@ pub fn Pagination(
} }
buttons.extend([total_pages - 1, total_pages]); buttons.extend([total_pages - 1, total_pages]);
} }
buttons
let page_nodes: Vec<_> = buttons
.iter()
.map(|page| {
if *page == 0 {
rsx! { span { class: "ui-page-button", style: "pointer-events: none;", "" } }
} else {
let mut page_signal = current.clone();
let page_handler = on_change.clone();
let target = *page;
rsx! {
button {
class: "ui-page-button",
"data-active": if page_signal() == target { "true" } else { "false" },
onclick: move |_| {
let new_page = target.max(1).min(total_pages.max(1));
page_signal.set(new_page);
if let Some(cb) = page_handler.clone() {
cb.call(new_page);
}
},
"{target}"
}
}
}
})
.collect();
let mut prev_signal = current.clone();
let prev_handler = on_change.clone();
let mut next_signal = current.clone();
let next_handler = on_change.clone();
rsx! {
nav {
class: "ui-pagination",
aria_label: "Pagination",
button {
class: "ui-page-button",
disabled: prev_signal() <= 1,
onclick: move |_| {
let new_page = prev_signal().saturating_sub(1).max(1);
prev_signal.set(new_page);
if let Some(cb) = prev_handler.clone() {
cb.call(new_page);
}
},
"Prev"
}
{page_nodes.into_iter()}
button {
class: "ui-page-button",
disabled: next_signal() >= total_pages.max(1),
onclick: move |_| {
let new_page = (next_signal() + 1).min(total_pages.max(1));
next_signal.set(new_page);
if let Some(cb) = next_handler.clone() {
cb.call(new_page);
}
},
"Next"
}
}
}
} }

View File

@@ -144,13 +144,12 @@ pub fn RadioChipGroup(
) -> Element { ) -> Element {
let classes = merge_class("ui-radio-chip-group", class); let classes = merge_class("ui-radio-chip-group", class);
let current_value = value(); let current_value = value();
let group_disabled = disabled;
rsx! { rsx! {
div { div {
class: classes, class: classes,
role: "radiogroup", role: "radiogroup",
"aria-disabled": group_disabled, "aria-disabled": disabled,
if let Some(label_text) = label.clone() { if let Some(label_text) = label.clone() {
span { span {
class: "ui-choice-group-label", class: "ui-choice-group-label",
@@ -159,52 +158,19 @@ pub fn RadioChipGroup(
} }
div { div {
class: "ui-choice-group-options", class: "ui-choice-group-options",
for option in options.iter().cloned() { for option in options {
{ {
let option_label = option.label.clone(); let is_selected = current_value.as_ref().map(|v| v == &option.value).unwrap_or(false);
let option_value = option.value.clone(); let disabled_state = disabled || option.disabled;
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! { rsx! {
button { RadioChipItem {
class: "ui-radio-chip", option,
"data-state": if is_selected { "selected" } else { "idle" }, is_selected,
"data-disabled": is_disabled, disabled: disabled_state,
role: "radio", on_select: move |selected_value: String| {
"aria-checked": if is_selected { "true" } else { "false" }, value.set(Some(selected_value.clone()));
"aria-disabled": if is_disabled { "true" } else { "false" }, if let Some(callback) = on_value_change.as_ref() {
r#type: "button", callback.call(selected_value);
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}"
} }
} }
} }
@@ -215,3 +181,41 @@ pub fn RadioChipGroup(
} }
} }
} }
#[component]
fn RadioChipItem(
option: RadioChipOption,
is_selected: bool,
disabled: bool,
on_select: EventHandler<String>,
) -> Element {
let value = option.value.clone();
rsx! {
button {
class: "ui-radio-chip",
"data-state": if is_selected { "selected" } else { "idle" },
"data-disabled": disabled,
role: "radio",
"aria-checked": if is_selected { "true" } else { "false" },
"aria-disabled": if disabled { "true" } else { "false" },
r#type: "button",
disabled,
onclick: move |_| {
if !disabled && !is_selected {
on_select.call(value.clone());
}
},
span {
class: "ui-chip-label",
"{option.label}"
}
if let Some(description) = option.description {
span {
class: "ui-chip-description",
"{description}"
}
}
}
}
}