Files
Modrinth-plus/packages/ui/src/composables/scroll-indicator.ts
Calum H. 620894aecb feat: backups page cleanup before worlds (#5844)
* feat: card alignment + fix modals

* feat: change admon title in restore alert modal

* fix: lint

* feat: backups queue api into api-client

* feat: impl backup queue api endpoints into frontend

* feat: ack fix

* feat: bulk actions

* feat: bulk delete impl

* fix: lint

* fix: align error states

* fix: transition group

* feat: ready for qa

* fix: lint

* feat: qa

* feat: stacked admonitions component

* fix: issues with stacking

* feat: hook up admonition stacking + fix app csp for staging kyros nodes

* fix: logs.vue

* qa: close stack on admonitions click

* fix: all problems with stacked admonitions

* qa: admonition cleanup and copy overhaul draft

* fix: qa issues padding

* fix: padding bug

* feat: qa

* fix: intercom in app csp bug

* fix: positioning intercom

* feat: loading overlay on top of console + admon consistency changes

* feat: scroll indicator fade in backup delete modal + admon timestamp fix

* feat: move action bar behind modal

* fix: lint + i18n

* fix: server ping spam on filter (cache but clear on unmount)

* fix: 1 admon fade in flicker issue

* chore: temp staging undo

* qa: changes

* fix: lint

* chore: revert staging to use staging

* fix: scoping
2026-04-27 19:03:48 +00:00

182 lines
3.9 KiB
TypeScript

import { nextTick, onUnmounted, type Ref, ref, watchEffect } from 'vue'
import { useDebugLogger } from './debug-logger'
export interface ScrollIndicatorOptions {
watchContent?: boolean
debounceMs?: number
tolerance?: number
debug?: boolean
}
export interface ScrollIndicator {
showTopFade: Ref<boolean>
showBottomFade: Ref<boolean>
checkScrollState: () => void
forceCheck: () => void
}
export function useScrollIndicator(
containerRef: Ref<HTMLElement | null>,
options: ScrollIndicatorOptions = {},
): ScrollIndicator {
const { watchContent = true, debounceMs = 0, tolerance = 1, debug = false } = options
const showTopFade = ref(false)
const showBottomFade = ref(false)
let resizeObserver: ResizeObserver | null = null
let mutationObserver: MutationObserver | null = null
let rafId: number | null = null
let debounceTimer: number | null = null
const log = useDebugLogger('ScrollIndicator')
const checkScrollStateInternal = () => {
const container = containerRef.value
if (!container) {
showTopFade.value = false
showBottomFade.value = false
if (debug) log('Container not found, hiding fades')
return
}
if (rafId) {
cancelAnimationFrame(rafId)
}
rafId = requestAnimationFrame(() => {
const { scrollTop, scrollHeight, clientHeight } = container
const isScrollable = scrollHeight > clientHeight + tolerance
if (debug) {
log('Checking scroll state', {
scrollTop,
scrollHeight,
clientHeight,
isScrollable,
})
}
if (!isScrollable) {
showTopFade.value = false
showBottomFade.value = false
if (debug) log('Content fits, no fades needed')
} else {
showTopFade.value = scrollTop > tolerance
showBottomFade.value = scrollTop < scrollHeight - clientHeight - tolerance
if (debug) {
log('Fades updated', {
showTop: showTopFade.value,
showBottom: showBottomFade.value,
})
}
}
})
}
const checkScrollState = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = window.setTimeout(() => {
checkScrollStateInternal()
}, debounceMs)
}
const forceCheck = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
checkScrollStateInternal()
}
watchEffect((onCleanup) => {
const container = containerRef.value
if (!container) {
if (debug) log('No container, skipping setup')
return
}
if (debug) log('Setting up observers for container', container)
nextTick(() => {
forceCheck()
})
resizeObserver = new ResizeObserver(() => {
if (debug) log('ResizeObserver triggered')
checkScrollState()
})
resizeObserver.observe(container)
if (watchContent) {
mutationObserver = new MutationObserver(() => {
if (debug) log('MutationObserver triggered')
checkScrollState()
})
mutationObserver.observe(container, {
childList: true,
subtree: true,
characterData: true,
attributes: false,
})
}
const handleScroll = () => {
if (debug) log('Scroll event triggered')
checkScrollState()
}
container.addEventListener('scroll', handleScroll, { passive: true })
const handleResize = () => {
if (debug) log('Window resize triggered')
checkScrollState()
}
window.addEventListener('resize', handleResize, { passive: true })
onCleanup(() => {
if (debug) log('Cleaning up observers and listeners')
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
}
resizeObserver?.disconnect()
resizeObserver = null
mutationObserver?.disconnect()
mutationObserver = null
container.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
})
})
onUnmounted(() => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
if (rafId) {
cancelAnimationFrame(rafId)
}
})
return {
showTopFade,
showBottomFade,
checkScrollState,
forceCheck,
}
}