1//go:build js && wasm
2
3package systemviews
4
5import (
6 "fmt"
7 "strconv"
8 "strings"
9 "syscall/js"
10)
11
12type browserDOM struct {
13 document js.Value
14 target js.Value
15 bindings []jsEventBinding
16 historyObserver js.Value
17 historyObserverCallback js.Func
18 activeConfirmCleanup func()
19 activeConfirmID uint64
20}
21
22type jsEventBinding struct {
23 target js.Value
24 event string
25 fn js.Func
26}
27
28func NewDOM() DOM {
29 doc := js.Global().Get("document")
30 if !doc.Truthy() {
31 return &browserDOM{}
32 }
33
34 target := doc.Call("getElementById", "app")
35 return &browserDOM{document: doc, target: target}
36}
37
38func (d *browserDOM) Mount(markup string) error {
39 if d == nil || !d.target.Truthy() {
40 return fmt.Errorf("DOM mount target #app not found")
41 }
42 d.target.Set("innerHTML", markup)
43 d.updateDocumentTitle()
44 d.scheduleAlertDismissal()
45 d.syncPerformanceCustomInputStates()
46 d.focusAutofocusTarget()
47 d.setupHistoryInfiniteScroll()
48 return nil
49}
50
51// SignalReady marks the shell ready after mount and action binding: the
52// loading busy flag is dropped and a data-ready marker is set for
53// fixtures. Mount replaces #app content, so readiness must be signaled
54// after every mount that completes binding.
55func (d *browserDOM) SignalReady() error {
56 if d == nil || !d.target.Truthy() {
57 return fmt.Errorf("DOM readiness target #app not found")
58 }
59 d.target.Call("removeAttribute", "aria-busy")
60 d.target.Call("setAttribute", "data-ready", "true")
61 return nil
62}
63
64// SignalError clears the loading state with a fatal message when mount or
65// binding fails. textContent assignment keeps the message inert.
66func (d *browserDOM) SignalError(message string) error {
67 if d == nil || !d.target.Truthy() {
68 return fmt.Errorf("DOM readiness target #app not found")
69 }
70 if message == "" {
71 message = "systemviews failed to start"
72 }
73 d.target.Call("removeAttribute", "aria-busy")
74 d.target.Call("setAttribute", "data-failed", "true")
75 d.target.Set("innerHTML", "")
76 doc := d.document
77 if !doc.Truthy() {
78 doc = js.Global().Get("document")
79 }
80 if doc.Truthy() {
81 failure := doc.Call("createElement", "div")
82 failure.Set("className", "sv-error")
83 failure.Set("textContent", message)
84 d.target.Call("appendChild", failure)
85 }
86 return nil
87}
88
89func (d *browserDOM) AppendHistoryTimeline(markup string) error {
90 if d == nil || !d.target.Truthy() {
91 return fmt.Errorf("DOM mount target #app not found")
92 }
93 timeline := d.target.Call("querySelector", "[data-sv-history-timeline]")
94 if !timeline.Truthy() {
95 return fmt.Errorf("history timeline target not found")
96 }
97 if old := timeline.Call("querySelector", "[data-sv-history-load-more-container]"); old.Truthy() && old.Get("remove").Truthy() {
98 old.Call("remove")
99 }
100
101 container := d.document.Call("createElement", "div")
102 container.Set("innerHTML", markup)
103 d.mergeLeadingHistoryGroup(timeline, container)
104 for container.Get("firstChild").Truthy() {
105 timeline.Call("appendChild", container.Get("firstChild"))
106 }
107 d.setupHistoryInfiniteScroll()
108 return nil
109}
110
111func (d *browserDOM) mergeLeadingHistoryGroup(timeline, container js.Value) {
112 if !timeline.Truthy() || !container.Truthy() {
113 return
114 }
115 first := container.Get("firstElementChild")
116 if !first.Truthy() || !first.Get("hasAttribute").Truthy() || !first.Call("hasAttribute", "data-sv-history-merge-date").Bool() {
117 return
118 }
119 date := strings.TrimSpace(first.Call("getAttribute", "data-sv-history-merge-date").String())
120 if date == "" {
121 return
122 }
123 groups := timeline.Call("querySelectorAll", "[data-sv-history-date]")
124 if !groups.Truthy() || groups.Get("length").Int() == 0 {
125 return
126 }
127 lastGroup := groups.Index(groups.Get("length").Int() - 1)
128 if strings.TrimSpace(lastGroup.Call("getAttribute", "data-sv-history-date").String()) != date {
129 return
130 }
131 list := lastGroup.Call("querySelector", "ul")
132 if !list.Truthy() {
133 return
134 }
135 for first.Get("firstChild").Truthy() {
136 list.Call("appendChild", first.Get("firstChild"))
137 }
138 if first.Get("remove").Truthy() {
139 first.Call("remove")
140 }
141}
142
143func (d *browserDOM) updateDocumentTitle() {
144 if d == nil || !d.document.Truthy() || !d.target.Truthy() {
145 return
146 }
147 root := d.target.Call("querySelector", "[data-page-title]")
148 if !root.Truthy() {
149 return
150 }
151 title := strings.TrimSpace(root.Call("getAttribute", "data-page-title").String())
152 if title == "" {
153 return
154 }
155 d.document.Set("title", title)
156}
157
158func (d *browserDOM) focusAutofocusTarget() {
159 if d == nil || !d.target.Truthy() {
160 return
161 }
162 target := d.target.Call("querySelector", "[data-sv-autofocus]")
163 if !target.Truthy() || !target.Get("focus").Truthy() {
164 return
165 }
166 target.Call("focus")
167}
168
169func (d *browserDOM) scheduleAlertDismissal() {
170 if d == nil || !d.target.Truthy() {
171 return
172 }
173 alerts := d.target.Call("querySelectorAll", ".sv-alert")
174 length := alerts.Get("length").Int()
175 if length == 0 {
176 return
177 }
178 setTimeout := js.Global().Get("setTimeout")
179 if !setTimeout.Truthy() {
180 return
181 }
182 for i := 0; i < length; i++ {
183 alert := alerts.Index(i)
184 if !alert.Truthy() {
185 continue
186 }
187 scheduleAlertRemoval(setTimeout, alert)
188 }
189}
190
191func scheduleAlertRemoval(setTimeout, alert js.Value) {
192 var fadeFn js.Func
193 fadeFn = js.FuncOf(func(js.Value, []js.Value) any {
194 defer fadeFn.Release()
195 if !alert.Truthy() || !alert.Get("classList").Truthy() {
196 return nil
197 }
198 alert.Get("classList").Call("add", "sv-alert-dismiss")
199
200 var removeFn js.Func
201 removeFn = js.FuncOf(func(js.Value, []js.Value) any {
202 defer removeFn.Release()
203 if alert.Truthy() && alert.Get("remove").Truthy() {
204 alert.Call("remove")
205 }
206 return nil
207 })
208 setTimeout.Invoke(removeFn, 220)
209 return nil
210 })
211 setTimeout.Invoke(fadeFn, 4200)
212}
213
214func (d *browserDOM) BindActions(handler DOMActionHandler) error {
215 if d == nil || !d.target.Truthy() || handler == nil {
216 return fmt.Errorf("DOM action binding target not available")
217 }
218 d.Release()
219
220 clickHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
221 event := firstJSArg(args)
222 if !event.Truthy() {
223 return nil
224 }
225 element := closestActionElement(event)
226 if !element.Truthy() {
227 return nil
228 }
229 if strings.EqualFold(element.Get("tagName").String(), "form") {
230 target := event.Get("target")
231 if !target.Truthy() {
232 target = event.Get("srcElement")
233 }
234 if !target.Truthy() || !element.Equal(target) {
235 return nil
236 }
237 }
238 action := element.Call("getAttribute", "data-sv-action").String()
239 if action == "" {
240 return nil
241 }
242 event.Call("preventDefault")
243 data := collectActionData(element)
244 d.confirmAction(element, func() {
245 handler(DOMAction{Action: action, Data: data})
246 })
247 return nil
248 })
249 d.addEventBinding(d.target, "click", clickHandler)
250
251 submitHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
252 event := firstJSArg(args)
253 if !event.Truthy() {
254 return nil
255 }
256 form := event.Get("target")
257 if !form.Truthy() {
258 return nil
259 }
260 action := form.Call("getAttribute", "data-sv-action").String()
261 if action == "" {
262 return nil
263 }
264 event.Call("preventDefault")
265 updatePerformanceCustomInputs(form)
266 data := collectActionData(form)
267 for key, value := range collectFormData(form) {
268 data[key] = value
269 }
270 d.confirmAction(form, func() {
271 handler(DOMAction{Action: action, Data: data})
272 })
273 return nil
274 })
275 d.addEventBinding(d.target, "submit", submitHandler)
276
277 changeHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
278 event := firstJSArg(args)
279 if !event.Truthy() {
280 return nil
281 }
282 target := event.Get("target")
283 if !target.Truthy() || !target.Get("matches").Truthy() || !target.Call("matches", "[data-sv-performance-profile]").Bool() {
284 return nil
285 }
286 form := target.Get("form")
287 if form.Truthy() {
288 updatePerformanceCustomInputs(form)
289 }
290 return nil
291 })
292 d.addEventBinding(d.target, "change", changeHandler)
293
294 keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
295 event := firstJSArg(args)
296 if !d.eventTargetInsideMount(event) {
297 return nil
298 }
299 d.handleKeyboard(event)
300 return nil
301 })
302 d.addEventBinding(d.document, "keydown", keydownHandler)
303
304 return nil
305}
306
307func (d *browserDOM) eventTargetInsideMount(event js.Value) bool {
308 if d == nil || !d.target.Truthy() || !event.Truthy() {
309 return false
310 }
311 target := event.Get("target")
312 if !target.Truthy() {
313 target = event.Get("srcElement")
314 }
315 if !target.Truthy() {
316 return false
317 }
318 if d.document.Truthy() && target.Equal(d.document) {
319 return true
320 }
321 if strings.EqualFold(target.Get("nodeName").String(), "body") {
322 return true
323 }
324 return d.target.Call("contains", target).Bool()
325}
326
327func (d *browserDOM) addEventBinding(target js.Value, event string, fn js.Func) {
328 if !target.Truthy() {
329 fn.Release()
330 return
331 }
332 target.Call("addEventListener", event, fn)
333 d.bindings = append(d.bindings, jsEventBinding{target: target, event: event, fn: fn})
334}
335
336func (d *browserDOM) setupHistoryInfiniteScroll() {
337 if d == nil || !d.target.Truthy() {
338 return
339 }
340 // setupHistoryInfiniteScroll callbacks close over the current load-more
341 // button. disconnectHistoryObserver owns the cleanup chain: it calls
342 // d.historyObserver.disconnect(), releases d.historyObserverCallback, and
343 // lets setupHistoryInfiniteScroll overwrite d.historyObserver with the new
344 // observer so old button closures are not retained.
345 d.disconnectHistoryObserver()
346 observerCtor := js.Global().Get("IntersectionObserver")
347 if !observerCtor.Truthy() {
348 return
349 }
350 button := d.target.Call("querySelector", "[data-sv-history-load-more]")
351 if !button.Truthy() {
352 return
353 }
354 callback := js.FuncOf(func(_ js.Value, args []js.Value) any {
355 if len(args) == 0 || !button.Truthy() {
356 return nil
357 }
358 entries := args[0]
359 for i := 0; i < entries.Get("length").Int(); i++ {
360 entry := entries.Index(i)
361 if !entry.Truthy() || !entry.Get("isIntersecting").Bool() {
362 continue
363 }
364 if button.Get("disabled").Bool() || button.Get("dataset").Get("svLoading").String() == "true" {
365 return nil
366 }
367 // A disabled button does not dispatch click events. Dispatch first, then
368 // mark the control busy so the observer cannot enqueue duplicate loads
369 // while the WASM action worker fetches the next history window.
370 button.Call("click")
371 button.Get("dataset").Set("svLoading", "true")
372 button.Set("disabled", true)
373 button.Set("textContent", "Loading older visits…")
374 return nil
375 }
376 return nil
377 })
378 d.historyObserverCallback = callback
379 options := js.Global().Get("Object").New()
380 options.Set("rootMargin", "400px 0px")
381 d.historyObserver = observerCtor.New(callback, options)
382 d.historyObserver.Call("observe", button)
383}
384
385func (d *browserDOM) disconnectHistoryObserver() {
386 if d == nil {
387 return
388 }
389 if d.historyObserver.Truthy() {
390 d.historyObserver.Call("disconnect")
391 d.historyObserver = js.Value{}
392 }
393 if d.historyObserverCallback.Truthy() {
394 d.historyObserverCallback.Release()
395 d.historyObserverCallback = js.Func{}
396 }
397}
398
399func (d *browserDOM) Release() {
400 if d == nil {
401 return
402 }
403 d.disconnectHistoryObserver()
404 d.cleanupActiveConfirmDialog()
405 for _, binding := range d.bindings {
406 if binding.target.Truthy() {
407 binding.target.Call("removeEventListener", binding.event, binding.fn)
408 }
409 binding.fn.Release()
410 }
411 d.bindings = nil
412}
413
414func firstJSArg(args []js.Value) js.Value {
415 if len(args) == 0 {
416 return js.Value{}
417 }
418 return args[0]
419}
420
421func closestActionElement(event js.Value) js.Value {
422 target := event.Get("target")
423 if !target.Truthy() || !target.Get("closest").Truthy() {
424 return js.Value{}
425 }
426 return target.Call("closest", "[data-sv-action]")
427}
428
429func collectActionData(element js.Value) map[string]string {
430 data := map[string]string{}
431 if !element.Truthy() {
432 return data
433 }
434 dataset := element.Get("dataset")
435 if !dataset.Truthy() {
436 return data
437 }
438 keys := js.Global().Get("Object").Call("keys", dataset)
439 for i := 0; i < keys.Get("length").Int(); i++ {
440 key := keys.Index(i).String()
441 if key == "svAction" || key == "svConfirm" {
442 continue
443 }
444 data[key] = dataset.Get(key).String()
445 }
446 return data
447}
448
449func (d *browserDOM) syncPerformanceCustomInputStates() {
450 if d == nil || !d.target.Truthy() || !d.target.Get("querySelectorAll").Truthy() {
451 return
452 }
453 forms := d.target.Call("querySelectorAll", "[data-sv-performance-form]")
454 for i := 0; i < forms.Get("length").Int(); i++ {
455 updatePerformanceCustomInputs(forms.Index(i))
456 }
457}
458
459func updatePerformanceCustomInputs(form js.Value) {
460 if !form.Truthy() || !form.Get("querySelector").Truthy() {
461 return
462 }
463 selectEl := form.Call("querySelector", "[data-sv-performance-profile]")
464 if !selectEl.Truthy() {
465 return
466 }
467 enabled := strings.EqualFold(selectEl.Get("value").String(), "custom")
468 inputs := form.Call("querySelectorAll", "[data-sv-performance-custom]")
469 for i := 0; i < inputs.Get("length").Int(); i++ {
470 inputs.Index(i).Set("disabled", !enabled)
471 }
472}
473
474func collectFormData(form js.Value) map[string]string {
475 data := map[string]string{}
476 if !form.Truthy() || !js.Global().Get("FormData").Truthy() {
477 return data
478 }
479 formData := js.Global().Get("FormData").New(form)
480 entries := js.Global().Get("Array").Call("from", formData.Call("entries"))
481 for i := 0; i < entries.Get("length").Int(); i++ {
482 pair := entries.Index(i)
483 if pair.Get("length").Int() < 2 {
484 continue
485 }
486 key := pair.Index(0).String()
487 value := pair.Index(1).String()
488 if existing := data[key]; existing != "" {
489 data[key] = existing + "," + value
490 continue
491 }
492 data[key] = value
493 }
494 return data
495}
496
497func (d *browserDOM) confirmAction(element js.Value, onConfirm func()) {
498 if onConfirm == nil {
499 return
500 }
501 confirmAttr := element.Call("getAttribute", "data-sv-confirm")
502 if confirmAttr.Type() != js.TypeString {
503 onConfirm()
504 return
505 }
506 message := strings.TrimSpace(confirmAttr.String())
507 if message == "" {
508 onConfirm()
509 return
510 }
511 if !d.showConfirmDialog(message, onConfirm) {
512 onConfirm()
513 }
514}
515
516func (d *browserDOM) cleanupActiveConfirmDialog() {
517 if d == nil || d.activeConfirmCleanup == nil {
518 return
519 }
520 cleanup := d.activeConfirmCleanup
521 d.activeConfirmCleanup = nil
522 cleanup()
523}
524
525func (d *browserDOM) showConfirmDialog(message string, onConfirm func()) bool {
526 if d == nil || !d.document.Truthy() {
527 return false
528 }
529 body := d.document.Get("body")
530 if !body.Truthy() {
531 return false
532 }
533 // Always close an existing modal through its cleanup path so key handlers and
534 // js.Func values are released before the replacement is mounted.
535 d.cleanupActiveConfirmDialog()
536
537 overlay := d.document.Call("createElement", "div")
538 overlay.Get("classList").Call("add", "sv-confirm-backdrop")
539 overlay.Set("role", "presentation")
540
541 dialog := d.document.Call("createElement", "div")
542 dialog.Get("classList").Call("add", "sv-confirm-dialog")
543 dialog.Set("role", "dialog")
544 dialog.Call("setAttribute", "aria-modal", "true")
545 dialog.Call("setAttribute", "aria-labelledby", "sv-confirm-title")
546 dialog.Call("setAttribute", "aria-describedby", "sv-confirm-message")
547 overlay.Call("appendChild", dialog)
548
549 title := d.document.Call("createElement", "h3")
550 title.Set("id", "sv-confirm-title")
551 title.Set("textContent", "Confirm action")
552 dialog.Call("appendChild", title)
553
554 text := d.document.Call("createElement", "p")
555 text.Set("id", "sv-confirm-message")
556 text.Set("textContent", message)
557 dialog.Call("appendChild", text)
558
559 actions := d.document.Call("createElement", "div")
560 actions.Get("classList").Call("add", "sv-confirm-actions")
561 dialog.Call("appendChild", actions)
562
563 cancelButton := d.document.Call("createElement", "button")
564 cancelButton.Set("type", "button")
565 cancelButton.Set("className", "sv-button sv-button-secondary")
566 cancelButton.Set("textContent", "Cancel")
567 actions.Call("appendChild", cancelButton)
568
569 confirmButton := d.document.Call("createElement", "button")
570 confirmButton.Set("type", "button")
571 confirmButton.Set("className", "sv-button sv-button-danger")
572 confirmButton.Set("textContent", "Confirm")
573 actions.Call("appendChild", confirmButton)
574
575 d.activeConfirmID++
576 confirmID := d.activeConfirmID
577
578 var cleanup func()
579 var cancelFn js.Func
580 var confirmFn js.Func
581 var keyFn js.Func
582 var backdropFn js.Func
583 cleaned := false
584 confirmed := false
585 cleanup = func() {
586 if cleaned {
587 return
588 }
589 cleaned = true
590 if d.activeConfirmID == confirmID {
591 d.activeConfirmCleanup = nil
592 }
593 if overlay.Truthy() {
594 overlay.Call("remove")
595 }
596 if d.document.Truthy() && keyFn.Value.Truthy() {
597 d.document.Call("removeEventListener", "keydown", keyFn)
598 }
599 cancelFn.Release()
600 confirmFn.Release()
601 keyFn.Release()
602 backdropFn.Release()
603 }
604 runConfirm := func() {
605 if confirmed {
606 return
607 }
608 confirmed = true
609 cleanup()
610 onConfirm()
611 }
612 cancelFn = js.FuncOf(func(js.Value, []js.Value) any {
613 cleanup()
614 return nil
615 })
616 confirmFn = js.FuncOf(func(js.Value, []js.Value) any {
617 runConfirm()
618 return nil
619 })
620 keyFn = js.FuncOf(func(_ js.Value, args []js.Value) any {
621 event := firstJSArg(args)
622 if !event.Truthy() {
623 return nil
624 }
625 switch strings.ToLower(event.Get("key").String()) {
626 case "escape":
627 event.Call("preventDefault")
628 event.Call("stopPropagation")
629 cleanup()
630 case "enter":
631 if confirmDialogEnterShouldAutoConfirm(d.document, dialog, confirmButton) {
632 event.Call("preventDefault")
633 event.Call("stopPropagation")
634 runConfirm()
635 }
636 }
637 return nil
638 })
639 backdropFn = js.FuncOf(func(_ js.Value, args []js.Value) any {
640 event := firstJSArg(args)
641 if event.Truthy() && event.Get("target").Equal(overlay) {
642 cleanup()
643 }
644 return nil
645 })
646
647 cancelButton.Call("addEventListener", "click", cancelFn)
648 confirmButton.Call("addEventListener", "click", confirmFn)
649 document := d.document
650 document.Call("addEventListener", "keydown", keyFn)
651 overlay.Call("addEventListener", "click", backdropFn)
652 d.activeConfirmCleanup = cleanup
653 body.Call("appendChild", overlay)
654 confirmButton.Call("focus")
655 return true
656}
657
658func confirmDialogEnterShouldAutoConfirm(document, dialog, confirmButton js.Value) bool {
659 if !document.Truthy() || !dialog.Truthy() || !confirmButton.Truthy() {
660 return false
661 }
662 active := document.Get("activeElement")
663 if !active.Truthy() {
664 return true
665 }
666 if active.Equal(confirmButton) {
667 return true
668 }
669 if dialog.Call("contains", active).Bool() {
670 return false
671 }
672 return true
673}
674
675func (d *browserDOM) handleKeyboard(event js.Value) {
676 if d == nil || !d.target.Truthy() || !event.Truthy() {
677 return
678 }
679 if d.isHistoryRoute() {
680 d.handleHistoryKeyboard(event)
681 return
682 }
683 if d.isFavoritesRoute() {
684 d.handleFavoritesKeyboard(event)
685 }
686}
687
688func (d *browserDOM) handleHistoryKeyboard(event js.Value) {
689 if d == nil || !d.target.Truthy() || !event.Truthy() {
690 return
691 }
692 key := strings.ToLower(event.Get("key").String())
693 target := event.Get("target")
694 if isEditableTarget(target) && key != "escape" {
695 return
696 }
697
698 switch key {
699 case "/":
700 if input := d.target.Call("querySelector", `[data-sv-history-search]`); input.Truthy() {
701 event.Call("preventDefault")
702 input.Call("focus")
703 }
704 case "j", "arrowdown":
705 event.Call("preventDefault")
706 d.focusHistoryRow(1)
707 case "k", "arrowup":
708 event.Call("preventDefault")
709 d.focusHistoryRow(-1)
710 case "enter":
711 if row := d.activeHistoryRow(); row.Truthy() {
712 event.Call("preventDefault")
713 if link := row.Call("querySelector", ".sv-history-open"); link.Truthy() {
714 link.Call("click")
715 }
716 }
717 case "d", "delete", "backspace":
718 // Delete the focused row through the same delegated click path as pointer users,
719 // including the data-sv-confirm guard on the delete button.
720 if row := d.activeHistoryRow(); row.Truthy() {
721 event.Call("preventDefault")
722 if button := row.Call("querySelector", `[data-sv-action="history.deleteEntry"]`); button.Truthy() {
723 button.Call("click")
724 }
725 }
726 case "escape":
727 if target.Truthy() && isEditableTarget(target) {
728 target.Call("blur")
729 }
730 }
731}
732
733func (d *browserDOM) isHistoryRoute() bool {
734 return d.target.Call("querySelector", `[data-route="history"]`).Truthy()
735}
736
737func (d *browserDOM) isFavoritesRoute() bool {
738 return d.target.Call("querySelector", `[data-route="favorites"]`).Truthy()
739}
740
741func (d *browserDOM) handleFavoritesKeyboard(event js.Value) {
742 key := strings.ToLower(event.Get("key").String())
743 target := event.Get("target")
744 if isEditableTarget(target) && key != "escape" {
745 return
746 }
747
748 switch key {
749 case "j", "arrowdown":
750 event.Call("preventDefault")
751 d.focusFavoriteRow(1)
752 case "k", "arrowup":
753 event.Call("preventDefault")
754 d.focusFavoriteRow(-1)
755 case "enter":
756 if row := d.activeFavoriteRow(); row.Truthy() {
757 event.Call("preventDefault")
758 if link := row.Call("querySelector", ".sv-favorite-open"); link.Truthy() {
759 link.Call("click")
760 }
761 }
762 case "e":
763 if row := d.activeFavoriteRow(); row.Truthy() {
764 event.Call("preventDefault")
765 if input := row.Call("querySelector", `[name="title"]`); input.Truthy() {
766 input.Call("focus")
767 }
768 }
769 case "d", "delete", "backspace":
770 if row := d.activeFavoriteRow(); row.Truthy() {
771 event.Call("preventDefault")
772 if button := row.Call("querySelector", `[data-sv-action="favorite.delete"]`); button.Truthy() {
773 button.Call("click")
774 }
775 }
776 case "escape":
777 if target.Truthy() && isEditableTarget(target) {
778 target.Call("blur")
779 }
780 }
781}
782
783func isEditableTarget(target js.Value) bool {
784 if !target.Truthy() {
785 return false
786 }
787 tag := strings.ToLower(target.Get("tagName").String())
788 role := strings.ToLower(target.Get("role").String())
789 return tag == "input" || tag == "textarea" || tag == "select" || tag == "button" ||
790 (tag == "a" && target.Get("href").String() != "") || role == "button" || role == "link" ||
791 target.Get("isContentEditable").Bool()
792}
793
794func (d *browserDOM) focusHistoryRow(delta int) {
795 d.focusRow("[data-sv-history-row]", "svActiveHistoryIndex", delta)
796}
797
798func (d *browserDOM) activeHistoryRow() js.Value {
799 return d.activeRow("[data-sv-history-row]", "svActiveHistoryIndex")
800}
801
802func (d *browserDOM) focusFavoriteRow(delta int) {
803 d.focusRow("[data-sv-favorite-row]", "svActiveFavoriteIndex", delta)
804}
805
806func (d *browserDOM) activeFavoriteRow() js.Value {
807 return d.activeRow("[data-sv-favorite-row]", "svActiveFavoriteIndex")
808}
809
810func (d *browserDOM) focusRow(selector, datasetKey string, delta int) {
811 rows := d.target.Call("querySelectorAll", selector)
812 length := rows.Get("length").Int()
813 if length == 0 {
814 return
815 }
816 idx := d.activeIndex(datasetKey)
817 if idx < 0 {
818 idx = 0
819 } else {
820 idx += delta
821 }
822 if idx < 0 {
823 idx = 0
824 }
825 if idx >= length {
826 idx = length - 1
827 }
828 for i := 0; i < length; i++ {
829 rows.Index(i).Get("classList").Call("remove", "sv-focused")
830 }
831 row := rows.Index(idx)
832 row.Get("classList").Call("add", "sv-focused")
833 d.target.Get("dataset").Set(datasetKey, strconv.Itoa(idx))
834 row.Call("scrollIntoView", map[string]any{"block": "nearest"})
835}
836
837func (d *browserDOM) activeRow(selector, datasetKey string) js.Value {
838 rows := d.target.Call("querySelectorAll", selector)
839 length := rows.Get("length").Int()
840 if length <= 0 {
841 return js.Value{}
842 }
843 idx := d.activeIndex(datasetKey)
844 if idx < 0 || idx >= length {
845 return js.Value{}
846 }
847 return rows.Index(idx)
848}
849
850func (d *browserDOM) activeIndex(datasetKey string) int {
851 value := d.target.Get("dataset").Get(datasetKey).String()
852 idx, err := strconv.Atoi(value)
853 if err != nil {
854 return -1
855 }
856 return idx
857}