wd-select-picker.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. <template>
  2. <view :class="`wd-select-picker ${customClass}`" :style="customStyle">
  3. <wd-cell
  4. v-if="!$slots.default"
  5. :title="label"
  6. :value="showValue || placeholder || translate('placeholder')"
  7. :required="required"
  8. :size="size"
  9. :title-width="labelWidth"
  10. :prop="prop"
  11. :rules="rules"
  12. :clickable="!disabled && !readonly"
  13. :value-align="alignRight ? 'right' : 'left'"
  14. :center="center"
  15. :custom-class="cellClass"
  16. :custom-style="customStyle"
  17. :custom-title-class="customLabelClass"
  18. :custom-value-class="customValueClass"
  19. :ellipsis="ellipsis"
  20. :use-title-slot="!!$slots.label"
  21. :marker-side="markerSide"
  22. @click="open"
  23. >
  24. <template v-if="$slots.label" #title>
  25. <slot name="label"></slot>
  26. </template>
  27. <template #right-icon>
  28. <wd-icon v-if="showArrow" custom-class="wd-select-picker__arrow" name="arrow-right" />
  29. <view v-else-if="showClear" @click.stop="handleClear">
  30. <wd-icon custom-class="wd-select-picker__clear" name="error-fill" />
  31. </view>
  32. </template>
  33. </wd-cell>
  34. <view v-else @click="open">
  35. <slot></slot>
  36. </view>
  37. <wd-action-sheet
  38. v-model="pickerShow"
  39. :duration="250"
  40. :title="title || translate('title')"
  41. :close-on-click-modal="closeOnClickModal"
  42. :z-index="zIndex"
  43. :safe-area-inset-bottom="safeAreaInsetBottom"
  44. :root-portal="rootPortal"
  45. @close="close"
  46. @opened="scrollIntoView ? setScrollIntoView() : ''"
  47. custom-header-class="wd-select-picker__header"
  48. >
  49. <wd-search
  50. v-if="filterable"
  51. v-model="filterVal"
  52. :placeholder="filterPlaceholder || translate('filterPlaceholder')"
  53. hide-cancel
  54. placeholder-left
  55. @change="handleFilterChange"
  56. />
  57. <scroll-view
  58. :class="`wd-select-picker__wrapper ${filterable ? 'is-filterable' : ''} ${loading ? 'is-loading' : ''} ${customContentClass}`"
  59. :scroll-y="!loading"
  60. :scroll-top="scrollTop"
  61. :scroll-with-animation="true"
  62. >
  63. <!-- 多选 -->
  64. <view v-if="type === 'checkbox' && isArray(selectList)" id="wd-checkbox-group">
  65. <wd-checkbox-group v-model="selectList" cell :size="selectSize" :checked-color="checkedColor" :min="min" :max="max" @change="handleChange">
  66. <view v-for="item in filterColumns" :key="item[valueKey]" :id="'check' + item[valueKey]">
  67. <wd-checkbox :modelValue="item[valueKey]" :disabled="item.disabled">
  68. <block v-if="filterable && filterVal">
  69. <block v-for="text in item[labelKey]" :key="text.label">
  70. <text v-if="text.type === 'active'" class="wd-select-picker__text-active">{{ text.label }}</text>
  71. <block v-else>{{ text.label }}</block>
  72. </block>
  73. </block>
  74. <block v-else>
  75. {{ item[labelKey] }}
  76. </block>
  77. </wd-checkbox>
  78. </view>
  79. </wd-checkbox-group>
  80. </view>
  81. <!-- 单选 -->
  82. <view v-if="type === 'radio' && !isArray(selectList)" id="wd-radio-group">
  83. <wd-radio-group v-model="selectList" cell :size="selectSize" :checked-color="checkedColor" @change="handleChange">
  84. <view v-for="(item, index) in filterColumns" :key="index" :id="'radio' + item[valueKey]">
  85. <wd-radio :value="item[valueKey]" :disabled="item.disabled">
  86. <block v-if="filterable && filterVal">
  87. <block v-for="text in item[labelKey]" :key="text.label">
  88. <text :class="`${text.type === 'active' ? 'wd-select-picker__text-active' : ''}`">{{ text.label }}</text>
  89. </block>
  90. </block>
  91. <block v-else>
  92. {{ item[labelKey] }}
  93. </block>
  94. </wd-radio>
  95. </view>
  96. </wd-radio-group>
  97. </view>
  98. <view v-if="loading" class="wd-select-picker__loading" @touchmove="noop">
  99. <wd-loading :color="loadingColor" />
  100. </view>
  101. </scroll-view>
  102. <!-- 确认按钮 -->
  103. <view v-if="showConfirm" class="wd-select-picker__footer">
  104. <wd-button block size="large" @click="onConfirm" :disabled="loading">{{ confirmButtonText || translate('confirm') }}</wd-button>
  105. </view>
  106. </wd-action-sheet>
  107. </view>
  108. </template>
  109. <script lang="ts">
  110. export default {
  111. name: 'wd-select-picker',
  112. options: {
  113. addGlobalClass: true,
  114. virtualHost: true,
  115. styleIsolation: 'shared'
  116. }
  117. }
  118. </script>
  119. <script lang="ts" setup>
  120. import wdActionSheet from '../wd-action-sheet/wd-action-sheet.vue'
  121. import wdCheckbox from '../wd-checkbox/wd-checkbox.vue'
  122. import wdCheckboxGroup from '../wd-checkbox-group/wd-checkbox-group.vue'
  123. import wdRadio from '../wd-radio/wd-radio.vue'
  124. import wdRadioGroup from '../wd-radio-group/wd-radio-group.vue'
  125. import wdButton from '../wd-button/wd-button.vue'
  126. import wdLoading from '../wd-loading/wd-loading.vue'
  127. import wdCell from '../wd-cell/wd-cell.vue'
  128. import { getCurrentInstance, onBeforeMount, ref, watch, nextTick, computed } from 'vue'
  129. import { getRect, isArray, isDef, isFunction, pause } from '../common/util'
  130. import { useTranslate } from '../composables/useTranslate'
  131. import { selectPickerProps, type SelectPickerExpose } from './types'
  132. const { translate } = useTranslate('select-picker')
  133. const props = defineProps(selectPickerProps)
  134. const emit = defineEmits(['change', 'cancel', 'confirm', 'clear', 'update:modelValue', 'open', 'close'])
  135. const pickerShow = ref<boolean>(false)
  136. const selectList = ref<Array<number | boolean | string> | number | boolean | string>([])
  137. const isConfirm = ref<boolean>(false)
  138. const lastSelectList = ref<Array<number | boolean | string> | number | boolean | string>([])
  139. const filterVal = ref<string>('')
  140. const filterColumns = ref<Array<Record<string, any>>>([])
  141. const scrollTop = ref<number>(0) // 滚动位置
  142. const showValue = computed(() => {
  143. const value = valueFormat(props.modelValue)
  144. let showValueTemp: string = ''
  145. if (props.displayFormat) {
  146. showValueTemp = props.displayFormat(value, props.columns)
  147. } else {
  148. const { type, labelKey } = props
  149. if (type === 'checkbox') {
  150. const selectedItems = (isArray(value) ? value : []).map((item) => {
  151. return getSelectedItem(item)
  152. })
  153. showValueTemp = selectedItems
  154. .map((item) => {
  155. return item[labelKey]
  156. })
  157. .join(', ')
  158. } else if (type === 'radio') {
  159. const selectedItem = getSelectedItem(value as string | number | boolean)
  160. showValueTemp = selectedItem[labelKey]
  161. } else {
  162. showValueTemp = value as string
  163. }
  164. }
  165. return showValueTemp
  166. })
  167. const cellClass = computed(() => {
  168. const classes = ['wd-select-picker__cell']
  169. if (props.disabled) classes.push('is-disabled')
  170. if (props.readonly) classes.push('is-readonly')
  171. if (props.error) classes.push('is-error')
  172. if (!showValue.value) classes.push('wd-select-picker__cell--placeholder')
  173. return classes.join(' ')
  174. })
  175. watch(
  176. () => props.modelValue,
  177. (newValue) => {
  178. if (newValue === selectList.value) return
  179. selectList.value = valueFormat(newValue)
  180. lastSelectList.value = valueFormat(newValue)
  181. },
  182. {
  183. deep: true,
  184. immediate: true
  185. }
  186. )
  187. watch(
  188. () => props.columns,
  189. (newValue) => {
  190. if (props.filterable && filterVal.value) {
  191. formatFilterColumns(newValue, filterVal.value)
  192. } else {
  193. filterColumns.value = newValue
  194. }
  195. },
  196. {
  197. deep: true,
  198. immediate: true
  199. }
  200. )
  201. watch(
  202. () => props.displayFormat,
  203. (fn) => {
  204. if (fn && !isFunction(fn)) {
  205. console.error('The type of displayFormat must be Function')
  206. }
  207. },
  208. {
  209. deep: true,
  210. immediate: true
  211. }
  212. )
  213. watch(
  214. () => props.beforeConfirm,
  215. (fn) => {
  216. if (fn && !isFunction(fn)) {
  217. console.error('The type of beforeConfirm must be Function')
  218. }
  219. },
  220. {
  221. deep: true,
  222. immediate: true
  223. }
  224. )
  225. onBeforeMount(() => {
  226. selectList.value = valueFormat(props.modelValue)
  227. filterColumns.value = props.columns
  228. })
  229. const { proxy } = getCurrentInstance() as any
  230. async function setScrollIntoView() {
  231. let wraperSelector: string = ''
  232. let selectorPromise: Promise<UniApp.NodeInfo>[] = []
  233. if (isDef(selectList.value) && selectList.value !== '' && !isArray(selectList.value)) {
  234. wraperSelector = '#wd-radio-group'
  235. selectorPromise = [getRect(`#radio${selectList.value}`, false, proxy)]
  236. } else if (isArray(selectList.value) && selectList.value.length > 0) {
  237. selectList.value.forEach((value) => {
  238. selectorPromise.push(getRect(`#check${value}`, false, proxy))
  239. })
  240. wraperSelector = '#wd-checkbox-group'
  241. }
  242. if (wraperSelector) {
  243. await pause(2000 / 30)
  244. Promise.all([getRect('.wd-select-picker__wrapper', false, proxy), getRect(wraperSelector, false, proxy), ...selectorPromise]).then((res) => {
  245. if (isDef(res) && isArray(res)) {
  246. const scrollView = res[0]
  247. const wraper = res[1]
  248. const target = res.slice(2) || []
  249. if (isDef(wraper) && isDef(scrollView)) {
  250. const index = target.findIndex((item) => {
  251. return item.bottom! > scrollView.top! && item.top! < scrollView.bottom!
  252. })
  253. if (index < 0) {
  254. scrollTop.value = -1
  255. nextTick(() => {
  256. scrollTop.value = Math.max(0, target[0].top! - wraper.top! - scrollView.height! / 2)
  257. })
  258. }
  259. }
  260. }
  261. })
  262. }
  263. }
  264. function noop() {}
  265. function getSelectedItem(value: string | number | boolean) {
  266. const { valueKey, labelKey, columns } = props
  267. const selecteds = columns.filter((item) => {
  268. return item[valueKey] === value
  269. })
  270. if (selecteds.length > 0) {
  271. return selecteds[0]
  272. }
  273. return {
  274. [valueKey]: value,
  275. [labelKey]: ''
  276. }
  277. }
  278. function valueFormat(value: string | number | boolean | (string | number | boolean)[]) {
  279. return props.type === 'checkbox' ? (isArray(value) ? value : []) : value
  280. }
  281. function handleChange({ value }: { value: string | number | boolean | (string | number | boolean)[] }) {
  282. selectList.value = value
  283. emit('change', { value })
  284. if (props.type === 'radio' && !props.showConfirm) {
  285. onConfirm()
  286. }
  287. }
  288. function close() {
  289. pickerShow.value = false
  290. // 未确定选项时,数据还原复位
  291. if (!isConfirm.value) {
  292. selectList.value = valueFormat(lastSelectList.value)
  293. }
  294. emit('cancel')
  295. emit('close')
  296. }
  297. function open() {
  298. if (props.disabled || props.readonly) return
  299. selectList.value = valueFormat(props.modelValue)
  300. pickerShow.value = true
  301. isConfirm.value = false
  302. emit('open')
  303. }
  304. function onConfirm() {
  305. if (props.loading) {
  306. pickerShow.value = false
  307. emit('confirm')
  308. emit('close')
  309. return
  310. }
  311. if (props.beforeConfirm) {
  312. props.beforeConfirm(selectList.value, (isPass: boolean) => {
  313. isPass && handleConfirm()
  314. })
  315. } else {
  316. handleConfirm()
  317. }
  318. }
  319. function handleConfirm() {
  320. isConfirm.value = true
  321. pickerShow.value = false
  322. lastSelectList.value = valueFormat(selectList.value)
  323. let selectedItems: Record<string, any> = {}
  324. if (props.type === 'checkbox') {
  325. selectedItems = (isArray(lastSelectList.value) ? lastSelectList.value : []).map((item) => {
  326. return getSelectedItem(item)
  327. })
  328. } else {
  329. selectedItems = getSelectedItem(lastSelectList.value as string | number | boolean)
  330. }
  331. emit('update:modelValue', lastSelectList.value)
  332. emit('confirm', {
  333. value: lastSelectList.value,
  334. selectedItems
  335. })
  336. emit('close')
  337. }
  338. function getFilterText(label: string, filterVal: string) {
  339. const reg = new RegExp(`(${filterVal})`, 'g')
  340. return label.split(reg).map((text) => {
  341. return {
  342. type: text === filterVal ? 'active' : 'normal',
  343. label: text
  344. }
  345. })
  346. }
  347. function handleFilterChange({ value }: { value: string }) {
  348. if (value === '') {
  349. filterColumns.value = []
  350. filterVal.value = value
  351. nextTick(() => {
  352. filterColumns.value = props.columns
  353. })
  354. } else {
  355. filterVal.value = value
  356. formatFilterColumns(props.columns, value)
  357. }
  358. }
  359. function formatFilterColumns(columns: Record<string, any>[], filterVal: string) {
  360. const filterColumnsTemp = columns.filter((item) => {
  361. return item[props.labelKey].indexOf(filterVal) > -1
  362. })
  363. const formatFilterColumns = filterColumnsTemp.map((item) => {
  364. return {
  365. ...item,
  366. [props.labelKey]: getFilterText(item[props.labelKey], filterVal)
  367. }
  368. })
  369. filterColumns.value = []
  370. nextTick(() => {
  371. filterColumns.value = formatFilterColumns
  372. })
  373. }
  374. const showConfirm = computed(() => {
  375. return (props.type === 'radio' && props.showConfirm) || props.type === 'checkbox'
  376. })
  377. // 是否展示清除按钮
  378. const showClear = computed(() => {
  379. return props.clearable && !props.disabled && !props.readonly && showValue.value.length
  380. })
  381. function handleClear() {
  382. emit('update:modelValue', props.type === 'checkbox' ? [] : '')
  383. emit('clear')
  384. }
  385. // 是否展示箭头
  386. const showArrow = computed(() => {
  387. return !props.disabled && !props.readonly && !showClear.value
  388. })
  389. defineExpose<SelectPickerExpose>({
  390. close,
  391. open
  392. })
  393. </script>
  394. <style lang="scss" scoped>
  395. @import './index.scss';
  396. </style>