Skip to content

Commit

Permalink
feat(component): add affix component (#152)
Browse files Browse the repository at this point in the history
* feat(component): affix component

* chore: add example for affix component

* docs: add affix component doc
  • Loading branch information
wzc520pyfm committed Mar 30, 2023
1 parent c209831 commit 33f0e5c
Show file tree
Hide file tree
Showing 15 changed files with 357 additions and 0 deletions.
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]
}
}

0 comments on commit 33f0e5c

Please sign in to comment.