Repository: ant-design/ant-design-mobile (ts)

Issue:
希望 DatePicker 增加精度选项 'quarter' 季度
### Version of antd-mobile

5.19.0

### What is this feature about?

目前的 DatePicker 精度 precision 的值没有 'quarter' 季度,能否做成类似的效果呢
![C@)I3{ )B5D4~Q}% RU@E%O](https://github.com/ant-design/ant-design-mobile/assets/62825909/b2166ed5-b1dc-4d34-845d-cc90044c696d)



Pull request description:
feat(DatePicker): add quarter precision
Add quarter precision to DatePicker and DatePickerView, close #6510

Patch:
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 53ac68a1f..9b83a55d8 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -14,7 +14,7 @@ jobs:
 
       - name: Install pnpm
         id: pnpm-install
-        uses: pnpm/action-setup@v2.2.4
+        uses: pnpm/action-setup@v4
         with:
           version: 7
           run_install: false
diff --git a/.github/workflows/compressed-size.yml b/.github/workflows/compressed-size.yml
index 4f79025a4..ab9e84e48 100644
--- a/.github/workflows/compressed-size.yml
+++ b/.github/workflows/compressed-size.yml
@@ -17,7 +17,7 @@ jobs:
     steps:
       - name: Install pnpm
         id: pnpm-install
-        uses: pnpm/action-setup@v2
+        uses: pnpm/action-setup@v4
         with:
           version: 7
           run_install: false
diff --git a/.github/workflows/doc-site.yml b/.github/workflows/doc-site.yml
index d29fa19da..b91fb0219 100644
--- a/.github/workflows/doc-site.yml
+++ b/.github/workflows/doc-site.yml
@@ -19,7 +19,7 @@ jobs:
 
       - name: Install pnpm
         id: pnpm-install
-        uses: pnpm/action-setup@v2
+        uses: pnpm/action-setup@v4
         with:
           version: 7
           run_install: false
diff --git a/.github/workflows/preview-build.yml b/.github/workflows/preview-build.yml
index f1327292e..c738f5666 100644
--- a/.github/workflows/preview-build.yml
+++ b/.github/workflows/preview-build.yml
@@ -20,7 +20,7 @@ jobs:
 
       - name: Install pnpm
         id: pnpm-install
-        uses: pnpm/action-setup@v2
+        uses: pnpm/action-setup@v4
         with:
           version: 7
           run_install: false
diff --git a/src/components/date-picker-view/demos/demo1.tsx b/src/components/date-picker-view/demos/demo1.tsx
index ef8016d85..e3c9ad039 100644
--- a/src/components/date-picker-view/demos/demo1.tsx
+++ b/src/components/date-picker-view/demos/demo1.tsx
@@ -43,6 +43,15 @@ export default () => {
         />
       </DemoBlock>
 
+      <DemoBlock title='季度选择器' padding='0'>
+        <DatePickerView
+          onChange={val => console.log('onChange', val)}
+          precision='quarter'
+          defaultValue={now}
+          renderLabel={quarterLabelRenderer}
+        />
+      </DemoBlock>
+
       <DemoBlock title='过滤可供选择的时间' padding='0'>
         <DatePickerView
           defaultValue={now}
@@ -96,6 +105,17 @@ const weekdayLabelRenderer = (type: string, data: number) => {
   }
 }
 
+const quarterLabelRenderer = (type: string, data: number) => {
+  switch (type) {
+    case 'year':
+      return data + '年'
+    case 'quarter':
+      return data + '季度'
+    default:
+      return data
+  }
+}
+
 const dateFilter: DatePickerFilter = {
   day: (val, { date }) => {
     // 去除所有周末
diff --git a/src/components/date-picker-view/demos/demo2.tsx b/src/components/date-picker-view/demos/demo2.tsx
index 51b404c09..bb98ebaa4 100644
--- a/src/components/date-picker-view/demos/demo2.tsx
+++ b/src/components/date-picker-view/demos/demo2.tsx
@@ -20,6 +20,15 @@ export default () => {
           filter={weekFilter}
         />
       </DemoBlock>
+
+      <DemoBlock title='季度选择器过滤' padding='0'>
+        <DatePickerView
+          onChange={val => console.log('onChange', val)}
+          precision='quarter'
+          defaultValue={now}
+          filter={quarterFilter}
+        />
+      </DemoBlock>
     </>
   )
 }
@@ -70,3 +79,14 @@ const weekFilter: DatePickerFilter = {
     return true
   },
 }
+
+const quarterFilter: DatePickerFilter = {
+  year: val => val > new Date().getFullYear() - 3,
+  quarter: val => {
+    // 去除每年的1季度和3季度
+    if (val === 1 || val === 3) {
+      return false
+    }
+    return true
+  },
+}
diff --git a/src/components/date-picker/date-picker-quarter-utils.ts b/src/components/date-picker/date-picker-quarter-utils.ts
new file mode 100644
index 000000000..f562d070d
--- /dev/null
+++ b/src/components/date-picker/date-picker-quarter-utils.ts
@@ -0,0 +1,111 @@
+import dayjs from 'dayjs'
+import quarterOfYear from 'dayjs/plugin/quarterOfYear'
+import type { ReactNode } from 'react'
+import { PickerColumn } from '../picker'
+import type { DatePickerFilter } from './date-picker-utils'
+
+dayjs.extend(quarterOfYear)
+
+export type QuarterPrecision = 'year' | 'quarter'
+
+const precisionRankRecord: Record<QuarterPrecision, number> = {
+  year: 0,
+  quarter: 1,
+}
+
+export function generateDatePickerColumns(
+  selected: string[],
+  min: Date,
+  max: Date,
+  precision: QuarterPrecision,
+  renderLabel: (type: QuarterPrecision, data: number) => ReactNode,
+  filter: DatePickerFilter | undefined
+) {
+  const ret: PickerColumn[] = []
+
+  const minYear = min.getFullYear()
+  const maxYear = max.getFullYear()
+
+  const rank = precisionRankRecord[precision]
+
+  const selectedYear = parseInt(selected[0])
+  const isInMinYear = selectedYear === minYear
+  const isInMaxYear = selectedYear === maxYear
+
+  const minDay = dayjs(min)
+  const maxDay = dayjs(max)
+  const minQuarter = minDay.quarter()
+  const maxQuarter = maxDay.quarter()
+
+  const generateColumn = (
+    from: number,
+    to: number,
+    precision: QuarterPrecision
+  ) => {
+    let column: number[] = []
+    for (let i = from; i <= to; i++) {
+      column.push(i)
+    }
+    const prefix = selected.slice(0, precisionRankRecord[precision])
+    const currentFilter = filter?.[precision]
+    if (currentFilter && typeof currentFilter === 'function') {
+      column = column.filter(i =>
+        currentFilter(i, {
+          get date() {
+            const stringArray = [...prefix, i.toString()]
+            return convertStringArrayToDate(stringArray)
+          },
+        })
+      )
+    }
+    return column
+  }
+
+  if (rank >= precisionRankRecord.year) {
+    const lower = minYear
+    const upper = maxYear
+    const years = generateColumn(lower, upper, 'year')
+    ret.push(
+      years.map(v => ({
+        label: renderLabel('year', v),
+        value: v.toString(),
+      }))
+    )
+  }
+
+  if (rank >= precisionRankRecord.quarter) {
+    const lower = isInMinYear ? minQuarter : 1
+    const upper = isInMaxYear ? maxQuarter : 4
+    const quarters = generateColumn(lower, upper, 'quarter')
+    ret.push(
+      quarters.map(v => ({
+        label: renderLabel('quarter', v),
+        value: v.toString(),
+      }))
+    )
+  }
+
+  return ret
+}
+
+export function convertDateToStringArray(
+  date: Date | undefined | null
+): string[] {
+  if (!date) return []
+  const day = dayjs(date)
+  return [day.year().toString(), day.quarter().toString()]
+}
+
+export function convertStringArrayToDate<
+  T extends string | number | null | undefined,
+>(value: T[]): Date {
+  const yearString = value[0] ?? '1900'
+  const quarterString = value[1] ?? '1'
+  const day = dayjs()
+    .year(parseInt(yearString as string))
+    .quarter(parseInt(quarterString as string))
+    .hour(0)
+    .minute(0)
+    .second(0)
+  return day.toDate()
+}
diff --git a/src/components/date-picker/date-picker-utils.ts b/src/components/date-picker/date-picker-utils.ts
index 9c9b1cd16..ce9e49f8f 100644
--- a/src/components/date-picker/date-picker-utils.ts
+++ b/src/components/date-picker/date-picker-utils.ts
@@ -1,12 +1,14 @@
+import { RenderLabel } from '../date-picker-view/date-picker-view'
 import type { DatePrecision } from './date-picker-date-utils'
-import type { WeekPrecision } from './date-picker-week-utils'
 import * as dateUtils from './date-picker-date-utils'
+import type { QuarterPrecision } from './date-picker-quarter-utils'
+import * as quarterUtils from './date-picker-quarter-utils'
+import type { WeekPrecision } from './date-picker-week-utils'
 import * as weekUtils from './date-picker-week-utils'
-import { RenderLabel } from '../date-picker-view/date-picker-view'
-import { TILL_NOW } from './util'
 import type { PickerDate } from './util'
+import { TILL_NOW } from './util'
 
-export type Precision = DatePrecision | WeekPrecision
+export type Precision = DatePrecision | WeekPrecision | QuarterPrecision
 
 export type DatePickerFilter = Partial<
   Record<
@@ -35,6 +37,8 @@ export const convertDateToStringArray = (
 ) => {
   if (precision.includes('week')) {
     return weekUtils.convertDateToStringArray(date)
+  } else if (precision.includes('quarter')) {
+    return quarterUtils.convertDateToStringArray(date)
   } else {
     const datePrecision = precision as DatePrecision
     const stringArray = dateUtils.convertDateToStringArray(date)
@@ -43,7 +47,7 @@ export const convertDateToStringArray = (
 }
 
 export const convertStringArrayToDate = <
-  T extends string | number | null | undefined
+  T extends string | number | null | undefined,
 >(
   value: T[],
   precision: Precision
@@ -57,6 +61,8 @@ export const convertStringArrayToDate = <
 
   if (precision.includes('week')) {
     return weekUtils.convertStringArrayToDate(value)
+  } else if (precision.includes('quarter')) {
+    return quarterUtils.convertStringArrayToDate(value)
   } else {
     return dateUtils.convertStringArrayToDate(value)
   }
@@ -80,6 +86,15 @@ export const generateDatePickerColumns = (
       renderLabel,
       filter
     )
+  } else if (precision.startsWith('quarter')) {
+    return quarterUtils.generateDatePickerColumns(
+      selected,
+      min,
+      max,
+      precision as QuarterPrecision,
+      renderLabel,
+      filter
+    )
   } else {
     return dateUtils.generateDatePickerColumns(
       selected,
diff --git a/src/components/date-picker/demos/demo1.tsx b/src/components/date-picker/demos/demo1.tsx
index d0f90ed0b..78d5ecab7 100644
--- a/src/components/date-picker/demos/demo1.tsx
+++ b/src/components/date-picker/demos/demo1.tsx
@@ -198,6 +198,47 @@ function DayOfWeekDemo() {
   )
 }
 
+// 季度选择器
+function QuarterDemo() {
+  const [visible, setVisible] = useState(false)
+
+  const labelRenderer = useCallback((type: string, data: number) => {
+    switch (type) {
+      case 'year':
+        return data + '年'
+      case 'quarter':
+        return data + '季度'
+      default:
+        return data
+    }
+  }, [])
+
+  return (
+    <>
+      <Button
+        onClick={() => {
+          setVisible(true)
+        }}
+      >
+        选择
+      </Button>
+      <DatePicker
+        visible={visible}
+        onClose={() => {
+          setVisible(false)
+        }}
+        defaultValue={now}
+        onConfirm={val => {
+          Toast.show(val.toDateString())
+        }}
+        onSelect={val => console.log(val)}
+        renderLabel={labelRenderer}
+        precision='quarter'
+      />
+    </>
+  )
+}
+
 // 基础用法
 function TillNowDemo() {
   const [value, setValue] = useState(() => new Date())
@@ -252,6 +293,10 @@ export default () => {
         <DayOfWeekDemo />
       </DemoBlock>
 
+      <DemoBlock title='季度选择器'>
+        <QuarterDemo />
+      </DemoBlock>
+
       <DemoBlock title='至今'>
         <TillNowDemo />
       </DemoBlock>
diff --git a/src/components/picker-view/index.en.md b/src/components/picker-view/index.en.md
index 4a5558a38..402f76500 100644
--- a/src/components/picker-view/index.en.md
+++ b/src/components/picker-view/index.en.md
@@ -83,7 +83,7 @@ type PickerDate = Date & {
 | min | Minimum value | `PickerDate` | ten years ago |
 | mouseWheel | Whether to allow interact with mouse wheel | `boolean` | `false` |
 | onChange | Triggered when the options are changed | `(value: PickerDate) => void` | - |
-| precision | Precision | `'year' \| 'month' \| 'day' \| 'hour' \| 'minute' \| 'second' \| 'week' \| 'week-day'` | `'day'` |
+| precision | Precision | `'year' \| 'month' \| 'day' \| 'hour' \| 'minute' \| 'second' \| 'week' \| 'week-day' \| 'quarter'` | `'day'` |
 | renderLabel | The function to custom rendering the label shown on a column. `type` means any value in `precision`, `data` means the default number | `(type: string, data: number) => ReactNode` | - |
 | value | Selected options | `PickerDate` | - |
 | loading | Should the Picker displays as loading state | `boolean` | `false` |
diff --git a/src/components/picker-view/index.zh.md b/src/components/picker-view/index.zh.md
index 69dcb6837..0aae90c30 100644
--- a/src/components/picker-view/index.zh.md
+++ b/src/components/picker-view/index.zh.md
@@ -83,7 +83,7 @@ type PickerDate = Date & {
 | min | 最小值 | `PickerDate` | 十年前 |
 | mouseWheel | 是否允许通过鼠标滚轮进行选择 | `boolean` | `false` |
 | onChange | 选项改变时触发 | `(value: PickerDate) => void` | - |
-| precision | 精度 | `'year' \| 'month' \| 'day' \| 'hour' \| 'minute' \| 'second' \| 'week' \| 'week-day'` | `'day'` |
+| precision | 精度 | `'year' \| 'month' \| 'day' \| 'hour' \| 'minute' \| 'second' \| 'week' \| 'week-day' \| 'quarter'` | `'day'` |
 | renderLabel | 自定义渲染每列展示的内容。其中 `type` 参数为 `precision` 中的任意值，`data` 参数为默认渲染的数字 | `(type: string, data: number) => ReactNode` | - |
 | value | 选中项 | `PickerDate` | - |
 | loading | 是否处于加载状态 | `boolean` | `false` |
diff --git a/src/components/picker/index.en.md b/src/components/picker/index.en.md
index 6089776dd..9b6b7578e 100644
--- a/src/components/picker/index.en.md
+++ b/src/components/picker/index.en.md
@@ -162,7 +162,7 @@ type PickerDate = Date & {
 | mouseWheel | Whether to allow interact with mouse wheel | `boolean` | `false` |
 | onConfirm | Triggered when confirming | `(value: PickerDate) => void` | - |
 | onSelect | Triggered when the options are changed | `(value: PickerDate) => void` | - |
-| precision | Precision | `'year' \| 'month' \| 'day' \| 'hour' \| 'minute' \| 'second' \| 'week' \| 'week-day'` | `'day'` |
+| precision | Precision | `'year' \| 'month' \| 'day' \| 'hour' \| 'minute' \| 'second' \| 'week' \| 'week-day' \| 'quarter'` | `'day'` |
 | renderLabel | The function to custom rendering the label shown on a column. `type` means any value in `precision` or `now`, `data` means the default number | `(type: Precision \| 'now', data: number) => ReactNode` | - |
 | tillNow | Show till now in list | `boolean` | - | 5.27.0 |
 | value | Selected value | `PickerDate` | - |
diff --git a/src/components/picker/index.zh.md b/src/components/picker/index.zh.md
index 79876746c..a2919a119 100644
--- a/src/components/picker/index.zh.md
+++ b/src/components/picker/index.zh.md
@@ -164,7 +164,7 @@ type PickerDate = Date & {
 | mouseWheel | 是否允许通过鼠标滚轮进行选择 | `boolean` | `false` |
 | onConfirm | 确认时触发 | `(value: PickerDate) => void` | - |
 | onSelect | 选项改变时触发 | `(value: PickerDate) => void` | - |
-| precision | 精度 | `'year' \| 'month' \| 'day' \| 'hour' \| 'minute' \| 'second' \| 'week' \| 'week-day'` | `'day'` |
+| precision | 精度 | `'year' \| 'month' \| 'day' \| 'hour' \| 'minute' \| 'second' \| 'week' \| 'week-day' \| 'quarter'` | `'day'` |
 | renderLabel | 自定义渲染每列展示的内容。其中 `type` 参数为 `precision` 中的任意值或 `now`，`data` 参数为默认渲染的数字 | `(type: Precision \| 'now', data: number) => ReactNode` | - |
 | tillNow | 是否展示“至今” | `boolean` | - | 5.27.0 |
 | value | 选中值 | `PickerDate` | - |


Test patch:
diff --git a/src/components/date-picker/tests/date-picker.test.tsx b/src/components/date-picker/tests/date-picker.test.tsx
index 9419f5475..8fc0527c5 100644
--- a/src/components/date-picker/tests/date-picker.test.tsx
+++ b/src/components/date-picker/tests/date-picker.test.tsx
@@ -132,6 +132,29 @@ describe('DatePicker', () => {
     expect(fn.mock.calls[0][0]).toEqual(today.toDateString())
   })
 
+  test('precision quarter', async () => {
+    const today = new Date()
+    const fn = jest.fn()
+    const { getByText } = render(
+      <DatePicker
+        visible
+        precision='quarter'
+        value={now}
+        onConfirm={val => {
+          fn(val.toDateString())
+        }}
+      />
+    )
+
+    expect(
+      document.body.querySelectorAll(`.${classPrefix}-view-column`).length
+    ).toBe(2)
+    await waitFor(() => {
+      fireEvent.click(getByText('确定'))
+    })
+    expect(fn.mock.calls[0][0]).toEqual(today.toDateString())
+  })
+
   test('test imperative call', async () => {
     const today = new Date()
     const fn = jest.fn()