Slider.vue 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. <template>
  2. <div class="slider" :class="{ 'disabled': disabled }" ref="sliderRef" @mousedown="$event => handleMousedown($event)">
  3. <div class="bar">
  4. <template v-if="!range">
  5. <div class="track" :style="{ width: `${percentage}%` }"></div>
  6. <div class="thumb" :style="{ left: `${percentage}%` }" :data-tooltip="tooltipValue"></div>
  7. </template>
  8. <template v-else>
  9. <div class="track" :style="{ width: `${end - start}%`, left: `${start}%` }"></div>
  10. <div class="thumb" :style="{ left: `${start}%` }" :data-tooltip="tooltipRangeStartValue"></div>
  11. <div class="thumb" :style="{ left: `${end}%` }" :data-tooltip="tooltipRangeEndValue"></div>
  12. </template>
  13. </div>
  14. </div>
  15. </template>
  16. <script lang="ts" setup>
  17. import { computed, ref, watch, useTemplateRef } from 'vue'
  18. import NP from 'number-precision'
  19. const getBoundingClientRectViewLeft = (element: HTMLElement) => {
  20. return element.getBoundingClientRect().left
  21. }
  22. const props = withDefaults(defineProps<{
  23. value: number | [number, number]
  24. disabled?: boolean
  25. min?: number
  26. max?: number
  27. step?: number
  28. range?: boolean
  29. }>(), {
  30. disabled: false,
  31. min: 0,
  32. max: 100,
  33. step: 1,
  34. range: false,
  35. })
  36. const emit = defineEmits<{
  37. (event: 'update:value', payload: number | [number, number]): void
  38. }>()
  39. const sliderRef = useTemplateRef<HTMLElement>('sliderRef')
  40. const percentage = ref(0)
  41. const start = ref(0)
  42. const end = ref(0)
  43. const handler = ref<'start' | 'end'>('end')
  44. const getNewValue = (percentage: number) => {
  45. let diff = percentage / 100 * (props.max - props.min)
  46. if (props.step >= 1) diff = Math.fround(diff)
  47. else {
  48. const str = props.step.toString()
  49. const match = str.match(/^[0.]*([1-9])/)
  50. if (match) {
  51. const targetNumber = match[1]
  52. const position = str.indexOf(targetNumber) - 1
  53. if (position > 0) {
  54. const accuracy = Math.pow(10, position)
  55. diff = Math.fround(diff * accuracy) / accuracy
  56. }
  57. }
  58. }
  59. return NP.plus(diff, props.min)
  60. }
  61. const tooltipValue = computed(() => {
  62. return getNewValue(percentage.value)
  63. })
  64. const tooltipRangeStartValue = computed(() => {
  65. return getNewValue(start.value)
  66. })
  67. const tooltipRangeEndValue = computed(() => {
  68. return getNewValue(end.value)
  69. })
  70. watch(() => props.value, () => {
  71. if (props.max === props.min) return
  72. if (typeof props.value === 'number') {
  73. percentage.value = (props.value - props.min) / (props.max - props.min) * 100
  74. }
  75. else {
  76. start.value = (props.value[0] - props.min) / (props.max - props.min) * 100
  77. end.value = (props.value[1] - props.min) / (props.max - props.min) * 100
  78. }
  79. }, {
  80. immediate: true,
  81. })
  82. const getPercentage = (e: MouseEvent | TouchEvent) => {
  83. if (!sliderRef.value) return 0
  84. const clientX = 'clientX' in e ? e.clientX : e.changedTouches[0].clientX
  85. let progress = (clientX - getBoundingClientRectViewLeft(sliderRef.value)) / sliderRef.value.clientWidth
  86. progress = Math.max(progress, 0)
  87. progress = Math.min(progress, 1)
  88. let _percentage = progress * 100
  89. const step = props.step / (props.max - props.min) * 100
  90. const remainder = _percentage % step
  91. if (remainder > 0) {
  92. if (remainder <= step / 2) _percentage = _percentage - remainder
  93. else _percentage = _percentage - remainder + step
  94. }
  95. return _percentage
  96. }
  97. // 双滑块(范围)模式
  98. const updateRange = (e: MouseEvent | TouchEvent) => {
  99. const value = getPercentage(e)
  100. if (handler.value === 'start') start.value = value
  101. else end.value = value
  102. }
  103. const updateRangeEnd = (e: MouseEvent | TouchEvent) => {
  104. updatePercentage(e)
  105. const newValue = getNewValue(percentage.value)
  106. const oldValueArr = props.value as [number, number]
  107. const newValueArr: [number, number] = handler.value === 'start' ? [newValue, oldValueArr[1]] : [oldValueArr[0], newValue]
  108. if (newValueArr[0] > newValueArr[1]) {
  109. [newValueArr[0], newValueArr[1]] = [newValueArr[1], newValueArr[0]]
  110. }
  111. emit('update:value', newValueArr)
  112. document.removeEventListener('mousemove', updateRange)
  113. document.removeEventListener('touchmove', updateRange)
  114. document.removeEventListener('mouseup', updateRangeEnd)
  115. document.removeEventListener('touchend', updateRangeEnd)
  116. }
  117. // 单滑块模式
  118. const updatePercentage = (e: MouseEvent | TouchEvent) => {
  119. percentage.value = getPercentage(e)
  120. }
  121. const updatePercentageEnd = (e: MouseEvent | TouchEvent) => {
  122. updatePercentage(e)
  123. const newValue = getNewValue(percentage.value)
  124. emit('update:value', newValue)
  125. document.removeEventListener('mousemove', updatePercentage)
  126. document.removeEventListener('touchmove', updatePercentage)
  127. document.removeEventListener('mouseup', updatePercentageEnd)
  128. document.removeEventListener('touchend', updatePercentageEnd)
  129. }
  130. const handleMousedown = (e: MouseEvent | TouchEvent) => {
  131. if (props.disabled) return
  132. if (props.range) {
  133. const _percentage = getPercentage(e)
  134. if (Math.abs(_percentage - start.value) < Math.abs(_percentage - end.value)) {
  135. handler.value = 'start'
  136. }
  137. else handler.value = 'end'
  138. document.addEventListener('mousemove', updateRange)
  139. document.addEventListener('touchmove', updateRange)
  140. document.addEventListener('mouseup', updateRangeEnd)
  141. document.addEventListener('touchend', updateRangeEnd)
  142. }
  143. else {
  144. document.addEventListener('mousemove', updatePercentage)
  145. document.addEventListener('touchmove', updatePercentage)
  146. document.addEventListener('mouseup', updatePercentageEnd)
  147. document.addEventListener('touchend', updatePercentageEnd)
  148. }
  149. }
  150. </script>
  151. <style scoped lang="scss">
  152. .slider {
  153. width: 100%;
  154. height: 12px;
  155. padding: 4px 0;
  156. user-select: none;
  157. &.disabled {
  158. .track {
  159. background-color: #b4b4b4;
  160. }
  161. .thumb {
  162. outline: 2px solid #b4b4b4;
  163. }
  164. }
  165. }
  166. .slider:not(.disabled) {
  167. cursor: pointer;
  168. .bar {
  169. &:hover {
  170. background-color: #f0f0f0;
  171. }
  172. }
  173. .track {
  174. &:hover {
  175. background-color: $themeHoverColor;
  176. }
  177. }
  178. .thumb {
  179. &:hover, &:active {
  180. outline: 4px solid $themeColor;
  181. }
  182. }
  183. }
  184. .bar {
  185. width: calc(100% - 10px);
  186. margin-left: 5px;
  187. height: 4px;
  188. border-radius: 2px;
  189. position: relative;
  190. background-color: #f5f5f5;
  191. user-select: none;
  192. transition: background-color .2s;
  193. }
  194. .track {
  195. position: absolute;
  196. top: 0;
  197. left: 0;
  198. height: 100%;
  199. background-color: $themeColor;
  200. transition: background-color .2s;
  201. }
  202. .thumb {
  203. position: absolute;
  204. top: 50%;
  205. left: 0;
  206. width: 10px;
  207. height: 10px;
  208. background-color: #fff;
  209. outline: 2px solid $themeColor;
  210. transform: translate(-50%, -50%);
  211. border-radius: 50%;
  212. z-index: 100;
  213. &:hover, &:active {
  214. &::before, &::after {
  215. display: block;
  216. }
  217. }
  218. &::before {
  219. content: attr(data-tooltip);
  220. min-width: 28px;
  221. display: none;
  222. position: absolute;
  223. left: 50%;
  224. bottom: 24px;
  225. transform: translateX(-50%);
  226. background-color: #262626;
  227. text-align: center;
  228. color: #fff;
  229. border-radius: $borderRadius;
  230. padding: 6px 5px;
  231. font-size: 12px;
  232. }
  233. &::after {
  234. content: '';
  235. display: none;
  236. position: absolute;
  237. left: 50%;
  238. bottom: 15px;
  239. transform: translateX(-50%);
  240. border: 5px solid transparent;
  241. border-top-color: #262626;
  242. }
  243. }
  244. </style>