Skip to content

Commit

Permalink
feat(sticky-scrollbar): init component (#4286)
Browse files Browse the repository at this point in the history
* feat(sticky-scrollbar): init component

* fix: expect scroll content to change

* fix: remove extra console logs

* fix: improve perf
  • Loading branch information
m0ksem committed Aug 26, 2024
1 parent e4697c0 commit 03fdfd9
Show file tree
Hide file tree
Showing 13 changed files with 271 additions and 4 deletions.
7 changes: 7 additions & 0 deletions packages/docs/page-config/navigationRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,13 @@ export const navigationRoutes: NavigationRoute[] = [
badge : navigationBadge.new('1.6.0 '),
}
},
{
name: 'sticky-scrollbar',
displayName: 'Sticky Scrollbar',
meta: {
badge : navigationBadge.new('1.9.10'),
}
},
{
name: "hover",
displayName: "Hover",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<template>
<div class="h-[110vh] w-full overflow-auto">
<VaStickyScrollbar />

<div class="h-full w-screen bg-gradient-to-l from-slate-300 to-slate-700" />
</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default definePageConfig({
blocks: [
block.title("StickyScrollbar"),
block.paragraph("This component adds floating scrollbar to element if it doesn't fit the screen. In case if you have large scroll container and want your scrollbar to be always visible, you need to use this component."),

block.example('Horizontal')
],
});
3 changes: 2 additions & 1 deletion packages/nuxt/src/config/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,6 @@ export default [
'VaMenuList',
'VaMenuItem',
'VaMenuGroup',
'VaFormField'
'VaFormField',
'VaStickyScrollbar'
]
1 change: 1 addition & 0 deletions packages/ui/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,4 @@ export * from './va-file-upload'
export * from './va-textarea'
export * from './va-menu'
export * from './va-form-field'
export * from './va-sticky-scrollbar'
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { defineComponent } from 'vue'
import { defineStory } from '../../../.storybook/types'
import VaStickyScrollbar from './StickyScrollbar.vue'

export default {
title: 'VaStickyScrollbar',
component: VaStickyScrollbar,
}

export const Horizontal = defineStory({
story: () => ({
components: { VaStickyScrollbar },

template: `
<div style="width: 200px; height: 200vh; overflow: auto; position: relative;">
<div style="width: 300px; height: 300vh; background: linear-gradient(45deg, #fff, #000);" />
<VaStickyScrollbar direction="horizontal" />
</div>
`,
}),
})

export const Vertical = defineStory({
story: () => ({
components: { VaStickyScrollbar },

template: `
<div style="width: 200vh; height: 200px; overflow: auto; position: relative;">
<div style="width: 300vh; height: 300px; background: linear-gradient(45deg, #fff, #000);" />
<VaStickyScrollbar direction="vertical" />
</div>
`,
}),
})

/** Scrollbar reacts to content changes */
export const DynamicContent = defineStory({
story: () => ({
data () {
return {
showBlock2: false,
}
},

mounted () {
setInterval(() => {
this.showBlock2 = !this.showBlock2
}, 2000)
},

components: { VaStickyScrollbar },

template: `
<div style="width: 200vh; height: 200px; overflow: auto; position: relative;">
<div v-if="showBlock2" style="width: 500vh; height: 300px; background: linear-gradient(45deg, #f0f, #0f0);" />
<div v-else style="width: 300vh; height: 300px; background: linear-gradient(45deg, #fff, #000);" />
<VaStickyScrollbar direction="vertical" />
</div>
`,
}),
})
122 changes: 122 additions & 0 deletions packages/ui/src/components/va-sticky-scrollbar/StickyScrollbar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { getScrollbarSize } from '../../utils/scrollbar-size'
import { useEvent, useElementRect, useResizeObserver } from '../../composables'
const props = withDefaults(defineProps<{
el?: HTMLElement | null
direction: 'vertical' | 'horizontal'
}>(), {
direction: 'horizontal',
})
const currentEl = ref(null as HTMLElement | null)
const parentElement = computed(() => {
if (props.el) { return props.el }
return currentEl.value?.parentNode as HTMLElement ?? null
})
const parentRect = useElementRect(parentElement)
const stickyScrollWrapperStyle = computed(() => {
const el = parentElement.value
if (!el) { return {} }
const parentEl = el as HTMLElement
const scrollSize = getScrollbarSize(parentEl)
const { bottom, left, right, top } = parentRect.value
if (props.direction === 'vertical') {
if (left > window.innerWidth) { return { display: 'none' } }
if (right < window.innerWidth) { return { display: 'none' } }
return {
position: 'fixed' as const,
top: `${top}px`,
right: 0,
height: `${parentEl.clientHeight}px`,
overflowY: 'auto' as const,
overflowX: 'hidden' as const,
}
}
if (top > window.innerHeight) { return { display: 'none' } }
if (bottom < window.innerHeight) { return { display: 'none' } }
return {
position: 'fixed' as const,
top: `${Math.min(bottom, window.innerHeight) - scrollSize}px`,
width: `${parentEl.clientWidth}px`,
overflowX: 'auto' as const,
overflowY: 'hidden' as const,
}
})
useEvent('scroll', (e) => {
if (!currentEl.value) { return }
if (props.direction === 'horizontal') {
parentElement.value?.scrollTo({
left: currentEl.value.scrollLeft,
})
} else {
parentElement.value?.scrollTo({
top: currentEl.value.scrollTop,
})
}
}, currentEl)
useEvent('scroll', (e) => {
if (!currentEl.value) { return }
if (props.direction === 'horizontal') {
if (parentElement.value?.scrollLeft === currentEl.value.scrollLeft) { return }
currentEl.value.scrollTo({
left: parentElement.value?.scrollLeft,
})
} else {
if (parentElement.value?.scrollTop === currentEl.value.scrollTop) { return }
currentEl.value.scrollTo({
top: parentElement.value?.scrollTop,
})
}
}, parentElement)
const scrollWidth = ref(0)
const scrollHeight = ref(0)
useResizeObserver(computed(() => {
if (!parentElement.value) { return [] }
return [...parentElement.value.children] as HTMLElement[]
}), () => {
scrollWidth.value = parentElement.value.scrollWidth
scrollHeight.value = parentElement.value.scrollHeight
})
const fakeContentStyle = computed(() => {
if (props.direction === 'vertical') {
return {
width: '1px',
height: `${scrollHeight.value}px`,
}
}
return {
height: '1px',
width: `${scrollWidth.value}px`,
}
})
</script>

<template>
<div :style="stickyScrollWrapperStyle" ref="currentEl">
<div :style="fakeContentStyle"></div>
</div>
</template>
1 change: 1 addition & 0 deletions packages/ui/src/components/va-sticky-scrollbar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as VaStickyScrollbar } from './StickyScrollbar.vue'
1 change: 1 addition & 0 deletions packages/ui/src/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,4 @@ export * from './useElementTextColor'
export * from './useElementBackground'
export * from './useImmediateFocus'
export * from './useNumericProp'
export * from './useElementRect'
41 changes: 41 additions & 0 deletions packages/ui/src/composables/useElementRect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Ref, onBeforeUnmount, onMounted, ref } from 'vue'

export const useElementRect = (element: Ref<HTMLElement | null>) => {
const rect = ref({ top: 0, left: 0, width: 0, height: 0, bottom: 0, right: 0 }) satisfies Ref<{
top: number
left: number
width: number
height: number
bottom: number
right: number
}>

let resizeObserver: ResizeObserver | undefined
let mutationObserver: MutationObserver | undefined

const updateRect = () => {
if (element.value) {
rect.value = element.value.getBoundingClientRect()
}
}

onMounted(() => {
resizeObserver = new ResizeObserver(updateRect)
mutationObserver = new MutationObserver(updateRect)

element.value && resizeObserver.observe(element.value)
element.value && mutationObserver.observe(element.value, { attributes: true, childList: true, subtree: true })

updateRect()
})

onBeforeUnmount(() => {
resizeObserver?.disconnect()
mutationObserver?.disconnect()

resizeObserver = undefined
mutationObserver = undefined
})

return rect
}
15 changes: 12 additions & 3 deletions packages/ui/src/composables/useResizeObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@ import { onBeforeUnmount, onMounted, ref, Ref, unref, watch } from 'vue'
type MaybeRef<T> = T | Ref<T>
type MaybeArray<T> = T | T[]

export const useResizeObserver = <T extends HTMLElement | undefined>(elementsList: MaybeRef<T>[] | Ref<T>, cb: ResizeObserverCallback) => {
const normalizeElements = <T>(elements: MaybeRef<T>[] | Ref<MaybeArray<T>>) => {
return Array.isArray(elements)
? elements
: Array.isArray(elements.value)
? elements.value
: [elements.value]
}

export const useResizeObserver = <T extends HTMLElement | undefined>(elementsList: MaybeRef<T>[] | Ref<MaybeArray<T>>, cb: ResizeObserverCallback) => {
let resizeObserver: ResizeObserver | undefined

const observeAll = (elementsList: MaybeRef<T>[]) => {
Expand All @@ -16,12 +24,13 @@ export const useResizeObserver = <T extends HTMLElement | undefined>(elementsLis

watch(elementsList, (newValue) => {
resizeObserver?.disconnect()
observeAll(Array.isArray(newValue) ? newValue : [newValue])

observeAll(normalizeElements(newValue))
})

onMounted(() => {
resizeObserver = new ResizeObserver(cb)
observeAll(Array.isArray(elementsList) ? elementsList : [elementsList])
observeAll(normalizeElements(elementsList))
})

onBeforeUnmount(() => resizeObserver?.disconnect())
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/services/vue-plugin/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,5 @@ export {
VaMenuItem,
VaMenuGroup,
VaFormField,
VaStickyScrollbar,
} from '../../components'
7 changes: 7 additions & 0 deletions packages/ui/src/utils/scrollbar-size.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const getScrollbarSize = (element: HTMLElement | null | undefined) => {
if (!element) { return 0 }

const scrollbarWidth = element.offsetWidth - element.clientWidth
const scrollbarHeight = element.offsetHeight - element.clientHeight
return Math.max(scrollbarWidth, scrollbarHeight)
}

0 comments on commit 03fdfd9

Please sign in to comment.