From 18b8ccc423c5380cfd5b556016c1ac43936c707c Mon Sep 17 00:00:00 2001 From: dyh_a Date: Tue, 12 Dec 2023 15:29:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(components/slider):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20=E9=94=AE=E7=9B=98=E6=8E=A7=E5=88=B6=E5=A2=9E=E5=87=8F?= =?UTF-8?q?=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/components/src/slider/Slider.tsx | 105 ++++++++++++++---- .../src/slider/components/Handlers.tsx | 87 ++++++++++----- .../components/src/slider/demo/keyboard.tsx | 28 +++++ packages/components/src/slider/index.scss | 3 + packages/components/src/slider/index.zh-CN.md | 2 + .../components/src/slider/slider.types.ts | 1 + 6 files changed, 179 insertions(+), 47 deletions(-) create mode 100644 packages/components/src/slider/demo/keyboard.tsx diff --git a/packages/components/src/slider/Slider.tsx b/packages/components/src/slider/Slider.tsx index 2663eb79..57bb2beb 100644 --- a/packages/components/src/slider/Slider.tsx +++ b/packages/components/src/slider/Slider.tsx @@ -1,9 +1,15 @@ -import { getClassNames, getSafeNum, strip } from '@tool-pack/basic'; +import { + getClassNames, + forEachRight, + getSafeNum, + forEach, + strip, +} from '@tool-pack/basic'; +import { HandlersControlRef, Handlers, Marks, Dots } from './components'; import type { RequiredPart, Point } from '@tool-pack/types'; import React, { useEffect, useMemo, useRef } from 'react'; import { useForceUpdate, getClasses } from '@pkg/shared'; import type { SliderStaticProps } from './slider.types'; -import { Handlers, Marks, Dots } from './components'; import { SliderFC } from './slider.types'; const cls = getClasses( @@ -12,6 +18,7 @@ const cls = getClasses( ['disabled', 'vertical', 'reverse', 'range', 'with-marks'], ); const defaultProps = { + keyboard: true, reverse: false, tooltip: true, max: 100, @@ -31,6 +38,7 @@ const _Slider: React.FC = React.forwardRef< keepRangeSorted, attrs = {}, disabled, + keyboard, vertical, onChange, reverse, @@ -42,12 +50,12 @@ const _Slider: React.FC = React.forwardRef< } = props as RequiredPart; const forceUpdate = useForceUpdate(); - - const isRange = Array.isArray(outerValue); const valuesRef = useRef([]); const sortedValuesRef = useRef([]); const railRef = useRef(null); + const handlersControlRef = useRef(null); + const isRange = Array.isArray(outerValue); const total = max - min; // 同步外部 value @@ -118,13 +126,16 @@ const _Slider: React.FC = React.forwardRef< /> = React.forwardRef< ); + function onHandlerKeyDown(stepScale: -1 | 1, index: number): void { + if (!keyboard) return; + const values = valuesRef.current; + const curr = values[index] as number; + setValueOfIndex(getLimitValue(getStepValue()), index); + + function getStepValue(): number { + if (step !== 'mark') return stepScale * step + curr; + return getByMark(); + + function getByMark(): number { + const [markValue, markValues] = findClosestFromObjectNumberKeys( + marks, + curr, + ); + + const index = markValues.indexOf(markValue); + if (index === -1) return curr; + + const next = getSafeNum(index + stepScale, 0, markValues.length - 1); + return markValues[next] as number; + } + } + } function getMinAndMaxFromValues(): readonly [min: number, max: number] { const sortedValues = sortedValuesRef.current; if (!isRange) return [min, valuesRef.current.at(-1) ?? min]; return [sortedValues[0] ?? min, sortedValues.at(-1) ?? min]; } + function setValueOfIndex(value: number, index: number): void { + const values = valuesRef.current; + + const prevChunk = values.slice(0, index); + const nextChunk = values.slice(index + 1); + + if (keepRangeSorted && values.length > 1) { + if (prevChunk.length > 0) keepPrevChunkSorted(); + if (nextChunk.length > 0) keepNextChunkSorted(); + } + + setValue([...prevChunk, strip(value), ...nextChunk]); + setTimeout(() => handlersControlRef.current?.focus(index)); + + function keepPrevChunkSorted() { + forEachRight(prevChunk, (v, i): false | void => { + if (v > value) { + prevChunk[i] = value; + } else return false; + }); + } + function keepNextChunkSorted() { + forEach(nextChunk, (v, i): false | void => { + if (v < value) { + nextChunk[i] = value; + } else return false; + }); + } + } function setValue(values: number[], emit = true): void { valuesRef.current = values; sortedValuesRef.current = valuesRef.current.toSorted((a, b) => a - b); @@ -154,14 +218,10 @@ const _Slider: React.FC = React.forwardRef< // 需要先找到最接近的数字,然后改成目标数字 const v = findClosestFromSortedArr(sortedValuesRef.current, compareValue); const index = innerValues.indexOf(v); - setValue([ - ...innerValues.slice(0, index), - value, - ...innerValues.slice(index + 1), - ]); + setValueOfIndex(value, index); return; } - setValue([...innerValues.slice(0, -1), value]); + setValueOfIndex(value, 0); } function handleRailClick(e: React.MouseEvent): void { if (disabled) return; @@ -205,14 +265,7 @@ const _Slider: React.FC = React.forwardRef< return getByMark(); function getByMark(): number { - if (!marks) return value; - - const keys = Object.keys(marks); - const len = keys.length; - if (!len) return value; - - const marksValues = keys.map(Number).sort((a, b) => a - b); - return findClosestFromSortedArr(marksValues, value); + return findClosestFromObjectNumberKeys(marks, value)[0]; } } function getScale() { @@ -220,6 +273,20 @@ const _Slider: React.FC = React.forwardRef< return (y - rect.y) / rect.height; } } + function findClosestFromObjectNumberKeys( + obj: Record | undefined, + defaults: number, + ): [closest: number, sortedKeys: number[]] { + if (!obj) return [defaults, []]; + + const keys = Object.keys(obj); + const len = keys.length; + if (!len) return [defaults, []]; + + const sortedKeys = keys.map(Number).sort((a, b) => a - b); + const closest = findClosestFromSortedArr(sortedKeys, defaults); + return [closest, sortedKeys]; + } function findClosestFromSortedArr(arr: number[], value: number): number { const len = arr.length; if (!len) return value; diff --git a/packages/components/src/slider/components/Handlers.tsx b/packages/components/src/slider/components/Handlers.tsx index 6013c459..3edeb8c4 100644 --- a/packages/components/src/slider/components/Handlers.tsx +++ b/packages/components/src/slider/components/Handlers.tsx @@ -1,11 +1,21 @@ -import React, { MutableRefObject, useEffect, useMemo, useRef } from 'react'; +import React, { + useImperativeHandle, + MutableRefObject, + useEffect, + useMemo, + useRef, + Ref, +} from 'react'; import type { ConvertOptional, Point } from '@tool-pack/types'; import type { SliderStaticProps } from '../slider.types'; -import { forEachRight, forEach } from '@tool-pack/basic'; import { getClasses, Placement } from '@pkg/shared'; import { onDragEvent } from '@tool-pack/dom'; import { Tooltip } from '~/tooltip'; +export interface HandlersControlRef { + focus(index: number): void; +} + interface Props extends ConvertOptional< Pick< @@ -14,6 +24,7 @@ interface Props | 'tooltipProps' | 'formatter' | 'disabled' + | 'keyboard' | 'vertical' | 'tooltip' | 'reverse' @@ -22,9 +33,11 @@ interface Props | 'min' > > { + onHandlerKeyDown: (stepScale: -1 | 1, index: number) => void; + setValueOfIndex: (value: number, index: number) => void; getValueFromMousePos: (pos: Point) => number; - setValues: (values: number[]) => void; valuesRef: MutableRefObject; + controlRef: Ref; total: number; } @@ -35,11 +48,14 @@ export const Handlers: React.FC = (props) => { getValueFromMousePos, formatter = (v) => v, tooltipProps = {}, + onHandlerKeyDown, keepRangeSorted, - setValues, + setValueOfIndex, + controlRef, valuesRef, vertical, disabled, + keyboard, reverse, tooltip, total, @@ -53,6 +69,15 @@ export const Handlers: React.FC = (props) => { const tooltipVisible = tooltip === 'always' ? true : undefined; const tooltipDisabled = tooltip === 'always' ? false : !tooltip; + useImperativeHandle(controlRef, () => { + return { + focus(index: number) { + ( + handlersRef.current?.children[index] as HTMLDivElement | undefined + )?.focus(); + }, + }; + }); // 拖动事件 useEffect(() => { const handlersEl = handlersRef.current; @@ -64,31 +89,8 @@ export const Handlers: React.FC = (props) => { return onDragEvent( ({ onMove }) => { onMove((_e, currentXY) => { - const values = valuesRef.current; - const prev = values.slice(0, index); - const next = values.slice(index + 1); const curr = getValueFromMousePos([currentXY.x, currentXY.y]); - - if (keepRangeSorted && values.length > 1) { - if (prev.length > 0) keepPrevSorted(); - if (next.length > 0) keepNextSorted(); - } - setValues([...prev, curr, ...next]); - - function keepPrevSorted() { - forEachRight(prev, (v, i): false | void => { - if (v > curr) { - prev[i] = curr; - } else return false; - }); - } - function keepNextSorted() { - forEach(next, (v, i): false | void => { - if (v < curr) { - next[i] = curr; - } else return false; - }); - } + setValueOfIndex(curr, index); }); }, { el: child }, @@ -146,7 +148,10 @@ export const Handlers: React.FC = (props) => { placement={_placement} key={index} > + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
handleKeyDown(e, index) : undefined} + tabIndex={disabled ? undefined : 1} className={cls.__.handler} style={styles[index]} draggable={false} @@ -155,4 +160,30 @@ export const Handlers: React.FC = (props) => { ))}
); + + function handleKeyDown( + e: React.KeyboardEvent, + index: number, + ) { + const key = e.key as (typeof ArrowKeys)[number]; + if (!ArrowKeys.includes(key)) return; + e.preventDefault(); + + let direct = 1; + if (vertical) { + if (reverse && ['ArrowDown', 'ArrowUp'].includes(key)) direct = -1; + } else { + if (reverse && ['ArrowRight', 'ArrowLeft'].includes(key)) direct = -1; + } + + onHandlerKeyDown((ArrowMap[key] * direct) as -1 | 1, index); + } +}; + +const ArrowKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'] as const; +const ArrowMap: Record<(typeof ArrowKeys)[number], -1 | 1> = { + ArrowDown: -1, + ArrowLeft: -1, + ArrowRight: 1, + ArrowUp: 1, }; diff --git a/packages/components/src/slider/demo/keyboard.tsx b/packages/components/src/slider/demo/keyboard.tsx new file mode 100644 index 00000000..5b87e90a --- /dev/null +++ b/packages/components/src/slider/demo/keyboard.tsx @@ -0,0 +1,28 @@ +/** + * title: 按键操作 + * description: 可以使用键盘上的上下左右箭头控制滑块的增减。 + */ + +import { Slider, Switch } from '@tool-pack/react-ui'; +import React, { useState } from 'react'; + +const App: React.FC = () => { + const [value, setValue] = useState(50); + const [keyboard, setKeyboard] = useState(true); + return ( + <> + keyboard: + + { + setValue(v); + }} + keyboard={keyboard} + value={value} + /> + {value} + + ); +}; + +export default App; diff --git a/packages/components/src/slider/index.scss b/packages/components/src/slider/index.scss index c80ba571..d36758dd 100644 --- a/packages/components/src/slider/index.scss +++ b/packages/components/src/slider/index.scss @@ -179,5 +179,8 @@ $hh: 16px; 0 1px 4px 0 var(--t-color-info-a-4), inset 0 0 1px 0 var(--t-color-info-a-7); user-select: none; + &:focus-visible { + outline: 0; + } } } diff --git a/packages/components/src/slider/index.zh-CN.md b/packages/components/src/slider/index.zh-CN.md index 7c2ac543..3a1df928 100644 --- a/packages/components/src/slider/index.zh-CN.md +++ b/packages/components/src/slider/index.zh-CN.md @@ -19,6 +19,7 @@ Slider 滑块。 + @@ -39,6 +40,7 @@ Slider 的属性说明如下: | step | 步长。当 step 为 'mark' 并且 marks 不为空时,步长为 marks 的 key 值;当 step 为 number 时不能小于 0 | number \| 'mark' | 1 | -- | | reverse | 反向 | boolean | -- | -- | | disabled | 禁用 | boolean | -- | -- | +| keyboard | 可以使用键盘上的上下左右箭头控制滑块的增减 | boolean | true | -- | | vertical | 垂直排列 | boolean | -- | -- | | tooltip | 设置 tooltip 的显隐 | boolean | true | -- | | tooltipProps | tooltip 参数,见 [Tooltip](./tooltip#api) | -- | -- | -- | diff --git a/packages/components/src/slider/slider.types.ts b/packages/components/src/slider/slider.types.ts index cf77cb1c..7e13a2d6 100644 --- a/packages/components/src/slider/slider.types.ts +++ b/packages/components/src/slider/slider.types.ts @@ -17,6 +17,7 @@ export interface SliderStaticProps value?: number[] | number; step?: number | 'mark'; marks?: SliderMarks; + keyboard?: boolean; vertical?: boolean; disabled?: boolean; reverse?: boolean;