1use std::rc::Rc;
13use wasm_bindgen::prelude::*;
14use wasm_bindgen::JsCast;
15use web_sys::{window, Event, HtmlInputElement, KeyboardEvent};
16use ferrox_front_core::dom::*;
17use ferrox_front_core::reactivity::Signal;
18use crate::icons::Icon;
19
20#[derive(Clone)]
26pub struct CommandAction {
27 pub id: String,
28 pub title: String,
29 pub subtitle: String,
30 pub category: String,
31 pub icon: String,
32 pub shortcut: Option<String>,
33 pub handler: Rc<dyn Fn()>,
34}
35
36impl CommandAction {
37 pub fn new(
38 id: impl Into<String>,
39 title: impl Into<String>,
40 subtitle: impl Into<String>,
41 category: impl Into<String>,
42 icon: impl Into<String>,
43 shortcut: Option<&str>,
44 handler: impl Fn() + 'static,
45 ) -> Self {
46 Self {
47 id: id.into(),
48 title: title.into(),
49 subtitle: subtitle.into(),
50 category: category.into(),
51 icon: icon.into(),
52 shortcut: shortcut.map(|s| s.to_string()),
53 handler: Rc::new(handler),
54 }
55 }
56}
57
58#[derive(Clone)]
60pub struct CommandPaletteState {
61 pub is_open: Signal<bool>,
62 pub query: Signal<String>,
63 pub selected_index: Signal<usize>,
64 pub actions: Vec<CommandAction>,
65}
66
67impl CommandPaletteState {
68 pub fn new(actions: Vec<CommandAction>) -> Self {
69 let state = Self {
70 is_open: Signal::new(false),
71 query: Signal::new(String::new()),
72 selected_index: Signal::new(0),
73 actions,
74 };
75
76 state.listen_global_shortcut();
78
79 state
80 }
81
82 pub fn open(&self) {
84 self.query.set(String::new());
85 self.selected_index.set(0);
86 self.is_open.set(true);
87 }
88
89 pub fn close(&self) {
91 self.is_open.set(false);
92 }
93
94 pub fn toggle(&self) {
96 if self.is_open.get() {
97 self.close();
98 } else {
99 self.open();
100 }
101 }
102
103 pub fn filtered_actions(&self) -> Vec<CommandAction> {
105 let q = self.query.get().trim().to_lowercase();
106 if q.is_empty() {
107 return self.actions.clone();
108 }
109 self.actions
110 .iter()
111 .filter(|a| {
112 a.title.to_lowercase().contains(&q)
113 || a.subtitle.to_lowercase().contains(&q)
114 || a.category.to_lowercase().contains(&q)
115 })
116 .cloned()
117 .collect()
118 }
119
120 pub fn select_next(&self) {
122 let count = self.filtered_actions().len();
123 if count > 0 {
124 let cur = self.selected_index.get();
125 self.selected_index.set((cur + 1) % count);
126 }
127 }
128
129 pub fn select_prev(&self) {
131 let count = self.filtered_actions().len();
132 if count > 0 {
133 let cur = self.selected_index.get();
134 if cur == 0 {
135 self.selected_index.set(count - 1);
136 } else {
137 self.selected_index.set(cur - 1);
138 }
139 }
140 }
141
142 pub fn execute_selected(&self) {
144 let filtered = self.filtered_actions();
145 let idx = self.selected_index.get();
146 if let Some(act) = filtered.get(idx) {
147 (act.handler)();
148 self.close();
149 }
150 }
151
152 fn listen_global_shortcut(&self) {
154 if let Some(win) = window() {
155 let self_clone = self.clone();
156 let closure = Closure::wrap(Box::new(move |e: KeyboardEvent| {
157 let key = e.key();
158 if (e.meta_key() || e.ctrl_key()) && (key == "k" || key == "K") {
159 e.prevent_default();
160 self_clone.toggle();
161 } else if self_clone.is_open.get() && key == "Escape" {
162 e.prevent_default();
163 self_clone.close();
164 }
165 }) as Box<dyn FnMut(KeyboardEvent)>);
166
167 let _ = win.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
168 closure.forget();
169 }
170 }
171}
172
173pub fn command_palette(state: CommandPaletteState) -> DomBuilder {
175 if !state.is_open.get() {
176 return div().attr("style", "display: none;");
177 }
178
179 let filtered = state.filtered_actions();
180 let selected_idx = state.selected_index.get();
181
182 let state_input = state.clone();
184 let search_el = input()
185 .id("ferrox-command-palette-input")
186 .attr("type", "text")
187 .attr("placeholder", "Digita un comando o cerca...")
188 .attr("value", &state.query.get())
189 .attr("autocomplete", "off")
190 .class("w-full bg-transparent px-4 py-3 text-sm text-white placeholder-slate-400 outline-none font-sans")
191 .on_input(move |e: Event| {
192 if let Some(target) = e.target() {
193 if let Ok(inp) = target.dyn_into::<HtmlInputElement>() {
194 state_input.query.set(inp.value());
195 state_input.selected_index.set(0);
196 }
197 }
198 });
199
200 let state_keys = state.clone();
202 let search_with_keys = search_el.on("keydown", move |e: Event| {
203 if let Ok(ke) = e.dyn_into::<KeyboardEvent>() {
204 match ke.key().as_str() {
205 "ArrowDown" => {
206 ke.prevent_default();
207 state_keys.select_next();
208 }
209 "ArrowUp" => {
210 ke.prevent_default();
211 state_keys.select_prev();
212 }
213 "Enter" => {
214 ke.prevent_default();
215 state_keys.execute_selected();
216 }
217 _ => {}
218 }
219 }
220 });
221
222 let mut item_list = div().class("max-h-80 overflow-y-auto p-2 space-y-1");
224
225 if filtered.is_empty() {
226 item_list = item_list.child(
227 div()
228 .class("p-6 text-center text-xs text-slate-500")
229 .text("Nessun comando o azione corrispondente.")
230 );
231 } else {
232 let mut last_category = String::new();
233
234 for (idx, action) in filtered.iter().enumerate() {
235 if action.category != last_category {
237 last_category = action.category.clone();
238 item_list = item_list.child(
239 div()
240 .class("px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-slate-400")
241 .text(&last_category)
242 );
243 }
244
245 let is_selected = idx == selected_idx;
246 let item_style = if is_selected {
247 "background: linear-gradient(135deg, rgba(99, 102, 241, 0.25), rgba(59, 130, 246, 0.2)); border: 1px solid rgba(99, 102, 241, 0.4);"
248 } else {
249 "background: transparent; border: 1px solid transparent;"
250 };
251
252 let handler_rc = action.handler.clone();
253 let state_close = state.clone();
254
255 let mut row = div()
256 .class("flex items-center justify-between px-3 py-2.5 rounded-xl cursor-pointer transition-all duration-150")
257 .style_attr(item_style)
258 .on_click(move || {
259 handler_rc();
260 state_close.close();
261 });
262
263 let left = div()
264 .class("flex items-center gap-3")
265 .children(vec![
266 div()
267 .class(&format!(
268 "w-7 h-7 rounded-lg flex items-center justify-center text-xs {}",
269 if is_selected { "bg-indigo-600 text-white" } else { "bg-slate-800 text-slate-400" }
270 ))
271 .child(Icon::render(&action.icon, 14, if is_selected { "#ffffff" } else { "#94a3b8" })),
272 div().children(vec![
273 div()
274 .class(&format!(
275 "text-xs font-semibold {}",
276 if is_selected { "text-white" } else { "text-slate-200" }
277 ))
278 .text(&action.title),
279 div().class("text-[11px] text-slate-400").text(&action.subtitle),
280 ]),
281 ]);
282
283 let right = if let Some(shortcut) = &action.shortcut {
284 div()
285 .class("px-2 py-0.5 rounded bg-slate-800/80 border border-slate-700/60 text-[10px] font-mono text-slate-400")
286 .text(shortcut)
287 } else {
288 div()
289 };
290
291 row = row.child(left).child(right);
292 item_list = item_list.child(row);
293 }
294 }
295
296 let modal_card = div()
298 .class("w-full max-w-xl mx-4 bg-slate-950/90 border border-slate-700/70 rounded-2xl shadow-2xl backdrop-blur-2xl overflow-hidden")
299 .children(vec![
300 div()
302 .class("flex items-center px-3 border-b border-slate-800/80")
303 .children(vec![
304 div().class("text-slate-400 pl-2").child(Icon::render("search", 16, "#94a3b8")),
305 search_with_keys,
306 div()
307 .class("px-2 py-1 rounded bg-slate-800 text-[10px] font-mono text-slate-400 cursor-pointer")
308 .text("ESC")
309 .on_click({
310 let s = state.clone();
311 move || s.close()
312 }),
313 ]),
314 item_list,
315 div()
317 .class("flex items-center justify-between px-4 py-2 bg-slate-900/60 border-t border-slate-800/60 text-[10px] text-slate-400")
318 .children(vec![
319 span().text("↑↓ per navigare • ↵ per selezionare"),
320 span().text("Ferrox Command Palette v0.3.0"),
321 ]),
322 ]);
323
324 let state_backdrop = state.clone();
326 div()
327 .id("ferrox-command-palette-backdrop")
328 .class("fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-start justify-center pt-24")
329 .on("click", move |e: Event| {
330 if let Some(target) = e.target() {
331 if let Ok(el) = target.dyn_into::<web_sys::HtmlElement>() {
332 if el.id() == "ferrox-command-palette-backdrop" {
333 state_backdrop.close();
334 }
335 }
336 }
337 })
338 .child(modal_card)
339}
340
341#[derive(Clone, Debug, PartialEq)]
347pub enum DatePickerSelection {
348 Single(String),
349 Range(String, String),
350}
351
352#[derive(Clone)]
354pub struct DatePickerState {
355 pub current_year: Signal<i32>,
356 pub current_month: Signal<u32>, pub selection: Signal<Option<DatePickerSelection>>,
358 pub is_range_mode: bool,
359 pub disabled_dates: Vec<String>,
360}
361
362impl DatePickerState {
363 pub fn new(year: i32, month: u32, is_range_mode: bool) -> Self {
364 Self {
365 current_year: Signal::new(year),
366 current_month: Signal::new(month.clamp(1, 12)),
367 selection: Signal::new(None),
368 is_range_mode,
369 disabled_dates: Vec::new(),
370 }
371 }
372
373 pub fn prev_month(&self) {
374 let m = self.current_month.get();
375 if m == 1 {
376 self.current_month.set(12);
377 self.current_year.set(self.current_year.get() - 1);
378 } else {
379 self.current_month.set(m - 1);
380 }
381 }
382
383 pub fn next_month(&self) {
384 let m = self.current_month.get();
385 if m == 12 {
386 self.current_month.set(1);
387 self.current_year.set(self.current_year.get() + 1);
388 } else {
389 self.current_month.set(m + 1);
390 }
391 }
392
393 pub fn select_date(&self, date_str: &str) {
395 if self.disabled_dates.contains(&date_str.to_string()) {
396 return;
397 }
398
399 if !self.is_range_mode {
400 self.selection.set(Some(DatePickerSelection::Single(date_str.to_string())));
401 } else {
402 match self.selection.get() {
403 Some(DatePickerSelection::Single(first)) => {
404 if date_str < &first {
405 self.selection.set(Some(DatePickerSelection::Range(date_str.to_string(), first)));
406 } else {
407 self.selection.set(Some(DatePickerSelection::Range(first, date_str.to_string())));
408 }
409 }
410 _ => {
411 self.selection.set(Some(DatePickerSelection::Single(date_str.to_string())));
412 }
413 }
414 }
415 }
416
417 pub fn apply_shortcut(&self, shortcut: &str) {
419 match shortcut {
420 "today" => {
421 self.selection.set(Some(DatePickerSelection::Single("2026-10-15".to_string())));
422 }
423 "last_7_days" => {
424 self.selection.set(Some(DatePickerSelection::Range(
425 "2026-10-08".to_string(),
426 "2026-10-15".to_string(),
427 )));
428 }
429 "last_30_days" => {
430 self.selection.set(Some(DatePickerSelection::Range(
431 "2026-09-15".to_string(),
432 "2026-10-15".to_string(),
433 )));
434 }
435 "this_month" => {
436 let y = self.current_year.get();
437 let m = self.current_month.get();
438 let start = format!("{:04}-{:02}-01", y, m);
439 let end = format!("{:04}-{:02}-{:02}", y, m, days_in_month(y, m));
440 self.selection.set(Some(DatePickerSelection::Range(start, end)));
441 }
442 _ => {}
443 }
444 }
445}
446
447fn days_in_month(year: i32, month: u32) -> u32 {
449 match month {
450 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
451 4 | 6 | 9 | 11 => 30,
452 2 => {
453 if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) {
454 29
455 } else {
456 28
457 }
458 }
459 _ => 30,
460 }
461}
462
463fn first_weekday_of_month(year: i32, month: u32) -> u32 {
465 let t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
467 let mut y = year;
468 if month < 3 {
469 y -= 1;
470 }
471 let dow_sunday_based = (y + y / 4 - y / 100 + y / 400 + t[(month - 1) as usize] + 1) % 7;
472 if dow_sunday_based == 0 {
474 6
475 } else {
476 (dow_sunday_based - 1) as u32
477 }
478}
479
480pub fn date_picker(
482 state: DatePickerState,
483 on_change: impl Fn(DatePickerSelection) + 'static,
484) -> DomBuilder {
485 let year = state.current_year.get();
486 let month = state.current_month.get();
487 let sel = state.selection.get();
488
489 let month_names = [
490 "Gennaio", "Febbraio", "Marzo", "Aprile", "Maggio", "Giugno",
491 "Luglio", "Agosto", "Settembre", "Ottobre", "Novembre", "Dicembre",
492 ];
493 let month_label = month_names.get((month - 1) as usize).unwrap_or(&"Mese");
494
495 let state_prev = state.clone();
497 let state_next = state.clone();
498
499 let header = div()
500 .class("flex items-center justify-between px-3 py-2 border-b border-slate-800")
501 .children(vec![
502 button()
503 .class("p-1.5 rounded-lg hover:bg-slate-800 text-slate-400 hover:text-white transition-colors")
504 .child(Icon::render("chevron-left", 14, "#94a3b8"))
505 .on_click(move || state_prev.prev_month()),
506 div()
507 .class("text-xs font-bold text-white tracking-wide")
508 .text(&format!("{} {}", month_label, year)),
509 button()
510 .class("p-1.5 rounded-lg hover:bg-slate-800 text-slate-400 hover:text-white transition-colors")
511 .child(Icon::render("chevron-right", 14, "#94a3b8"))
512 .on_click(move || state_next.next_month()),
513 ]);
514
515 let weekdays = ["Lu", "Ma", "Me", "Gi", "Ve", "Sa", "Do"];
517 let mut weekday_row = div().class("grid grid-cols-7 text-center py-1.5 border-b border-slate-800/60");
518 for w in weekdays {
519 weekday_row = weekday_row.child(
520 div().class("text-[10px] font-bold text-slate-500 uppercase").text(w)
521 );
522 }
523
524 let mut days_grid = div().class("grid grid-cols-7 gap-1 p-2");
526
527 let first_weekday = first_weekday_of_month(year, month);
528 let total_days = days_in_month(year, month);
529
530 for _ in 0..first_weekday {
532 days_grid = days_grid.child(div().class("h-7 w-7"));
533 }
534
535 let on_change_rc = Rc::new(on_change);
536
537 for d in 1..=total_days {
539 let date_str = format!("{:04}-{:02}-{:02}", year, month, d);
540 let is_disabled = state.disabled_dates.contains(&date_str);
541
542 let (is_selected, is_in_range) = match &sel {
543 Some(DatePickerSelection::Single(s)) => (s == &date_str, false),
544 Some(DatePickerSelection::Range(s, e)) => {
545 let eq = s == &date_str || e == &date_str;
546 let inside = &date_str > s && &date_str < e;
547 (eq, inside)
548 }
549 None => (false, false),
550 };
551
552 let cell_style = if is_selected {
553 "background: linear-gradient(135deg, #6366f1, #3b82f6); color: #ffffff; font-weight: 700; box-shadow: 0 0 10px rgba(99, 102, 241, 0.5);"
554 } else if is_in_range {
555 "background: rgba(99, 102, 241, 0.2); color: #c7d2fe;"
556 } else if is_disabled {
557 "background: transparent; color: #475569; cursor: not-allowed; opacity: 0.4;"
558 } else {
559 "background: transparent; color: #e2e8f0; hover:background: rgba(255, 255, 255, 0.08);"
560 };
561
562 let state_sel = state.clone();
563 let oc = on_change_rc.clone();
564 let ds = date_str.clone();
565
566 let day_cell = div()
567 .class("h-7 w-7 rounded-lg flex items-center justify-center text-xs cursor-pointer transition-all duration-150 mx-auto")
568 .style_attr(cell_style)
569 .text(&d.to_string())
570 .on_click(move || {
571 if !is_disabled {
572 state_sel.select_date(&ds);
573 if let Some(s) = state_sel.selection.get() {
574 oc(s);
575 }
576 }
577 });
578
579 days_grid = days_grid.child(day_cell);
580 }
581
582 let state_sc1 = state.clone();
584 let state_sc2 = state.clone();
585 let state_sc3 = state.clone();
586 let state_sc4 = state.clone();
587
588 let shortcuts_bar = div()
589 .class("flex items-center justify-between p-2 border-t border-slate-800/80 bg-slate-900/50 text-[10px]")
590 .children(vec![
591 button()
592 .class("px-2 py-1 rounded bg-slate-800/80 text-slate-300 hover:text-white hover:bg-slate-700 transition-colors")
593 .text("Oggi")
594 .on_click(move || state_sc1.apply_shortcut("today")),
595 button()
596 .class("px-2 py-1 rounded bg-slate-800/80 text-slate-300 hover:text-white hover:bg-slate-700 transition-colors")
597 .text("7 Giorni")
598 .on_click(move || state_sc2.apply_shortcut("last_7_days")),
599 button()
600 .class("px-2 py-1 rounded bg-slate-800/80 text-slate-300 hover:text-white hover:bg-slate-700 transition-colors")
601 .text("30 Giorni")
602 .on_click(move || state_sc3.apply_shortcut("last_30_days")),
603 button()
604 .class("px-2 py-1 rounded bg-slate-800/80 text-slate-300 hover:text-white hover:bg-slate-700 transition-colors")
605 .text("Mese")
606 .on_click(move || state_sc4.apply_shortcut("this_month")),
607 ]);
608
609 div()
610 .class("ferrox-datepicker w-64 rounded-2xl bg-slate-950/90 border border-slate-800/80 shadow-xl backdrop-blur-xl overflow-hidden")
611 .children(vec![header, weekday_row, days_grid, shortcuts_bar])
612}
613
614#[derive(Clone)]
620pub struct TreeNode {
621 pub id: String,
622 pub label: String,
623 pub icon: Option<String>,
624 pub badge: Option<String>,
625 pub is_expanded: Signal<bool>,
626 pub is_selected: Signal<bool>,
627 pub children: Vec<TreeNode>,
628}
629
630impl TreeNode {
631 pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
632 Self {
633 id: id.into(),
634 label: label.into(),
635 icon: None,
636 badge: None,
637 is_expanded: Signal::new(false),
638 is_selected: Signal::new(false),
639 children: Vec::new(),
640 }
641 }
642
643 pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
644 self.icon = Some(icon.into());
645 self
646 }
647
648 pub fn with_badge(mut self, badge: impl Into<String>) -> Self {
649 self.badge = Some(badge.into());
650 self
651 }
652
653 pub fn with_children(mut self, children: Vec<TreeNode>) -> Self {
654 self.children = children;
655 self
656 }
657
658 pub fn expanded(self, expanded: bool) -> Self {
659 self.is_expanded.set(expanded);
660 self
661 }
662}
663
664fn render_tree_node(
666 node: &TreeNode,
667 depth: usize,
668 on_select: &Rc<dyn Fn(&str)>,
669) -> DomBuilder {
670 let has_children = !node.children.is_empty();
671 let is_exp = node.is_expanded.get();
672 let is_sel = node.is_selected.get();
673
674 let node_clone = node.clone();
675 let on_sel_clone = on_select.clone();
676 let node_id = node.id.clone();
677
678 let mut row = div()
680 .class(&format!(
681 "flex items-center justify-between py-1.5 px-2 rounded-lg cursor-pointer transition-all duration-150 text-xs {}",
682 if is_sel {
683 "bg-indigo-600/20 text-indigo-300 font-semibold border border-indigo-500/30"
684 } else {
685 "text-slate-300 hover:bg-slate-800/60 hover:text-white border border-transparent"
686 }
687 ))
688 .style_attr(&format!("padding-left: {}px;", depth * 16 + 8))
689 .on_click(move || {
690 if has_children {
691 let cur = node_clone.is_expanded.get();
692 node_clone.is_expanded.set(!cur);
693 }
694 on_sel_clone(&node_id);
695 });
696
697 let mut left = div().class("flex items-center gap-2");
699
700 if has_children {
701 let chevron_style = if is_exp { "transform: rotate(90deg);" } else { "" };
703 left = left.child(
704 span()
705 .class("w-4 h-4 flex items-center justify-center transition-transform duration-200")
706 .style_attr(chevron_style)
707 .child(Icon::render("chevron-right", 12, "#94a3b8"))
708 );
709 } else {
710 left = left.child(span().class("w-4 h-4"));
711 }
712
713 let icon_name = node.icon.as_deref().unwrap_or(if has_children { "folder" } else { "file" });
715 left = left.child(Icon::render(icon_name, 14, if is_sel { "#818cf8" } else { "#94a3b8" }));
716
717 left = left.child(span().text(&node.label));
719 row = row.child(left);
720
721 if let Some(b) = &node.badge {
723 row = row.child(
724 span()
725 .class("px-1.5 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400 font-mono")
726 .text(b)
727 );
728 }
729
730 let mut container = div().class("flex flex-col").child(row);
731
732 if has_children && is_exp {
734 let mut children_box = div().class("flex flex-col");
735 for child in &node.children {
736 children_box = children_box.child(render_tree_node(child, depth + 1, on_select));
737 }
738 container = container.child(children_box);
739 }
740
741 container
742}
743
744pub fn tree_view(
746 nodes: Vec<TreeNode>,
747 on_select: impl Fn(&str) + 'static,
748) -> DomBuilder {
749 let on_select_rc: Rc<dyn Fn(&str)> = Rc::new(on_select);
750 let mut root = div().class("ferrox-treeview w-full p-2 space-y-0.5 rounded-2xl bg-slate-950/70 border border-slate-800/80 shadow-lg backdrop-blur-xl");
751
752 for node in &nodes {
753 root = root.child(render_tree_node(node, 0, &on_select_rc));
754 }
755
756 root
757}
758
759pub fn modal_focus_trap(container_id: &str) {
765 if let Some(win) = window() {
766 let doc = match win.document() {
767 Some(d) => d,
768 None => return,
769 };
770
771 if let Some(container) = doc.get_element_by_id(container_id) {
772 let closure = Closure::wrap(Box::new(move |e: Event| {
773 if let Ok(ke) = e.dyn_into::<KeyboardEvent>() {
774 if ke.key() == "Tab" {
775 }
777 }
778 }) as Box<dyn FnMut(Event)>);
779
780 let _ = container.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
781 closure.forget();
782 }
783 }
784}