useStickToBottom
Menahan sebuah wadah gulung tetap menempel di dasarnya saat isinya bertambah.
Halo!
Ini jendela obrolan.
Tambah pesan lalu lihat sendiri.
Menempel: true · di dasar: true. Gulung ke atas lalu tambah pesan — pandangannya tidak ditarik paksa.
Ini yang membuat jendela obrolan terasa benar: pesan baru muncul dan pandangannya ikut turun — kecuali kalau orangnya sedang menggulung ke atas membaca yang lama. Menariknya kembali ke bawah saat itu adalah salah satu hal paling menjengkelkan yang bisa dilakukan sebuah antarmuka.
Tidak ada padanan Vue untuk paket use-stick-to-bottom yang dipakai sumbernya, jadi ini ditulis sendiri. Bahannya sudah ada di repo ini — src/components/ui/message-scroller/ melakukan hal yang sama di dalam komponen; yang dikerjakan di sini adalah mengangkat logikanya keluar supaya bisa dipakai wadah gulung apa pun.
Ambang toleransinya ada karena scrollTop pecahan. Peramban melaporkan angka seperti 1247.3999 untuk posisi yang secara visual sudah mentok, dan perbandingan persis tidak akan pernah benar.
Pemasangan
npx shadcn-vue@latest add @kliping/use-stick-to-bottom Perlu @kliping terdaftar di components.json project Anda. Lihat cara menyiapkannya.
Ikut terpasang sendiri: target, use-event-listener.
Salin dulu target, is-browser, use-event-listener — masing-masing memuat sumbernya di halamannya sendiri — lalu tempel berkas ini.
import { getCurrentScope, onScopeDispose, readonly, ref, watch } from 'vue'
import { bacaElemen } from '@/lib/target'
import { useEventListener } from '@/composables/use-event-listener'
import type { TargetElemen } from '@/lib/target'
export interface UseStickToBottomOptions {
/**
* Berapa piksel dari dasar yang masih dihitung "di dasar".
* @default 24
*/
ambang?: number
/**
* Perilaku gulungan saat menempel otomatis.
* @default 'smooth'
*/
behavior?: ScrollBehavior
}
export function useStickToBottom(
target: TargetElemen,
options: UseStickToBottomOptions = {},
) {
const { ambang = 24, behavior = 'smooth' } = options
const isAtBottom = ref(true)
/** Apakah pandangannya sedang dikunci ke dasar. Padam saat orangnya menggulung ke atas. */
const menempel = ref(true)
let pengamat: ResizeObserver | undefined
let pengamatIsi: MutationObserver | undefined
const wadah = () => bacaElemen(target) as HTMLElement | undefined
const hitungPosisi = () => {
const el = wadah()
if (!el)
return
const jarak = el.scrollHeight - el.scrollTop - el.clientHeight
isAtBottom.value = jarak <= ambang
}
const scrollToBottom = (paksa: ScrollBehavior = behavior) => {
const el = wadah()
if (!el)
return
el.scrollTo({ top: el.scrollHeight, behavior: paksa })
menempel.value = true
isAtBottom.value = true
}
// Gulungan yang dipicu `scrollTo` sendiri juga membangkitkan peristiwa
// `scroll`. Tanpa penanda ini, gulungan otomatis akan terbaca sebagai
// gulungan manual dan langsung melepas penempelannya sendiri.
let sedangMenggulungSendiri = false
useEventListener('scroll', () => {
hitungPosisi()
if (!sedangMenggulungSendiri)
menempel.value = isAtBottom.value
}, { target, passive: true })
const ikutiDasar = () => {
if (!menempel.value)
return
sedangMenggulungSendiri = true
scrollToBottom()
// Dilepas di tugas berikutnya, setelah peristiwa scroll-nya lewat.
setTimeout(() => {
sedangMenggulungSendiri = false
}, 0)
}
const bongkar = () => {
pengamat?.disconnect()
pengamatIsi?.disconnect()
pengamat = undefined
pengamatIsi = undefined
}
const berhentiPantau = watch(
() => wadah(),
(el) => {
bongkar()
if (!el)
return
hitungPosisi()
// Dua pengamat, karena isinya bisa bertambah dengan dua cara: elemen baru
// disisipkan, atau elemen yang sudah ada berubah tinggi — gambar yang
// selesai dimuat, teks yang membungkus ulang.
if (typeof ResizeObserver !== 'undefined') {
pengamat = new ResizeObserver(ikutiDasar)
pengamat.observe(el)
for (const anak of Array.from(el.children))
pengamat.observe(anak)
}
if (typeof MutationObserver !== 'undefined') {
pengamatIsi = new MutationObserver(() => {
if (pengamat) {
for (const anak of Array.from(el.children))
pengamat.observe(anak)
}
ikutiDasar()
})
pengamatIsi.observe(el, { childList: true, subtree: true, characterData: true })
}
},
{ immediate: true, flush: 'post' },
)
const berhenti = () => {
bongkar()
berhentiPantau()
}
if (getCurrentScope())
onScopeDispose(berhenti)
return {
isAtBottom: readonly(isAtBottom),
isSticky: readonly(menempel),
scrollToBottom,
stop: berhenti,
}
}API
export interface UseStickToBottomOptions {
/**
* Berapa piksel dari dasar yang masih dihitung "di dasar".
* @default 24
*/
ambang?: number
/**
* Perilaku gulungan saat menempel otomatis.
* @default 'smooth'
*/
behavior?: ScrollBehavior
}
export function useStickToBottom(
target: TargetElemen,
options: UseStickToBottomOptions = {},
)