BottomSheet.svelte 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. <script>
  2. import { onMount, createEventDispatcher, getContext } from 'svelte';
  3. import { fly } from 'svelte/transition';
  4. import { debounce } from '../utils';
  5. import zh_CN from '../../lang/zh_CN';
  6. import Mask from '../mask/Mask.svelte';
  7. import Icon from '../icon/Icon.svelte';
  8. // 当前语言
  9. // current language
  10. const currentLang = getContext('STDF_lang') || zh_CN;
  11. const bottomSheetLang = currentLang.bottomSheet;
  12. // 是否显示
  13. // show or not
  14. export let visible = false;
  15. // 标题
  16. // title
  17. export let title = bottomSheetLang.title;
  18. // 标题对齐方式
  19. // title align
  20. export let titleAlign = 'left';
  21. // 是否显示返回图标
  22. // show back icon or not
  23. export let showBackIcon = false;
  24. // 关闭区域内容,‘’表示不显示,‘closeIcon’表示显示关闭图标,’downIcon‘表示显示下拉图标,其余文字表示显示文字
  25. // close content, '' means not show, 'closeIcon' means show close icon, 'downIcon' means show down icon,other text means show text
  26. export let closeContent = 'downIcon';
  27. // 是否在顶部显示分割线
  28. // show divider or not at the top
  29. export let showDivider = true;
  30. // 过渡动画出现时间
  31. // transition animation appear time
  32. export let duration = 450;
  33. // 过渡动画退出时间
  34. // transition animation exit time
  35. export let outDuration = 240;
  36. // 遮罩层参数
  37. // mask params
  38. export let mask = {};
  39. // 点击遮罩层是否关闭
  40. // click mask to close or not
  41. export let maskClosable = false;
  42. // z-index
  43. export let zIndex = 600;
  44. // 固定高度列表
  45. // stay height list
  46. export let stayHeightList = [10, 50, 90];
  47. // 初始固定高度索引
  48. // initial stay height index
  49. export let stayHeightIndex = 1;
  50. // 滑动结束时位置低于此高度自动关闭
  51. // close when position lower than this height
  52. export let closeHeight = 0;
  53. // 圆角风格
  54. // radius style
  55. export let radius = 'full';
  56. // 创建事件派发器
  57. // create event dispatcher
  58. const dispatch = createEventDispatcher();
  59. // 固定高度
  60. // stay height
  61. let stayHeight = stayHeightList[stayHeightIndex] || stayHeightList[stayHeightList.length - 1];
  62. // 此时是否正在滑动
  63. // is sliding or not now
  64. let isTouch = false;
  65. // 滑动开始Y坐标,px
  66. // start Y coordinate, px
  67. let startY = 0;
  68. // 滑动中Y方向当前位置,px
  69. // current Y coordinate, px
  70. let currentY = 0;
  71. // 滑动开始距离顶部高度,%
  72. // start distance from top, %
  73. let startTop = 100 - stayHeight;
  74. // 滑动距离,%
  75. // move distance, %
  76. let moveDistance = 0;
  77. // 顶部滚动区域高度,%
  78. // top scroll area height, %
  79. let scrollTopHeight = 5;
  80. // 顶部滚动区域元素
  81. // top scroll area element
  82. let scrollTopDom = null;
  83. // 当前距离顶部高度,%
  84. // current distance from top, %
  85. $: currentTop = startTop + moveDistance;
  86. // 如果 stayHeightList 不是数组,或者元素不是正数,或者元素不是 0-100 之间的数,或者元素不是整数,给出警告。
  87. // If stayHeightList is not an array, or the element is not a positive number, or the element is not a number between 0 and 100, or the element is not an integer, give a warning.
  88. if (
  89. !Array.isArray(stayHeightList) ||
  90. stayHeightList.some(item => typeof item !== 'number' || item < 0 || item > 100 || item % 1 !== 0)
  91. ) {
  92. console.error(
  93. '[STDF BottomSheet error]stayHeightList 必须是一个 0-100 之间的正整数数组。(stayHeightList must be an array of positive integers between 0 and 100)'
  94. );
  95. }
  96. // 如果 stayHeightList 元素不是递增的,给出警告。
  97. // If the elements of stayHeightList are not increasing, give a warning.
  98. if (stayHeightList.some((item, index) => index > 0 && item <= stayHeightList[index - 1])) {
  99. console.error(
  100. '[STDF BottomSheet error]stayHeightList 数组元素必须是升序排列。(stayHeightList array elements must be in ascending order)'
  101. );
  102. }
  103. // 如果 stayHeightIndex 超出 stayHeightList 长度给出警告。
  104. // If stayHeightIndex exceeds the length of stayHeightList, give a warning.
  105. if (stayHeightIndex > stayHeightList.length - 1) {
  106. console.warn(
  107. '[STDF BottomSheet warn]stayHeightIndex 超出 stayHeightList 长度,将使用 stayHeightList 最后一个值。(stayHeightIndex exceeds the length of stayHeightList, the last value of stayHeightList will be used.)'
  108. );
  109. }
  110. // 如果 closeHeight 大于 stayHeightList 最小值给出警告。
  111. // If closeHeight is greater than the minimum value of stayHeightList, give a warning.
  112. if (closeHeight > stayHeightList[0]) {
  113. console.warn(
  114. '[STDF BottomSheet warn]closeHeight 大于 stayHeightList 最小值,closeHeight 将失效。(closeHeight is greater than the minimum value of stayHeightList, closeHeight will be invalid.)'
  115. );
  116. }
  117. // 标题对齐方式
  118. // title align
  119. const titleAlignClass = {
  120. left: ' text-left',
  121. center: ' text-center',
  122. right: ' text-right',
  123. };
  124. // 窗口圆角风格
  125. // window radius style
  126. const windowRadiusClass = {
  127. none: ' rounded-none',
  128. base: ' rounded-t-lg',
  129. full: ' rounded-t-2xl',
  130. };
  131. // 图标圆角风格
  132. // icon radius style
  133. const iconRadiusClass = {
  134. none: ' rounded-none',
  135. base: ' rounded',
  136. full: ' rounded-full',
  137. };
  138. // 滑动开始
  139. // start sliding
  140. const touchstartFun = e => {
  141. moveDistance = 0;
  142. startTop = currentTop;
  143. startY = e.clientY;
  144. isTouch = true;
  145. };
  146. // 滑动中
  147. // sliding
  148. const touchmoveFun = e => {
  149. if (!isTouch) return;
  150. scrollTopDom.setPointerCapture(e.pointerId);
  151. currentY = e.clientY;
  152. //移动百分比,moveDistance为正时,向下移动
  153. //Move percentage, moveDistance is positive when moving down
  154. moveDistance = ((currentY - startY) / window.innerHeight) * 100;
  155. //处理向上滑动时超过最大高度情况
  156. //Handle the case when the maximum height is exceeded when sliding up
  157. if (moveDistance + startTop < 100 - stayHeightList[stayHeightList.length - 1]) {
  158. moveDistance = 100 - stayHeightList[stayHeightList.length - 1] - startTop;
  159. }
  160. };
  161. // 滑动结束
  162. // end sliding
  163. const touchendFun = () => {
  164. isTouch = false;
  165. //计算出每个高度对应的top值
  166. //Calculate the top value corresponding to each height
  167. let toTopList = stayHeightList.map(item => 100 - item);
  168. let min = 100;
  169. let currentIndex = 0;
  170. toTopList.forEach((item, index) => {
  171. if (Math.abs(item - currentTop) < min) {
  172. min = Math.abs(item - currentTop);
  173. currentIndex = index;
  174. }
  175. });
  176. currentTop = toTopList[currentIndex];
  177. //派发事件,传递当前高度
  178. //Dispatch events and pass the current
  179. dispatch('heightChange', stayHeightList[currentIndex]);
  180. //判断滑动结束时如果位置低于 closeHeight 自动关闭,并派发事件
  181. //If the position is lower than closeHeight at the end of the slide, it will be automatically closed and the event will be dispatched.
  182. if (((window.innerHeight - currentY) / window.innerHeight) * 100 < closeHeight && closeHeight > 0) {
  183. visible = false;
  184. dispatch('close');
  185. }
  186. };
  187. //点击遮罩层
  188. //click mask
  189. const clickMask = () => {
  190. //点击遮罩时派发clickMask事件
  191. //Dispatch clickMask event when clicking mask
  192. dispatch('clickMask');
  193. if (maskClosable) {
  194. visible = false;
  195. }
  196. };
  197. //点击关闭图标
  198. //click close icon
  199. const closeFunc = () => {
  200. visible = false;
  201. //点击关闭时派发close事件
  202. //Dispatch close event when clicking close
  203. dispatch('close');
  204. };
  205. //点击返回图标
  206. //click back icon
  207. const backFunc = () => {
  208. //点击返回时派发back事件
  209. //Dispatch back event when clicking back
  210. dispatch('back');
  211. };
  212. // 滚动时禁止 body 滚动
  213. // Disable body scrolling when scrolling
  214. $: {
  215. if (visible) {
  216. //当 visible 为 true 时,禁止 body 滚动
  217. //When visible is true, body scrolling is disabled
  218. const top = document.documentElement.scrollTop || document.body.scrollTop;
  219. document.body.style.cssText += `
  220. position: fixed;
  221. width: 100vw;
  222. left: 0;
  223. top: ${-top}px;
  224. touch-action:none;
  225. `;
  226. } else {
  227. const top = document.body.style.top;
  228. document.body.style.cssText += `
  229. position: static;
  230. touch-action:auto;
  231. `;
  232. window.scrollTo(0, Math.abs(parseFloat(top)));
  233. }
  234. }
  235. onMount(() => {
  236. if (visible) {
  237. // 滚动内容高度
  238. // Scroll content height
  239. scrollTopHeight = scrollTopDom.clientHeight;
  240. }
  241. });
  242. </script>
  243. {#if visible}
  244. <Mask visible {duration} {outDuration} {...mask} on:clickMask={clickMask} />
  245. {/if}
  246. <div class={`fixed w-screen h-screen inset-0 flex flex-col justify-end px-0 pointer-events-none`} style={`z-index:${zIndex};`}>
  247. {#if visible}
  248. <div
  249. class={`fixed w-screen bg-white dark:bg-gray1${windowRadiusClass[radius] || windowRadiusClass['full']} pointer-events-auto${
  250. isTouch ? '' : ' transition-all duration-300'
  251. }`}
  252. style={`height:${stayHeightList[stayHeightList.length - 1]}%;top:${currentTop}%;`}
  253. in:fly={{ y: (stayHeightList[stayHeightList.length - 1] / 100) * window.innerHeight, opacity: 1, duration }}
  254. out:fly={{ y: (stayHeightList[stayHeightList.length - 1] / 100) * window.innerHeight, opacity: 1, duration: outDuration }}
  255. >
  256. <div
  257. on:pointerdown={touchstartFun}
  258. on:pointermove={debounce(touchmoveFun, 5)}
  259. on:pointerup={touchendFun}
  260. bind:this={scrollTopDom}
  261. class="py-1 touch-none cursor-move select-none"
  262. >
  263. <div class={`w-8 h-1 bg-black/20 dark:bg-white/30 mx-auto${radius === 'none' ? ' rounded-none' : ' rounded-full'}`} />
  264. <div class="px-3 py-1 flex justify-between items-center gap-2">
  265. {#if showBackIcon}
  266. <!-- svelte-ignore a11y-click-events-have-key-events -->
  267. <div
  268. class={`flex-none bg-black/5 dark:bg-white/10 w-6 h-6 text-center${
  269. iconRadiusClass[radius] || iconRadiusClass['full']
  270. }`}
  271. on:click={backFunc}
  272. >
  273. <Icon name="ri-arrow-left-s-line" alpha={0.4} size={16} top={-2} />
  274. </div>
  275. {/if}
  276. <div class={`font-bold text-lg h-7 truncate grow${titleAlignClass[titleAlign] || titleAlignClass['left']}`}>
  277. {title}
  278. </div>
  279. {#if closeContent === ''}
  280. <!-- null -->
  281. {:else if closeContent === 'closeIcon'}
  282. <!-- svelte-ignore a11y-click-events-have-key-events -->
  283. <div
  284. class={`flex-none bg-black/5 dark:bg-white/10 w-6 h-6 text-center${
  285. iconRadiusClass[radius] || iconRadiusClass['full']
  286. }`}
  287. on:click={closeFunc}
  288. >
  289. <Icon name="ri-close-line" alpha={0.4} size={14} top={-2} />
  290. </div>
  291. {:else if closeContent === 'downIcon'}
  292. <!-- svelte-ignore a11y-click-events-have-key-events -->
  293. <div
  294. class={`flex-none bg-black/5 dark:bg-white/10 w-6 h-6 text-center${
  295. iconRadiusClass[radius] || iconRadiusClass['full']
  296. }`}
  297. on:click={closeFunc}
  298. >
  299. <Icon name="ri-arrow-down-s-line" alpha={0.4} size={16} top={-1} />
  300. </div>
  301. {:else}
  302. <!-- svelte-ignore a11y-click-events-have-key-events -->
  303. <div class="text-primary dark:text-dark font-bold cursor-pointer" on:click={closeFunc}>{closeContent}</div>
  304. {/if}
  305. </div>
  306. </div>
  307. {#if showDivider}
  308. <div class="w-full h-px bg-black/5 dark:bg-white/10" />
  309. {/if}
  310. <div
  311. class="overflow-auto"
  312. style="height:{window.innerHeight * ((100 - currentTop) / 100) - scrollTopHeight}px;overscroll-behavior-y: contain;"
  313. >
  314. <slot />
  315. </div>
  316. </div>
  317. {/if}
  318. </div>