diff --git a/src/packages/configprovider/types.ts b/src/packages/configprovider/types.ts index bf1e53ad89..8e5db803b5 100644 --- a/src/packages/configprovider/types.ts +++ b/src/packages/configprovider/types.ts @@ -691,6 +691,23 @@ export type NutCSSVariables = | 'nutuiProgressTextPositionTop' | 'nutuiProgressTextPositionBottom' | 'nutuiProgressTextBorderRadius' + | 'nutuiProgressVideoTrackColor' + | 'nutuiProgressVideoFillColor' + | 'nutuiProgressVideoThumbColor' + | 'nutuiProgressVideoHeight' + | 'nutuiProgressVideoActiveHeight' + | 'nutuiProgressVideoPausedHeight' + | 'nutuiProgressVideoThumbWidth' + | 'nutuiProgressVideoThumbHeight' + | 'nutuiProgressVideoThumbRadius' + | 'nutuiProgressVideoThumbActiveWidth' + | 'nutuiProgressVideoThumbActiveHeight' + | 'nutuiProgressVideoThumbActiveRadius' + | 'nutuiProgressVideoThumbPausedWidth' + | 'nutuiProgressVideoThumbPausedHeight' + | 'nutuiProgressVideoThumbPausedRadius' + | 'nutuiProgressVideoOpacityStatic' + | 'nutuiProgressVideoOpacityPaused' | 'nutuiPaginationColor' | 'nutuiPaginationLiteColor' | 'nutuiPaginationFontSize' diff --git a/src/packages/progress/__tests__/progress.spec.tsx b/src/packages/progress/__tests__/progress.spec.tsx index 73d8faa486..b1a3e1e64a 100644 --- a/src/packages/progress/__tests__/progress.spec.tsx +++ b/src/packages/progress/__tests__/progress.spec.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { render } from '@testing-library/react' +import { fireEvent, render } from '@testing-library/react' import '@testing-library/jest-dom' import { Progress } from '../progress' @@ -53,7 +53,6 @@ test('should handle animation mode and duration', () => { 'transition: width 500ms ease-in-out' ) - // 测试动画完成回调 rerender( { expect(onActiveEndMock).toHaveBeenCalled() }, 600) }) + +// ==== video mode ==== +describe('Progress video mode', () => { + test('renders track/fill/thumb structure with is-static by default', () => { + const { container } = render() + const root = container.querySelector('.nut-progress--video') + expect(root).toBeTruthy() + expect(root?.classList.contains('is-static')).toBe(true) + expect(container.querySelector('.nut-progress-track')).toBeTruthy() + const fill = container.querySelector('.nut-progress-fill') as HTMLElement + expect(fill.style.width).toBe('40%') + const thumb = container.querySelector('.nut-progress-thumb') as HTMLElement + expect(thumb.style.left).toBe('40%') + }) + + test('applies is-paused state and renders paused icon', () => { + const { container } = render( + } + /> + ) + const root = container.querySelector('.nut-progress--video') + expect(root?.classList.contains('is-paused')).toBe(true) + expect(container.querySelector('[data-testid="paused-icon"]')).toBeTruthy() + }) + + test('hides thumb when showThumb=false', () => { + const { container } = render( + + ) + expect(container.querySelector('.nut-progress-thumb')).toBeNull() + }) + + test('clamps percent to [min, max]', () => { + const { container, rerender } = render( + + ) + const fill = () => + container.querySelector('.nut-progress-fill') as HTMLElement + expect(fill().style.width).toBe('0%') + rerender() + expect(fill().style.width).toBe('100%') + }) + + test('drag with mouse triggers onDragStart / onDragEnd and onChange', () => { + const onDragStart = vi.fn() + const onDragEnd = vi.fn() + const onChange = vi.fn() + const { container } = render( + + ) + const root = container.querySelector('.nut-progress--video') as HTMLElement + root.getBoundingClientRect = () => + ({ + left: 0, + top: 0, + right: 200, + bottom: 10, + width: 200, + height: 10, + x: 0, + y: 0, + toJSON: () => ({}), + }) as DOMRect + + fireEvent.mouseDown(root, { clientX: 100 }) + expect(onDragStart).toHaveBeenCalled() + expect(onChange).toHaveBeenCalledWith(50) + fireEvent.mouseUp(window, { clientX: 160 }) + expect(onDragEnd).toHaveBeenCalledWith(80) + }) + + test('keyboard ArrowRight increments percent by step', () => { + const onChange = vi.fn() + const { container } = render( + + ) + const root = container.querySelector('.nut-progress--video') as HTMLElement + fireEvent.keyDown(root, { key: 'ArrowRight' }) + expect(onChange).toHaveBeenCalledWith(55) + fireEvent.keyDown(root, { key: 'Home' }) + expect(onChange).toHaveBeenCalledWith(0) + fireEvent.keyDown(root, { key: 'End' }) + expect(onChange).toHaveBeenCalledWith(100) + }) +}) diff --git a/src/packages/progress/demo.taro.tsx b/src/packages/progress/demo.taro.tsx index 5b0750bab3..8e3c19c7bf 100644 --- a/src/packages/progress/demo.taro.tsx +++ b/src/packages/progress/demo.taro.tsx @@ -12,6 +12,7 @@ import Demo6 from './demos/taro/demo6' import Demo7 from './demos/taro/demo7' import Demo8 from './demos/taro/demo8' import Demo9 from './demos/taro/demo9' +import Demo10 from './demos/taro/demo10' const ProgressDemo = () => { const [translated] = useTranslate({ @@ -25,6 +26,7 @@ const ProgressDemo = () => { dynamicChange: '动态改变', lazy: '延迟加载数据', activeMode: '设置动画时长与播放方式', + videoBasic: '视频进度条', }, 'zh-TW': { basic: '基礎用法', @@ -36,6 +38,7 @@ const ProgressDemo = () => { dynamicChange: '動態改變', lazy: '延遲加載數據', activeMode: '設置動畫時長與播放方式', + videoBasic: '視頻進度條', }, 'en-US': { basic: 'Basic Usage', @@ -47,6 +50,7 @@ const ProgressDemo = () => { dynamicChange: 'Dynamic Change', lazy: 'Delay Time', activeMode: 'Duration And Animation Mode', + videoBasic: 'Video Progress', }, }) @@ -77,6 +81,8 @@ const ProgressDemo = () => { )} {translated.activeMode} + {translated.videoBasic} + ) diff --git a/src/packages/progress/demo.tsx b/src/packages/progress/demo.tsx index a58feaf69e..ad4ff6f0a5 100644 --- a/src/packages/progress/demo.tsx +++ b/src/packages/progress/demo.tsx @@ -9,6 +9,7 @@ import Demo6 from './demos/h5/demo6' import Demo7 from './demos/h5/demo7' import Demo8 from './demos/h5/demo8' import Demo9 from './demos/h5/demo9' +import Demo10 from './demos/h5/demo10' const ProgressDemo = () => { const [translated] = useTranslate({ @@ -22,6 +23,7 @@ const ProgressDemo = () => { dynamicChange: '动态改变', lazy: '延迟加载数据', activeMode: '设置动画时长与播放方式', + videoBasic: '视频进度条', }, 'zh-TW': { basic: '基礎用法', @@ -33,6 +35,7 @@ const ProgressDemo = () => { dynamicChange: '動態改變', lazy: '延遲加載數據', activeMode: '設置動畫時長與播放方式', + videoBasic: '視頻進度條', }, 'en-US': { basic: 'Basic Usage', @@ -44,6 +47,7 @@ const ProgressDemo = () => { dynamicChange: 'Dynamic Change', lazy: 'Delay Time', activeMode: 'Duration And Animation Mode', + videoBasic: 'Video Progress', }, }) @@ -68,6 +72,8 @@ const ProgressDemo = () => {

{translated.activeMode}

+

{translated.videoBasic}

+ ) diff --git a/src/packages/progress/demos/h5/demo10.tsx b/src/packages/progress/demos/h5/demo10.tsx new file mode 100644 index 0000000000..79fa7da669 --- /dev/null +++ b/src/packages/progress/demos/h5/demo10.tsx @@ -0,0 +1,47 @@ +import React, { useState } from 'react' +import { Progress, Cell } from '@nutui/nutui-react' + +const cardStyle: React.CSSProperties = { + padding: '15px 18px', + background: '#999', + borderRadius: 8, + marginBottom: 12, +} + +const progressStyle: React.CSSProperties = { + height: 38, +} + +const Demo10 = () => { + const [percent, setPercent] = useState(20) + return ( + + + + + + + + + setPercent(p)} + style={progressStyle} + /> + + + ) +} +export default Demo10 diff --git a/src/packages/progress/demos/taro/demo10.tsx b/src/packages/progress/demos/taro/demo10.tsx new file mode 100644 index 0000000000..155e7015a0 --- /dev/null +++ b/src/packages/progress/demos/taro/demo10.tsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react' +import { Progress } from '@nutui/nutui-react-taro' +import { View } from '@tarojs/components' +import { PlayStart } from '@nutui/icons-react-taro' + +const cardStyle = { + padding: '16px 20px', + background: '#111', + borderRadius: '8px', + marginBottom: '12px', +} + +const labelStyle = { + color: 'rgba(255,255,255,0.6)', + fontSize: '12px', + marginBottom: '8px', +} + +const Demo10 = () => { + const [percent, setPercent] = useState(20) + return ( + <> + + 默认(静态) + + + + 视频暂停 + } + /> + + + 拖动 + setPercent(p)} + /> + + + ) +} +export default Demo10 diff --git a/src/packages/progress/doc.en-US.md b/src/packages/progress/doc.en-US.md index 85f6530563..3b49af9ac7 100644 --- a/src/packages/progress/doc.en-US.md +++ b/src/packages/progress/doc.en-US.md @@ -80,6 +80,16 @@ import { Progress } from '@nutui/nutui-react' ::: +### Video Progress + +For video-player-like immersive scenarios. Default colors: track `rgba(255,255,255,0.2)`, fill `rgba(255,255,255,0.7)`, thumb `#FFFFFF`. The demo covers three states: static (dimmed), paused (medium intensity, paired with a play icon), and drag (highlighted, enlarged thumb). + +:::demo + + + +::: + ## Progress ### Props @@ -100,6 +110,20 @@ import { Progress } from '@nutui/nutui-react' | duration | Animation completion time (in milliseconds) | `number` | `30` | | ariaLabel | AccessibilityLabel | `string` | `-` | | onActiveEnd | Callback function after animation is completed | `() => void` | `-` | +| mode | Progress bar form; `video` enables the video-player style | `default \| video` | `default` | +| status | External state in video mode (`static` / `paused`); drag state is derived internally | `static \| paused` | `static` | +| draggable | Whether users can drag to seek in video mode | `boolean` | `false` | +| showThumb | Whether the draggable thumb is visible in video mode | `boolean` | `true` | +| pausedIcon | Icon rendered when `status='paused'` | `ReactNode` | `-` | +| min | Minimum progress value | `number` | `0` | +| max | Maximum progress value | `number` | `100` | +| step | Step size for drag / keyboard | `number` | `-` | +| onChange | Fires when the progress value changes | `(value: number) => void` | `-` | +| onDragStart | Fires on drag start | `(value: number) => void` | `-` | +| onDragging | Fires while dragging | `(value: number) => void` | `-` | +| onDragEnd | Fires on drag end | `(value: number) => void` | `-` | + +> Note: when `mode='video'`, `color / background / strokeWidth` are ignored. Customize the look via the CSS variables below or via the `pausedIcon` slot. ## Theming @@ -120,5 +144,22 @@ The component provides the following CSS variables, which can be used to customi | \--nutui-progress-text-position-bottom | text bottom | `-4px` | | \--nutui-progress-text-border-radius | text borderRadius | `5px` | | \--nutui-progress-text-background | text background | `$progress-color` | +| \--nutui-progress-video-track-color | video mode track color | `rgba(255, 255, 255, 0.2)` | +| \--nutui-progress-video-fill-color | video mode filled color | `rgba(255, 255, 255, 0.7)` | +| \--nutui-progress-video-thumb-color | video mode thumb color | `#ffffff` | +| \--nutui-progress-video-height | video mode track height | `1px` | +| \--nutui-progress-video-active-height | track height on drag | `8px` | +| \--nutui-progress-video-paused-height | track height in paused state | `3px` | +| \--nutui-progress-video-thumb-width | video mode thumb width | `2px` | +| \--nutui-progress-video-thumb-height | video mode thumb height | `1px` | +| \--nutui-progress-video-thumb-radius | video mode thumb corner radius | `0.5px` | +| \--nutui-progress-video-thumb-active-width | thumb width on drag | `6px` | +| \--nutui-progress-video-thumb-active-height | thumb height on drag | `12px` | +| \--nutui-progress-video-thumb-active-radius | thumb corner radius on drag | `3px` | +| \--nutui-progress-video-thumb-paused-width | thumb width in paused state | `6px` | +| \--nutui-progress-video-thumb-paused-height | thumb height in paused state | `3px` | +| \--nutui-progress-video-thumb-paused-radius | thumb corner radius in paused state | `1.5px` | +| \--nutui-progress-video-opacity-static | opacity in static state | `0.6` | +| \--nutui-progress-video-opacity-paused | opacity in paused state | `0.85` | diff --git a/src/packages/progress/doc.md b/src/packages/progress/doc.md index 7a3164589d..42c35601d4 100644 --- a/src/packages/progress/doc.md +++ b/src/packages/progress/doc.md @@ -80,6 +80,16 @@ import { Progress } from '@nutui/nutui-react' ::: +### 视频进度条 + +用于视频播放器等沉浸式场景。默认色值:轨道 `rgba(255,255,255,0.2)`、已填充 `rgba(255,255,255,0.7)`、滑块 `#FFFFFF`。演示包含三种状态:默认(静态,弱化显示)、视频暂停(中等强度,配合播放 icon)、拖动(突出显示、滑块变大)。 + +:::demo + + + +::: + ## Progress ### Props @@ -100,6 +110,20 @@ import { Progress } from '@nutui/nutui-react' | duration | 动画完成时间(单位:毫秒) | `number` | `30` | | ariaLabel | 无障碍标签 | `string` | `-` | | onActiveEnd | 动画完成后的回调函数 | `() => void` | `-` | +| mode | 进度条形态,`video` 时启用视频播放器样式 | `default \| video` | `default` | +| status | 视频模式下的外部状态(`static` 静态 / `paused` 暂停),拖动态由组件内部推导 | `static \| paused` | `static` | +| draggable | 视频模式下是否允许拖动改变进度 | `boolean` | `false` | +| showThumb | 视频模式下是否显示可拖动滑块 | `boolean` | `true` | +| pausedIcon | `status='paused'` 时展示的播放/暂停图标 | `ReactNode` | `-` | +| min | 进度值最小值 | `number` | `0` | +| max | 进度值最大值 | `number` | `100` | +| step | 拖动步长 | `number` | `-` | +| onChange | 拖动/点击/键盘改变进度回调 | `(value: number) => void` | `-` | +| onDragStart | 拖动开始回调 | `(value: number) => void` | `-` | +| onDragging | 拖动进行中回调 | `(value: number) => void` | `-` | +| onDragEnd | 拖动结束回调 | `(value: number) => void` | `-` | + +> 注:`mode='video'` 时 `color / background / strokeWidth` 属性不生效,请通过下方的 CSS 变量或 `pausedIcon` 插槽定制外观。 ## 主题定制 @@ -120,5 +144,22 @@ import { Progress } from '@nutui/nutui-react' | \--nutui-progress-text-position-bottom | 文本定位 bottom | `-4px` | | \--nutui-progress-text-border-radius | 文本边框圆角 | `5px` | | \--nutui-progress-text-background | 文本背景颜色 | `$progress-color` | +| \--nutui-progress-video-track-color | 视频模式轨道底色 | `rgba(255, 255, 255, 0.2)` | +| \--nutui-progress-video-fill-color | 视频模式已填充色 | `rgba(255, 255, 255, 0.7)` | +| \--nutui-progress-video-thumb-color | 视频模式滑块颜色 | `#ffffff` | +| \--nutui-progress-video-height | 视频模式轨道高度 | `1px` | +| \--nutui-progress-video-active-height | 拖动态轨道高度 | `8px` | +| \--nutui-progress-video-paused-height | 暂停态轨道高度 | `3px` | +| \--nutui-progress-video-thumb-width | 视频模式滑块宽度 | `2px` | +| \--nutui-progress-video-thumb-height | 视频模式滑块高度 | `1px` | +| \--nutui-progress-video-thumb-radius | 视频模式滑块圆角 | `0.5px` | +| \--nutui-progress-video-thumb-active-width | 拖动态滑块宽度 | `6px` | +| \--nutui-progress-video-thumb-active-height | 拖动态滑块高度 | `12px` | +| \--nutui-progress-video-thumb-active-radius | 拖动态滑块圆角 | `3px` | +| \--nutui-progress-video-thumb-paused-width | 暂停态滑块宽度 | `6px` | +| \--nutui-progress-video-thumb-paused-height | 暂停态滑块高度 | `3px` | +| \--nutui-progress-video-thumb-paused-radius | 暂停态滑块圆角 | `1.5px` | +| \--nutui-progress-video-opacity-static | 默认(静态)态整体透明度 | `0.6` | +| \--nutui-progress-video-opacity-paused | 暂停态整体透明度 | `0.85` | diff --git a/src/packages/progress/doc.taro.md b/src/packages/progress/doc.taro.md index f3ba4e2bf9..6a0ff868df 100644 --- a/src/packages/progress/doc.taro.md +++ b/src/packages/progress/doc.taro.md @@ -80,6 +80,21 @@ import { Progress } from '@nutui/nutui-react-taro' ::: +### 视频进度条 + +用于视频播放器等沉浸式场景。默认色值:轨道 `rgba(255,255,255,0.2)`、已填充 `rgba(255,255,255,0.7)`、滑块 `#FFFFFF`。演示包含三种状态:默认(静态,弱化显示)、视频暂停(中等强度,配合播放 icon)、拖动(突出显示、滑块变大)。 + +:::demo + + + +::: + +> Taro 端注意事项: +> +> - 小程序端仅支持 touch 事件驱动交互。 +> - 所有尺寸走 `rpx`,可通过 [ConfigProvider](#/zh-CN/component/configprovider) 缩放。 + ## Progress ### Props @@ -100,6 +115,20 @@ import { Progress } from '@nutui/nutui-react-taro' | duration | 动画完成时间(单位:毫秒) | `number` | `30` | | ariaLabel | 无障碍标签 | `string` | `-` | | onActiveEnd | 动画完成后的回调函数 | `() => void` | `-` | +| mode | 进度条形态,`video` 时启用视频播放器样式 | `default \| video` | `default` | +| status | 视频模式下的外部状态(`static` 静态 / `paused` 暂停) | `static \| paused` | `static` | +| draggable | 视频模式下是否允许拖动改变进度 | `boolean` | `false` | +| showThumb | 视频模式下是否显示可拖动滑块 | `boolean` | `true` | +| pausedIcon | `status='paused'` 时展示的播放/暂停图标 | `ReactNode` | `-` | +| min | 进度值最小值 | `number` | `0` | +| max | 进度值最大值 | `number` | `100` | +| step | 拖动步长 | `number` | `-` | +| onChange | 拖动/点击改变进度回调 | `(value: number) => void` | `-` | +| onDragStart | 拖动开始回调 | `(value: number) => void` | `-` | +| onDragging | 拖动进行中回调 | `(value: number) => void` | `-` | +| onDragEnd | 拖动结束回调 | `(value: number) => void` | `-` | + +> 注:`mode='video'` 时 `color / background / strokeWidth` 属性不生效,请通过下方的 CSS 变量或 `pausedIcon` 插槽定制外观。 ## 主题定制 @@ -120,5 +149,22 @@ import { Progress } from '@nutui/nutui-react-taro' | \--nutui-progress-text-position-bottom | 文本定位 bottom | `-4px` | | \--nutui-progress-text-border-radius | 文本边框圆角 | `5px` | | \--nutui-progress-text-background | 文本背景颜色 | `$progress-color` | +| \--nutui-progress-video-track-color | 视频模式轨道底色 | `rgba(255, 255, 255, 0.2)` | +| \--nutui-progress-video-fill-color | 视频模式已填充色 | `rgba(255, 255, 255, 0.7)` | +| \--nutui-progress-video-thumb-color | 视频模式滑块颜色 | `#ffffff` | +| \--nutui-progress-video-height | 视频模式轨道高度 | `1px` | +| \--nutui-progress-video-active-height | 拖动态轨道高度 | `8px` | +| \--nutui-progress-video-paused-height | 暂停态轨道高度 | `3px` | +| \--nutui-progress-video-thumb-width | 视频模式滑块宽度 | `2px` | +| \--nutui-progress-video-thumb-height | 视频模式滑块高度 | `1px` | +| \--nutui-progress-video-thumb-radius | 视频模式滑块圆角 | `0.5px` | +| \--nutui-progress-video-thumb-active-width | 拖动态滑块宽度 | `6px` | +| \--nutui-progress-video-thumb-active-height | 拖动态滑块高度 | `12px` | +| \--nutui-progress-video-thumb-active-radius | 拖动态滑块圆角 | `3px` | +| \--nutui-progress-video-thumb-paused-width | 暂停态滑块宽度 | `6px` | +| \--nutui-progress-video-thumb-paused-height | 暂停态滑块高度 | `3px` | +| \--nutui-progress-video-thumb-paused-radius | 暂停态滑块圆角 | `1.5px` | +| \--nutui-progress-video-opacity-static | 默认(静态)态整体透明度 | `0.6` | +| \--nutui-progress-video-opacity-paused | 暂停态整体透明度 | `0.85` | diff --git a/src/packages/progress/doc.zh-TW.md b/src/packages/progress/doc.zh-TW.md index fa93f275f8..626900e7aa 100644 --- a/src/packages/progress/doc.zh-TW.md +++ b/src/packages/progress/doc.zh-TW.md @@ -80,6 +80,16 @@ import { Progress } from '@nutui/nutui-react' ::: +### 視頻進度條 + +用於視頻播放器等沉浸式場景。默認色值:軌道 `rgba(255,255,255,0.2)`、已填充 `rgba(255,255,255,0.7)`、滑塊 `#FFFFFF`。演示包含三種狀態:默認(靜態,弱化顯示)、視頻暫停(中等強度,配合播放 icon)、拖動(突出顯示、滑塊變大)。 + +:::demo + + + +::: + ## Progress ### Props @@ -100,6 +110,20 @@ import { Progress } from '@nutui/nutui-react' | duration | 動畫完成時間(單位:毫秒) | `number` | `30` | | ariaLabel | 無障礙標簽 | `string` | `-` | | onActiveEnd | 動畫完成後的回調函數 | `() => void` | `-` | +| mode | 進度條形態,`video` 時啟用視頻播放器樣式 | `default \| video` | `default` | +| status | 視頻模式下的外部狀態(`static` 靜態 / `paused` 暫停),拖動態由組件內部推導 | `static \| paused` | `static` | +| draggable | 視頻模式下是否允許拖動改變進度 | `boolean` | `false` | +| showThumb | 視頻模式下是否顯示可拖動滑塊 | `boolean` | `true` | +| pausedIcon | `status='paused'` 時展示的播放/暫停圖標 | `ReactNode` | `-` | +| min | 進度值最小值 | `number` | `0` | +| max | 進度值最大值 | `number` | `100` | +| step | 拖動步長 | `number` | `-` | +| onChange | 拖動/點擊/鍵盤改變進度回調 | `(value: number) => void` | `-` | +| onDragStart | 拖動開始回調 | `(value: number) => void` | `-` | +| onDragging | 拖動進行中回調 | `(value: number) => void` | `-` | +| onDragEnd | 拖動結束回調 | `(value: number) => void` | `-` | + +> 註:`mode='video'` 時 `color / background / strokeWidth` 屬性不生效,請通過下方的 CSS 變量或 `pausedIcon` 插槽定制外觀。 ## 主題定制 @@ -120,5 +144,22 @@ import { Progress } from '@nutui/nutui-react' | \--nutui-progress-text-position-bottom | 文本定位 bottom | `-4px` | | \--nutui-progress-text-border-radius | 文本邊框圓角 | `5px` | | \--nutui-progress-text-background | 文本背景顏色 | `$progress-color` | +| \--nutui-progress-video-track-color | 視頻模式軌道底色 | `rgba(255, 255, 255, 0.2)` | +| \--nutui-progress-video-fill-color | 視頻模式已填充色 | `rgba(255, 255, 255, 0.7)` | +| \--nutui-progress-video-thumb-color | 視頻模式滑塊顏色 | `#ffffff` | +| \--nutui-progress-video-height | 視頻模式軌道高度 | `1px` | +| \--nutui-progress-video-active-height | 拖動態軌道高度 | `8px` | +| \--nutui-progress-video-paused-height | 暫停態軌道高度 | `3px` | +| \--nutui-progress-video-thumb-width | 視頻模式滑塊寬度 | `2px` | +| \--nutui-progress-video-thumb-height | 視頻模式滑塊高度 | `1px` | +| \--nutui-progress-video-thumb-radius | 視頻模式滑塊圓角 | `0.5px` | +| \--nutui-progress-video-thumb-active-width | 拖動態滑塊寬度 | `6px` | +| \--nutui-progress-video-thumb-active-height | 拖動態滑塊高度 | `12px` | +| \--nutui-progress-video-thumb-active-radius | 拖動態滑塊圓角 | `3px` | +| \--nutui-progress-video-thumb-paused-width | 暫停態滑塊寬度 | `6px` | +| \--nutui-progress-video-thumb-paused-height | 暫停態滑塊高度 | `3px` | +| \--nutui-progress-video-thumb-paused-radius | 暫停態滑塊圓角 | `1.5px` | +| \--nutui-progress-video-opacity-static | 默認(靜態)態整體透明度 | `0.6` | +| \--nutui-progress-video-opacity-paused | 暫停態整體透明度 | `0.85` | diff --git a/src/packages/progress/progress.scss b/src/packages/progress/progress.scss index 9b7a7685e3..ac0f6d5e53 100644 --- a/src/packages/progress/progress.scss +++ b/src/packages/progress/progress.scss @@ -59,6 +59,115 @@ } } +.nut-progress--video { + position: relative; + width: 100%; + display: flex; + align-items: center; + user-select: none; + outline: none; + transition: opacity 0.2s ease; + height: $progress-video-container-height; + + &.is-static { + opacity: $progress-video-opacity-static; + } + + &.is-paused { + opacity: $progress-video-opacity-paused; + } + + &.is-active { + opacity: 1; + } + + .nut-progress-track { + position: relative; + width: 100%; + height: $progress-video-height; + background: $progress-video-track-color; + border-radius: 999px; + overflow: visible; + transition: height 0.15s ease; + } + &.is-active .nut-progress-track { + height: $progress-video-active-height; + } + + &.is-paused .nut-progress-track { + height: $progress-video-paused-height; + } + + .nut-progress-fill { + position: absolute; + left: 0; + top: 0; + height: 100%; + background: $progress-video-fill-color; + border-radius: 999px; + transition: width 0.1s linear; + } + + .nut-progress-thumb { + position: absolute; + top: 0; + bottom: 0; + margin: auto 0; + width: $progress-video-thumb-width; + height: $progress-video-thumb-height; + background: $progress-video-thumb-color; + border-radius: $progress-video-thumb-radius; + transform: translateX(-50%); + pointer-events: none; + transition: + width 0.15s ease, + height 0.15s ease, + border-radius 0.15s ease; + } + + &.is-active .nut-progress-thumb { + width: $progress-video-thumb-active-width; + height: $progress-video-thumb-active-height; + border-radius: $progress-video-thumb-active-radius; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.25); + } + + &.is-paused .nut-progress-thumb { + width: $progress-video-thumb-paused-width; + height: $progress-video-thumb-paused-height; + border-radius: $progress-video-thumb-paused-radius; + } + + &.is-draggable { + touch-action: none; + cursor: pointer; + .nut-progress-thumb { + cursor: grab; + } + &.is-dragging .nut-progress-thumb { + cursor: grabbing; + } + } + + &.is-dragging { + .nut-progress-fill { + transition: none; + } + } + + .nut-progress-icon { + position: absolute; + right: 100%; + top: 50%; + margin-right: 8px; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + color: #ffffff; + } +} + [dir='rtl'] .nut-progress, .nut-rtl .nut-progress { &-text { diff --git a/src/packages/progress/progress.taro.tsx b/src/packages/progress/progress.taro.tsx index 56ec296f95..8362135d46 100644 --- a/src/packages/progress/progress.taro.tsx +++ b/src/packages/progress/progress.taro.tsx @@ -1,7 +1,13 @@ -import React, { FunctionComponent, useEffect, useRef, useState } from 'react' +import React, { + FunctionComponent, + useCallback, + useEffect, + useRef, + useState, +} from 'react' import classNames from 'classnames' -import Taro, { PageInstance } from '@tarojs/taro' -import { View } from '@tarojs/components' +import Taro, { PageInstance, createSelectorQuery } from '@tarojs/taro' +import { ITouchEvent, View } from '@tarojs/components' import { pxTransform } from '@/utils/taro/px-transform' import { ComponentDefaults } from '@/utils/typings' import { useRtl } from '@/packages/configprovider/index.taro' @@ -16,10 +22,29 @@ const defaultProps = { animated: false, lazy: false, delay: 0, + mode: 'default', + status: 'static', + draggable: false, + showThumb: true, + min: 0, + max: 100, } as TaroProgressProps +const clamp = (val: number, min: number, max: number) => { + if (Number.isNaN(val)) return min + if (val < min) return min + if (val > max) return max + return val +} + +interface RectLike { + left: number + width: number +} + export const Progress: FunctionComponent< - Partial & React.HTMLAttributes + Partial & + Omit, 'onChange'> > = (props) => { const rtl = useRtl() const { @@ -44,6 +69,19 @@ export const Progress: FunctionComponent< activeMode, duration, onActiveEnd, + mode, + status, + draggable, + showThumb, + pausedIcon, + min, + max, + step, + onChange, + onDragStart, + onDragging, + onDragEnd, + ariaLabel, ...rest } = { ...defaultProps, @@ -63,7 +101,6 @@ export const Progress: FunctionComponent< const [displayPercent, setDispalyPercent] = useState(percent) const getStyles = () => { - // 基础样式 const baseStyles = { height: strokeWidth && pxTransform(Number(strokeWidth)), borderRadius: @@ -72,7 +109,6 @@ export const Progress: FunctionComponent< const transitionStyle = { transition: `width ${duration || 300}ms ease-in-out`, } - return { outer: { width: '100%', @@ -123,7 +159,7 @@ export const Progress: FunctionComponent< }, [percent, activeMode, duration]) const [intersecting, setIntersecting] = useState(false) - const progressRef = useRef(null) + const progressRef = useRef(null) const webObserver: any = useRef(null) const uuid = useUuid() const selector = `${classPrefix}-lazy-${uuid}` @@ -146,8 +182,6 @@ export const Progress: FunctionComponent< } }, [intersecting]) const handleWebObserver = () => { - /// web环境 - if (lazy) { webObserver.current = new IntersectionObserver( (entires, self) => { @@ -165,7 +199,6 @@ export const Progress: FunctionComponent< handlePercent() } const handleOtherObserver = () => { - // 非web环境 let observer: any = null if (lazy) { observer = Taro.createIntersectionObserver( @@ -185,12 +218,131 @@ export const Progress: FunctionComponent< } useEffect(() => { + if (mode === 'video') return if (web()) { handleWebObserver() } else if (!harmony()) { handleOtherObserver() } }, []) + + // ==== video mode ==== + const minVal = min ?? 0 + const maxVal = max ?? 100 + const range = Math.max(maxVal - minVal, 1) + const normalized = clamp(((percent - minVal) / range) * 100, 0, 100) + + const [dragging, setDragging] = useState(false) + const [previewPercent, setPreviewPercent] = useState(normalized) + const rectRef = useRef(null) + const draggingRef = useRef(false) + // 记录最近一次触摸的 clientX:await measureRect 期间提前派发的 touchmove + // 会因 rectRef 尚未就绪而无法正确换算,故缓存坐标,待 rect 就绪后补算 + const lastClientXRef = useRef(0) + + useEffect(() => { + if (!dragging) setPreviewPercent(normalized) + }, [normalized, dragging]) + + const getState = (): 'static' | 'paused' | 'active' => { + if (dragging) return 'active' + if (status === 'paused') return 'paused' + return 'static' + } + const state = getState() + + const applyStep = (pct: number): number => { + if (!step || step <= 0) return pct + const stepInPct = (step / range) * 100 + return Math.round(pct / stepInPct) * stepInPct + } + + const measureRect = (): Promise => { + return new Promise((resolve) => { + if (web() && progressRef.current?.getBoundingClientRect) { + const r = progressRef.current.getBoundingClientRect() + resolve({ left: r.left, width: r.width }) + return + } + createSelectorQuery() + .select(`#${selector}`) + .boundingClientRect() + .exec((res: any) => { + const r = res && res[0] + if (r) { + resolve({ left: r.left, width: r.width }) + } else { + resolve(null) + } + }) + }) + } + + const percentFromClientX = (clientX: number): number => { + const rect = rectRef.current + if (!rect || rect.width === 0) return normalized + const raw = ((clientX - rect.left) / rect.width) * 100 + return clamp(applyStep(raw), 0, 100) + } + + const emitChange = (pct: number) => { + const raw = minVal + (pct / 100) * range + const value = Math.round(raw * 1e6) / 1e6 + onChange?.(value) + } + + const handleTouchStart = useCallback( + async (e: ITouchEvent) => { + if (mode !== 'video' || !draggable) return + if (!e.touches || e.touches.length === 0) return + const clientX = e.touches[0].clientX + // 同步置位拖动态:小程序 measureRect 为异步,await 期间已可能派发 touchmove, + // 若仅依赖异步的 setDragging 会丢弃起步阶段的 touchmove,故用 ref 立即生效供事件判断 + draggingRef.current = true + lastClientXRef.current = clientX + setDragging(true) + const rect = await measureRect() + // await 期间可能已 touchEnd,此时不应再进入拖动流程 + if (!draggingRef.current) return + rectRef.current = rect + // 以最近一次触摸坐标补算,覆盖 await 期间提前派发的 touchmove + const pct = percentFromClientX(lastClientXRef.current) + setPreviewPercent(pct) + onDragStart?.(minVal + (pct / 100) * range) + emitChange(pct) + }, + [mode, draggable, minVal, range, step] + ) + + const handleTouchMove = useCallback( + (e: ITouchEvent) => { + if (mode !== 'video' || !draggingRef.current) return + if (!e.touches || e.touches.length === 0) return + const clientX = e.touches[0].clientX + lastClientXRef.current = clientX + // rect 尚未就绪(measureRect 未完成),先缓存坐标,待 touchStart await 结束后补算 + if (!rectRef.current) return + const pct = percentFromClientX(clientX) + setPreviewPercent(pct) + onDragging?.(minVal + (pct / 100) * range) + emitChange(pct) + }, + [mode, minVal, range, step, onDragging] + ) + + const handleTouchEnd = useCallback( + (e: ITouchEvent) => { + if (mode !== 'video' || !draggingRef.current) return + const touch = e.changedTouches && e.changedTouches[0] + const pct = touch ? percentFromClientX(touch.clientX) : previewPercent + draggingRef.current = false + setDragging(false) + rectRef.current = null + onDragEnd?.(minVal + (pct / 100) * range) + }, + [mode, minVal, range, step, previewPercent, onDragEnd] + ) + const getTextStyle = () => { return rtl ? { right: '100%' } : { left: '100%' } } @@ -215,6 +367,51 @@ export const Progress: FunctionComponent< } return style } + + if (mode === 'video') { + const rootCls = classNames( + classPrefix, + `${classPrefix}--video`, + `is-${state}`, + { + 'is-draggable': draggable, + 'is-dragging': dragging, + }, + className + ) + const displayValue = dragging ? previewPercent : normalized + return ( + + {status === 'paused' && pausedIcon && ( + {pausedIcon} + )} + + + {showThumb && ( + + )} + + + ) + } + return ( { + if (Number.isNaN(val)) return min + if (val < min) return min + if (val > max) return max + return val +} + export const Progress: FunctionComponent< - Partial & React.HTMLAttributes + Partial & + Omit, 'onChange'> > = (props) => { const rtl = useRtl() const { @@ -34,6 +57,19 @@ export const Progress: FunctionComponent< activeMode, duration, onActiveEnd, + mode, + status, + draggable, + showThumb, + pausedIcon, + min, + max, + step, + onChange, + onDragStart, + onDragging, + onDragEnd, + ariaLabel, ...rest } = { ...defaultProps, @@ -82,7 +118,7 @@ export const Progress: FunctionComponent< const [intersecting, setIntersecting] = useState(false) - const progressRef = useRef(null) + const progressRef = useRef(null) const observer: any = useRef(null) const initObserver = () => { const options = { @@ -114,6 +150,7 @@ export const Progress: FunctionComponent< }, [intersecting]) useEffect(() => { + if (mode === 'video') return undefined lazy && initObserver() let timer: any = null if (delay) { @@ -129,6 +166,230 @@ export const Progress: FunctionComponent< } }, []) + // ==== video mode ==== + const minVal = min ?? 0 + const maxVal = max ?? 100 + const range = Math.max(maxVal - minVal, 1) + const normalized = clamp(((percent - minVal) / range) * 100, 0, 100) + + const [dragging, setDragging] = useState(false) + const [previewPercent, setPreviewPercent] = useState(normalized) + const rafId = useRef(null) + const rectRef = useRef(null) + // 保存 startDrag 实际注册的处理器身份,确保卸载清理时能精确移除这些函数 + const dragListenersRef = useRef<{ + mousemove: (e: MouseEvent) => void + mouseup: (e: MouseEvent) => void + touchmove: (e: TouchEvent) => void + touchend: (e: TouchEvent) => void + } | null>(null) + + useEffect(() => { + if (!dragging) { + setPreviewPercent(normalized) + } + }, [normalized, dragging]) + + const getState = (): 'static' | 'paused' | 'active' => { + if (dragging) return 'active' + if (status === 'paused') return 'paused' + return 'static' + } + const state = getState() + + const applyStep = (pct: number): number => { + if (!step || step <= 0) return pct + const stepInPct = (step / range) * 100 + return Math.round(pct / stepInPct) * stepInPct + } + + const percentFromClientX = (clientX: number): number => { + const rect = rectRef.current || progressRef.current?.getBoundingClientRect() + if (!rect || rect.width === 0) return normalized + const raw = ((clientX - rect.left) / rect.width) * 100 + return clamp(applyStep(raw), 0, 100) + } + + const emitChange = (pct: number) => { + const raw = minVal + (pct / 100) * range + const value = Math.round(raw * 1e6) / 1e6 + onChange?.(value) + } + + const handleMove = useCallback( + (clientX: number) => { + if (rafId.current) cancelAnimationFrame(rafId.current) + rafId.current = requestAnimationFrame(() => { + const pct = percentFromClientX(clientX) + setPreviewPercent(pct) + onDragging?.(minVal + (pct / 100) * range) + emitChange(pct) + }) + }, + [minVal, range, step, onDragging, onChange] + ) + + const handleMouseMove = useCallback( + (e: MouseEvent) => handleMove(e.clientX), + [handleMove] + ) + + const handleTouchMove = useCallback( + (e: TouchEvent) => { + if (e.touches.length === 0) return + handleMove(e.touches[0].clientX) + }, + [handleMove] + ) + + const cleanupDragListeners = () => { + const listeners = dragListenersRef.current + if (listeners) { + window.removeEventListener('mousemove', listeners.mousemove) + window.removeEventListener('mouseup', listeners.mouseup) + window.removeEventListener('touchmove', listeners.touchmove) + window.removeEventListener('touchend', listeners.touchend) + dragListenersRef.current = null + } + if (rafId.current) { + cancelAnimationFrame(rafId.current) + rafId.current = null + } + } + + function handleMouseUp(e: MouseEvent) { + const pct = percentFromClientX(e.clientX) + setDragging(false) + rectRef.current = null + onDragEnd?.(minVal + (pct / 100) * range) + cleanupDragListeners() + } + + function handleTouchEnd(e: TouchEvent) { + const touch = e.changedTouches[0] + const pct = touch ? percentFromClientX(touch.clientX) : previewPercent + setDragging(false) + rectRef.current = null + onDragEnd?.(minVal + (pct / 100) * range) + cleanupDragListeners() + } + + const startDrag = (clientX: number) => { + if (!draggable) return + rectRef.current = progressRef.current?.getBoundingClientRect() || null + setDragging(true) + const pct = percentFromClientX(clientX) + setPreviewPercent(pct) + onDragStart?.(minVal + (pct / 100) * range) + emitChange(pct) + const listeners = { + mousemove: handleMouseMove, + mouseup: handleMouseUp, + touchmove: handleTouchMove, + touchend: handleTouchEnd, + } + dragListenersRef.current = listeners + window.addEventListener('mousemove', listeners.mousemove) + window.addEventListener('mouseup', listeners.mouseup) + window.addEventListener('touchmove', listeners.touchmove, { + passive: false, + }) + window.addEventListener('touchend', listeners.touchend) + } + + const handleMouseDown = (e: ReactMouseEvent) => { + if (mode !== 'video') return + e.preventDefault() + startDrag(e.clientX) + } + + const handleTouchStart = (e: ReactTouchEvent) => { + if (mode !== 'video') return + if (e.touches.length === 0) return + startDrag(e.touches[0].clientX) + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (mode !== 'video' || !draggable) return + const inc = step && step > 0 ? (step / range) * 100 : 1 + let next = normalized + switch (e.key) { + case 'ArrowLeft': + case 'ArrowDown': + next = clamp(normalized - inc, 0, 100) + break + case 'ArrowRight': + case 'ArrowUp': + next = clamp(normalized + inc, 0, 100) + break + case 'Home': + next = 0 + break + case 'End': + next = 100 + break + default: + return + } + e.preventDefault() + emitChange(next) + } + + useEffect(() => { + return () => cleanupDragListeners() + }, []) + + if (mode === 'video') { + const rootCls = classNames( + classPrefix, + `${classPrefix}--video`, + `is-${state}`, + { + 'is-draggable': draggable, + 'is-dragging': dragging, + }, + className + ) + const displayValue = dragging ? previewPercent : normalized + return ( +
+ {status === 'paused' && pausedIcon && ( +
{pausedIcon}
+ )} +
+
+ {showThumb && ( +
+ )} +
+
+ ) + } + return (
void + + /** 进度条展示模式:default 普通进度条,video 视频风格进度条 */ + mode?: ProgressMode + /** video 模式下的播放状态:static 播放中,paused 暂停 */ + status?: ProgressStatus + /** video 模式下是否允许拖动滑块调整进度 */ + draggable?: boolean + /** video 模式下是否显示滑块 */ + showThumb?: boolean + /** video 模式下 paused 状态时滑块中央展示的图标 */ + pausedIcon?: ReactNode + /** 进度值最小值 */ + min?: number + /** 进度值最大值 */ + max?: number + /** 拖动时的步长,未设置时按像素连续变化 */ + step?: number + /** 进度值变化的回调,拖动过程中连续触发 */ + onChange?: (percent: number) => void + /** 拖动开始时的回调 */ + onDragStart?: (percent: number) => void + /** 拖动过程中的回调 */ + onDragging?: (percent: number) => void + /** 拖动结束时的回调 */ + onDragEnd?: (percent: number) => void }