Satu Rol Roda, Dua Tugas: Scroll Dulu, Flip Nanti
Gestur roda mouse di popup jawaban AI: pasang listenernya gampang, tapi timing effect React, passive listener, dan reduced motion bikin ada empat jebakan.
Ringkasan
Fitur flip jawaban AI lewat roda mouse di popup nyaris gagal karena dua jebakan: useEffect jalan saat ref masih null sehingga listener tak pernah nempel, dan event wheel default-nya passive sehingga preventDefault jadi no-op tanpa opsi passive: false. Solusinya pasang listener bergantung state terbuka plus deteksi ujung scroll. Animasi slide diatur CSS dan otomatis dihormati prefers-reduced-motion.
Di SearchDialog.tsx ada popup yang nampilin jawaban AI. Satu jawaban bisa panjang banget, user scroll ke bawah buat baca. Mentok di bawah, roda mouse diputar lagi, dan harusnya yang muncul justru jawaban berikutnya, di-slide kaya flip halaman. Kenyataannya: cuma nyangkut di ujung, nggak kejadian apa-apa.
Saya pikir ini gampang. Pasang event listener wheel, cek posisi scroll, kalau udah mentok trigger animasi ganti jawaban. Selesai, mungkin puluhan baris.
Dua asumsi salah dari situ.
Listenernya Nggak Pernah Nempel
Commit pertama (23227b5) saya pasang listener di useEffect biasa. Logikanya: component di-mount, effect jalan, listener terpasang, selesai.
Ternyata nggak.
Popup-nya di-mount dalam keadaan tertutup. Kontennya baru benar-benar ada pas state open jadi true. Jadi pas effect jalan pertama kali, sectionRef.current masih null. Listener "terpasang" ke ref yang isinya nggak ada. React menjalankan setup effect pas component di-add ke halaman dan mengulanginya tiap dependencies berubah [5], tapi di commit pertama nggak ada dependency yang nunjukin popup sudah kebuka. Listener nggak pernah nempel sama sekali.
Fix-nya di commit lanjutan (6fc31f4): pindahin pemasangan listener ke effect yang ikut bergantung pada kondisi popup terbuka. Listener baru nempel pas dialog beneran kebuka, dan cleanup-nya lepas pas nutup.
Pelajaran kecil soal timing React. useEffect jalan di mount, tapi kapan mounting terjadi relatif terhadap visibilitas UI itu urusan lain. Kalau elemen target baru muncul di kondisi render tertentu, dependensi effect harus mengikuti kondisi itu.
preventDefault Nggak Gratis
Asumsi kedua yang kebalik: panggil event.preventDefault(), scroll berhenti, browser nurut. Nggak juga.
Untuk event wheel, opsi passive di pemasangan listener default-nya true di browser selain Safari [2]. Artinya panggilan preventDefault() di dalam handler dilangkahi. Nggak error, nggak warning, scroll jalan terus.
Solusinya harus eksplisit:
el.addEventListener("wheel", handler, { passive: false });
Tanpa objek opsi itu, handler tetap jalan tapi preventDefault() di dalamnya jadi no-op. Dokumentasi MDN juga nyatet bahwa event wheel itu cancelable, tapi di sebagian browser cuma event pertama dalam satu urutan yang bisa dibatalkan [1]. Saya nggak benchmark sendiri sejauh mana efeknya, tapi intinya jangan andelin preventDefault buat ngunci semua event dalam burst scroll. Pasang non-passive cuma di listener yang emang butuh, sisanya biarin default.
Deteksi Ujung Scroll
Logika intinya: kapan flip boleh kejadian? Selama jawaban masih punya sisa buat ke-scroll, jangan flip dulu. User lagi baca, bukan mau ganti halaman.
Cara ngeceknya standar: bandingin posisi scrollTop di pane jawaban dengan tinggi kontennya. Posisi nol berarti di paling atas. Posisi plus tinggi jendela nyentuh tinggi total konten berarti di paling bawah. Di dua ujung itulah roda diizinkan pindah ke jawaban sebelumnya atau berikutnya.
Ini persis masalah scroll chaining yang didokumentasikan MDN: konten yang bisa di-scroll bakal "minum" event dulu, dan halaman di bawahnya baru ikut gerak setelah boundary tersentuh [3]. Versi commit pertama cukup nerjemahin boundary itu manual: konten yang lebih tinggi dari popup dikasih jalan dulu, sisanya dipakai buat flip. Jawaban pendek yang nggak perlu scroll? Langsung flip tanpa nunggu.
Sisanya detail: lock timestamp 400 milidetik biar satu putaran roda cuma menghasilkan satu flip, dan state pageAnim yang nentuin class animasi mana yang nempel di popup.
Animasi Ikhlas Dilepas
Flip-nya dikasih rasa pake keyframe CSS ai-page-in-left dan ai-page-in-right, durasi 220 milidetik ease-out. Satu halaman slide masuk dari arah sesuai arah roda.
Bagian yang paling gampang kelupaan justru di globals.css: kedua class animasi itu didaftarin ke media query prefers-reduced-motion: reduce bareng animasi dekoratif lain. Fitur CSS ini didesain buat mendeteksi user yang sengaja mengurangi animasi yang bukan kebutuhan di perangkatnya [4]. Jadi user yang setelan OS-nya minimize animasi tetap bisa pindah jawaban, cuma tanpa slide.
Nggak perlu matchMedia di JavaScript. CSS-nya udah cukup, dan lebih jujur: satu sumber kebenaran di stylesheet, nggak ada state duplikat di komponen.
Wheel di UI kecil itu ujungnya kontrak dua tugas: scroll konten dulu, navigasi di tepi. Bisa juga diselesaikan pake dua tombol panah doang, dan itu lebih gampang ditulis. Tapi hasilnya beda rasa. Gesture roda ngasih akses ke riwayat jawaban tanpa narik perhatian ke UI tambahan, dan begitu timing listener-nya bener, kode tambahannya juga nggak banyak.
Sumber
2. EventTarget: addEventListener(), MDN
4. prefers-reduced-motion, MDN
Sumber:
[1] https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event
[2] https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
[3] https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior
[4] https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion