diff --git a/src/components/ui/checkbox.rs b/src/components/ui/checkbox.rs index e00808d..9d60f20 100644 --- a/src/components/ui/checkbox.rs +++ b/src/components/ui/checkbox.rs @@ -97,13 +97,12 @@ pub fn CheckboxChipGroup( ) -> 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, + "aria-disabled": disabled, if let Some(label_text) = label.clone() { span { class: "ui-choice-group-label", @@ -112,49 +111,25 @@ pub fn CheckboxChipGroup( } div { class: "ui-choice-group-options", - for option in options.iter().cloned() { + for option in options { { - 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(); - + let is_selected = current_values.iter().any(|item| item == &option.value); + let disabled_state = disabled || option.disabled; 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) { + CheckboxChipItem { + option, + is_selected, + disabled: disabled_state, + on_toggle: move |value: String| { + values.with_mut(|items| { + if let Some(index) = items.iter().position(|item| item == &value) { items.remove(index); } else { - items.push(option_value.clone()); + items.push(value); } }); - 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}" + if let Some(callback) = on_values_change.as_ref() { + callback.call(values()); } } } @@ -165,3 +140,41 @@ pub fn CheckboxChipGroup( } } } + +#[component] +fn CheckboxChipItem( + option: CheckboxChipOption, + is_selected: bool, + disabled: bool, + on_toggle: EventHandler, +) -> 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}" + } + } + } + } +} diff --git a/src/components/ui/combobox.rs b/src/components/ui/combobox.rs index fe9a033..4eea3f3 100644 --- a/src/components/ui/combobox.rs +++ b/src/components/ui/combobox.rs @@ -43,11 +43,10 @@ pub fn Combobox( ) -> Element { let classes = merge_class("ui-combobox", class); let trigger_id = id.unwrap_or_default(); - let search_placeholder = search_placeholder.unwrap_or_else(|| "Search...".to_string()); - let open = use_signal(|| false); - let current_selection = use_signal(move || selected.clone()); - let query = use_signal(|| String::new()); - let on_select_handler = on_select.clone(); + let search_placeholder_text = search_placeholder.unwrap_or_else(|| "Search...".to_string()); + let mut open = use_signal(|| false); + let mut current_selection = use_signal(move || selected.clone()); + let mut query = use_signal(|| String::new()); let current_value = current_selection(); let display_label = current_value @@ -77,94 +76,102 @@ pub fn Combobox( div { class: classes, "data-disabled": disabled, - onfocusout: { - let mut open_signal = open.clone(); - move |_| open_signal.set(false) - }, - button { - class: "ui-combobox-trigger", - id: trigger_id.clone(), - "aria-haspopup": "dialog", - "aria-expanded": if open() { "true" } else { "false" }, + onfocusout: move |_| open.set(false), + ComboboxTrigger { + id: trigger_id, disabled, - onclick: { - let mut open_signal = open.clone(); - move |_| { - if !disabled { - let next_state = !open_signal(); - open_signal.set(next_state); - if !next_state { - let mut query_signal = query.clone(); - query_signal.set(String::new()); - } + open: open(), + display_label, + on_toggle: move |_| { + if !disabled { + let next_state = !open(); + open.set(next_state); + if !next_state { + query.set(String::new()); } } - }, - span { "{display_label}" } - span { class: "ui-combobox-caret", if open() { "▲" } else { "▼" } } + } } if open() { - div { - class: "ui-combobox-content", - div { - class: "ui-combobox-search", - input { - class: "ui-combobox-input", - placeholder: search_placeholder.clone(), - r#type: "text", - autofocus: true, - value: "{query()}", - oninput: { - let mut query_signal = query.clone(); - move |event| query_signal.set(event.value()) - }, + 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()); } - if filtered_options.is_empty() { - div { - class: "ui-combobox-empty", - "No results found" - } - } else { - ul { - class: "ui-combobox-list", - for option in filtered_options { - { - let is_active = current_value - .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! { - li { - class: "ui-combobox-item", - "data-state": if is_active { "active" } else { "inactive" }, - button { - 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}" } - } - } - } - } + } + } + } + } +} + +#[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, + filtered_options: Vec, + current_value: Option, + on_select: EventHandler, +) -> Element { + rsx! { + div { + class: "ui-combobox-content", + div { + class: "ui-combobox-search", + input { + class: "ui-combobox-input", + placeholder: search_placeholder, + r#type: "text", + autofocus: true, + value: "{query()}", + oninput: move |event| query.set(event.value()), + } + } + if filtered_options.is_empty() { + div { + class: "ui-combobox-empty", + "No results found" + } + } else { + ul { + class: "ui-combobox-list", + for option in filtered_options { + { + let is_active = current_value.as_ref().map(|v| v == &option.value).unwrap_or(false); + rsx! { + ComboboxItem { + option, + is_active, + on_select } } } @@ -174,3 +181,27 @@ pub fn Combobox( } } } + +#[component] +fn ComboboxItem( + option: ComboboxOption, + is_active: bool, + on_select: EventHandler, +) -> 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}" } + } + } + } + } +} diff --git a/src/components/ui/dropdown_menu.rs b/src/components/ui/dropdown_menu.rs index e60647b..03c7ea0 100644 --- a/src/components/ui/dropdown_menu.rs +++ b/src/components/ui/dropdown_menu.rs @@ -1,4 +1,5 @@ use dioxus::prelude::*; +use super::button::{Button, ButtonVariant, ButtonSize}; #[derive(Clone, PartialEq)] pub enum DropdownItemVariant { @@ -7,10 +8,10 @@ pub enum DropdownItemVariant { } impl DropdownItemVariant { - fn as_str(&self) -> &'static str { + fn to_button_variant(&self) -> ButtonVariant { match self { - DropdownItemVariant::Default => "default", - DropdownItemVariant::Destructive => "destructive", + DropdownItemVariant::Default => ButtonVariant::Ghost, + DropdownItemVariant::Destructive => ButtonVariant::Destructive, } } } @@ -56,25 +57,19 @@ pub fn DropdownMenu( #[props(into)] items: Vec, #[props(optional)] on_select: Option>, ) -> Element { - let open = use_signal(|| false); - let on_select_handler = on_select.clone(); + let mut open = use_signal(|| false); rsx! { div { class: "ui-dropdown", - button { + Button { + variant: ButtonVariant::Ghost, + size: ButtonSize::Sm, class: "ui-dropdown-trigger", - "data-open": if open() { "true" } else { "false" }, - onclick: { - let mut signal = open.clone(); - move |_| { - let new_state = !signal(); - signal.set(new_state); - } - }, - span { "{label}" } + on_click: move |_| open.set(!open()), + "{label}" 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", div { class: "ui-dropdown-list", - for item in items.iter().cloned() { - { - let value = item.value.clone(); - let shortcut = item.shortcut.clone(); - let variant = item.variant.as_str().to_string(); - let mut open_signal = open.clone(); - 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}" } - } + for item in items { + DropdownMenuItemButton { + item, + on_click: move |value: String| { + if let Some(callback) = on_select.as_ref() { + callback.call(value); } + open.set(false); } } } @@ -119,3 +95,27 @@ pub fn DropdownMenu( } } } + +#[component] +fn DropdownMenuItemButton( + item: DropdownMenuItem, + on_click: EventHandler, +) -> 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}" + } + } + } + } +} diff --git a/src/components/ui/menubar.rs b/src/components/ui/menubar.rs index 4323d3f..d5a66a5 100644 --- a/src/components/ui/menubar.rs +++ b/src/components/ui/menubar.rs @@ -1,4 +1,5 @@ use dioxus::prelude::*; +use super::button::{Button, ButtonVariant, ButtonSize}; #[derive(Clone, PartialEq)] pub struct MenubarItem { @@ -50,67 +51,90 @@ pub fn Menubar( #[props(optional)] on_select: Option>, ) -> Element { let mut open = use_signal(|| None::); - 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! { div { class: "ui-menubar", 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, + on_click: EventHandler, + on_item_select: EventHandler, +) -> 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, +) -> 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}" + } + } } } } diff --git a/src/components/ui/pagination.rs b/src/components/ui/pagination.rs index f191cd6..733b76c 100644 --- a/src/components/ui/pagination.rs +++ b/src/components/ui/pagination.rs @@ -1,4 +1,5 @@ use dioxus::prelude::*; +use super::button::{Button, ButtonVariant, ButtonSize}; #[component] 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 { let mut buttons = vec![]; if total_pages <= 7 { buttons.extend(1..=total_pages); } else { - let active = current(); buttons.extend([1, 2]); if active > 4 { buttons.push(0); // ellipsis indicator @@ -34,68 +97,5 @@ pub fn Pagination( } buttons.extend([total_pages - 1, total_pages]); } - - 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" - } - } - } + buttons } diff --git a/src/components/ui/radio_group.rs b/src/components/ui/radio_group.rs index 766d1dc..675201f 100644 --- a/src/components/ui/radio_group.rs +++ b/src/components/ui/radio_group.rs @@ -144,13 +144,12 @@ pub fn RadioChipGroup( ) -> 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, + "aria-disabled": disabled, if let Some(label_text) = label.clone() { span { class: "ui-choice-group-label", @@ -159,52 +158,19 @@ pub fn RadioChipGroup( } div { class: "ui-choice-group-options", - for option in options.iter().cloned() { + for option in options { { - 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(); - + let is_selected = current_value.as_ref().map(|v| v == &option.value).unwrap_or(false); + let disabled_state = disabled || option.disabled; 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}" + RadioChipItem { + option, + is_selected, + disabled: disabled_state, + on_select: move |selected_value: String| { + value.set(Some(selected_value.clone())); + if let Some(callback) = on_value_change.as_ref() { + callback.call(selected_value); } } } @@ -215,3 +181,41 @@ pub fn RadioChipGroup( } } } + +#[component] +fn RadioChipItem( + option: RadioChipOption, + is_selected: bool, + disabled: bool, + on_select: EventHandler, +) -> 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}" + } + } + } + } +}