diff --git a/.dumi/theme/builtins/preview-default/Previewer.tsx b/.dumi/theme/builtins/preview-default/Previewer.tsx index 202df0fd04..1e63eae3db 100644 --- a/.dumi/theme/builtins/preview-default/Previewer.tsx +++ b/.dumi/theme/builtins/preview-default/Previewer.tsx @@ -5,7 +5,6 @@ import { history } from 'dumi' import type { IPreviewerComponentProps } from 'dumi/theme' import { context, - // useRiddle, useMotions, useCopy, useLocaleProps, @@ -66,9 +65,7 @@ const Previewer: React.FC = oProps => { const openCSB = useCodeSandbox( props.hideActions?.includes('CSB') ? null : props ) - // const openRiddle = useRiddle( - // props.hideActions?.includes('RIDDLE') ? null : props - // ) + const [execMotions, isMotionRunning] = useMotions( props.motions || [], demoRef.current @@ -119,14 +116,6 @@ const Previewer: React.FC = oProps => { onClick={openCSB} /> )} - {/*{openRiddle && (*/} - {/* */} - {/*)}*/} {props.motions && ( ) - fireEvent.click(screen.getByText('show')) await screen.findAllByRole('img') const slides = document.querySelectorAll(`.${classPrefix}-slides`)[0] expect(screen.getByText('1 / 4')).toBeInTheDocument() - mockDrag(slides, [ - { - clientX: 300, - }, - { - clientX: 200, - }, - { - clientX: 100, - }, - ]) - await waitFor(() => expect(onIndexChange).toBeCalledWith(1)) + await act(async () => { + await mockDrag( + slides, + [ + { + clientX: 300, + }, + { + clientX: 200, + }, + { + clientX: 100, + }, + ], + 5 + ) + }) + + expect(onIndexChange).toBeCalledWith(1) expect(screen.getByText('2 / 4')).toBeInTheDocument() }) }) diff --git a/src/components/input/input.tsx b/src/components/input/input.tsx index 9d2a9a3159..0ab19f3d06 100644 --- a/src/components/input/input.tsx +++ b/src/components/input/input.tsx @@ -121,9 +121,13 @@ export const Input = forwardRef((p, ref) => { function checkValue() { let nextValue = value if (props.type === 'number') { - nextValue = + const boundValue = nextValue && bound(parseFloat(nextValue), props.min, props.max).toString() + // fix the display issue of numbers starting with 0 + if (Number(nextValue) !== Number(boundValue)) { + nextValue = boundValue + } } if (nextValue !== value) { setValue(nextValue) diff --git a/src/components/input/tests/input.test.tsx b/src/components/input/tests/input.test.tsx index c6373bee4b..46fee22293 100644 --- a/src/components/input/tests/input.test.tsx +++ b/src/components/input/tests/input.test.tsx @@ -190,4 +190,22 @@ describe('Input', () => { }) expect(ref.current?.nativeElement?.value).toBe('') }) + + test('numbers that start with 0 should be work', () => { + const ref = createRef() + render() + const input = document.querySelector('input')! + fireEvent.change(input, { + target: { value: '012' }, + }) + // input.blur() + act(() => { + input.focus() + }) + act(() => { + input.blur() + }) + + expect(input.value).toBe('012') + }) }) diff --git a/src/components/list/list-item.tsx b/src/components/list/list-item.tsx index 9a6cc4b668..f1501e4dde 100644 --- a/src/components/list/list-item.tsx +++ b/src/components/list/list-item.tsx @@ -15,7 +15,7 @@ export type ListItemProps = { clickable?: boolean arrow?: boolean | ReactNode disabled?: boolean - onClick?: (e: React.MouseEvent) => void + onClick?: (e: React.MouseEvent) => void } & NativeProps< '--prefix-width' | '--align-items' | '--active-background-color' > diff --git a/src/components/notice-bar/notice-bar.less b/src/components/notice-bar/notice-bar.less index f34e1ae0b4..bb2082e372 100644 --- a/src/components/notice-bar/notice-bar.less +++ b/src/components/notice-bar/notice-bar.less @@ -66,6 +66,8 @@ } & .@{class-prefix-notice-bar}-right { + display: flex; + align-items: center; flex-shrink: 0; margin-left: 12px; } diff --git a/src/components/passcode-input/tests/passcode-input.test.tsx b/src/components/passcode-input/tests/passcode-input.test.tsx index 31261ca825..ccd8e66020 100644 --- a/src/components/passcode-input/tests/passcode-input.test.tsx +++ b/src/components/passcode-input/tests/passcode-input.test.tsx @@ -69,7 +69,9 @@ describe('PasscodeInput', () => { render() const input = screen.getByRole('button', { name: '密码输入框' }) fireEvent.focus(input) - await userEvent.keyboard('abc') + await act(async () => { + await userEvent.keyboard('abc') + }) expect(input).toHaveTextContent('abc') }) @@ -91,7 +93,9 @@ describe('PasscodeInput', () => { const input = screen.getByRole('button', { name: '密码输入框' }) fireEvent.focus(input) expect(onFocus).toBeCalled() - await userEvent.keyboard('abcde') + await act(async () => { + await userEvent.keyboard('abcde') + }) expect(onFill).toBeCalled() expect(onChange).toBeCalledTimes(4) fireEvent.blur(input) diff --git a/src/components/popup/index.en.md b/src/components/popup/index.en.md index cc9ba46403..30937dc00e 100644 --- a/src/components/popup/index.en.md +++ b/src/components/popup/index.en.md @@ -38,6 +38,7 @@ It is suitable for displaying pop-up windows, information prompts, selection inp | stopPropagation | Stop the propagation of some events. | `PropagationEvent[]` | `['click']` | | style | Container style | `React.CSSProperties` | - | | visible | Whether visible | `boolean` | `false` | +| closeOnSwipe | Whether to support closing by swiping up/down | `boolean` | `false` | ### CSS Variables diff --git a/src/components/popup/index.zh.md b/src/components/popup/index.zh.md index 6c21b72e8b..ecff39dd3e 100644 --- a/src/components/popup/index.zh.md +++ b/src/components/popup/index.zh.md @@ -38,6 +38,7 @@ | stopPropagation | 阻止某些事件的冒泡 | `PropagationEvent[]` | `['click']` | | style | 容器样式 | `React.CSSProperties` | - | | visible | 是否可见 | `boolean` | `false` | +| closeOnSwipe | 是否支持向上/下滑动关闭 | `boolean` | `false` | ### CSS 变量 diff --git a/src/components/popup/popup.tsx b/src/components/popup/popup.tsx index ab6a067945..ab3e7a1b16 100644 --- a/src/components/popup/popup.tsx +++ b/src/components/popup/popup.tsx @@ -20,11 +20,13 @@ const classPrefix = `adm-popup` export type PopupProps = PopupBaseProps & PropsWithChildren<{ position?: 'bottom' | 'top' | 'left' | 'right' + closeOnSwipe?: boolean }> & NativeProps<'--z-index'> const defaultProps = { ...defaultPopupBaseProps, + closeOnSwipe: false, position: 'bottom', } @@ -70,6 +72,7 @@ export const Popup: FC = p => { const bind = useDrag( ({ swipe: [, swipeY] }) => { + if (!props.closeOnSwipe) return if ( (swipeY === 1 && props.position === 'bottom') || (swipeY === -1 && props.position === 'top') diff --git a/src/components/popup/tests/popup.test.tsx b/src/components/popup/tests/popup.test.tsx index a6d4d55dd4..6565beb1f5 100644 --- a/src/components/popup/tests/popup.test.tsx +++ b/src/components/popup/tests/popup.test.tsx @@ -1,45 +1,45 @@ import * as React from 'react' -import { render, cleanup, fireEvent, mockDrag } from 'testing' +import { render, mockDrag, act, waitFor } from 'testing' import Popup from '..' describe('Popup', () => { - test('top swipe should be closed', () => { + test('top swipe should be closed', async () => { const onClose = jest.fn() render( - +
) - mockDrag( + await mockDrag( document.querySelector('.adm-popup') as Element, - new Array(8).fill(0).map((_, i) => { + new Array(4).fill(0).map((_, i) => { return { clientY: 400 - 50 * i, } - }) + }), + 5 ) - expect(onClose).toBeCalledTimes(1) }) - test('bottom swipe should be closed', () => { + test('bottom swipe should be closed', async () => { const onClose = jest.fn() render( - +
) - mockDrag( + await mockDrag( document.querySelector('.adm-popup') as Element, - new Array(8).fill(0).map((_, i) => { + new Array(6).fill(0).map((_, i) => { return { clientY: 50 * i, } - }) + }), + 5 ) - expect(onClose).toBeCalledTimes(1) }) }) diff --git a/src/components/pull-to-refresh/pull-to-refresh.tsx b/src/components/pull-to-refresh/pull-to-refresh.tsx index 9a02691a8a..fc63f1d209 100644 --- a/src/components/pull-to-refresh/pull-to-refresh.tsx +++ b/src/components/pull-to-refresh/pull-to-refresh.tsx @@ -59,6 +59,7 @@ export const PullToRefresh: FC = p => { config: { tension: 300, friction: 30, + round: true, clamp: true, }, })) @@ -119,7 +120,9 @@ export const PullToRefresh: FC = p => { } const [, y] = state.movement - if (state.first && y > 0) { + const parsedY = Math.ceil(y) + + if (state.first && parsedY > 0) { const target = state.event.target if (!target || !(target instanceof Element)) return let scrollParent = getScrollParent(target) @@ -147,7 +150,7 @@ export const PullToRefresh: FC = p => { } event.stopPropagation() const height = Math.max( - rubberbandIfOutOfBounds(y, 0, 0, headHeight * 5, 0.5), + rubberbandIfOutOfBounds(parsedY, 0, 0, headHeight * 5, 0.5), 0 ) api.start({ height }) diff --git a/src/components/radio/tests/radio.test.tsx b/src/components/radio/tests/radio.test.tsx index 40e8d99920..fa3b56fed1 100644 --- a/src/components/radio/tests/radio.test.tsx +++ b/src/components/radio/tests/radio.test.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { fireEvent, render, testA11y, userEvent, screen } from 'testing' +import { fireEvent, render, testA11y, userEvent, screen, act } from 'testing' import Radio from '../' import { RadioGroupProps } from '../group' @@ -30,7 +30,10 @@ describe('Radio', () => { 1 ) - await userEvent.tripleClick(screen.getByRole('radio')) + + await act(async () => { + await userEvent.tripleClick(screen.getByRole('radio')) + }) expect(onChange).toBeCalledTimes(1) }) }) @@ -125,7 +128,9 @@ describe('Radio.Group', () => { 2 ) - await userEvent.tripleClick(screen.getByRole('radio', { name: '1' })) + await act(async () => { + await userEvent.tripleClick(screen.getByRole('radio', { name: '1' })) + }) expect(onChange).toBeCalledTimes(1) }) }) diff --git a/src/components/search-bar/tests/search-bar.test.tsx b/src/components/search-bar/tests/search-bar.test.tsx index f68cf4e11e..53543ed29c 100644 --- a/src/components/search-bar/tests/search-bar.test.tsx +++ b/src/components/search-bar/tests/search-bar.test.tsx @@ -53,7 +53,9 @@ describe('adm-search-bar', () => { render() const input = screen.getByRole('searchbox') fireEvent.focus(input) - await userEvent.type(input, '12') + await act(async () => { + await userEvent.type(input, '12') + }) fireEvent.click(screen.getByText('取消')) expect(input).toHaveValue('') }) @@ -62,7 +64,9 @@ describe('adm-search-bar', () => { const onSearch = jest.fn() render() const input = screen.getByRole('searchbox') - await userEvent.type(input, '12{enter}') + await act(async () => { + await userEvent.type(input, '12{enter}') + }) expect(onSearch).toBeCalledWith('12') }) @@ -80,7 +84,10 @@ describe('adm-search-bar', () => { expect(input).toHaveFocus() expect(onFocus).toBeCalled() - await userEvent.type(input, '12') + await act(async () => { + await userEvent.type(input, '12') + }) + act(() => { ref.current?.clear() }) diff --git a/src/components/slider/slider.tsx b/src/components/slider/slider.tsx index 59227af690..69c4ada271 100644 --- a/src/components/slider/slider.tsx +++ b/src/components/slider/slider.tsx @@ -111,7 +111,7 @@ export const Slider: FC = p => { return Object.keys(marks) .map(parseFloat) .sort((a, b) => a - b) - } else { + } else if (ticks) { const points: number[] = [] for ( let i = getMiniDecimal(min); @@ -122,6 +122,8 @@ export const Slider: FC = p => { } return points } + + return [] }, [marks, ticks, step, min, max]) function getValueByPosition(position: number) { @@ -133,9 +135,10 @@ export const Slider: FC = p => { if (pointList.length) { value = nearest(pointList, newPosition) } else { - const lengthPerStep = 100 / ((max - min) / step) - const steps = Math.round(newPosition / lengthPerStep) - value = steps * lengthPerStep * (max - min) * 0.01 + min + // 使用 MiniDecimal 避免精度问题 + const cell = Math.round((newPosition - min) / step) + const nextVal = getMiniDecimal(cell).multi(step) + value = getMiniDecimal(min).add(nextVal.toString()).toNumber() } return value } diff --git a/src/components/swiper/demos/demo1.tsx b/src/components/swiper/demos/demo1.tsx index ca3e2bae70..f550fb17ad 100644 --- a/src/components/swiper/demos/demo1.tsx +++ b/src/components/swiper/demos/demo1.tsx @@ -34,7 +34,15 @@ export default () => { - {items} + { + console.log(i, 'onIndexChange1') + }} + > + {items} + diff --git a/src/components/swiper/swiper.tsx b/src/components/swiper/swiper.tsx index 8f61a296a3..acb4d9f039 100644 --- a/src/components/swiper/swiper.tsx +++ b/src/components/swiper/swiper.tsx @@ -20,7 +20,7 @@ import PageIndicator, { PageIndicatorProps } from '../page-indicator' import { staged } from 'staged-components' import { useRefState } from '../../utils/use-ref-state' import { bound } from '../../utils/bound' -import { useIsomorphicLayoutEffect } from 'ahooks' +import { useIsomorphicLayoutEffect, useGetState } from 'ahooks' import { mergeFuncProps } from '../../utils/with-func-props' const classPrefix = `adm-swiper` @@ -81,6 +81,7 @@ export const Swiper = forwardRef( staged((p, ref) => { const props = mergeProps(defaultProps, p) const [uid] = useState({}) + const timeoutRef = useRef(null) const isVertical = props.direction === 'vertical' const slideRatio = props.slideSize / 100 @@ -125,7 +126,7 @@ export const Swiper = forwardRef( return (trackPixels * props.slideSize) / 100 } - const [current, setCurrent] = useState(props.defaultIndex) + const [current, setCurrent, getCurrent] = useGetState(props.defaultIndex) const [dragging, setDragging, draggingRef] = useRefState(false) @@ -237,10 +238,13 @@ export const Swiper = forwardRef( const targetIndex = loop ? modulus(roundedIndex, count) : bound(roundedIndex, 0, count - 1) - setCurrent(targetIndex) - if (targetIndex !== current) { + + if (targetIndex !== getCurrent()) { props.onIndexChange?.(targetIndex) } + + setCurrent(targetIndex) + api.start({ position: (loop ? roundedIndex : boundIndex(roundedIndex)) * 100, immediate, @@ -269,19 +273,20 @@ export const Swiper = forwardRef( }) const { autoplay, autoplayInterval } = props - useEffect(() => { - if (!autoplay || dragging) return - let interval: number - function tick() { - interval = window.setTimeout(tick, autoplayInterval) + const runTimeSwiper = () => { + timeoutRef.current = window.setTimeout(() => { swipeNext() - } + runTimeSwiper() + }, autoplayInterval) + } + useEffect(() => { + if (!autoplay || dragging) return - interval = window.setTimeout(tick, autoplayInterval) + runTimeSwiper() return () => { - if (interval) window.clearTimeout(interval) + if (timeoutRef.current) window.clearTimeout(timeoutRef.current) } }, [autoplay, autoplayInterval, dragging, count]) @@ -324,8 +329,16 @@ export const Swiper = forwardRef( ), }} > - {React.Children.map(validChildren, child => { - return
{child}
+ {React.Children.map(validChildren, (child, index) => { + return ( +
+ {child} +
+ ) })} ) diff --git a/src/components/swiper/tests/swiper.test.tsx b/src/components/swiper/tests/swiper.test.tsx index a9b3298bdb..ded1fa445b 100644 --- a/src/components/swiper/tests/swiper.test.tsx +++ b/src/components/swiper/tests/swiper.test.tsx @@ -237,17 +237,21 @@ describe('Swiper', () => { ) const el = $$(`.${classPrefix}-track`)[0] - mockDrag(el, [ - { clientX: 50, clientY: 300 }, - { - clientX: 50, - clientY: 200, - }, - { - clientX: 60, - clientY: 50, - }, - ]) + await mockDrag( + el, + [ + { clientX: 50, clientY: 300 }, + { + clientX: 50, + clientY: 200, + }, + { + clientX: 60, + clientY: 50, + }, + ], + 5 + ) expect($$(`.${classPrefix}-track-inner`)[0]).toHaveStyle( 'transform: translate3d(0,-100%,0)' diff --git a/src/locales/ru-RU.ts b/src/locales/ru-RU.ts new file mode 100644 index 0000000000..083d1b9584 --- /dev/null +++ b/src/locales/ru-RU.ts @@ -0,0 +1,140 @@ +import { mergeLocale } from '../utils/merge-locale' +import { base } from './base' + +const typeTemplate = '${label} не соответствует типу ${type}' + +const ruRU = mergeLocale(base, { + locale: 'ru', + common: { + confirm: 'Подтвердить', + cancel: 'Отменить', + loading: 'Загрузка', + close: 'Закрыть', + }, + Calendar: { + markItems: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'], + renderYearAndMonth: (year: number, month: number) => `${year}/${month}`, + }, + Cascader: { + placeholder: 'Выбор', + }, + Dialog: { + ok: 'ОК', + }, + DatePicker: { + tillNow: 'До настоящего времени', + }, + ErrorBlock: { + default: { + title: 'Упс! Что-то пошло не так', + description: 'Пожалуйста, подождите минуту и повторите попытку', + }, + busy: { + title: 'Упс, не загружается', + description: 'Попробуйте обновить страницу', + }, + disconnected: { + title: 'Сеть занята', + description: 'Попробуйте обновить страницу', + }, + empty: { + title: "Хм, не могу найти...", + description: 'Хотите попробовать другой запрос?', + }, + }, + Form: { + required: 'Обязательное', + optional: 'Опциональное', + defaultValidateMessages: { + default: 'Ошибка валидации поля ${label}', + required: 'Пожалуйста, заполните поле ${label}', + enum: 'Значение ${label} должно быть одним из [${enum}]', + whitespace: '${label} не может быть пустым символом', + date: { + format: '${label} имеет некорректный формат даты', + parse: '${label} не может быть конвертировано в дату', + invalid: '${label} не является валидной датой', + }, + types: { + string: typeTemplate, + method: typeTemplate, + array: typeTemplate, + object: typeTemplate, + number: typeTemplate, + date: typeTemplate, + boolean: typeTemplate, + integer: typeTemplate, + float: typeTemplate, + regexp: typeTemplate, + email: typeTemplate, + url: typeTemplate, + hex: typeTemplate, + }, + string: { + len: 'Длина ${label} должна быть ${len} символов(-а)', + min: 'Длина ${label} должна быть не меньше ${min} символов(-а)', + max: 'Длина ${label} должна быть не больше ${max} символов(-а)', + range: 'Длина ${label} должна быть в диапазоне от ${min} до ${max} символов(-а)', + }, + number: { + len: '${label} должно быть равно ${len}', + min: '${label} должно быть значением не меньше ${min}', + max: '${label} должно быть значением не больше ${max}', + range: '${label} должно быть значением в диапазоне от ${min} до ${max}', + }, + array: { + len: 'Размер ${label} должен быть ${len}', + min: 'Размер ${label} должен быть не меньше ${min}', + max: 'Размер ${label} должен быть не больше ${max}', + range: 'Размер ${label} должен быть в диапазоне от ${min} до ${max}', + }, + pattern: { + mismatch: '${label} не соответствует шаблону ${pattern}', + }, + }, + }, + ImageUploader: { + uploading: 'Выгружается...', + upload: 'Выгрузить', + }, + InfiniteScroll: { + noMore: 'Больше нет', + failedToLoad: 'Ошибка загрузки', + retry: 'Повторить', + }, + Input: { + clear: 'очистить', + }, + Mask: { + name: 'Маска', + }, + Modal: { + ok: 'ОК', + }, + PasscodeInput: { + name: 'Ввод пароля', + }, + PullToRefresh: { + pulling: 'Прокрутите вниз, чтобы обновления', + canRelease: 'Отпустите, чтобы немедленно обновить', + complete: 'Обновление успешно', + }, + SearchBar: { + name: 'Панель поиска', + }, + Slider: { + name: 'Слайдер', + }, + Stepper: { + decrease: 'вычесть', + increase: 'прибавить', + }, + Switch: { + name: 'Переключатель', + }, + Selector: { + name: 'Селектор', + }, +}) + +export default ruRU diff --git a/src/tests/testing.tsx b/src/tests/testing.tsx index d3d59cac12..06c5916e7c 100644 --- a/src/tests/testing.tsx +++ b/src/tests/testing.tsx @@ -104,7 +104,7 @@ export { customRender as render } export const testA11y = async (ui: UI | Element) => { const container = React.isValidElement(ui) ? customRender(ui).container : ui - const results = await axe(container) + const results = await axe(container as Element) expect(results).toHaveNoViolations() } @@ -116,7 +116,7 @@ export const actSleep = (time: number) => { return act(() => sleep(time)) } -export const mockDrag = (el: Element, options: any[]) => { +export const mockDrag = async (el: Element, options: any[], time?: number) => { const [downOptions, ...moveOptions] = options fireEvent.mouseDown(el, { buttons: 1, @@ -127,6 +127,10 @@ export const mockDrag = (el: Element, options: any[]) => { buttons: 1, ...item, }) + + if (time) { + await sleep(time) + } } fireEvent.mouseUp(el) } diff --git a/src/utils/reduce-and-restore-motion.ts b/src/utils/reduce-and-restore-motion.ts index 6f2bc6aec5..08d7ca5206 100644 --- a/src/utils/reduce-and-restore-motion.ts +++ b/src/utils/reduce-and-restore-motion.ts @@ -39,5 +39,5 @@ function subscribe(onStoreChange: () => void) { } export function useMotionReduced() { - return useSyncExternalStore(subscribe, isMotionReduced) + return useSyncExternalStore(subscribe, isMotionReduced, isMotionReduced) }