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