BottomSheet.svelte 12 KB

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