This commit is contained in:
2026-01-05 12:47:14 +08:00
commit 1fc846fae3
1614 changed files with 162035 additions and 0 deletions

View File

@@ -0,0 +1,253 @@
@import "../popup/index";
.nut-popover {
position: absolute;
display: inline-block;
word-break: normal;
.nut-popover-arrow {
position: absolute;
width: 0;
height: 0;
border: 8px solid transparent;
&-top {
bottom: 0;
margin-bottom: -8px;
border-top-color: $popover-white-background-color;
border-bottom-width: 0;
}
&-bottom {
top: 0;
margin-top: -8px;
border-top-width: 0;
border-bottom-color: $popover-white-background-color;
&.nut-popover-arrow--bottom {
left: 50%;
transform: translateX(-50%);
}
&.nut-popover-arrow--bottom-start {
left: 16px;
transform: translateX(0%);
}
&.nut-popover-arrow--bottom-end {
right: 16px;
transform: translateX(0%);
}
}
&-left {
right: 0;
margin-right: -8px;
border-right-width: 0;
border-left-color: $popover-white-background-color;
&.nut-popover-arrow--left {
top: 50%;
transform: translateY(-50%);
}
&.nut-popover-arrow--left-start {
top: 16px;
transform: translateY(0%);
}
&.nut-popover-arrow--left-end {
bottom: 16px;
transform: translateY(0%);
}
}
&-right {
left: 0;
margin-left: -8px;
border-right-color: $popover-white-background-color;
border-left-width: 0;
&.nut-popover-arrow--right {
top: 50%;
transform: translateY(-50%);
}
&.nut-popover-arrow--right-start {
top: 16px;
transform: translateY(0%);
}
&.nut-popover-arrow--right-end {
bottom: 16px;
transform: translateY(0%);
}
}
}
.nut-popover-content {
position: absolute;
z-index: 9999;
max-height: initial;
overflow-y: initial;
font-size: 14px;
font-weight: normal;
color: #333;
background: #fff;
border-radius: 5px;
box-shadow: 0 2px 12px #3232331f;
opacity: 1;
transition: opacity 0.15s,
transform 0.15s;
&-group {
display: block;
width: 100%;
height: 100%;
}
.nut-popover-menu-item {
display: flex;
align-items: center;
padding: 8px;
border-bottom: 1px solid $popover-border-bottom-color;
&:first-child {
margin-top: 15px;
}
&:last-child {
margin-bottom: 2px;
border-bottom: none;
}
.nut-popover-item-img {
margin-right: 3px;
vertical-align: top;
}
.nut-popover-menu-item-name {
width: 100%;
text-align: center;
word-break: keep-all;
}
&.nut-popover-menu-disabled {
color: $popover-disable-color;
cursor: not-allowed;
}
}
&--top {
.nut-popover-arrow--top {
left: 50%;
transform: translateX(-50%);
}
}
&--top-end {
right: 0;
.nut-popover-arrow--top-end {
right: 16px;
transform: translateX(0%);
}
}
&--top-start {
left: 0;
.nut-popover-arrow--top-start {
left: 16px;
transform: translateX(0%);
}
}
&--bottom-end {
right: 0;
}
&--left-end {
bottom: 0;
}
&--left-start {
top: 0;
}
&--right-end {
bottom: 0;
}
&--right-start {
top: 0;
}
}
}
.nut-popover--dark {
.nut-popover-content {
color: $popover-white-background-color;
background: $popover-dark-background-color;
&--bottom,
&--bottom-start,
&--bottom-end {
.nut-popover-arrow {
border-bottom-color: $popover-dark-background-color;
}
}
&--top,
&--top-start,
&--top-end {
.nut-popover-arrow {
border-top-color: $popover-dark-background-color;
}
}
&--left,
&--left-start,
&--left-end {
.nut-popover-arrow {
border-left-color: $popover-dark-background-color;
}
}
&--right,
&--right-start,
&--right-end {
.nut-popover-arrow {
border-right-color: $popover-dark-background-color;
}
}
}
}
.nut-popover-enter-from,
.nut-popover-leave-active {
opacity: 0;
transform: scale(0.8);
}
.nut-popover-enter-active {
transition-timing-function: ease-out;
}
.nut-popover-leave-active {
transition-timing-function: ease-in;
}
.nut-popover-content-bg {
position: fixed;
top: 0;
left: 0;
z-index: 300;
width: 100%;
height: 100%;
background: transparent;
}
.nut-popover-wrapper {
display: inline-block;
}

View File

@@ -0,0 +1,2 @@
export type * from './popover'
export * from './type'

View File

@@ -0,0 +1,85 @@
import type { CSSProperties, ExtractPropTypes, PropType } from 'vue'
import { CHOOSE_EVENT, CLOSE_EVENT, OPEN_EVENT, UPDATE_VISIBLE_EVENT } from '../_constants'
import { commonProps, isBoolean, isString, makeArrayProp, makeNumberProp, makeNumericProp, makeStringProp, truthProp } from '../_utils'
import type { PopoverList, PopoverLocation, PopoverTheme } from './type'
export const popoverProps = {
...commonProps,
/**
* @description 是否展示气泡弹出层
*/
visible: Boolean,
/**
* @description 选项列表
*/
list: makeArrayProp<PopoverList>([]),
/**
* @description 主题风格,可选值为 dark
*/
theme: makeStringProp<PopoverTheme>('light'),
/**
* @description 弹出位置
*/
location: makeStringProp<PopoverLocation>('bottom'),
/**
* @description 出现位置的偏移量
*/
offset: makeArrayProp<number>([0, 12]),
/**
* @description 小箭头的偏移量
*/
arrowOffset: makeNumberProp(0),
/**
* @description 是否显示小箭头
*/
showArrow: truthProp,
/**
* @description 动画时长,单位毫秒
*/
duration: makeNumericProp(300),
/**
* @description 是否显示遮罩层
*/
overlay: Boolean,
/**
* @description 自定义遮罩层类名
*/
overlayClass: makeStringProp(''),
/**
* @description 自定义遮罩层样式
*/
overlayStyle: { type: Object as PropType<CSSProperties> },
/**
* @description 是否在点击遮罩层后关闭菜单
*/
closeOnClickOverlay: truthProp,
/**
* @description 是否在点击选项后关闭
*/
closeOnClickAction: truthProp,
/**
* @description 是否在点击外部元素后关闭菜单
*/
closeOnClickOutside: truthProp,
/**
* @description 自定义背景色
*/
bgColor: makeStringProp(''),
/**
* @description 自定义目标元素 id, 暂不支持该属性
* @deprecated
*/
targetId: makeStringProp(''),
}
export type PopoverProps = ExtractPropTypes<typeof popoverProps>
export const popoverEmits = {
update: (val: boolean) => isBoolean(val),
[UPDATE_VISIBLE_EVENT]: (val: boolean) => isBoolean(val),
[OPEN_EVENT]: () => true,
[CLOSE_EVENT]: () => true,
[CHOOSE_EVENT]: (item: any, index: number) => (item instanceof Object) || isString(index),
}
export type PopoverEmits = typeof popoverEmits

View File

@@ -0,0 +1,295 @@
<script setup lang="ts">
import { computed, defineComponent, getCurrentInstance, nextTick, onMounted, ref, watch } from 'vue'
import type { ComponentInternalInstance, CSSProperties } from 'vue'
import { CHOOSE_EVENT, CLOSE_EVENT, OPEN_EVENT, PREFIX, UPDATE_VISIBLE_EVENT } from '../_constants'
import { useRect } from '../_hooks'
import { getMainClass, getMainStyle, getRandomId } from '../_utils'
import NutIcon from '../icon/icon.vue'
import NutPopup from '../popup/popup.vue'
import { popoverEmits, popoverProps } from './popover'
import type { PopoverRootPosition } from './type'
const props = defineProps(popoverProps)
const emit = defineEmits(popoverEmits)
const instance = getCurrentInstance() as ComponentInternalInstance
const popoverID = `popoverRef${getRandomId()}`
const popoverContentID = `popoverContentRef${getRandomId()}`
const showPopup = ref(props.visible)
const rootPosition = ref<PopoverRootPosition>()
const elRect = ref({
width: 0,
height: 0,
})
const classes = computed(() => {
return getMainClass(props, componentName, {
[`nut-popover--${props.theme}`]: true,
})
})
const popoverArrow = computed(() => {
const prefixCls = 'nut-popover-arrow'
const loca = props.location
const direction = loca.split('-')[0]
return `${prefixCls} ${prefixCls}-${direction} ${prefixCls}--${loca}`
})
const popoverArrowStyle = computed(() => {
const styles: CSSProperties = {}
const { bgColor, arrowOffset, location } = props
const direction = location.split('-')[0]
const skew = location.split('-')[1]
const base = 16
if (bgColor)
styles[`border${upperCaseFirst(direction)}Color` as any] = bgColor
if (props.arrowOffset !== 0) {
if (['bottom', 'top'].includes(direction)) {
if (!skew)
styles.left = `calc(50% + ${arrowOffset}px)`
if (skew === 'start')
styles.left = `${base + arrowOffset}px`
if (skew === 'end')
styles.right = `${base - arrowOffset}px`
}
if (['left', 'right'].includes(direction)) {
if (!skew)
styles.top = `calc(50% - ${arrowOffset}px)`
if (skew === 'start')
styles.top = `${base - arrowOffset}px`
if (skew === 'end')
styles.bottom = `${base + arrowOffset}px`
}
}
return styles
})
function upperCaseFirst(str: string) {
str = str.toLowerCase()
str = str.replace(/\b\w+\b/g, word => word.substring(0, 1).toUpperCase() + word.substring(1))
return str
}
const getRootPosition = computed(() => {
const styles: CSSProperties = {}
if (!rootPosition.value) {
styles.visibility = 'hidden'
return styles
}
const contentWidth = elRect.value.width
const contentHeight = elRect.value.height
const { width, height, left, top, right } = rootPosition.value
const { location, offset } = props
const direction = location?.split('-')[0]
const skew = location?.split('-')[1]
let cross = 0
let parallel = 0
if (Array.isArray(offset) && offset?.length === 2) {
cross += Number(offset[1])
parallel += Number(offset[0])
}
if (width) {
if (['bottom', 'top'].includes(direction)) {
const h = direction === 'bottom' ? height + cross : -(contentHeight + cross)
styles.top = `${top + h}px`
if (!skew)
styles.left = `${-(contentWidth - width) / 2 + left + parallel}px`
if (skew === 'start')
styles.left = `${left + parallel}px`
if (skew === 'end')
styles.left = `${right + parallel}px`
}
if (['left', 'right'].includes(direction)) {
const contentW = direction === 'left' ? -(contentWidth + cross) : width + cross
styles.left = `${left + contentW}px`
if (!skew)
styles.top = `${top - contentHeight / 2 + height / 2 - 4 + parallel}px`
if (skew === 'start')
styles.top = `${top + parallel}px`
if (skew === 'end')
styles.top = `${top + height + parallel}px`
}
}
if (elRect.value.width === 0)
styles.visibility = 'hidden'
else
styles.visibility = 'initial'
return styles
})
const styles = computed(() => {
const styles: CSSProperties = {}
if (props.bgColor)
styles.background = props.bgColor
return getMainStyle(props, styles)
})
// 获取宽度
async function getContentWidth() {
uni.createSelectorQuery().selectViewport().scrollOffset((res: any) => {
const distance = res.scrollTop
if (props.targetId) {
useRect(props.targetId, instance.root).then((rect) => {
rootPosition.value = {
width: rect.width!,
height: rect.height!,
left: rect.left!,
top: rect.top! + distance!,
right: rect.right!,
}
})
}
else {
useRect(popoverID, instance).then((rect) => {
rootPosition.value = {
width: rect.width!,
height: rect.height!,
left: rect.left!,
top: rect.top! + distance!,
right: rect.right!,
}
})
}
}).exec()
setTimeout(() => {
getPopoverContentW()
}, 300)
}
async function getPopoverContentW() {
useRect(popoverContentID, instance).then((rect) => {
elRect.value = {
width: rect.width!,
height: rect.height!,
}
})
}
watch(
() => props.visible,
(value) => {
showPopup.value = value
if (value) {
nextTick(() => {
getContentWidth()
})
}
},
)
watch(
() => props.location,
(value) => {
getContentWidth()
},
)
function update(val: boolean) {
emit('update', val)
emit(UPDATE_VISIBLE_EVENT, val)
}
function openPopover() {
update(!props.visible)
emit(OPEN_EVENT)
}
function closePopover() {
emit(UPDATE_VISIBLE_EVENT, false)
emit(CLOSE_EVENT)
}
function chooseItem(item: any, index: number) {
!item.disabled && emit(CHOOSE_EVENT, item, index)
}
function clickContent() {
if (props.closeOnClickAction)
closePopover()
}
function clickAway() {
props.closeOnClickOutside && closePopover()
}
onMounted(() => {
setTimeout(() => {
getContentWidth()
}, 300)
})
</script>
<script lang="ts">
const componentName = `${PREFIX}-popover`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<view
v-if="!targetId"
:id="popoverID"
class="nut-popover-wrapper"
@click="openPopover"
>
<slot name="reference" />
</view>
<view :class="classes" :style="getRootPosition">
<NutPopup
v-model:visible="showPopup"
:destroy-on-close="false"
:pop-class="`nut-popover-content nut-popover-content--${location}`"
:custom-style="styles"
:position="`` as any"
:transition="`nut-popover` as any"
:overlay="overlay"
:duration="+duration"
:overlay-style="overlayStyle"
:overlay-class="overlayClass"
:close-on-click-overlay="closeOnClickOverlay"
>
<view :id="popoverContentID" class="nut-popover-content-group" @click.stop="clickContent">
<view v-if="showArrow" :class="popoverArrow" :style="popoverArrowStyle" />
<slot name="content" />
<view
v-for="(item, index) in list"
:key="index"
class="nut-popover-menu-item"
:class="[item.className, item.disabled && 'nut-popover-menu-disabled']"
@click="chooseItem(item, index)"
>
<NutIcon v-if="item.icon" :name="item.icon" custom-class="nut-popover-item-img" />
<view class="nut-popover-menu-item-name">
{{ item.name }}
</view>
</view>
</view>
</NutPopup>
<view
class="nut-popover-content-bg"
:class="{ 'nut-hidden': !showPopup }"
@touchmove="clickAway"
@click="clickAway"
/>
</view>
</template>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,20 @@
export type PopoverTheme = 'light' | 'dark'
export interface PopoverRootPosition {
width: number
height: number
left: number
top: number
right: number
}
export const popoverLocation
= ['bottom', 'top', 'left', 'right', 'top-start', 'top-end', 'bottom-start', 'bottom-end', 'left-start', 'left-end', 'right-start', 'right-end'] as const
export type PopoverLocation = (typeof popoverLocation)[number]
export interface PopoverList {
name: string
icon?: string
disabled?: boolean
className?: any
[key: PropertyKey]: any
}