diff --git a/src/config.json b/src/config.json index 03457edc3f..fbd278151d 100644 --- a/src/config.json +++ b/src/config.json @@ -690,6 +690,19 @@ "author": "dsj", "dd": false }, + { + "version": "3.0.0", + "name": "PickerView", + "type": "component", + "cName": "选择器视图", + "desc": "PickerView 是 Picker 的内容区域。", + "sort": 15, + "show": true, + "taro": true, + "v15": false, + "dd": true, + "author": "songsong" + }, { "version": "3.0.0", "name": "Radio", diff --git a/src/packages/pickerview/__test__/__snapshots__/pickerview.spec.tsx.snap b/src/packages/pickerview/__test__/__snapshots__/pickerview.spec.tsx.snap new file mode 100644 index 0000000000..723cd2df1b --- /dev/null +++ b/src/packages/pickerview/__test__/__snapshots__/pickerview.spec.tsx.snap @@ -0,0 +1,318 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`should match base 1`] = ` +
+
+
+
+
+ 南京市 +
+
+ 无锡市 +
+
+ 海北藏族自治区 +
+
+ 北京市 +
+
+ 连云港市 +
+
+ 大庆市 +
+
+ 绥化市 +
+
+ 潍坊市 +
+
+ 乌鲁木齐市 +
+
+
+
+
+
+
+`; + +exports[`should match cascade 1`] = ` +
+
+
+
+
+ 北京 | 测试 +
+
+ 上海 | 测试 +
+
+
+
+
+
+ 朝阳区 | 测试 +
+
+ 海淀区 | 测试 +
+
+ 大兴区 | 测试 +
+
+ 东城区 | 测试 +
+
+ 西城区 | 测试 +
+
+ 丰台区 | 测试 +
+
+
+
+
+
+
+`; + +exports[`should match onchange 1`] = ` +
+
+
+`; + +exports[`should render tiled 1`] = ` +
+
+
+
+
+ 南京市 +
+
+ 无锡市 +
+
+ 海北藏族自治区 +
+
+ 北京市 +
+
+ 连云港市 +
+
+ 大庆市 +
+
+ 绥化市 +
+
+ 潍坊市 +
+
+ 乌鲁木齐市 +
+
+
+
+
+
+
+`; + +exports[`should render with Multi Column 1`] = ` +
+
+
+
+
+ 周一 +
+
+ 周二 +
+
+ 周三 +
+
+ 周四 +
+
+ 周五 +
+
+
+
+
+
+ 上午 +
+
+ 下午 +
+
+ 晚上 +
+
+
+
+
+
+
+`; diff --git a/src/packages/pickerview/__test__/pickerview.spec.tsx b/src/packages/pickerview/__test__/pickerview.spec.tsx new file mode 100644 index 0000000000..5dfd175869 --- /dev/null +++ b/src/packages/pickerview/__test__/pickerview.spec.tsx @@ -0,0 +1,217 @@ +import React, { useEffect, useRef, useState } from 'react' +import { render, waitFor, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import PickerView from '../pickerview' +import { PickerOptions } from '../types' + +const listData = [ + [ + { value: 1, label: '南京市' }, + { value: 2, label: '无锡市' }, + { value: 3, label: '海北藏族自治区' }, + { value: 4, label: '北京市' }, + { value: 5, label: '连云港市' }, + { value: 8, label: '大庆市' }, + { value: 9, label: '绥化市' }, + { value: 10, label: '潍坊市' }, + { value: 12, label: '乌鲁木齐市' }, + ], +] + +const MultiColumnData = [ + [ + { label: '周一', value: 'Monday' }, + { label: '周二', value: 'Tuesday' }, + { label: '周三', value: 'Wednesday' }, + { label: '周四', value: 'Thursday' }, + { label: '周五', value: 'Friday' }, + ], + [ + { label: '上午', value: 'Morning' }, + { label: '下午', value: 'Afternoon' }, + { label: '晚上', value: 'Evening' }, + ], +] + +const cascadeData = [ + [ + { + value: 1, + label: '北京', + children: [ + { + value: 1, + label: '朝阳区', + }, + { + value: 2, + label: '海淀区', + }, + { + value: 3, + label: '大兴区', + }, + { + value: 4, + label: '东城区', + }, + { + value: 5, + label: '西城区', + }, + { + value: 6, + label: '丰台区', + }, + ], + }, + { + value: 2, + label: '上海', + children: [ + { + value: 1, + label: '黄埔区', + }, + { + value: 2, + label: '长宁区', + }, + { + value: 3, + label: '普陀区', + }, + { + value: 4, + label: '杨浦区', + }, + { + value: 5, + label: '浦东新区', + }, + ], + }, + ], +] + +test('should match base', () => { + const { container } = render( + + ) + expect(container).toMatchSnapshot() + expect(screen.getByText('南京市')).toBeInTheDocument() +}) + +test('should render tiled', () => { + const { container } = render( + + ) + expect(container).toMatchSnapshot() +}) + +test('should render with Multi Column', () => { + const { container } = render( + + ) + const columns = container.querySelectorAll('.nut-pickerview-list') + expect(columns.length).toBe(2) + + // 检查列内容 + const firstColumn = columns[0] + expect(firstColumn.textContent).toContain('周一') + expect(firstColumn.textContent).toContain('周二') + + const secondColumn = columns[1] + expect(secondColumn.textContent).toContain('上午') + expect(secondColumn.textContent).toContain('下午') + expect(container).toMatchSnapshot() +}) + +test('should match onchange', async () => { + const PenderContent = () => { + const [value, setValue] = useState([]) + const [options, setInnerOptions] = useState([]) + + useEffect(() => { + const timer = setTimeout(() => { + setInnerOptions(listData) + setValue([1]) + }, 1000) + + return () => clearTimeout(timer) // 清理定时器 + }, []) + + return ( + { + if (value[0] === 1) { + setValue([3]) + } + }} + /> + ) + } + + const { container } = render() + + await waitFor(() => { + expect(container).toMatchSnapshot() + }) +}) + +test('should match cascade', () => { + const { container } = render( + `${item.label} | 测试`} + options={cascadeData} + onChange={() => {}} + /> + ) + expect(container).toMatchSnapshot() +}) +test('should match stopMomentum', async () => { + const PenderContent = () => { + function useRefs() { + const refs = React.useRef([]) + + const setRefs = React.useCallback( + (index: number) => (el: HTMLDivElement) => { + if (el) refs.current[index] = el + }, + [] + ) + + const reset = React.useCallback(() => { + refs.current = [] + }, []) + + return [refs.current, setRefs as any, reset] + } + + const [refs, setRefs] = useRefs() + const first = useRef(true) + + return ( + { + if (!first.current) { + refs[0].stopMomentum() + } else { + first.current = false + } + }} + /> + ) + } + render() +}) diff --git a/src/packages/pickerview/demo.taro.tsx b/src/packages/pickerview/demo.taro.tsx new file mode 100644 index 0000000000..8f34000096 --- /dev/null +++ b/src/packages/pickerview/demo.taro.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import Taro from '@tarojs/taro' +import { ScrollView, View } from '@tarojs/components' +import { useTranslate } from '@/sites/assets/locale/taro' +import Header from '@/sites/components/header' +import Demo1 from './demos/taro/demo1' +import Demo2 from './demos/taro/demo2' +import Demo3 from './demos/taro/demo3' +import Demo4 from './demos/taro/demo4' +import Demo5 from './demos/taro/demo5' +import Demo6 from './demos/taro/demo6' +import Demo7 from './demos/taro/demo7' + +const PickerViewDemo = () => { + const [translated] = useTranslate({ + 'zh-CN': { + title: '基础用法', + adjustHeight: '自适应高度', + multiColumn: '多列', + controlled: '受控', + tiled: '平铺', + cascade: '级联', + asynchronous: '异步数据', + }, + 'en-US': { + title: 'Basic Usage', + adjustHeight: 'Adjust Height', + multiColumn: 'Multi Column', + controlled: 'Controlled', + tiled: 'Tiled', + cascade: 'Cascade', + asynchronous: 'Asynchronous', + }, + 'zh-TW': { + title: '基礎用法', + adjustHeight: '自適應高度', + multiColumn: '多列', + controlled: '受控', + tiled: '平鋪', + cascade: '級聯', + asynchronous: '異步數據', + }, + }) + return ( + <> +
+ + {translated.title} + + {translated.controlled} + + {translated.adjustHeight} + + {translated.multiColumn} + + {translated.tiled} + + {translated.cascade} + + {translated.asynchronous} + + + + ) +} + +export default PickerViewDemo diff --git a/src/packages/pickerview/demo.tsx b/src/packages/pickerview/demo.tsx new file mode 100644 index 0000000000..8d95022679 --- /dev/null +++ b/src/packages/pickerview/demo.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { useTranslate } from '@/sites/assets/locale' +import Demo1 from './demos/h5/demo1' +import Demo2 from './demos/h5/demo2' +import Demo3 from './demos/h5/demo3' +import Demo4 from './demos/h5/demo4' +import Demo5 from './demos/h5/demo5' +import Demo6 from './demos/h5/demo6' +import Demo7 from './demos/h5/demo7' + +const PickerViewDemo = () => { + const [translated] = useTranslate({ + 'zh-CN': { + title: '基础用法', + adjustHeight: '自适应高度', + multiColumn: '多列', + controlled: '受控', + tiled: '平铺', + cascade: '级联', + asynchronous: '异步数据', + }, + 'en-US': { + title: 'Basic Usage', + adjustHeight: 'Adjust Height', + multiColumn: 'Multi Column', + controlled: 'Controlled', + tiled: 'Tiled', + cascade: 'Cascade', + asynchronous: 'Asynchronous', + }, + 'zh-TW': { + title: '基礎用法', + adjustHeight: '自適應高度', + multiColumn: '多列', + controlled: '受控', + tiled: '平鋪', + cascade: '級聯', + asynchronous: '異步數據', + }, + }) + return ( +
+

{translated.title}

+ +

{translated.controlled}

+ +

{translated.adjustHeight}

+ +

{translated.multiColumn}

+ +

{translated.tiled}

+ +

{translated.cascade}

+ +

{translated.asynchronous}

+ +
+ ) +} + +export default PickerViewDemo diff --git a/src/packages/pickerview/demos/h5/demo1.tsx b/src/packages/pickerview/demos/h5/demo1.tsx new file mode 100644 index 0000000000..dbadb8c5a7 --- /dev/null +++ b/src/packages/pickerview/demos/h5/demo1.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import { PickerView, Cell } from '@nutui/nutui-react' + +const Demo1 = () => { + const listData = [ + [ + { value: 1, label: '南京市' }, + { value: 2, label: '无锡市' }, + { value: 3, label: '海北藏族自治区' }, + { value: 4, label: '北京市' }, + { value: 5, label: '连云港市' }, + { value: 8, label: '大庆市' }, + { value: 9, label: '绥化市' }, + { value: 10, label: '潍坊市' }, + { value: 12, label: '乌鲁木齐市' }, + ], + ] + + return ( + <> + + { + console.log('onChange', value, selectedOptions) + }} + /> + + + ) +} +export default Demo1 diff --git a/src/packages/pickerview/demos/h5/demo2.tsx b/src/packages/pickerview/demos/h5/demo2.tsx new file mode 100644 index 0000000000..894b633b01 --- /dev/null +++ b/src/packages/pickerview/demos/h5/demo2.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import { PickerView, Cell } from '@nutui/nutui-react' + +const Demo2 = () => { + const listData = [ + [ + { value: 1, label: '南京市' }, + { value: 2, label: '无锡市' }, + { value: 3, label: '海北藏族自治区' }, + { value: 4, label: '北京市' }, + { value: 5, label: '连云港市' }, + { value: 8, label: '大庆市' }, + { value: 9, label: '绥化市' }, + { value: 10, label: '潍坊市' }, + { value: 12, label: '乌鲁木齐市' }, + ], + ] + + return ( + <> + + { + console.log('onChange', value, selectedOptions) + }} + /> + + + ) +} +export default Demo2 diff --git a/src/packages/pickerview/demos/h5/demo3.tsx b/src/packages/pickerview/demos/h5/demo3.tsx new file mode 100644 index 0000000000..d23f719786 --- /dev/null +++ b/src/packages/pickerview/demos/h5/demo3.tsx @@ -0,0 +1,39 @@ +import React, { useState } from 'react' +import { PickerView, Cell } from '@nutui/nutui-react' +import isEqual from 'react-fast-compare' + +const Demo3 = () => { + const listData = [ + [ + { label: '周一', value: 'Monday' }, + { label: '周二', value: 'Tuesday' }, + { label: '周三', value: 'Wednesday' }, + { label: '周四', value: 'Thursday' }, + { label: '周五', value: 'Friday' }, + ], + [ + { label: '上午', value: 'Morning' }, + { label: '下午', value: 'Afternoon' }, + { label: '晚上', value: 'Evening' }, + ], + ] + const [value, setValue] = useState(['Tuesday', 'Evening']) + + return ( + <> + + { + console.log('onChange', value, selectedOptions) + if (isEqual(value, ['Tuesday', 'Afternoon'])) { + setValue(['Monday', 'Evening']) + } + }} + /> + + + ) +} +export default Demo3 diff --git a/src/packages/pickerview/demos/h5/demo4.tsx b/src/packages/pickerview/demos/h5/demo4.tsx new file mode 100644 index 0000000000..cfa2ab063e --- /dev/null +++ b/src/packages/pickerview/demos/h5/demo4.tsx @@ -0,0 +1,38 @@ +import React, { useState } from 'react' +import { PickerView, Cell } from '@nutui/nutui-react' + +const Demo4 = () => { + const listData = [ + [ + { value: 1, label: '南京市' }, + { value: 2, label: '无锡市' }, + { value: 3, label: '海北藏族自治区' }, + { value: 4, label: '北京市' }, + { value: 5, label: '连云港市' }, + { value: 8, label: '大庆市' }, + { value: 9, label: '绥化市' }, + { value: 10, label: '潍坊市' }, + { value: 12, label: '乌鲁木齐市' }, + ], + ] + + const [value, setValue] = useState([2]) + + return ( + <> + + { + console.log('onChange', value, selectedOptions) + if (value[0] === 3) { + setValue([1]) + } + }} + /> + + + ) +} +export default Demo4 diff --git a/src/packages/pickerview/demos/h5/demo5.tsx b/src/packages/pickerview/demos/h5/demo5.tsx new file mode 100644 index 0000000000..e9795b79f4 --- /dev/null +++ b/src/packages/pickerview/demos/h5/demo5.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { PickerView, Cell } from '@nutui/nutui-react' + +const Demo5 = () => { + const listData = [ + [ + { value: 1, label: '南京市' }, + { value: 2, label: '无锡市' }, + { value: 3, label: '海北藏族自治区' }, + { value: 4, label: '北京市' }, + { value: 5, label: '连云港市' }, + { value: 8, label: '大庆市' }, + { value: 9, label: '绥化市' }, + { value: 10, label: '潍坊市' }, + { value: 12, label: '乌鲁木齐市' }, + ], + ] + + return ( + <> + + { + console.log('onChange', value, selectedOptions) + }} + /> + + + ) +} +export default Demo5 diff --git a/src/packages/pickerview/demos/h5/demo6.tsx b/src/packages/pickerview/demos/h5/demo6.tsx new file mode 100644 index 0000000000..44aa46d8ae --- /dev/null +++ b/src/packages/pickerview/demos/h5/demo6.tsx @@ -0,0 +1,80 @@ +import React from 'react' +import { PickerView, Cell } from '@nutui/nutui-react' + +const Demo6 = () => { + const listData = [ + [ + { + value: 1, + label: '北京', + children: [ + { + value: 1, + label: '朝阳区', + }, + { + value: 2, + label: '海淀区', + }, + { + value: 3, + label: '大兴区', + }, + { + value: 4, + label: '东城区', + }, + { + value: 5, + label: '西城区', + }, + { + value: 6, + label: '丰台区', + }, + ], + }, + { + value: 2, + label: '上海', + children: [ + { + value: 1, + label: '黄埔区', + }, + { + value: 2, + label: '长宁区', + }, + { + value: 3, + label: '普陀区', + }, + { + value: 4, + label: '杨浦区', + }, + { + value: 5, + label: '浦东新区', + }, + ], + }, + ], + ] + + return ( + <> + + { + console.log('onChange', value, selectedOptions) + }} + /> + + + ) +} +export default Demo6 diff --git a/src/packages/pickerview/demos/h5/demo7.tsx b/src/packages/pickerview/demos/h5/demo7.tsx new file mode 100644 index 0000000000..d245a189c5 --- /dev/null +++ b/src/packages/pickerview/demos/h5/demo7.tsx @@ -0,0 +1,38 @@ +import React, { useEffect, useState } from 'react' +import { PickerView, Cell, PickerOptions } from '@nutui/nutui-react' + +const Demo7 = () => { + const [columnsList, setColumnsList] = useState([] as PickerOptions[]) + + useEffect(() => { + setTimeout(() => { + setColumnsList([ + [ + { value: 1, label: '南京市' }, + { value: 2, label: '无锡市' }, + { value: 3, label: '海北藏族自治区' }, + { value: 4, label: '北京市' }, + { value: 5, label: '连云港市' }, + { value: 8, label: '大庆市' }, + { value: 9, label: '绥化市' }, + { value: 10, label: '潍坊市' }, + { value: 12, label: '乌鲁木齐市' }, + ], + ]) + }, 3000) + }, []) + return ( + <> + + { + console.log('onChange', value, selectedOptions) + }} + /> + + + ) +} +export default Demo7 diff --git a/src/packages/pickerview/demos/taro/demo1.tsx b/src/packages/pickerview/demos/taro/demo1.tsx new file mode 100644 index 0000000000..44c4bc6f3b --- /dev/null +++ b/src/packages/pickerview/demos/taro/demo1.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import { PickerView, Cell } from '@nutui/nutui-react-taro' + +const Demo1 = () => { + const listData = [ + [ + { value: 1, label: '南京市' }, + { value: 2, label: '无锡市' }, + { value: 3, label: '海北藏族自治区' }, + { value: 4, label: '北京市' }, + { value: 5, label: '连云港市' }, + { value: 8, label: '大庆市' }, + { value: 9, label: '绥化市' }, + { value: 10, label: '潍坊市' }, + { value: 12, label: '乌鲁木齐市' }, + ], + ] + + return ( + <> + + { + console.log('onChange', value, selectedOptions) + }} + /> + + + ) +} +export default Demo1 diff --git a/src/packages/pickerview/demos/taro/demo2.tsx b/src/packages/pickerview/demos/taro/demo2.tsx new file mode 100644 index 0000000000..838e0ea546 --- /dev/null +++ b/src/packages/pickerview/demos/taro/demo2.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import { PickerView, Cell } from '@nutui/nutui-react-taro' + +const Demo2 = () => { + const listData = [ + [ + { value: 1, label: '南京市' }, + { value: 2, label: '无锡市' }, + { value: 3, label: '海北藏族自治区' }, + { value: 4, label: '北京市' }, + { value: 5, label: '连云港市' }, + { value: 8, label: '大庆市' }, + { value: 9, label: '绥化市' }, + { value: 10, label: '潍坊市' }, + { value: 12, label: '乌鲁木齐市' }, + ], + ] + + return ( + <> + + { + console.log('onChange', value, selectedOptions) + }} + /> + + + ) +} +export default Demo2 diff --git a/src/packages/pickerview/demos/taro/demo3.tsx b/src/packages/pickerview/demos/taro/demo3.tsx new file mode 100644 index 0000000000..9c7b9cd1f9 --- /dev/null +++ b/src/packages/pickerview/demos/taro/demo3.tsx @@ -0,0 +1,39 @@ +import React, { useState } from 'react' +import { PickerView, Cell } from '@nutui/nutui-react-taro' +import isEqual from 'react-fast-compare' + +const Demo3 = () => { + const listData = [ + [ + { label: '周一', value: 'Monday' }, + { label: '周二', value: 'Tuesday' }, + { label: '周三', value: 'Wednesday' }, + { label: '周四', value: 'Thursday' }, + { label: '周五', value: 'Friday' }, + ], + [ + { label: '上午', value: 'Morning' }, + { label: '下午', value: 'Afternoon' }, + { label: '晚上', value: 'Evening' }, + ], + ] + const [value, setValue] = useState(['Tuesday', 'Evening']) + + return ( + <> + + { + console.log('onChange', value, selectedOptions) + if (isEqual(value, ['Tuesday', 'Afternoon'])) { + setValue(['Monday', 'Evening']) + } + }} + /> + + + ) +} +export default Demo3 diff --git a/src/packages/pickerview/demos/taro/demo4.tsx b/src/packages/pickerview/demos/taro/demo4.tsx new file mode 100644 index 0000000000..e6c5666a3e --- /dev/null +++ b/src/packages/pickerview/demos/taro/demo4.tsx @@ -0,0 +1,38 @@ +import React, { useState } from 'react' +import { PickerView, Cell } from '@nutui/nutui-react-taro' + +const Demo4 = () => { + const listData = [ + [ + { value: 1, label: '南京市' }, + { value: 2, label: '无锡市' }, + { value: 3, label: '海北藏族自治区' }, + { value: 4, label: '北京市' }, + { value: 5, label: '连云港市' }, + { value: 8, label: '大庆市' }, + { value: 9, label: '绥化市' }, + { value: 10, label: '潍坊市' }, + { value: 12, label: '乌鲁木齐市' }, + ], + ] + + const [value, setValue] = useState([2]) + + return ( + <> + + { + console.log('onChange', value, selectedOptions) + if (value[0] === 3) { + setValue([1]) + } + }} + /> + + + ) +} +export default Demo4 diff --git a/src/packages/pickerview/demos/taro/demo5.tsx b/src/packages/pickerview/demos/taro/demo5.tsx new file mode 100644 index 0000000000..f313af7bb5 --- /dev/null +++ b/src/packages/pickerview/demos/taro/demo5.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { PickerView, Cell } from '@nutui/nutui-react-taro' + +const Demo5 = () => { + const listData = [ + [ + { value: 1, label: '南京市' }, + { value: 2, label: '无锡市' }, + { value: 3, label: '海北藏族自治区' }, + { value: 4, label: '北京市' }, + { value: 5, label: '连云港市' }, + { value: 8, label: '大庆市' }, + { value: 9, label: '绥化市' }, + { value: 10, label: '潍坊市' }, + { value: 12, label: '乌鲁木齐市' }, + ], + ] + + return ( + <> + + { + console.log('onChange', value, selectedOptions) + }} + /> + + + ) +} +export default Demo5 diff --git a/src/packages/pickerview/demos/taro/demo6.tsx b/src/packages/pickerview/demos/taro/demo6.tsx new file mode 100644 index 0000000000..9cf8ae0ac2 --- /dev/null +++ b/src/packages/pickerview/demos/taro/demo6.tsx @@ -0,0 +1,80 @@ +import React from 'react' +import { PickerView, Cell } from '@nutui/nutui-react-taro' + +const Demo6 = () => { + const listData = [ + [ + { + value: 1, + label: '北京', + children: [ + { + value: 1, + label: '朝阳区', + }, + { + value: 2, + label: '海淀区', + }, + { + value: 3, + label: '大兴区', + }, + { + value: 4, + label: '东城区', + }, + { + value: 5, + label: '西城区', + }, + { + value: 6, + label: '丰台区', + }, + ], + }, + { + value: 2, + label: '上海', + children: [ + { + value: 1, + label: '黄埔区', + }, + { + value: 2, + label: '长宁区', + }, + { + value: 3, + label: '普陀区', + }, + { + value: 4, + label: '杨浦区', + }, + { + value: 5, + label: '浦东新区', + }, + ], + }, + ], + ] + + return ( + <> + + { + console.log('onChange', value, selectedOptions) + }} + /> + + + ) +} +export default Demo6 diff --git a/src/packages/pickerview/demos/taro/demo7.tsx b/src/packages/pickerview/demos/taro/demo7.tsx new file mode 100644 index 0000000000..a32defecfc --- /dev/null +++ b/src/packages/pickerview/demos/taro/demo7.tsx @@ -0,0 +1,38 @@ +import React, { useEffect, useState } from 'react' +import { PickerView, Cell, PickerOptions } from '@nutui/nutui-react-taro' + +const Demo7 = () => { + const [columnsList, setColumnsList] = useState([] as PickerOptions[]) + + useEffect(() => { + setTimeout(() => { + setColumnsList([ + [ + { value: 1, label: '南京市' }, + { value: 2, label: '无锡市' }, + { value: 3, label: '海北藏族自治区' }, + { value: 4, label: '北京市' }, + { value: 5, label: '连云港市' }, + { value: 8, label: '大庆市' }, + { value: 9, label: '绥化市' }, + { value: 10, label: '潍坊市' }, + { value: 12, label: '乌鲁木齐市' }, + ], + ]) + }, 3000) + }, []) + return ( + <> + + { + console.log('onChange', value, selectedOptions) + }} + /> + + + ) +} +export default Demo7 diff --git a/src/packages/pickerview/doc.en-US.md b/src/packages/pickerview/doc.en-US.md new file mode 100644 index 0000000000..42006732bd --- /dev/null +++ b/src/packages/pickerview/doc.en-US.md @@ -0,0 +1,94 @@ +# PickerView + +The PickerView is the content area of the Picker. + +## Import + +```tsx +import { PickerView } from '@nutui/nutui-react' +``` + +## Demo + +### Basic Usage + +:::demo + + + +::: + +### Controlled + +:::demo + + + +::: + +### Adjust Height + +:::demo + + + +::: + +### Multi Column + +:::demo + + + +::: + +### Tiled + +:::demo + + + +::: + +### Cascade + +:::demo + + + +::: + +## PickerView + +### Props + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| options | Tabular data | `PickerOptions[]` | `[]` | +| value | Selected value, controlled | `PickerValue[]` | `[]` | +| defaultValue | Default value | `PickerValue[]` | `[]` | +| threeDimensional | Whether to enable 3D effect | `boolean` | `true` | +| duration | The duration of inertial rolling during rapid sliding, in ms | `string` \| `number` | `1000` | +| onChange | Called when the value of each column changes | `({value, index, selectedOptions}) => void` | `-` | + +### PickerOptionItem + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| label | Text of column | `string` \| `number` | `-` | +| value | Value of column | `string` \| `number` | `-` | +| children | Cascader Option | `PickerOptionItem[]` | `-` | + +## Theming + +### CSS Variables + +The component provides the following CSS variables, which can be used to customize styles. Please refer to [ConfigProvider component](#/en-US/component/configprovider). + +| Name | Description | Default | +| --- | --- | --- | +| \--nutui-picker-item-height | Height of each data item on the panel | `36px` | +| \--nutui-picker-item-text-color | The color of each piece of data in the panel | `$color-title` | +| \--nutui-picker-item-text-font-size | The font size of each piece of data in the panel | `$font-size-base` | +| \--nutui-picker-item-active-line-border | The border value currently selected by the panel | `1px solid $color-border` | +| \--nut-picker-mask-background | Panel shade gradient value | `linear-gradient(180deg, var(--nutui-white-12), var(--nutui-white-7)),linear-gradient(0deg, var(--nutui-white-12), var(--nutui-white-7))` | diff --git a/src/packages/pickerview/doc.md b/src/packages/pickerview/doc.md new file mode 100644 index 0000000000..ef3cb7dc18 --- /dev/null +++ b/src/packages/pickerview/doc.md @@ -0,0 +1,94 @@ +# PickerView 选择器视图 + +PickerView 是 Picker 的内容区域。 + +## 引入 + +```tsx +import { PickerView } from '@nutui/nutui-react' +``` + +## 示例代码 + +### 基础用法 + +:::demo + + + +::: + +### 受控 + +:::demo + + + +::: + +### 自定义高度 + +:::demo + + + +::: + +### 多列 + +:::demo + + + +::: + +### 平铺 + +:::demo + + + +::: + +### 级联 + +:::demo + + + +::: + +## PickerView + +### Props + +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| options | 列表数据 | `PickerOptions[]` | `[]` | +| value | 选中值,受控 | `PickerValue[]` | `[]` | +| defaultValue | 默认选中 | `PickerValue[]` | `[]` | +| threeDimensional | 是否开启3D效果 | `boolean` | `true` | +| duration | 快速滑动时惯性滚动的时长,单位 ms | `string` \| `number` | `1000` | +| onChange | 每一列值变更时调用 | `({value, index, selectedOptions}) => void` | `-` | + +### PickerOptionItem + +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| label | 选项的文字内容 | `string` \| `number` | `-` | +| value | 选项对应的值,且唯一 | `string` \| `number` | `-` | +| children | 用于级联选项 | `PickerOptionItem[]` | `-` | + +## 主题定制 + +### 样式变量 + +组件提供了下列 CSS 变量,可用于自定义样式,使用方法请参考 [ConfigProvider 组件](#/zh-CN/component/configprovider)。 + +| 名称 | 说明 | 默认值 | +| --- | --- | --- | +| \--nutui-picker-item-height | 面板每条数据高度 | `36px` | +| \--nutui-picker-item-text-color | 面板每条数据的字色 | `$color-title` | +| \--nutui-picker-item-text-font-size | 面板每条数据的字号 | `$font-size-base` | +| \--nutui-picker-item-active-line-border | 面板当前选中的border值 | `1px solid $color-border` | +| \--nut-picker-mask-background | 面板遮挡区渐变值 | `linear-gradient(180deg, var(--nutui-white-12), var(--nutui-white-7)),linear-gradient(0deg, var(--nutui-white-12), var(--nutui-white-7))` | diff --git a/src/packages/pickerview/doc.taro.md b/src/packages/pickerview/doc.taro.md new file mode 100644 index 0000000000..344fc9dee3 --- /dev/null +++ b/src/packages/pickerview/doc.taro.md @@ -0,0 +1,94 @@ +# PickerView 选择器视图 + +PickerView 是 Picker 的内容区域。 + +## 引入 + +```tsx +import { PickerView } from '@nutui/nutui-react-taro' +``` + +## 示例代码 + +### 基础用法 + +:::demo + + + +::: + +### 受控 + +:::demo + + + +::: + +### 自定义高度 + +:::demo + + + +::: + +### 多列 + +:::demo + + + +::: + +### 平铺 + +:::demo + + + +::: + +### 级联 + +:::demo + + + +::: + +## PickerView + +### Props + +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| options | 列表数据 | `PickerOptions[]` | `[]` | +| value | 选中值,受控 | `PickerValue[]` | `[]` | +| defaultValue | 默认选中 | `PickerValue[]` | `[]` | +| threeDimensional | 是否开启3D效果 | `boolean` | `true` | +| duration | 快速滑动时惯性滚动的时长,单位 ms | `string` \| `number` | `1000` | +| onChange | 每一列值变更时调用 | `({value, index, selectedOptions}) => void` | `-` | + +### PickerOptionItem + +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| label | 选项的文字内容 | `string` \| `number` | `-` | +| value | 选项对应的值,且唯一 | `string` \| `number` | `-` | +| children | 用于级联选项 | `PickerOptionItem[]` | `-` | + +## 主题定制 + +### 样式变量 + +组件提供了下列 CSS 变量,可用于自定义样式,使用方法请参考 [ConfigProvider 组件](#/zh-CN/component/configprovider)。 + +| 名称 | 说明 | 默认值 | +| --- | --- | --- | +| \--nutui-picker-item-height | 面板每条数据高度 | `36px` | +| \--nutui-picker-item-text-color | 面板每条数据的字色 | `$color-title` | +| \--nutui-picker-item-text-font-size | 面板每条数据的字号 | `$font-size-base` | +| \--nutui-picker-item-active-line-border | 面板当前选中的border值 | `1px solid $color-border` | +| \--nut-picker-mask-background | 面板遮挡区渐变值 | `linear-gradient(180deg, var(--nutui-white-12), var(--nutui-white-7)),linear-gradient(0deg, var(--nutui-white-12), var(--nutui-white-7))` | diff --git a/src/packages/pickerview/doc.zh-TW.md b/src/packages/pickerview/doc.zh-TW.md new file mode 100644 index 0000000000..073f5b8e4f --- /dev/null +++ b/src/packages/pickerview/doc.zh-TW.md @@ -0,0 +1,94 @@ +# PickerView 選擇器視圖 + +PickerView 是 Picker 的內容區域。 + +## 引入 + +```tsx +import { PickerView } from '@nutui/nutui-react-taro' +``` + +## 示例代碼 + +### 基礎用法 + +:::demo + + + +::: + +### 受控 + +:::demo + + + +::: + +### 自定義高度 + +:::demo + + + +::: + +### 多列 + +:::demo + + + +::: + +### 平鋪 + +:::demo + + + +::: + +### 級聯 + +:::demo + + + +::: + +## PickerView + +### Props + +| 屬性 | 說明 | 類型 | 默認值 | +| --- | --- | --- | --- | +| options | 列錶數據 | `PickerOptions[]` | `[]` | +| value | 選中值,受控 | `PickerValue[]` | `[]` | +| defaultValue | 默認選中 | `PickerValue[]` | `[]` | +| threeDimensional | 是否開啟3D效果 | `boolean` | `true` | +| duration | 快速滑動時慣性滾動的時長,單位 ms | `string` \| `number` | `1000` | +| onChange | 每一列值變更時調用 | `({value, index, selectedOptions}) => void` | `-` | + +### PickerOptionItem + +| 屬性 | 說明 | 類型 | 默認值 | +| --- | --- | --- | --- | +| label | 選項的文字內容 | `string` \| `number` | `-` | +| value | 選項對應的值,且唯一 | `string` \| `number` | `-` | +| children | 用於級聯選項 | `PickerOptionItem[]` | `-` | + +## 主題定制 + +### 樣式變量 + +組件提供了下列 CSS 變量,可用於自定義樣式,使用方法請參考 [ConfigProvider 組件](#/zh-CN/component/configprovider)。 + +| 名稱 | 說明 | 默認值 | +| --- | --- | --- | +| \--nutui-picker-item-height | 面闆每條數據高度 | `36px` | +| \--nutui-picker-item-text-color | 面闆每條數據的字色 | `$color-title` | +| \--nutui-picker-item-text-font-size | 面闆每條數據的字號 | `$font-size-base` | +| \--nutui-picker-item-active-line-border | 面闆當前選中的border值 | `1px solid $color-border` | +| \--nut-picker-mask-background | 面闆遮擋區漸變值 | `linear-gradient(180deg, var(--nutui-white-12), var(--nutui-white-7)),linear-gradient(0deg, var(--nutui-white-12), var(--nutui-white-7))` | diff --git a/src/packages/pickerview/index.taro.ts b/src/packages/pickerview/index.taro.ts new file mode 100644 index 0000000000..1bd5f2a397 --- /dev/null +++ b/src/packages/pickerview/index.taro.ts @@ -0,0 +1,11 @@ +import PickerView from './pickerview.taro' + +export type { + PickerViewProps, + PickerOptionItem, + PickerRollerProps, + PickerValue, + PickerOptions, + PickerOnChangeCallbackParameter, +} from './types' +export default PickerView diff --git a/src/packages/pickerview/index.ts b/src/packages/pickerview/index.ts new file mode 100644 index 0000000000..15c781a272 --- /dev/null +++ b/src/packages/pickerview/index.ts @@ -0,0 +1,11 @@ +import PickerView from './pickerview' + +export type { + PickerViewProps, + PickerOptionItem, + PickerRollerProps, + PickerValue, + PickerOptions, + PickerOnChangeCallbackParameter, +} from './types' +export default PickerView diff --git a/src/packages/pickerview/pickerroller.taro.tsx b/src/packages/pickerview/pickerroller.taro.tsx new file mode 100644 index 0000000000..10c38d145f --- /dev/null +++ b/src/packages/pickerview/pickerroller.taro.tsx @@ -0,0 +1,259 @@ +import React, { + useState, + useEffect, + useRef, + ForwardRefRenderFunction, + useImperativeHandle, +} from 'react' +import { View } from '@tarojs/components' +import { useTouch } from '@/hooks/use-touch' +import { passiveSupported } from '@/utils/supports-passive' +import { PickerRollerProps, PickerOptionItem } from './types' +import { web } from '@/utils/platform-taro' +import { preventDefault } from '@/utils' +import { momentum, useStyles } from './utils' + +const InternalPickerRoller: ForwardRefRenderFunction< + { stopMomentum: () => void; moving: boolean }, + Partial +> = (props, ref) => { + const { + keyIndex = 0, + options = [], + threeDimensional = true, + duration = 1000, + onSelect, + renderLabel = (item: PickerOptionItem) => item.label, + } = props + + const DEFAULT_DURATION = 200 + const INERTIA_TIME = 300 + const INERTIA_DISTANCE = 15 + const ROTATION = 20 + const touch = useTouch() + const [currentIndex, setCurrentIndex] = useState(1) + const lineSpacing = useRef(36) + const [touchTime, setTouchTime] = useState(0) + const [touchDeg, setTouchDeg] = useState('0deg') + const isMoving = useRef(false) + const rollerRef = useRef(null) + const pickerRollerRef = useRef(null) + const [startTime, setStartTime] = useState(0) + const [startY, setStartY] = useState(0) + const transformY = useRef(0) + const [scrollDistance, setScrollDistance] = useState(0) + + const { touchRollerStyle, touchTiledStyle, rollerStyle } = useStyles( + touchTime, + touchDeg, + scrollDistance, + lineSpacing, + ROTATION + ) + + const isItemHidden = (index: number) => + index >= currentIndex + 8 || index <= currentIndex - 8 + + const applyTransform = ( + type: string, + deg: string, + time = DEFAULT_DURATION, + translateY = 0 + ) => { + setTouchTime(type !== 'end' ? 0 : time) + setTouchDeg(deg) + setScrollDistance(translateY) + } + + const handleMove = (move: number, type?: string, time?: number) => { + let updatedMove = move + transformY.current + if (type === 'end') { + updatedMove = Math.max( + Math.min(updatedMove, 0), + -(options.length - 1) * lineSpacing.current + ) + + // 滚动距离为lineSpacing.current的倍数值 + const endMove = + Math.round(updatedMove / lineSpacing.current) * lineSpacing.current + const deg = `${(Math.abs(Math.round(endMove / lineSpacing.current)) + 1) * ROTATION}deg` + applyTransform(type, deg, time, endMove) + setCurrentIndex(Math.abs(Math.round(endMove / lineSpacing.current)) + 1) + } else { + const currentDeg = (-updatedMove / lineSpacing.current + 1) * ROTATION + const deg = Math.min( + Math.max(currentDeg, 0), + (options.length + 1) * ROTATION + ) + if (deg >= 0 && deg < (options.length + 1) * ROTATION) { + applyTransform('', `${deg}deg`, undefined, updatedMove) + setCurrentIndex( + Math.abs(Math.round(updatedMove / lineSpacing.current)) + 1 + ) + } + } + } + + const selectValue = (move: number) => { + onSelect?.(options?.[Math.round(-move / lineSpacing.current)], keyIndex) + } + + const handleTouchStart = (event: React.TouchEvent) => { + touch.start(event) + setStartY(touch.deltaY.current) + setStartTime(Date.now()) + transformY.current = scrollDistance + } + + const handleTouchMove = (event: React.TouchEvent) => { + touch.move(event) + if ((touch as any).isVertical) { + isMoving.current = true + preventDefault(event, true) + } + const move = touch.deltaY.current - startY + handleMove(move) + } + + const handleTouchEnd = () => { + if (!isMoving.current) return + const move = touch.deltaY.current - startY + const moveTime = Date.now() - startTime + if (moveTime <= INERTIA_TIME && Math.abs(move) > INERTIA_DISTANCE) { + const distance = momentum(move, moveTime) + handleMove(distance, 'end', +duration) + } else { + handleMove(move, 'end') + } + setTimeout(() => { + touch.reset() + }, 0) + } + + const updateStatus = (shouldSelect?: boolean, value?: string | number) => { + const selectedValue = value || props.value + const index = options.findIndex((item) => item.value === selectedValue) + setCurrentIndex(index === -1 ? 1 : index + 1) + const move = index * lineSpacing.current + shouldSelect && selectValue(-move) + handleMove(-move) + } + + const stopMomentumScroll = () => { + isMoving.current = false + setTouchTime(0) + selectValue(scrollDistance) + } + + // lineSpacing.current CSS variable + useEffect(() => { + const element = pickerRollerRef.current + if (element && web()) { + const computedStyle = getComputedStyle(element) + const currentLineSpacing = computedStyle.getPropertyValue( + '--nutui-picker-item-height' + ) + !!currentLineSpacing && + (lineSpacing.current = parseFloat(currentLineSpacing)) + } + }, [pickerRollerRef.current]) + + useEffect(() => { + isMoving.current = false + setScrollDistance(0) + transformY.current = 0 + updateStatus(false) + }, [options, props.value]) + + useImperativeHandle(ref, () => ({ + stopMomentum: stopMomentumScroll, + moving: isMoving.current, + })) + + useEffect(() => { + const eventOptions = passiveSupported + ? { passive: false, once: true } + : false + pickerRollerRef.current?.addEventListener( + 'touchstart', + handleTouchStart, + eventOptions + ) + pickerRollerRef.current?.addEventListener( + 'touchmove', + handleTouchMove, + eventOptions + ) + pickerRollerRef.current?.addEventListener( + 'touchend', + handleTouchEnd, + eventOptions + ) + return () => { + pickerRollerRef.current?.removeEventListener( + 'touchstart', + handleTouchStart, + eventOptions + ) + pickerRollerRef.current?.removeEventListener( + 'touchmove', + handleTouchMove, + eventOptions + ) + pickerRollerRef.current?.removeEventListener( + 'touchend', + handleTouchEnd, + eventOptions + ) + } + }, [ + pickerRollerRef.current, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + ]) + + return ( + + + {/* 3D 效果 */} + {threeDimensional && + options.map((item, index) => ( + + {renderLabel(item)} + + ))} + {/* Tiled */} + {!threeDimensional && + options.map((item, index) => { + return ( + + {renderLabel(item)} + + ) + })} + + + ) +} + +const PickerRoller = React.forwardRef< + { stopMomentum: () => void; moving: boolean }, + Partial +>(InternalPickerRoller) + +export default PickerRoller diff --git a/src/packages/pickerview/pickerroller.tsx b/src/packages/pickerview/pickerroller.tsx new file mode 100644 index 0000000000..deb1512a76 --- /dev/null +++ b/src/packages/pickerview/pickerroller.tsx @@ -0,0 +1,244 @@ +import React, { + useState, + useEffect, + useRef, + ForwardRefRenderFunction, + useImperativeHandle, +} from 'react' +import { useTouch } from '@/hooks/use-touch' +import { passiveSupported } from '@/utils/supports-passive' +import { PickerRollerProps, PickerOptionItem } from './types' +import { preventDefault } from '@/utils' +import { momentum, useStyles } from './utils' + +const InternalPickerRoller: ForwardRefRenderFunction< + { stopMomentum: () => void; moving: boolean }, + Partial +> = (props, ref) => { + const { + keyIndex = 0, + options = [], + threeDimensional = true, + duration = 1000, + onSelect, + renderLabel = (item: PickerOptionItem) => item.label, + } = props + + const DEFAULT_DURATION = 200 + const INERTIA_TIME = 300 + const INERTIA_DISTANCE = 15 + const ROTATION = 20 + const touch = useTouch() + const [currentIndex, setCurrentIndex] = useState(1) + const lineSpacing = useRef(36) + const [touchTime, setTouchTime] = useState(0) + const [touchDeg, setTouchDeg] = useState('0deg') + const isMoving = useRef(false) + const rollerRef = useRef(null) + const pickerRollerRef = useRef(null) + const [startTime, setStartTime] = useState(0) + const [startY, setStartY] = useState(0) + const transformY = useRef(0) + const [scrollDistance, setScrollDistance] = useState(0) + + const { touchRollerStyle, touchTiledStyle, rollerStyle } = useStyles( + touchTime, + touchDeg, + scrollDistance, + lineSpacing, + ROTATION + ) + + const isItemHidden = (index: number) => + index >= currentIndex + 8 || index <= currentIndex - 8 + + const applyTransform = ( + type: string, + deg: string, + time = DEFAULT_DURATION, + translateY = 0 + ) => { + setTouchTime(type !== 'end' ? 0 : time) + setTouchDeg(deg) + setScrollDistance(translateY) + } + + const handleMove = (move: number, type?: string, time?: number) => { + let updatedMove = move + transformY.current + if (type === 'end') { + updatedMove = Math.max( + Math.min(updatedMove, 0), + -(options.length - 1) * lineSpacing.current + ) + + // 滚动距离为lineSpacing.current的倍数值 + const endMove = + Math.round(updatedMove / lineSpacing.current) * lineSpacing.current + const deg = `${(Math.abs(Math.round(endMove / lineSpacing.current)) + 1) * ROTATION}deg` + applyTransform(type, deg, time, endMove) + setCurrentIndex(Math.abs(Math.round(endMove / lineSpacing.current)) + 1) + } else { + const currentDeg = (-updatedMove / lineSpacing.current + 1) * ROTATION + const deg = Math.min( + Math.max(currentDeg, 0), + (options.length + 1) * ROTATION + ) + if (deg >= 0 && deg < (options.length + 1) * ROTATION) { + applyTransform('', `${deg}deg`, undefined, updatedMove) + setCurrentIndex( + Math.abs(Math.round(updatedMove / lineSpacing.current)) + 1 + ) + } + } + } + + const selectValue = (move: number) => { + onSelect?.(options?.[Math.round(-move / lineSpacing.current)], keyIndex) + } + + const handleTouchStart = (event: React.TouchEvent) => { + touch.start(event) + setStartY(touch.deltaY.current) + setStartTime(Date.now()) + transformY.current = scrollDistance + } + + const handleTouchMove = (event: React.TouchEvent) => { + touch.move(event) + if ((touch as any).isVertical) { + isMoving.current = true + preventDefault(event, true) + } + const move = touch.deltaY.current - startY + handleMove(move) + } + + const handleTouchEnd = () => { + if (!isMoving.current) return + const move = touch.deltaY.current - startY + const moveTime = Date.now() - startTime + if (moveTime <= INERTIA_TIME && Math.abs(move) > INERTIA_DISTANCE) { + const distance = momentum(move, moveTime) + handleMove(distance, 'end', +duration) + } else { + handleMove(move, 'end') + } + setTimeout(() => { + touch.reset() + }, 0) + } + + const updateStatus = (shouldSelect?: boolean, value?: string | number) => { + const selectedValue = value || props.value + const index = options.findIndex((item) => item.value === selectedValue) + setCurrentIndex(index === -1 ? 1 : index + 1) + const move = index * lineSpacing.current + shouldSelect && selectValue(-move) + handleMove(-move) + } + + const stopMomentumScroll = () => { + isMoving.current = false + setTouchTime(0) + selectValue(scrollDistance) + } + + // lineSpacing.current CSS variable + useEffect(() => { + const element = pickerRollerRef.current + if (element) { + const computedStyle = getComputedStyle(element) + const currentLineSpacing = computedStyle.getPropertyValue( + '--nutui-picker-item-height' + ) + !!currentLineSpacing && + (lineSpacing.current = parseFloat(currentLineSpacing)) + } + }, [pickerRollerRef.current]) + + useEffect(() => { + isMoving.current = false + setScrollDistance(0) + transformY.current = 0 + updateStatus(false) + }, [options, props.value]) + + useImperativeHandle(ref, () => ({ + stopMomentum: stopMomentumScroll, + moving: isMoving.current, + })) + + useEffect(() => { + const options = passiveSupported ? { passive: false } : false + pickerRollerRef.current?.addEventListener( + 'touchstart', + handleTouchStart, + options + ) + pickerRollerRef.current?.addEventListener( + 'touchmove', + handleTouchMove, + options + ) + pickerRollerRef.current?.addEventListener( + 'touchend', + handleTouchEnd, + options + ) + return () => { + pickerRollerRef.current?.removeEventListener( + 'touchstart', + handleTouchStart + ) + pickerRollerRef.current?.removeEventListener('touchmove', handleTouchMove) + pickerRollerRef.current?.removeEventListener('touchend', handleTouchEnd) + } + }, [ + pickerRollerRef.current, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + ]) + + return ( +
+
+ {/* 3D */} + {threeDimensional && + options.map((item, index) => ( +
+ {renderLabel(item)} +
+ ))} + {/* Tiled */} + {!threeDimensional && + options.map((item, index) => ( +
+ {renderLabel(item)} +
+ ))} +
+
+ ) +} + +const PickerRoller = React.forwardRef< + { stopMomentum: () => void; moving: boolean }, + Partial +>(InternalPickerRoller) + +export default PickerRoller diff --git a/src/packages/pickerview/pickerview.scss b/src/packages/pickerview/pickerview.scss new file mode 100644 index 0000000000..1face81104 --- /dev/null +++ b/src/packages/pickerview/pickerview.scss @@ -0,0 +1,79 @@ +.nut-pickerview { + --nutui-picker-item-height: 36px; + position: relative; + display: flex; + width: 100%; + height: $picker-list-height; + $pickerview-top: calc(($picker-list-height - $picker-item-height) / 2); + overflow: hidden; + + &-mask, + &-indicator { + position: absolute; + left: 0; + right: 0; + z-index: 3; + pointer-events: none; + } + + &-mask { + top: 0; + bottom: 0; + background-image: $picker-mask-background; + background-position: top, bottom; + background-size: 100% $pickerview-top; + background-repeat: no-repeat; + transform: translateZ(0); + } + + &-indicator { + top: $pickerview-top; + height: $picker-item-height; + border: $picker-item-active-line-border; + border-left: 0; + border-right: 0; + box-sizing: border-box; + } + + &-list { + position: relative; + flex: 1; + height: $picker-list-height; + overflow: hidden; + touch-action: none; + } + + &-roller { + position: absolute; + top: $pickerview-top; + width: 100%; + height: $picker-item-height; + z-index: 1; + transform-style: preserve-3d; + } + + &-roller-item { + position: absolute; + top: 0; + backface-visibility: hidden; + -moz-backface-visibility: hidden; + -webkit-backface-visibility: hidden; + &-hidden { + visibility: hidden; + opacity: 0; + } + } + + &-roller-item, + &-roller-item-tiled { + width: 100%; + height: $picker-item-height; + line-height: $picker-item-height; + color: $picker-item-text-color; + font-size: $picker-item-text-font-size; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/src/packages/pickerview/pickerview.taro.tsx b/src/packages/pickerview/pickerview.taro.tsx new file mode 100644 index 0000000000..8a52e52e97 --- /dev/null +++ b/src/packages/pickerview/pickerview.taro.tsx @@ -0,0 +1,225 @@ +import React, { + ForwardRefRenderFunction, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import classNames from 'classnames' +import { View } from '@tarojs/components' +import isEqual from 'react-fast-compare' +import { ComponentDefaults } from '@/utils/typings' +import { usePropsValue } from '@/hooks/use-props-value' +import { + PickerViewProps, + PickerOptionItem, + PickerValue, + PickerOptions, +} from './types' +import PickerRoller from './pickerroller.taro' + +const defaultProps = { + ...ComponentDefaults, + options: [], + defaultValue: [], + value: undefined, + renderLabel: (item: PickerOptionItem) => item.label, +} as PickerViewProps + +const InternalPickerView: ForwardRefRenderFunction< + unknown, + Partial +> = (props, ref) => { + const { + options, + defaultValue = [], + value, + duration, + threeDimensional, + renderLabel, + className, + style, + onChange, + } = { ...defaultProps, ...props } + const classPrefix = 'nut-pickerview' + const cls = classNames(classPrefix, className) + + const [selectedValue] = usePropsValue({ + value, + defaultValue: [...defaultValue], + finalValue: [...defaultValue], + }) + + const [innerValue, setInnerValue] = useState(selectedValue) + const [innerOptions, setInnerOptions] = useState([] as PickerOptions[]) + const changeIndex = useRef(0) + + /** + * 数据类型:级联、多列 + */ + const columnsType = useMemo(() => { + const firstColumn = (props.options as PickerOptions[])[0] || [] + if ( + Array.isArray(firstColumn) && + firstColumn.length > 0 && + 'children' in firstColumn[0] + ) { + return 'cascade' + } + return 'multiple' + }, [props.options]) + + const formatCascadeOptions = ( + options: PickerOptions, + value: PickerValue[] + ) => { + if (!options.length) return [] // 如果 options 为空,直接返回空数组 + + const formatted: PickerOptions[] = [] + let columnOptions: PickerOptionItem = { + label: '', + value: '', + children: options, + } + + let columnIndex = 0 + while (columnOptions && columnOptions.children) { + const currentOptions: PickerOptions = columnOptions.children + formatted.push(currentOptions) + + const currentValue = value?.[columnIndex] + if (currentValue === 0) { + // 如果 currentValue 为 0,返回第一个 children + columnOptions = currentOptions[0] + } else if (currentValue) { + // 如果 currentValue 存在,查找匹配的项 + const index = currentOptions.findIndex( + (columnItem) => columnItem.value === currentValue + ) + columnOptions = currentOptions[index === -1 ? 0 : index] // 如果未找到,默认取第一个 + } else { + break // 如果 currentValue 不存在,终止循环 + } + + columnIndex++ + } + return formatted + } + + const formatOptions = useMemo(() => { + if (columnsType === 'cascade') { + return formatCascadeOptions( + props?.options?.[0] as PickerOptions, + innerValue + ) + } + return props.options + }, [innerValue, options, columnsType]) + + useEffect(() => { + const options = props.options + if (Array.isArray(options) && options.length && options !== innerOptions) { + setInnerOptions(formatOptions as PickerOptions[]) + } + }, [props.options, innerValue]) + + useEffect(() => { + if (selectedValue !== innerValue) { + setInnerValue(selectedValue) + } + }, [selectedValue]) + + const handleSelect = useCallback( + (option: PickerOptionItem, index: number) => { + const newValue = option?.value + if (!newValue || innerValue[index] === newValue) return + changeIndex.current = index + if (columnsType === 'multiple') { + setInnerValue((prev) => { + const next = [...prev] + next[index] = newValue + return next + }) + } else { + const startIndex = index + const values: PickerValue[] = [] + values[index] = option.value + while (option?.children?.[0]) { + values[index + 1] = option.children[0].value + index++ + option = option.children[0] + } + // 当前改变列的下一列 children 值为空 + if (option?.children?.length) { + values[index + 1] = '' + } + const combineResult = [ + ...innerValue.slice(0, startIndex), + ...values.splice(startIndex), + ] + setInnerValue([...combineResult]) + const optionFirst = props?.options?.[0] as PickerOptionItem[] + if ( + !isEqual( + formatCascadeOptions(optionFirst, combineResult), + innerOptions + ) + ) { + setInnerOptions(formatCascadeOptions(optionFirst, combineResult)) + } + } + }, + [innerValue, props.options, columnsType, innerOptions] + ) + + const selectedOptions = useMemo(() => { + return innerOptions + .map((columnOptions, index) => { + const selectedOption = columnOptions.find( + (item) => item.value === innerValue[index] + ) + return selectedOption + // return selectedOption || columnOptions[0] + }) + .filter(Boolean) as PickerOptionItem[] + }, [innerOptions, innerValue]) + + useEffect(() => { + onChange?.({ + value: innerValue, + index: changeIndex.current, + selectedOptions, + }) + }, [innerValue, selectedOptions, onChange]) + + return ( + + {innerOptions.map((item, index) => ( + + ))} + {innerOptions?.length ? ( + <> + + + + ) : null} + + ) +} + +const PickerView = React.forwardRef>( + InternalPickerView +) + +export default PickerView diff --git a/src/packages/pickerview/pickerview.tsx b/src/packages/pickerview/pickerview.tsx new file mode 100644 index 0000000000..0fef411bea --- /dev/null +++ b/src/packages/pickerview/pickerview.tsx @@ -0,0 +1,224 @@ +import React, { + ForwardRefRenderFunction, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import classNames from 'classnames' +import isEqual from 'react-fast-compare' +import { ComponentDefaults } from '@/utils/typings' +import { usePropsValue } from '@/hooks/use-props-value' +import { + PickerViewProps, + PickerOptionItem, + PickerValue, + PickerOptions, +} from './types' +import PickerRoller from './pickerroller' + +const defaultProps = { + ...ComponentDefaults, + options: [], + defaultValue: [], + value: undefined, + renderLabel: (item: PickerOptionItem) => item.label, +} as PickerViewProps + +const InternalPickerView: ForwardRefRenderFunction< + unknown, + Partial +> = (props, ref) => { + const { + options, + defaultValue = [], + value, + duration, + threeDimensional, + renderLabel, + className, + style, + onChange, + } = { ...defaultProps, ...props } + const classPrefix = 'nut-pickerview' + const cls = classNames(classPrefix, className) + + const [selectedValue] = usePropsValue({ + value, + defaultValue: [...defaultValue], + finalValue: [...defaultValue], + }) + + const [innerValue, setInnerValue] = useState(selectedValue) + const [innerOptions, setInnerOptions] = useState([] as PickerOptions[]) + const changeIndex = useRef(0) + + /** + * 数据类型:级联、多列 + */ + const columnsType = useMemo(() => { + const firstColumn = (props.options as PickerOptions[])[0] || [] + if ( + Array.isArray(firstColumn) && + firstColumn.length > 0 && + 'children' in firstColumn[0] + ) { + return 'cascade' + } + return 'multiple' + }, [props.options]) + + const formatCascadeOptions = ( + options: PickerOptions, + value: PickerValue[] + ) => { + if (!options.length) return [] // 如果 options 为空,直接返回空数组 + + const formatted: PickerOptions[] = [] + let columnOptions: PickerOptionItem = { + label: '', + value: '', + children: options, + } + + let columnIndex = 0 + while (columnOptions && columnOptions.children) { + const currentOptions: PickerOptions = columnOptions.children + formatted.push(currentOptions) + + const currentValue = value?.[columnIndex] + if (currentValue === 0) { + // 如果 currentValue 为 0,返回第一个 children + columnOptions = currentOptions[0] + } else if (currentValue) { + // 如果 currentValue 存在,查找匹配的项 + const index = currentOptions.findIndex( + (columnItem) => columnItem.value === currentValue + ) + columnOptions = currentOptions[index === -1 ? 0 : index] // 如果未找到,默认取第一个 + } else { + break // 如果 currentValue 不存在,终止循环 + } + + columnIndex++ + } + return formatted + } + + const formatOptions = useMemo(() => { + if (columnsType === 'cascade') { + return formatCascadeOptions( + props?.options?.[0] as PickerOptions, + innerValue + ) + } + return props.options + }, [innerValue, options, columnsType]) + + useEffect(() => { + const options = props.options + if (Array.isArray(options) && options.length && options !== innerOptions) { + setInnerOptions(formatOptions as PickerOptions[]) + } + }, [props.options, innerValue]) + + useEffect(() => { + if (selectedValue !== innerValue) { + setInnerValue(selectedValue) + } + }, [selectedValue]) + + const handleSelect = useCallback( + (option: PickerOptionItem, index: number) => { + const newValue = option?.value + if (!newValue || innerValue[index] === newValue) return + changeIndex.current = index + if (columnsType === 'multiple') { + setInnerValue((prev) => { + const next = [...prev] + next[index] = newValue + return next + }) + } else { + const startIndex = index + const values: PickerValue[] = [] + values[index] = option.value + while (option?.children?.[0]) { + values[index + 1] = option.children[0].value + index++ + option = option.children[0] + } + // 当前改变列的下一列 children 值为空 + if (option?.children?.length) { + values[index + 1] = '' + } + const combineResult = [ + ...innerValue.slice(0, startIndex), + ...values.splice(startIndex), + ] + setInnerValue([...combineResult]) + const optionFirst = props?.options?.[0] as PickerOptionItem[] + if ( + !isEqual( + formatCascadeOptions(optionFirst, combineResult), + innerOptions + ) + ) { + setInnerOptions(formatCascadeOptions(optionFirst, combineResult)) + } + } + }, + [innerValue, props.options, columnsType, innerOptions] + ) + + const selectedOptions = useMemo(() => { + return innerOptions + .map((columnOptions, index) => { + const selectedOption = columnOptions.find( + (item) => item.value === innerValue[index] + ) + return selectedOption + // return selectedOption || columnOptions[0] + }) + .filter(Boolean) as PickerOptionItem[] + }, [innerOptions, innerValue]) + + useEffect(() => { + onChange?.({ + value: innerValue, + index: changeIndex.current, + selectedOptions, + }) + }, [innerValue, selectedOptions, onChange]) + + return ( +
+ {innerOptions.map((item, index) => ( + + ))} + {innerOptions?.length ? ( + <> +
+
+ + ) : null} +
+ ) +} + +const PickerView = React.forwardRef>( + InternalPickerView +) + +export default PickerView diff --git a/src/packages/pickerview/types.ts b/src/packages/pickerview/types.ts new file mode 100644 index 0000000000..e25d83faf5 --- /dev/null +++ b/src/packages/pickerview/types.ts @@ -0,0 +1,38 @@ +import { BasicComponent } from '@/utils/typings' + +export type PickerValue = string | number | null + +export interface PickerOptionItem { + label: string | number + value: string | number + children?: PickerOptionItem[] +} + +export type PickerOptions = PickerOptionItem[] + +export interface PickerRollerProps { + options: PickerOptionItem[] + keyIndex: number + value: PickerValue + threeDimensional?: boolean + duration?: number | string + onSelect: (option: PickerOptionItem, index: number) => void + renderLabel: (item: PickerOptionItem) => React.ReactNode +} + +export interface PickerOnChangeCallbackParameter { + value: PickerValue[] + index: number + selectedOptions: PickerOptionItem[] +} + +export interface PickerViewProps extends BasicComponent { + setRefs?: (ref: any) => any + options: PickerOptions[] + value?: PickerValue[] + defaultValue?: PickerValue[] + threeDimensional?: boolean + duration?: number | string + renderLabel: (item: PickerOptionItem) => React.ReactNode + onChange?: (arg0: PickerOnChangeCallbackParameter) => void +} diff --git a/src/packages/pickerview/utils.ts b/src/packages/pickerview/utils.ts new file mode 100644 index 0000000000..2d948b7361 --- /dev/null +++ b/src/packages/pickerview/utils.ts @@ -0,0 +1,31 @@ +export const momentum = (distance: number, duration: number) => { + const speed = Math.abs(distance / duration) + return (speed / 0.003) * (distance < 0 ? -1 : 1) +} + +export const useStyles = ( + touchTime: number, + touchDeg: string, + scrollDistance: number, + lineSpacing: React.MutableRefObject, + rotation: number +) => { + const getTransitionStyle = (transformValue: string) => ({ + transition: `transform ${touchTime}ms cubic-bezier(0.17, 0.89, 0.45, 1)`, + transform: transformValue, + }) + + const touchRollerStyle = () => + getTransitionStyle(`rotate3d(1, 0, 0, ${touchDeg})`) + + const touchTiledStyle = () => + getTransitionStyle(`translate3d(0, ${scrollDistance}px, 0)`) + + const rollerStyle = (index: number) => ({ + transform: `rotate3d(1, 0, 0, ${-rotation * (index + 1)}deg) translate3d(0px, 0px, ${Math.round( + lineSpacing.current * 3.2 + )}px)`, + }) + + return { touchRollerStyle, touchTiledStyle, rollerStyle } +} diff --git a/src/styles/variables.scss b/src/styles/variables.scss index 99b31976c7..51a884d520 100644 --- a/src/styles/variables.scss +++ b/src/styles/variables.scss @@ -559,8 +559,10 @@ $picker-title-ok-font-size: var( --nutui-picker-title-ok-font-size, $font-size-base ) !default; -$picker-list-height: var(--nutui-picker-list-height, 252px) !default; + +// picker-view(✅) $picker-item-height: var(--nutui-picker-item-height, 36px) !default; +$picker-list-height: calc($picker-item-height * 7) !default; $picker-item-text-color: var( --nutui-picker-item-text-color, $color-title