diff --git a/packages/oneclient_app/src/components/dropdown.rs b/packages/oneclient_app/src/components/dropdown.rs index a8cd3e7b..f40a0ebf 100644 --- a/packages/oneclient_app/src/components/dropdown.rs +++ b/packages/oneclient_app/src/components/dropdown.rs @@ -59,6 +59,9 @@ impl Component for Dropdown { let mut open = use_state(|| false); let mut hovering = use_state(|| false); + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let mut button_area = use_state(|| None::); let mut list_size = use_state(|| None::); @@ -115,13 +118,19 @@ impl Component for Dropdown { .padding(Gaps::new_symmetric(0., 8.)) .corner_radius(CornerRadius::new_all(6.)) .background(trigger_bg) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .maybe(focus().is_focused(), |el| { + el.border(border_all_color(1., colors::brand())) + }) .on_pointer_enter(move |_| hovering.set(true)) .on_pointer_leave(move |_| hovering.set(false)) .on_global_pointer_press(on_global_pointer_press) .on_sized(move |e: Event| { button_area.set_if_modified(Some(e.area)); }) - .on_press(move |e: Event| { + .on_all_press(move |e: Event| { e.prevent_default(); e.stop_propagation(); open.toggle(); @@ -211,18 +220,24 @@ impl Component for DropdownOption { fn render(&self) -> impl IntoElement { let mut hovering = use_state(|| false); + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + rect() .width(Size::fill()) .padding(Gaps::new_symmetric(6., 8.)) .corner_radius(CornerRadius::new_all(6.)) - .background(if hovering() { + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .background(if hovering() || focus().is_focused() { colors::ghost_overlay_hover() } else { Color::TRANSPARENT }) .on_pointer_enter(move |_| hovering.set(true)) .on_pointer_leave(move |_| hovering.set(false)) - .on_press(self.on_press.clone()) + .on_all_press(self.on_press.clone()) .child( label() .text(self.text.clone()) diff --git a/packages/oneclient_app/src/components/major_version_card.rs b/packages/oneclient_app/src/components/major_version_card.rs index bec57c63..b8c1600c 100644 --- a/packages/oneclient_app/src/components/major_version_card.rs +++ b/packages/oneclient_app/src/components/major_version_card.rs @@ -46,19 +46,25 @@ impl Component for MajorVersionCard { fn render(&self) -> impl IntoElement { let mut hovering = use_state(|| false); + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let selected = self.selected; let hovered = *hovering.read(); + let focused = focus().is_focused(); let on_press = self.on_press.clone(); - let opacity = if selected { - 1.0 - } else if hovered { - 0.85 + let opacity = if selected || hovered || focused { + if selected { + 1.0 + } else { + 0.85 + } } else { 0.6 }; - let border_color = if selected { + let border_color = if selected || focused { colors::brand() } else if hovered { colors::component_border_hover() @@ -70,7 +76,10 @@ impl Component for MajorVersionCard { .key(self.major) .width(Size::flex(1.0)) .height(Size::px(CARD_HEIGHT_PX)) - .on_press(move |e| on_press.call(e)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .on_all_press(move |e| on_press.call(e)) .on_pointer_enter(move |_| { *hovering.write() = true; }) diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index 9f4caf5e..898dfb7c 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -106,7 +106,7 @@ impl Component for NavLink { Cursor::set(CursorIcon::default()); }); - let color = if active || *hovering.peek() || focused.peek().is_focused() { + let color = if active || hovering() || focused().is_focused() { theme::colors::fg_primary() } else { theme::colors::fg_secondary() @@ -114,7 +114,7 @@ impl Component for NavLink { let underline_width = if active { 27. - } else if *hovering.peek() || focused.peek().is_focused() { + } else if hovering() || focused().is_focused() { 18. } else { 0. diff --git a/packages/oneclient_app/src/components/pagination.rs b/packages/oneclient_app/src/components/pagination.rs index 9a3c072b..bb3913b4 100644 --- a/packages/oneclient_app/src/components/pagination.rs +++ b/packages/oneclient_app/src/components/pagination.rs @@ -1,6 +1,6 @@ use freya::prelude::*; -use crate::components::{Icon, IconType}; +use crate::components::{Button, Icon, IconType}; use crate::theme::colors; use crate::ui::border_all_color; @@ -45,18 +45,11 @@ impl Component for Pagination { let icon_btn = move |target: usize, enabled: bool, icon: IconType| { let mut page = page; - rect() - .center() - .width(Size::px(32.)) - .height(Size::px(32.)) - .corner_radius(CornerRadius::new_all(8.)) - .background(colors::component_bg()) - .border(border_all_color(1., colors::component_border())) - .maybe(enabled, |el| { - el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| page.set(target)) - }) + Button::new() + .secondary() + .icon() + .enabled(enabled) + .on_press(move |_| page.set(target)) .child(Icon::new(icon).size(14.).color(if enabled { colors::fg_primary() } else { @@ -68,43 +61,38 @@ impl Component for Pagination { let num_btn = move |target: usize| { let mut page = page; let is_current = target == current; - rect() - .center() + + if is_current { + return rect() + .center() + .width(Size::px(32.)) + .height(Size::px(32.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg_pressed()) + .border(border_all_color(1., colors::brand())) + .child( + label() + .text(format!("{}", target + 1)) + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .into_element(); + } + + Button::new() + .secondary() .width(Size::px(32.)) .height(Size::px(32.)) - .corner_radius(CornerRadius::new_all(8.)) - .background(if is_current { - colors::component_bg_pressed() - } else { - colors::component_bg() - }) - .border(border_all_color( - 1., - if is_current { - colors::brand() - } else { - colors::component_border() - }, - )) - .maybe(!is_current, |el| { - el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| page.set(target)) - }) + .padding(Gaps::new_symmetric(6., 0.)) + .on_press(move |_| page.set(target)) .child( label() .text(format!("{}", target + 1)) .font_size(12.) - .font_weight(if is_current { - FontWeight::SEMI_BOLD - } else { - FontWeight::NORMAL - }) - .color(if is_current { - colors::fg_primary() - } else { - colors::fg_secondary() - }), + .font_weight(FontWeight::NORMAL) + .max_lines(1) + .color(colors::fg_secondary()), ) .into_element() }; diff --git a/packages/oneclient_app/src/components/recents_row.rs b/packages/oneclient_app/src/components/recents_row.rs index e586793a..3a3c80bf 100644 --- a/packages/oneclient_app/src/components/recents_row.rs +++ b/packages/oneclient_app/src/components/recents_row.rs @@ -101,6 +101,10 @@ impl Component for ClusterCard { let active = *active_id.read() == Some(self.cluster.id); let mut hovering = use_state(|| false); + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + let total = CARD_MS as f32 + (self.count.saturating_sub(1) as f32) * STAGGER_MS as f32; let elapsed = self.progress * total; let local = @@ -135,7 +139,7 @@ impl Component for ClusterCard { .corner_radius(CornerRadius::new_all(12.)) .overflow(Overflow::Clip) .border( - if active { + if active || focused { border_all_color(2., colors::brand()) } else if *hovering.read() { border_all_color(1., colors::component_border_hover()) @@ -144,6 +148,9 @@ impl Component for ClusterCard { } .alignment(BorderAlignment::Outer), ) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) .on_pointer_over(move |_| { if !*hovering.peek() { *hovering.write() = true; @@ -160,7 +167,7 @@ impl Component for ClusterCard { .y(0.) .color(Color::from_af32rgb(0.3, 0, 0, 0)), ) - .on_press(on_press) + .on_all_press(on_press) .child( rect() .width(Size::fill()) @@ -220,17 +227,31 @@ impl Component for OtherVersionsTile { fn render(&self) -> impl IntoElement { let mut hovering = use_state(|| false); + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + rect() .width(Size::px(MORE_TILE_WIDTH_PX)) .height(Size::fill()) .corner_radius(CornerRadius::new_all(12.)) - .border(border_all(1.)) + .border( + if focused { + border_all_color(2., colors::brand()) + } else { + border_all(1.) + } + .alignment(BorderAlignment::Outer), + ) .background(if *hovering.read() { colors::ghost_overlay_pressed() } else { colors::ghost_overlay_hover() }) .blur(32.) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) .on_pointer_enter(move |_| { *hovering.write() = true; }) @@ -245,7 +266,7 @@ impl Component for OtherVersionsTile { .y(0.) .color(Color::from_af32rgb(0.3, 0, 0, 0)), ) - .on_press(|_| { + .on_all_press(|_| { let _ = RouterContext::get().push(Route::Clusters {}); }) .child( @@ -259,10 +280,6 @@ impl Component for OtherVersionsTile { } fn recent_card_slots_for_width(row_width_px: f32) -> usize { - // Layout is: [card gap]* more-tile — i.e. N cards each followed by a gap, - // then the fixed-width more-tile. So width for N cards is: - // N * MIN_CARD_WIDTH + N * GAP + MORE_TILE_WIDTH - // Solve for the largest N that fits. if row_width_px <= MORE_TILE_WIDTH_PX + CARD_GAP_PX { return 0; } diff --git a/packages/oneclient_app/src/components/scrollview.rs b/packages/oneclient_app/src/components/scrollview.rs index b33b207a..ba49b6c3 100644 --- a/packages/oneclient_app/src/components/scrollview.rs +++ b/packages/oneclient_app/src/components/scrollview.rs @@ -311,36 +311,42 @@ impl ScrollArea { let can_scroll_h = horizontal && vp_w > 0. && vp_w < content_w; let on_wheel = move |e: Event| { - let wants_h = horizontal && *shift_held.read(); + let shift = *shift_held.read(); + + let h_delta = if horizontal { + if shift && e.delta_x == 0.0 { e.delta_y } else { e.delta_x } + } else { + 0.0 + }; + let v_delta = if horizontal && shift { 0.0 } else { e.delta_y }; + + let do_h = h_delta != 0.0 && can_scroll_h; + let do_v = v_delta != 0.0 && can_scroll_v; // check if scrollable - if (wants_h && !can_scroll_h) || !can_scroll_v { + if !do_h && !do_v { return; } e.stop_propagation(); - if (e.delta_y != 0.0 || e.delta_x != 0.0) - && let Some(cb) = &us_wheel - { + if let Some(cb) = &us_wheel { cb.call(()); } - if wants_h { - let delta = if e.delta_x != 0.0 { e.delta_x } else { e.delta_y }; - if delta != 0.0 { - let cur_x = corrected_scroll(content_w, vp_w, *scroll_x.read()); - let new_x = scroll_pos_from_wheel(delta as f32, content_w, vp_w, cur_x); - scroll_x.set(new_x as f32); - } - return; + if do_h { + let cur_x = corrected_scroll(content_w, vp_w, *scroll_x.read()); + let new_x = scroll_pos_from_wheel(h_delta as f32, content_w, vp_w, cur_x); + scroll_x.set(new_x as f32); } - let (_, cur_y) = controller.into(); - let current = corrected_scroll(ct_h, vp_h, cur_y as f32); - let new_y = scroll_pos_from_wheel(e.delta_y as f32, ct_h, vp_h, current); - if new_y != cur_y { - controller.scroll_to_y(new_y); + if do_v { + let (_, cur_y) = controller.into(); + let current = corrected_scroll(ct_h, vp_h, cur_y as f32); + let new_y = scroll_pos_from_wheel(v_delta as f32, ct_h, vp_h, current); + if new_y != cur_y { + controller.scroll_to_y(new_y); + } } }; @@ -403,6 +409,11 @@ impl ScrollArea { rect() .width(self.width.clone()) .height(self.height.clone()) + .scrollable(true) + .a11y_builder(move |node| { + node.set_scroll_x(corrected_x as f64); + node.set_scroll_y(corrected_y as f64); + }) .on_wheel(on_wheel) .on_capture_global_pointer_move(on_global_move) .on_capture_global_pointer_press(on_global_release) diff --git a/packages/oneclient_app/src/components/segmented_control.rs b/packages/oneclient_app/src/components/segmented_control.rs index 87b91dba..384711a7 100644 --- a/packages/oneclient_app/src/components/segmented_control.rs +++ b/packages/oneclient_app/src/components/segmented_control.rs @@ -101,16 +101,6 @@ impl Component for SegmentedControl { let equal_width = self.equal_width; let disabled = self.disabled; - let active_color = |is_selected: bool| { - if disabled { - colors::fg_secondary().with_a(110) - } else if is_selected { - colors::fg_primary() - } else { - colors::fg_secondary() - } - }; - rect() .horizontal() .height(Size::px(self.height)) @@ -121,55 +111,103 @@ impl Component for SegmentedControl { .background(colors::component_bg()) .border(border_all_color(1., colors::component_border())) .children(self.segments.iter().map(move |seg| { - let value = seg.value; - let is_selected = value == active; - let mut selected = selected; - let label_text = seg.label.clone(); + SegmentButton { + selected, + value: seg.value, + is_selected: seg.value == active, + icon: seg.icon, + label: seg.label.clone(), + icon_size, + tint, + equal_width, + disabled, + } + .into_element() + })) + } +} - rect() - .horizontal() - .height(Size::fill()) - .cross_align(Alignment::Center) - .spacing(6.) - .corner_radius(CornerRadius::new_all(6.)) - .map(equal_width, |el, w| { - el.width(Size::px(w)).main_align(Alignment::Center) - }) - .maybe(equal_width.is_none(), |el| { - el.padding(Gaps::new_symmetric(0., 12.)) - }) - .background(if is_selected { - colors::component_bg_pressed() +#[derive(PartialEq)] +struct SegmentButton { + selected: State, + value: T, + is_selected: bool, + icon: Option, + label: Option, + icon_size: Option, + tint: bool, + equal_width: Option, + disabled: bool, +} + +impl Component for SegmentButton { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let mut selected = self.selected; + let value = self.value; + let is_selected = self.is_selected; + let disabled = self.disabled; + let equal_width = self.equal_width; + let focused = focus().is_focused(); + + let content_color = if disabled { + colors::fg_secondary().with_a(110) + } else if is_selected { + colors::fg_primary() + } else { + colors::fg_secondary() + }; + + rect() + .horizontal() + .height(Size::fill()) + .cross_align(Alignment::Center) + .spacing(6.) + .corner_radius(CornerRadius::new_all(6.)) + .a11y_id(a11y_id) + .a11y_focusable(!disabled) + .a11y_role(AccessibilityRole::Button) + .map(equal_width, |el, w| { + el.width(Size::px(w)).main_align(Alignment::Center) + }) + .maybe(equal_width.is_none(), |el| { + el.padding(Gaps::new_symmetric(0., 12.)) + }) + .background(if is_selected { + colors::component_bg_pressed() + } else { + Color::TRANSPARENT + }) + .maybe(focused && !disabled, |el| { + el.border(border_all_color(1., colors::brand())) + }) + .maybe(!disabled, |el| { + el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .on_all_press(move |_| *selected.write() = value) + }) + .maybe_child(self.icon.map(|icon| { + let mut ic = Icon::new(icon); + if let Some(size) = self.icon_size { + ic = ic.size(size); + } + if self.tint { + ic = ic.color(content_color); + } + ic.into_element() + })) + .maybe_child(self.label.clone().map(|text| { + label() + .text(text) + .font_size(13.) + .font_weight(if is_selected { + FontWeight::MEDIUM } else { - Color::TRANSPARENT + FontWeight::NORMAL }) - .maybe(!disabled, |el| { - el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| *selected.write() = value) - }) - .maybe_child(seg.icon.map(|icon| { - let mut ic = Icon::new(icon); - if let Some(size) = icon_size { - ic = ic.size(size); - } - if tint { - ic = ic.color(active_color(is_selected)); - } - ic.into_element() - })) - .maybe_child(label_text.map(|text| { - label() - .text(text) - .font_size(13.) - .font_weight(if is_selected { - FontWeight::MEDIUM - } else { - FontWeight::NORMAL - }) - .color(active_color(is_selected)) - .into_element() - })) + .color(content_color) .into_element() })) } diff --git a/packages/oneclient_app/src/components/tab_bar.rs b/packages/oneclient_app/src/components/tab_bar.rs index 910f98c4..f79d3409 100644 --- a/packages/oneclient_app/src/components/tab_bar.rs +++ b/packages/oneclient_app/src/components/tab_bar.rs @@ -6,7 +6,7 @@ pub struct TabItem { label: String, active: bool, count: Option, - on_press: Option)>>, + on_press: Option>>, } impl TabItem { @@ -25,8 +25,8 @@ impl TabItem { self } - pub fn on_press(mut self, handler: impl FnMut(Event) + 'static) -> Self { - self.on_press = Some(Box::new(handler)); + pub fn on_press(mut self, handler: impl Into>>) -> Self { + self.on_press = Some(handler.into()); self } } @@ -147,46 +147,79 @@ impl IntoElement for TabBar { .cross_align(Alignment::Center) .content(Content::Fit) .children(self.tabs.into_iter().map(|tab| { - let active = tab.active; - - let mut el = rect() - .vertical() - .content(Content::Fit) - .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())); - - if let Some(handler) = tab.on_press { - el = el.on_press(handler); + TabButton { + label: tab.label, + active: tab.active, + count: tab.count, + font_size, + on_press: tab.on_press, } - - el.child( - rect() - .horizontal() - .cross_align(Alignment::Center) - .spacing(6.) - .content(Content::Fit) - .child( - rect() - .vertical() - .content(Content::Fit) - .child(tab_label(&tab.label, font_size, active)) - .child( - rect() - .height(Size::px(1.5)) - .width(Size::fill_minimum()) - .margin(Gaps::new_symmetric(0., 4.0)) - .corner_radius(CornerRadius::new_all(2.)) - .background(if active { - colors::fg_primary() - } else { - Color::TRANSPARENT - }), - ), - ) - .maybe_child(tab.count.map(|c| count_pill(&c, font_size).into_element())), - ) .into_element() })) .into_element() } } + +#[derive(PartialEq)] +struct TabButton { + label: String, + active: bool, + count: Option, + font_size: f32, + on_press: Option>>, +} + +impl Component for TabButton { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let active = self.active; + let font_size = self.font_size; + let underline_on = active || focus().is_focused(); + + let mut el = rect() + .vertical() + .content(Content::Fit) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())); + + if let Some(handler) = self.on_press.clone() { + el = el.on_all_press(move |e| handler.call(e)); + } + + el.child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .content(Content::Fit) + .child( + rect() + .vertical() + .content(Content::Fit) + .child(tab_label(&self.label, font_size, active)) + .child( + rect() + .height(Size::px(1.5)) + .width(Size::fill_minimum()) + .margin(Gaps::new_symmetric(0., 4.0)) + .corner_radius(CornerRadius::new_all(2.)) + .background(if underline_on { + colors::fg_primary() + } else { + Color::TRANSPARENT + }), + ), + ) + .maybe_child( + self.count + .as_ref() + .map(|c| count_pill(c, font_size).into_element()), + ), + ) + } +} diff --git a/packages/oneclient_app/src/components/toggle.rs b/packages/oneclient_app/src/components/toggle.rs index eef1b4ba..453ecdd4 100644 --- a/packages/oneclient_app/src/components/toggle.rs +++ b/packages/oneclient_app/src/components/toggle.rs @@ -50,8 +50,17 @@ impl Component for Switch { let on_press = self.on_press.clone(); let on = *value.read(); + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let knob_align = if on { Alignment::End } else { Alignment::Start }; + let track_border = if focus().is_focused() { + colors::fg_primary() + } else { + colors::component_border() + }; + let background = use_animation_transition(value, |_, on| { if on { AnimColor::new(colors::component_bg(), colors::brand()) @@ -84,9 +93,12 @@ impl Component for Switch { .horizontal() .cross_align(Alignment::Center) .spacing(8.) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| { + .on_all_press(move |_| { on_press.call(()); }) .child( @@ -104,7 +116,7 @@ impl Component for Switch { .main_align(knob_align) .cross_align(Alignment::Center) .background(&*background.read()) - .border(ui::border_all_color(1., colors::component_border())) + .border(ui::border_all_color(1., track_border)) .child( rect() .width(Size::px(HANDLE_SIZE)) diff --git a/packages/oneclient_app/src/layout/settings_shell.rs b/packages/oneclient_app/src/layout/settings_shell.rs index b9d196cb..773be6c1 100644 --- a/packages/oneclient_app/src/layout/settings_shell.rs +++ b/packages/oneclient_app/src/layout/settings_shell.rs @@ -717,6 +717,9 @@ impl Component for SidebarItem { fn render(&self) -> impl IntoElement { let mut hovering = use_state(|| false); + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let tab = self.tab; let active = self.active; let route = tab.route(); @@ -731,7 +734,7 @@ impl Component for SidebarItem { let has_dot = matches!(tab, SettingsTab::Changelog); - rect() + let mut el = rect() .horizontal() .content(Content::Flex) .width(Size::fill()) @@ -741,6 +744,9 @@ impl Component for SidebarItem { .padding(Gaps::new_symmetric(0., 12.)) .corner_radius(CornerRadius::new_all(7.)) .background(background) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) .on_pointer_enter(move |_| { *hovering.write() = true; Cursor::set(CursorIcon::Pointer); @@ -750,11 +756,21 @@ impl Component for SidebarItem { Cursor::set(CursorIcon::default()); }) .map(route, |el, route| { - el.on_press(move |_| { + el.on_all_press(move |_| { let _ = RouterContext::get().push(route.clone()); }) - }) - .child(Icon::new(tab.icon()).size(18.).color(colors::fg_primary())) + }); + + if focus().is_focused() { + el = el.border( + Border::new() + .fill(colors::brand()) + .width(1.) + .alignment(BorderAlignment::Inner), + ); + } + + el.child(Icon::new(tab.icon()).size(18.).color(colors::fg_primary())) .child( rect() .horizontal() diff --git a/packages/oneclient_app/src/view/app/browser/index/cards.rs b/packages/oneclient_app/src/view/app/browser/index/cards.rs index f734b8eb..bc9c10cb 100644 --- a/packages/oneclient_app/src/view/app/browser/index/cards.rs +++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs @@ -66,6 +66,10 @@ impl Component for PackageCard { let icon_url = self.item.icon_url.clone(); let cluster_id = self.cluster_id; + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + use_drop(|| { Cursor::set(CursorIcon::default()); }); @@ -76,11 +80,21 @@ impl Component for PackageCard { .height(Size::px(CARD_H)) .corner_radius(CornerRadius::new_all(10.)) .background(CARD_BG) - .border(border_all_color(1., colors::component_border())) + .border(border_all_color( + 1., + if focused { + colors::brand() + } else { + colors::component_border() + }, + )) .overflow(Overflow::Clip) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| open_package(cluster_id, &package_type, provider, &id)) + .on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id)) .child( rect() .margin(Gaps::new_all(1.)) @@ -141,9 +155,35 @@ impl Component for PackageCard { } pub(super) fn list_row(item: ProjectSummary, cluster_id: i64, package_type: &str) -> impl IntoElement { - let id = item.id.clone(); - let provider = item.provider; - let package_type = package_type.to_string(); + ListRow { + item, + cluster_id, + package_type: package_type.to_string(), + } +} + +#[derive(PartialEq)] +struct ListRow { + item: ProjectSummary, + cluster_id: i64, + package_type: String, +} + +impl Component for ListRow { + fn render(&self) -> impl IntoElement { + let item = self.item.clone(); + let cluster_id = self.cluster_id; + let id = item.id.clone(); + let provider = item.provider; + let package_type = self.package_type.clone(); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + use_drop(|| { + Cursor::set(CursorIcon::default()); + }); rect() .horizontal() @@ -154,12 +194,22 @@ pub(super) fn list_row(item: ProjectSummary, cluster_id: i64, package_type: &str .padding(Gaps::new_all(16.)) .corner_radius(CornerRadius::new_all(10.)) .background(CARD_BG) - .border(border_all_color(1., colors::component_border())) + .border(border_all_color( + 1., + if focused { + colors::brand() + } else { + colors::component_border() + }, + )) .overflow(Overflow::Clip) .content(Content::Flex) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| open_package(cluster_id, &package_type, provider, &id)) + .on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id)) .child(Thumbnail::new(item.icon_url.clone(), 48.).radius(8.)) .child( rect() @@ -198,6 +248,7 @@ pub(super) fn list_row(item: ProjectSummary, cluster_id: i64, package_type: &str ), ) .child(downloads_row(item.downloads)) + } } fn downloads_row(downloads: u64) -> impl IntoElement { diff --git a/packages/oneclient_app/src/view/app/browser/index/sidebar.rs b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs index 5faa471f..1e03d381 100644 --- a/packages/oneclient_app/src/view/app/browser/index/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs @@ -77,10 +77,15 @@ impl KeyExt for CategoryRow { impl Component for CategoryRow { fn render(&self) -> impl IntoElement { let mut hovering = use_state(|| false); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let selected = self.selected; let on_toggle = self.on_toggle.clone(); let hovered = *hovering.read(); - let color = if selected || hovered { + let focused = focus().is_focused(); + let color = if selected || hovered || focused { colors::fg_primary() } else { colors::fg_secondary() @@ -89,6 +94,9 @@ impl Component for CategoryRow { rect() .width(Size::fill()) .padding(Gaps::new_symmetric(3., 0.)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) .on_pointer_enter(move |_| { hovering.set(true); Cursor::set(CursorIcon::Pointer); @@ -97,7 +105,7 @@ impl Component for CategoryRow { hovering.set(false); Cursor::set(CursorIcon::default()); }) - .on_press(move |_| on_toggle.call(())) + .on_all_press(move |_| on_toggle.call(())) .child( label() .text(capitalize(&self.name)) diff --git a/packages/oneclient_app/src/view/app/browser/package/mod.rs b/packages/oneclient_app/src/view/app/browser/package/mod.rs index bfee57af..db7aadd6 100644 --- a/packages/oneclient_app/src/view/app/browser/package/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -184,7 +184,7 @@ impl Component for BrowserPackage { .vertical() .width(Size::flex(1.0)) .spacing(12.) - .child(tabs(current, active_tab, has_gallery)) + .child(tabs(active_tab, has_gallery)) .child(body), ) .into_element(); diff --git a/packages/oneclient_app/src/view/app/browser/package/panels.rs b/packages/oneclient_app/src/view/app/browser/package/panels.rs index e054105f..c858f419 100644 --- a/packages/oneclient_app/src/view/app/browser/package/panels.rs +++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs @@ -6,7 +6,7 @@ use oneclient_core::packages::types::{ use oneclient_core::packages::ProviderId; use crate::BridgeDispatch; -use crate::components::{Button, Icon, IconType}; +use crate::components::{Button, Icon, IconType, Segment, SegmentedControl}; use crate::hooks::VERSIONS_PAGE_SIZE; use crate::theme::colors; use crate::ui::border_all_color; @@ -27,40 +27,15 @@ pub(super) fn loading_body() -> impl IntoElement { .into_element() } -pub(super) fn tabs(current: usize, active_tab: State, has_gallery: bool) -> impl IntoElement { - let mut labels: Vec<&str> = vec!["About", "Versions"]; +pub(super) fn tabs(active_tab: State, has_gallery: bool) -> impl IntoElement { + let mut control = SegmentedControl::new(active_tab) + .height(36.) + .segment(Segment::new(0usize).label("About")) + .segment(Segment::new(1usize).label("Versions")); if has_gallery { - labels.push("Gallery"); + control = control.segment(Segment::new(2usize).label("Gallery")); } - rect() - .horizontal() - .spacing(6.) - .padding(Gaps::new_all(4.)) - .corner_radius(CornerRadius::new_all(9.)) - .background(colors::component_bg()) - .children(labels.into_iter().enumerate().map(move |(i, t)| { - let selected = i == current; - let mut active_tab = active_tab; - rect() - .center() - .padding(Gaps::new_symmetric(6., 16.)) - .corner_radius(CornerRadius::new_all(7.)) - .background(if selected { - colors::component_bg_pressed() - } else { - Color::TRANSPARENT - }) - .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| *active_tab.write() = i) - .child(label().text(t).font_size(13.).color(if selected { - colors::fg_primary() - } else { - colors::fg_secondary() - })) - .into_element() - })) - .into_element() + control } pub(super) fn about_panel(project: &ProjectDetail) -> impl IntoElement { diff --git a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs index 3543eb9a..3c4e40fc 100644 --- a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs @@ -174,44 +174,78 @@ fn authors_card(project: &ProjectDetail, confirm: State>) -> impl card("Authors", rows) } -fn author_row(member: &ProjectMember, mut confirm: State>) -> Element { - let url = member.url.clone(); - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(8.) - .corner_radius(CornerRadius::new_all(6.)) - .padding(Gaps::new_all(4.)) - .maybe(url.is_some(), |el| { - let url = url.clone().unwrap_or_default(); - el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| confirm.set(Some(url.clone()))) - }) - .child(Thumbnail::new(member.avatar_url.clone(), 32.).radius(6.)) - .child( - rect() - .vertical() - .width(Size::flex(1.0)) - .child( - label() - .text(member.name.clone()) - .font_size(12.) - .max_lines(1) - .color(colors::fg_primary()), - ) - .maybe(!member.role.is_empty(), |el| { - el.child( +fn author_row(member: &ProjectMember, confirm: State>) -> Element { + AuthorRow { + name: member.name.clone(), + role: member.role.clone(), + url: member.url.clone(), + avatar_url: member.avatar_url.clone(), + confirm, + } + .into_element() +} + +#[derive(PartialEq)] +struct AuthorRow { + name: String, + role: String, + url: Option, + avatar_url: Option, + confirm: State>, +} + +impl Component for AuthorRow { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let url = self.url.clone(); + let interactive = url.is_some(); + let mut confirm = self.confirm; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .corner_radius(CornerRadius::new_all(6.)) + .padding(Gaps::new_all(4.)) + .maybe(interactive, |el| { + let url = url.clone().unwrap_or_default(); + el.a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .on_all_press(move |_| confirm.set(Some(url.clone()))) + }) + .maybe(interactive && focused, |el| { + el.border(border_all_color(1., colors::brand())) + }) + .child(Thumbnail::new(self.avatar_url.clone(), 32.).radius(6.)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .child( label() - .text(member.role.clone()) - .font_size(10.) + .text(self.name.clone()) + .font_size(12.) .max_lines(1) - .color(colors::fg_secondary()), + .color(colors::fg_primary()), ) - }), - ) - .into_element() + .maybe(!self.role.is_empty(), |el| { + el.child( + label() + .text(self.role.clone()) + .font_size(10.) + .max_lines(1) + .color(colors::fg_secondary()), + ) + }), + ) + } } fn details_card(project: &ProjectDetail) -> impl IntoElement { @@ -282,25 +316,57 @@ fn links_card(links: &[(String, String)], confirm: State>) -> imp card("Links", rows) } -fn link_row(label_text: String, url: String, mut confirm: State>) -> Element { - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(8.) - .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| confirm.set(Some(url.clone()))) - .child( - Icon::new(IconType::Link03) - .size(14.) - .color(colors::code_info()), - ) - .child( - label() - .text(label_text) - .font_size(12.) - .color(colors::code_info()), - ) - .into_element() +fn link_row(label_text: String, url: String, confirm: State>) -> Element { + LinkRow { + label_text, + url, + confirm, + } + .into_element() +} + +#[derive(PartialEq)] +struct LinkRow { + label_text: String, + url: String, + confirm: State>, +} + +impl Component for LinkRow { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let url = self.url.clone(); + let mut confirm = self.confirm; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .corner_radius(CornerRadius::new_all(6.)) + .padding(Gaps::new_all(4.)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .maybe(focused, |el| { + el.border(border_all_color(1., colors::brand())) + }) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .on_all_press(move |_| confirm.set(Some(url.clone()))) + .child( + Icon::new(IconType::Link03) + .size(14.) + .color(colors::code_info()), + ) + .child( + label() + .text(self.label_text.clone()) + .font_size(12.) + .color(colors::code_info()), + ) + } } diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs index 15f7c16b..827836ee 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -6,7 +6,7 @@ use oneclient_core::packages::domain::GameLoader; use oneclient_core::settings::{GameSettingsProfile, ProfileUpdate, Resolution}; use crate::components::{ - Dropdown, Icon, IconType, ScrollArea, TextInput, toggle, toggle_controlled, validate_number, + Button, Dropdown, Icon, IconType, ScrollArea, TextInput, toggle, toggle_controlled, validate_number }; use crate::hooks::{ ClusterAction, java_runtimes, loader_versions, try_game_profile, use_cluster_mutation, @@ -147,16 +147,15 @@ fn reset_button(overridden: bool, on_reset: EventHandler<()>) -> impl IntoElemen colors::fg_secondary().with_a(60) }; - rect() - .center() - .width(Size::px(30.)) - .height(Size::px(30.)) + Button::new() + .small() + .ghost() + .icon() .corner_radius(CornerRadius::new_all(7.)) .maybe(overridden, |el| { - el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| on_reset.call(())) + el.on_press(move |_| on_reset.call(())) }) + .enabled(overridden) .child(Icon::new(IconType::RefreshCcw02).size(15.).color(color)) .into_element() } diff --git a/packages/oneclient_app/src/view/app/cluster/logs.rs b/packages/oneclient_app/src/view/app/cluster/logs.rs index 41d07fda..fa61a342 100644 --- a/packages/oneclient_app/src/view/app/cluster/logs.rs +++ b/packages/oneclient_app/src/view/app/cluster/logs.rs @@ -355,6 +355,9 @@ impl Component for LogPicker { let mut open = use_state(|| false); let filter = use_state(String::new); + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let enabled = self.enabled; let label_text = self.label.clone(); let files = self.files.clone(); @@ -400,14 +403,24 @@ impl Component for LogPicker { .padding(Gaps::new_symmetric(0., 12.)) .corner_radius(CornerRadius::new_all(9.)) .background(colors::component_bg()) - .border(ui::border_all_color(1., colors::component_border())) + .a11y_id(a11y_id) + .a11y_focusable(enabled) + .a11y_role(AccessibilityRole::Button) + .border(ui::border_all_color( + 1., + if enabled && focus().is_focused() { + colors::brand() + } else { + colors::component_border() + }, + )) .on_pointer_enter(move |_| { if enabled { Cursor::set(CursorIcon::Pointer) } }) .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |e: Event| { + .on_all_press(move |e: Event| { if !enabled { return; } @@ -495,6 +508,9 @@ impl Component for PickerRow { let mut hovering = use_state(|| false); let file = self.file.clone(); + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + rect() .width(Size::fill()) .horizontal() @@ -503,7 +519,10 @@ impl Component for PickerRow { .spacing(8.) .padding(Gaps::new_symmetric(6., 8.)) .corner_radius(CornerRadius::new_all(7.)) - .background(if hovering() { + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .background(if hovering() || focus().is_focused() { colors::ghost_overlay_hover() } else { Color::TRANSPARENT @@ -516,7 +535,7 @@ impl Component for PickerRow { hovering.set(false); Cursor::set(CursorIcon::default()); }) - .on_press(self.on_press.clone()) + .on_all_press(self.on_press.clone()) .child( rect() .vertical() diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index 97d3953d..bc8b3cea 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -1,5 +1,6 @@ use super::*; +use freya::animation::{AnimNum, Ease, OnCreation, use_animation}; use freya::router::RouterContext; use oneclient_core::packages::ContentType; use oneclient_core::settings::ViewLayout; @@ -184,7 +185,6 @@ struct FilterButton { impl Component for FilterButton { fn render(&self) -> impl IntoElement { let mut open = use_state(|| false); - let mut hovering = use_state(|| false); let sort = self.sort; let current_sort = self.current_sort; @@ -194,11 +194,6 @@ impl Component for FilterButton { let active = current_sort != SortMode::NameAsc || *enabled_filter.read() != EnabledFilter::All; - let bg = if is_open || hovering() { - colors::component_bg_hover() - } else { - colors::component_bg() - }; let icon_color = if active { colors::brand() } else { @@ -208,21 +203,11 @@ impl Component for FilterButton { rect() .width(Size::px(FILTER_BTN_W)) .child( - rect() + Button::new() + .secondary() + .icon() .width(Size::px(FILTER_BTN_W)) .height(Size::px(34.)) - .center() - .corner_radius(CornerRadius::new_all(8.)) - .background(bg) - .border(crate::ui::border_all_color(1., colors::component_border())) - .on_pointer_enter(move |_| { - hovering.set(true); - Cursor::set(CursorIcon::Pointer); - }) - .on_pointer_leave(move |_| { - hovering.set(false); - Cursor::set(CursorIcon::default()); - }) .on_press(move |e: Event| { e.stop_propagation(); open.toggle(); @@ -230,67 +215,112 @@ impl Component for FilterButton { .child(Icon::new(IconType::Sliders04).size(16.).color(icon_color)), ) .maybe_child(is_open.then(|| { - filter_popover(sort, current_sort, enabled_filter, move || open.set(false)) - .into_element() + let on_close: EventHandler<()> = (move |()| open.set(false)).into(); + FilterPopover { + sort, + current_sort, + enabled_filter, + on_close, + } + .into_element() })) } } -fn filter_popover( - mut sort: State>, +#[derive(PartialEq)] +struct FilterPopover { + sort: State>, current_sort: SortMode, - mut enabled_filter: State, - on_close: impl FnMut() + Clone + 'static, -) -> impl IntoElement { - let mut close_backdrop = on_close.clone(); + enabled_filter: State, + on_close: EventHandler<()>, +} - let show = *enabled_filter.read(); +impl Component for FilterPopover { + fn render(&self) -> impl IntoElement { + let mut sort = self.sort; + let current_sort = self.current_sort; + let mut enabled_filter = self.enabled_filter; + let backdrop_close = self.on_close.clone(); + let key_close = self.on_close.clone(); - let mut panel = rect() - .vertical() - .width(Size::fill()) - .spacing(4.) - .padding(Gaps::new_all(8.)) - .corner_radius(CornerRadius::new_all(10.)) - .border(crate::ui::border_all_color(1., colors::component_border())) - .background(colors::page_elevated()) - .child(section_label("Sort by")); + let a11y_id = use_a11y(); - for mode in SortMode::ALL { - let selected = mode == current_sort; - panel = panel.child(choice_row(mode.label(), selected, move |_| { - sort.set(Some(mode.key().to_string())); - })); + let fade = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.).time(160).ease(Ease::Out) + }); + let progress = fade.read().value(); + + let show = *enabled_filter.read(); + + let mut panel = rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .padding(Gaps::new_all(8.)) + .opacity(progress) + .offset_y((progress - 1.0) * 6.) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::page_elevated().with_a(230)) + .blur(12.) + .border(crate::ui::border_all_color(1., colors::component_border())) + .shadow(Shadow::from((0., 8., 32., 0., Color::from_argb(120, 0, 0, 0)))) + .a11y_id(a11y_id) + .a11y_role(AccessibilityRole::Dialog) + .on_global_key_down(move |e: Event| { + if e.key == Key::Named(NamedKey::Escape) { + key_close.call(()); + } + }) + .child(section_label("Sort by")); + + for mode in SortMode::ALL { + let selected = mode == current_sort; + let on_press: EventHandler> = (move |_| { + sort.set(Some(mode.key().to_string())); + }) + .into(); + panel = panel.child(ChoiceRow { + text: mode.label(), + selected, + on_press, + }); + } + + panel = panel.child(section_label("Show")); + for filter in EnabledFilter::ALL { + let selected = filter == show; + let on_press: EventHandler> = (move |_| { + enabled_filter.set(filter); + }) + .into(); + panel = panel.child(ChoiceRow { + text: filter.label(), + selected, + on_press, + }); + } + + rect() + .height(Size::px(0.)) + .width(Size::px(FILTER_PANEL_W)) + .layer(Layer::Overlay) + .child( + rect() + .layer(Layer::RelativeOverlay(10)) + .position(Position::new_global().top(0.).left(0.)) + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .on_press(move |_| backdrop_close.call(())), + ) + .child( + rect() + .width(Size::fill()) + .layer(Layer::RelativeOverlay(12)) + .margin(Gaps::new(6., 0., 0., -(FILTER_PANEL_W - FILTER_BTN_W))) + .child(panel), + ) } - - panel = panel.child(section_label("Show")); - for filter in EnabledFilter::ALL { - let selected = filter == show; - panel = panel.child(choice_row(filter.label(), selected, move |_| { - enabled_filter.set(filter); - })); - } - - rect() - .height(Size::px(0.)) - .width(Size::px(FILTER_PANEL_W)) - .layer(Layer::Overlay) - .child( - rect() - .layer(Layer::RelativeOverlay(10)) - .position(Position::new_global().top(0.).left(0.)) - .width(Size::window_percent(100.)) - .height(Size::window_percent(100.)) - .on_press(move |_| close_backdrop()), - ) - .child( - rect() - .width(Size::fill()) - .layer(Layer::RelativeOverlay(12)) - .margin(Gaps::new(6., 0., 0., -(FILTER_PANEL_W - FILTER_BTN_W))) - .child(panel), - ) - .into_element() } fn section_label(text: &'static str) -> impl IntoElement { @@ -301,45 +331,68 @@ fn section_label(text: &'static str) -> impl IntoElement { .color(colors::fg_secondary()) } -fn choice_row( +#[derive(PartialEq)] +struct ChoiceRow { text: &'static str, selected: bool, - on_press: impl FnMut(Event) + 'static, -) -> impl IntoElement { - let color = if selected { - colors::fg_primary() - } else { - colors::fg_secondary() - }; - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(8.) - .padding(Gaps::new_symmetric(5., 8.)) - .corner_radius(CornerRadius::new_all(6.)) - .background(if selected { + on_press: EventHandler>, +} + +impl Component for ChoiceRow { + fn render(&self) -> impl IntoElement { + let text = self.text; + let selected = self.selected; + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let color = if selected { + colors::fg_primary() + } else { + colors::fg_secondary() + }; + + let bg = if selected { colors::component_bg() + } else if focused { + colors::ghost_overlay_hover() } else { Color::TRANSPARENT - }) - .content(Content::Flex) - .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(on_press) - .child( - label() - .text(text) - .font_size(12.) - .width(Size::flex(1.0)) - .color(color), - ) - .maybe_child(selected.then(|| { - Icon::new(IconType::Check) - .size(14.) - .color(colors::brand()) - .into_element() - })) + }; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(5., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(bg) + .content(Content::Flex) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::MenuItemRadio) + .maybe(focused, |el| { + el.border(crate::ui::border_all_color(1., colors::brand())) + }) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .on_all_press(self.on_press.clone()) + .child( + label() + .text(text) + .font_size(12.) + .width(Size::flex(1.0)) + .color(color), + ) + .maybe_child(selected.then(|| { + Icon::new(IconType::Check) + .size(14.) + .color(colors::brand()) + .into_element() + })) + } } fn add_from_file_button( @@ -470,14 +523,29 @@ impl Component for ContentBox { ContentKind::Other => empty_state(noun_plural).into_element(), }); + let header = (count > 0) + .then(|| match kind { + ContentKind::External => Some( + action_header(browse_button(cluster_id, package_type)).into_element(), + ), + ContentKind::Local => Some( + action_header(add_from_file_button(cluster_id, content_type, dispatch.clone())) + .into_element(), + ), + ContentKind::Other => None, + }) + .flatten(); + let mut bottom_corners = CornerRadius::new_all(0.); bottom_corners.fill_bottom(12.); let drop_dispatch = dispatch.clone(); rect() + .vertical() .width(Size::fill()) .height(Size::flex(1.0)) + .spacing(8.) .padding(Gaps::new_all(8.)) .corner_radius(bottom_corners) .background(colors::page_elevated()) @@ -498,11 +566,22 @@ impl Component for ContentBox { drop_dispatch.import_local_file(cluster_id, content_type, path.clone()); } }) + .maybe_child(header) .maybe_child(scroll) .maybe_child(empty) } } +fn action_header(button: impl IntoElement) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .child(rect().width(Size::flex(1.0))) + .child(button) +} + fn grid_content( items: &[PackageEntry], package_type: &'static str,