BubbleBox.vue 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. <template>
  2. <view
  3. class="nana-bubble-box"
  4. :style="outerStyle"
  5. @mouseenter="handleHover(true)"
  6. @mouseleave="handleHover(false)"
  7. >
  8. <view v-if="trigger === 'click'" @click.native.capture="handleClick">
  9. <slot />
  10. </view>
  11. <slot v-else />
  12. <SimpleTransition name="bubble-box" :show="showState" :duration="200">
  13. <template #show="{ classNames }">
  14. <view v-if="clickOutSideClose" class="nana-bubble-box-popup-mask" @click="hide" />
  15. <view class="nana-bubble-box-holder-position" @click="hideAndEmitClickOnHolder" />
  16. <FlexView
  17. position="absolute"
  18. :direction="direction"
  19. :backgroundColor="backgroundColor"
  20. :radius="radius"
  21. :gap="10"
  22. :padding="10"
  23. :zIndex="1001"
  24. :margin="selectObjectByType(position, 'left', {
  25. top: [arrowWidth,0],
  26. bottom: [arrowWidth,0],
  27. left: [0,arrowWidth],
  28. right: [0,arrowWidth],
  29. })"
  30. :innerClass="['nana-bubble-box-popup',position,classNames]"
  31. shadow="light"
  32. v-bind="innerProps"
  33. :innerStyle="innerStyle"
  34. >
  35. <view
  36. class="nana-bubble-box-arrow"
  37. :style="{
  38. marginTop: theme.resolveThemeSize(arrowOffsetY),
  39. marginLeft: theme.resolveThemeSize(arrowOffsetX),
  40. borderWidth: theme.resolveThemeSize(arrowWidth),
  41. borderColor: backgroundColor,
  42. borderRightColor: 'transparent',
  43. borderBottomColor: 'transparent',
  44. borderLeftColor: 'transparent',
  45. }"
  46. />
  47. <slot name="content">
  48. <Touchable
  49. v-for="item in items"
  50. :key="item.text"
  51. direction="row"
  52. align-items="center"
  53. :gap="10"
  54. :padding="[5, 20]"
  55. v-bind="itemProps"
  56. @click="handleItemClick(item)"
  57. >
  58. <Icon
  59. :name="item.icon"
  60. :size="44"
  61. :color="item.textColor || itemTextColor"
  62. v-bind="{ ...itemIconProps, ...item.iconProps }"
  63. />
  64. <Text
  65. :wrap="false"
  66. v-bind="itemTextProps"
  67. :color="item.textColor || itemTextColor"
  68. :text="item.text"
  69. />
  70. </Touchable>
  71. </slot>
  72. </FlexView>
  73. </template>
  74. </SimpleTransition>
  75. </view>
  76. </template>
  77. <script setup lang="ts">
  78. import { computed, ref } from 'vue';
  79. import { propGetThemeVar, useTheme } from '../theme/ThemeDefine';
  80. import { selectObjectByType, selectStyleType } from '../theme/ThemeTools';
  81. import type { FlexProps } from '../layout/FlexView.vue';
  82. import type { TextProps } from '../basic/Text.vue';
  83. import Icon, { type IconProps } from '../basic/Icon.vue';
  84. import FlexView from '../layout/FlexView.vue';
  85. import Text from '../basic/Text.vue';
  86. import Touchable from './Touchable.vue';
  87. import SimpleTransition from '../anim/SimpleTransition.vue';
  88. export interface BubbleBoxItem {
  89. text: string,
  90. textColor?: string,
  91. icon?: string,
  92. iconProps?: IconProps,
  93. onClick: () => void,
  94. }
  95. export interface BubbleBoxProps {
  96. /**
  97. * 气泡框位置
  98. * @default top
  99. */
  100. position?: 'left' | 'right' | 'top' | 'bottom',
  101. /**
  102. * 气泡在横轴上的对齐位置,默认居中
  103. * @default center
  104. */
  105. crossPosition?: 'left' | 'center' | 'right',
  106. /**
  107. * 触发点击事件模式
  108. * @default click
  109. */
  110. trigger?: 'click'|'hover'|'none',
  111. /**
  112. * 气泡框按钮排列方向
  113. * @default column
  114. */
  115. direction?: 'column' | 'row',
  116. /**
  117. * 是否禁用
  118. * @default false
  119. */
  120. disabled?: boolean,
  121. /**
  122. * 气泡框按钮数组
  123. */
  124. items?: BubbleBoxItem[],
  125. /**
  126. * 气泡框按钮文本颜色
  127. * @default 'text.content'
  128. */
  129. itemTextColor?: string,
  130. /**
  131. * 气泡框按钮文本样式
  132. */
  133. itemTextProps?: TextProps,
  134. /**
  135. * 气泡框按钮图标样式
  136. */
  137. itemIconProps?: IconProps,
  138. /**
  139. * 气泡框按钮样式
  140. */
  141. itemProps?: FlexProps,
  142. /**
  143. * 气泡框外层容器样式
  144. */
  145. outerStyle?: Record<string, string>,
  146. /**
  147. * 气泡框背景颜色
  148. * @default white
  149. */
  150. backgroundColor?: string,
  151. /**
  152. * 气泡框箭头宽度
  153. * @default 12
  154. */
  155. arrowWidth?: number,
  156. /**
  157. * 气泡框箭头X轴偏移
  158. * @default 0
  159. */
  160. arrowOffsetX?: number|string,
  161. /**
  162. * 气泡框箭头X轴偏移
  163. * @default 0
  164. */
  165. arrowOffsetY?: number|string,
  166. /**
  167. * 是否允许点击外部自动关闭
  168. * @default true
  169. */
  170. clickOutSideClose?: boolean,
  171. /**
  172. * 气泡框圆角半径
  173. * @default 12
  174. */
  175. radius?: number,
  176. /**
  177. * 气泡框内层容器样式
  178. */
  179. innerProps?: FlexProps,
  180. /**
  181. * 气泡框内层容器样式
  182. */
  183. innerStyle?: Record<string, string>,
  184. }
  185. export interface BubbleBoxExpose {
  186. show: () => void,
  187. hide: () => void,
  188. }
  189. const theme = useTheme();
  190. const props = withDefaults(defineProps<BubbleBoxProps>(), {
  191. position: 'top',
  192. trigger: 'click',
  193. direction: 'column',
  194. clickOutSideClose: true,
  195. arrowWidth: () => propGetThemeVar('BubbleBoxArrowWidth', 12),
  196. items: () => [],
  197. itemTextColor: () => propGetThemeVar('BubbleBoxItemTextColor', 'text.content'),
  198. backgroundColor: () => propGetThemeVar('BubbleBoxBackgroundColor', 'white'),
  199. radius: () => propGetThemeVar('BubbleBoxRadius', 12),
  200. });
  201. const emit = defineEmits(['show', 'hide', 'clickOnHolder']);
  202. const backgroundColor = computed(() => theme.resolveThemeColor(props.backgroundColor));
  203. const showState = ref(false);
  204. const lock = ref(false);
  205. function handleItemClick(item: BubbleBoxItem) {
  206. hide();
  207. item.onClick();
  208. }
  209. function handleClick() {
  210. if (lock.value) return;
  211. if (props.trigger === 'click' && !props.disabled) {
  212. enterLock();
  213. showState.value = !showState.value;
  214. emit(showState.value ? 'show' : 'hide');
  215. }
  216. }
  217. function handleHover(show: boolean) {
  218. if (lock.value) return;
  219. if (props.trigger === 'hover' && !props.disabled) {
  220. showState.value = show;
  221. emit(show ? 'show' : 'hide');
  222. }
  223. }
  224. function enterLock() {
  225. lock.value = true;
  226. setTimeout(() => {
  227. lock.value = false;
  228. }, 300);
  229. }
  230. function show() {
  231. showState.value = true;
  232. emit('show');
  233. enterLock();
  234. }
  235. function hide() {
  236. showState.value = false;
  237. emit('hide');
  238. enterLock();
  239. }
  240. function hideAndEmitClickOnHolder() {
  241. hide();
  242. emit('clickOnHolder');
  243. }
  244. const innerStyle = computed(() => {
  245. const horzLayout = selectStyleType(props.crossPosition, 'center', {
  246. left: {
  247. k: 'top',
  248. y: '0%',
  249. t: 'translateY(0%)',
  250. },
  251. center: {
  252. k: 'top',
  253. y: '50%',
  254. t: 'translateY(-50%)',
  255. },
  256. right: {
  257. k: 'bottom',
  258. y: '0',
  259. t: 'translateY(0%)',
  260. }
  261. });
  262. const vertLayout = selectStyleType(props.crossPosition, 'center', {
  263. left: {
  264. k: 'left',
  265. x: '0%',
  266. t: 'translateX(0%)',
  267. },
  268. center: {
  269. k: 'left',
  270. x: '50%',
  271. t: 'translateX(-50%)',
  272. },
  273. right: {
  274. k: 'right',
  275. x: '0%',
  276. t: 'translateX(0%)',
  277. }
  278. });
  279. return {
  280. ...props.innerStyle,
  281. ...selectStyleType(props.position, 'top', {
  282. left: {
  283. [horzLayout.k]: horzLayout.y,
  284. right: '100%',
  285. transform: horzLayout.t + ' translateX(0)',
  286. },
  287. right: {
  288. [horzLayout.k]: horzLayout.y,
  289. left: '100%',
  290. transform: horzLayout.t + '',
  291. },
  292. top: {
  293. top: '0%',
  294. [vertLayout.k]: vertLayout.x,
  295. transform: vertLayout.t + ' translateY(-100%)',
  296. },
  297. bottom: {
  298. bottom: '0%',
  299. [vertLayout.k]: vertLayout.x,
  300. transform: vertLayout.t + ' translateY(100%)',
  301. }
  302. })
  303. }
  304. })
  305. defineExpose<BubbleBoxExpose>({
  306. show,
  307. hide,
  308. })
  309. defineOptions({
  310. options: {
  311. virtualHost: true,
  312. styleIsolation: "shared",
  313. }
  314. })
  315. </script>
  316. <style lang="scss">
  317. .nana-bubble-box {
  318. position: relative;
  319. overflow: visible;
  320. .nana-bubble-box-popup {
  321. position: absolute;
  322. transition: opacity ease-in-out 0.2s;
  323. &.left {
  324. .nana-bubble-box-arrow {
  325. top: 50%;
  326. left: 100%;
  327. transform: translateY(-50%) rotate(-90deg);
  328. }
  329. }
  330. &.right {
  331. .nana-bubble-box-arrow {
  332. top: 50%;
  333. left: 0;
  334. transform: translateX(-100%) translateY(-50%) rotate(90deg);
  335. }
  336. }
  337. &.top {
  338. .nana-bubble-box-arrow {
  339. top: 100%;
  340. left: 50%;
  341. transform: translateX(-50%) rotate(0);
  342. }
  343. }
  344. &.bottom {
  345. .nana-bubble-box-arrow {
  346. top: 0;
  347. left: 50%;
  348. transform: translateX(-50%) translateY(-100%) rotate(180deg);
  349. }
  350. }
  351. &.bubble-box-enter-active,
  352. &.bubble-box-leave-active {
  353. opacity: 1;
  354. }
  355. &.bubble-box-enter-from,
  356. &.bubble-box-leave-to {
  357. opacity: 0;
  358. }
  359. }
  360. .nana-bubble-box-holder-position {
  361. position: absolute;
  362. top: 0;
  363. left: 0;
  364. right: 0;
  365. bottom: 0;
  366. z-index: 1001;
  367. }
  368. .nana-bubble-box-popup-mask {
  369. position: fixed;
  370. top: 0;
  371. left: 0;
  372. width: 100%;
  373. height: 100%;
  374. z-index: 1000;
  375. }
  376. .nana-bubble-box-arrow {
  377. position: absolute;
  378. width: 0;
  379. height: 0;
  380. border-style: solid;
  381. }
  382. }
  383. </style>