Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(component): add affix component #152

Merged
merged 4 commits into from
Mar 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ const components = [
text: 'Navigation',
collapsed: false,
items: [
{
text: 'Affix',
link: '/components/affix',
},
{
text: 'Backtop',
link: '/components/backtop',
Expand Down
59 changes: 59 additions & 0 deletions docs/components/affix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
title: Affix
lang: en-US
---

# Affix <new-badge/>

Fix the element to a specific visible area.

## Basic usage

Affix is fixed at the top of the page by default.

You can set `offset` attribute to change the offset top,the default value is 0.

<demo src="../example/affix/basic.vue"></demo>

## Target Container

You can set `target` attribute to keep the affix in the container at all times. It will be hidden if out of range.

Please notice that the container avoid having scrollbar.

<demo src="../example/affix/target.vue"></demo>

## Fixed Position

The affix component provides two fixed positions: `top` and `bottom`.

You can set `position` attribute to change the fixed position, the default value is `top`.

<demo src="../example/affix/fixed.vue"></demo>

## Affix Props
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| offset | `number` | `0` | Offset distance.
| position | `'top' \| 'bottom'` | `'top'` | Position of affix.
| target | `string` | `''` | Target container. (CSS selector) |
| z-index | `number` | `100` | `z-index` of affix |


## Affix Methods
| Name | Parameters | Description |
| --- | --- | --- |
| change | `(fixed: boolean) => void` | Triggers when fixed state changed. |
| scroll | `(value: { scrollTop: number, fixed: boolean }) => void` | Triggers when scrolling. |


## Affix Slots
| Name | Parameters | Description |
| --- | --- | --- |
| default | `()` | Customize default content. |

## Affix Exposes
| Name | Parameters | Description |
| --- | --- | --- |
| update | `() => void` | Update affix state manually. |
| updateRoot | `() => void` | Update rootRect info. |
14 changes: 14 additions & 0 deletions docs/example/affix/basic.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<template>
<div fscw gap-2>
<o-affix>
<o-button shadow dashed type="primary">
Offset top 0px
</o-button>
</o-affix>
<o-affix :offset="120">
<o-button shadow dashed type="secondary">
Offset top 120px
</o-button>
</o-affix>
</div>
</template>
9 changes: 9 additions & 0 deletions docs/example/affix/fixed.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<template>
<div fscw gap-2>
<o-affix position="bottom" :offset="20">
<o-button shadow dashed type="success">
Offset bottom 20px
</o-button>
</o-affix>
</div>
</template>
9 changes: 9 additions & 0 deletions docs/example/affix/target.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<template>
<div class="affix-container text-center w-full h-400px rounded-4px bg-blue-300">
<o-affix target=".affix-container" :offset="80">
<o-button shadow dashed type="primary">
Target container
</o-button>
</o-affix>
</div>
</template>
38 changes: 38 additions & 0 deletions example/src/components/TheAffix.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script setup lang='ts'>

</script>

<template>
<OCard title="Affix">
<div space-y-2>
<div fsc gap-2>
<o-affix>
<o-button shadow dashed type="primary">
Offset top 0px
</o-button>
</o-affix>
<o-affix :offset="40">
<o-button shadow dashed type="secondary">
Offset top 40px
</o-button>
</o-affix>
</div>
<div fsc gap-2>
<div class="affix-container text-center w-full h-400px rounded-4px bg-blue-300">
<o-affix target=".affix-container" :offset="80">
<o-button shadow dashed type="primary">
Target container
</o-button>
</o-affix>
</div>
</div>
<div fsc gap-2>
<o-affix position="bottom" :offset="20">
<o-button shadow dashed type="success">
Offset bottom 20px
</o-button>
</o-affix>
</div>
</div>
</OCard>
</template>
1 change: 1 addition & 0 deletions example/src/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<TheBadge />
<TheTag /> -->
<TheMessage />
<TheAffix />
<TheEmpty />
<TheLink />
<TheRadio />
Expand Down
7 changes: 7 additions & 0 deletions packages/components/affix/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { withInstall } from '@onu-ui/utils'
import Affix from './src/affix.vue'

export const OAffix = withInstall(Affix)
export default OAffix

export * from './src/affix'
30 changes: 30 additions & 0 deletions packages/components/affix/src/affix.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { ExtractPropTypes } from 'vue'
import { isBoolean, isNumber } from './../../../utils/shared/is'

export const affixProps = {
zIndex: {
type: Number,
default: 100,
},
target: {
type: String,
default: '',
},
offset: {
type: Number,
default: 0,
},
position: {
type: String,
values: ['top', 'bottom'],
default: 'top',
},
} as const

export type OAffixProps = ExtractPropTypes<typeof affixProps>

export const affixEmits = {
scroll: ({ scrollTop, fixed }: { scrollTop: number; fixed: boolean }) => isNumber(scrollTop) && isBoolean(fixed),
change: (fixed: boolean) => isBoolean(fixed),
}
export type OAffixEmits = typeof affixEmits
118 changes: 118 additions & 0 deletions packages/components/affix/src/affix.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<script setup lang="ts" name="OAffix">
import { getScrollContainer } from '@onu-ui/utils'
import type { CSSProperties } from 'vue'
import { affixEmits, affixProps } from './affix'

const props = defineProps(affixProps)
const emit = defineEmits(affixEmits)

const target = shallowRef<HTMLElement>()
const root = shallowRef<HTMLDivElement>()
const scrollContainer = shallowRef<HTMLElement | Window>()
const { height: windowHeight } = useWindowSize()
const {
height: rootHeight,
width: rootWidth,
top: rootTop,
bottom: rootBottom,
update: updateRoot,
} = useElementBounding(root, { windowScroll: false })
const targetRect = useElementBounding(target)

const fixed = ref(false)
const scrollTop = ref(0)
const transform = ref(0)

const rootStyle = computed<CSSProperties>(() => {
return {
height: fixed.value ? `${rootHeight.value}px` : '',
width: fixed.value ? `${rootWidth.value}px` : '',
}
})

const affixStyle = computed<CSSProperties>(() => {
if (!fixed.value)
return {}

const offset = props.offset ? `${props.offset}px` : 0
return {
height: `${rootHeight.value}px`,
width: `${rootWidth.value}px`,
top: props.position === 'top' ? offset : '',
bottom: props.position === 'bottom' ? offset : '',
transform: transform.value ? `translateY(${transform.value}px)` : '',
zIndex: props.zIndex,
}
})

const update = () => {
if (!scrollContainer.value)
return

const isTop = props.position === 'top'
const isTarget = props.target
const isFixed
= (isTop && isTarget)
? (props.offset > rootTop.value && targetRect.bottom.value > 0)
: isTop
? props.offset > rootTop.value
: isTarget
? (windowHeight.value - props.offset < rootBottom.value && windowHeight.value > targetRect.top.value)
: windowHeight.value - props.offset < rootBottom.value
const difference
= (isTop && isTarget)
? targetRect.bottom.value - props.offset - rootHeight.value
: (!isTop && isTarget)
? windowHeight.value - targetRect.top.value - props.offset - rootHeight.value
: 0
scrollTop.value = scrollContainer.value instanceof Window ? document.documentElement.scrollTop : (scrollContainer.value.scrollTop || 0)
transform.value = difference < 0 ? (isTop ? difference : -difference) : 0
fixed.value = isFixed
}

const handleScroll = () => {
updateRoot()
emit('scroll', {
scrollTop: scrollTop.value,
fixed: fixed.value,
})
}

useEventListener(scrollContainer, 'scroll', handleScroll)
watchEffect(update)

watch(fixed, val => emit('change', val))

onMounted(() => {
const targetElement = props.target
? document.querySelector<HTMLElement>(props.target)
: document.documentElement
if (!targetElement)
throw new Error(`[OAffix] target is not existed: ${props.target}`)
target.value = targetElement
scrollContainer.value = getScrollContainer(root.value!, true)
updateRoot()
})

defineExpose({
/** @description update affix status */
update,
/** @description update rootRect info */
updateRoot,
})
</script>

<template>
<div
ref="root" :style="rootStyle"
>
<div
:class="[
fixed && `fixed`,
]"
:style="affixStyle"
>
<slot />
</div>
</div>
</template>
1 change: 1 addition & 0 deletions packages/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ export * from './backtop'
export * from './link'
export * from './radio'
export * from './radio-group'
export * from './affix'
2 changes: 2 additions & 0 deletions packages/onu-ui/src/components.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
OAffix,
OAlert,
OAvatar,
OAvatarGroup,
Expand Down Expand Up @@ -48,4 +49,5 @@ export default [
OLink,
ORadio,
ORadioGroup,
OAffix,
] as Plugin[]
2 changes: 2 additions & 0 deletions packages/utils/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ export * from './common'
export * from './is'
export * from './vue-helper'
export * from './dom'
export * from './scroll'
export * from './style'
38 changes: 38 additions & 0 deletions packages/utils/shared/scroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { isClient } from '@vueuse/core'
import { getStyle } from './style'

export const isScroll = (el: HTMLElement, isVertical?: boolean): boolean => {
if (!isClient)
return false

const key = (
{
undefined: 'overflow',
true: 'overflow-y',
false: 'overflow-x',
} as const
)[String(isVertical)]!
const overflow = getStyle(el, key)
return ['scroll', 'auto', 'overlay'].some(s => overflow.includes(s))
}

export const getScrollContainer = (
el: HTMLElement,
isVertical?: boolean,
): Window | HTMLElement | undefined => {
if (!isClient)
return

let parent: HTMLElement = el
while (parent) {
if ([window, document, document.documentElement].includes(parent))
return window

if (isScroll(parent, isVertical))
return parent

parent = parent.parentNode as HTMLElement
}

return parent
}
25 changes: 25 additions & 0 deletions packages/utils/shared/style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { isClient } from '@vueuse/core'
import type { CSSProperties } from 'vue'
import { camelize } from 'vue'

export const getStyle = (
element: HTMLElement,
styleName: keyof CSSProperties,
): string => {
if (!isClient || !element || !styleName)
return ''

let key = camelize(styleName)
if (key === 'float')
key = 'cssFloat'
try {
const style = (element.style as any)[key]
if (style)
return style
const computed: any = document.defaultView?.getComputedStyle(element, '')
return computed ? computed[key] : ''
}
catch {
return (element.style as any)[key]
}
}