From 061c938262a4cc3111bbe20f9c09f3851fe7adec Mon Sep 17 00:00:00 2001 From: rozhkovs Date: Sun, 24 Aug 2025 14:25:07 +0700 Subject: [PATCH 01/14] fix(types): value is not required for WheelPicker --- src/base/picker/Picker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/base/picker/Picker.tsx b/src/base/picker/Picker.tsx index 8c54c9b..6a270a1 100644 --- a/src/base/picker/Picker.tsx +++ b/src/base/picker/Picker.tsx @@ -27,7 +27,7 @@ import List from '../list/List'; export type PickerProps> = { data: ReadonlyArray; - value?: ItemT['value']; + value: ItemT['value']; extraValues?: unknown[]; itemHeight?: number; visibleItemCount?: number; From 53e9a81a2d94aead8a12ec6d98a06dea16086ee2 Mon Sep 17 00:00:00 2001 From: rozhkovs Date: Sun, 24 Aug 2025 14:34:43 +0700 Subject: [PATCH 02/14] feat: add _enableSyncScrollAfterScrollEnd; fix: sync scrolling; refactoring --- src/base/index.ts | 2 +- src/base/item/PickerItem.tsx | 2 +- src/base/item/PickerItemContainer.tsx | 4 ++-- src/base/list/List.tsx | 6 ++--- src/base/overlay/Overlay.tsx | 2 +- src/base/picker/Picker.tsx | 23 +++++++++++++++++--- src/base/picker/hooks/useSyncScrollEffect.ts | 18 +++++++-------- src/base/types.ts | 18 +++++++++++---- 8 files changed, 51 insertions(+), 24 deletions(-) diff --git a/src/base/index.ts b/src/base/index.ts index e9e05ee..9cc53bc 100644 --- a/src/base/index.ts +++ b/src/base/index.ts @@ -17,6 +17,6 @@ export type { export {useScrollContentOffset} from './contexts/ScrollContentOffsetContext'; export {usePickerItemHeight} from './contexts/PickerItemHeightContext'; -export {PickerProps} from './picker/Picker'; +export {type PickerProps, useValueIndex} from './picker/Picker'; import WheelPicker from './picker/Picker'; export default WheelPicker; diff --git a/src/base/item/PickerItem.tsx b/src/base/item/PickerItem.tsx index 8fc6f52..5552f41 100644 --- a/src/base/item/PickerItem.tsx +++ b/src/base/item/PickerItem.tsx @@ -1,5 +1,5 @@ import React, {memo} from 'react'; -import {StyleProp, StyleSheet, Text, TextStyle} from 'react-native'; +import {type StyleProp, type TextStyle, StyleSheet, Text} from 'react-native'; import {usePickerItemHeight} from '../contexts/PickerItemHeightContext'; type PickerItemProps = { diff --git a/src/base/item/PickerItemContainer.tsx b/src/base/item/PickerItemContainer.tsx index 19b8e6f..49db0fb 100644 --- a/src/base/item/PickerItemContainer.tsx +++ b/src/base/item/PickerItemContainer.tsx @@ -1,8 +1,8 @@ import React, {memo, type RefObject, useMemo} from 'react'; import { + type TextStyle, + type StyleProp, Animated, - StyleProp, - TextStyle, TouchableWithoutFeedback, } from 'react-native'; import {useScrollContentOffset} from '../contexts/ScrollContentOffsetContext'; diff --git a/src/base/list/List.tsx b/src/base/list/List.tsx index 188f30c..5c35cdc 100644 --- a/src/base/list/List.tsx +++ b/src/base/list/List.tsx @@ -1,5 +1,5 @@ import React, { - ForwardedRef, + type ForwardedRef, forwardRef, memo, useImperativeHandle, @@ -13,11 +13,11 @@ import type { RenderPickerItem, } from '../types'; import { + type ViewStyle, + type StyleProp, Animated, ScrollView, - StyleProp, StyleSheet, - ViewStyle, } from 'react-native'; import {useInit} from '@rozhkov/react-useful-hooks'; import {withScrollStartEndEvent} from '@utils/scrolling'; diff --git a/src/base/overlay/Overlay.tsx b/src/base/overlay/Overlay.tsx index db57e75..12bcc52 100644 --- a/src/base/overlay/Overlay.tsx +++ b/src/base/overlay/Overlay.tsx @@ -1,5 +1,5 @@ import React, {memo} from 'react'; -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'; +import {type StyleProp, type ViewStyle, StyleSheet, View} from 'react-native'; type OverlayProps = { itemHeight: number; diff --git a/src/base/picker/Picker.tsx b/src/base/picker/Picker.tsx index 6a270a1..ddae22b 100644 --- a/src/base/picker/Picker.tsx +++ b/src/base/picker/Picker.tsx @@ -1,6 +1,12 @@ import React, {useCallback, useMemo, useRef} from 'react'; import type {TextStyle} from 'react-native'; -import {Animated, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'; +import { + type StyleProp, + type ViewStyle, + Animated, + StyleSheet, + View, +} from 'react-native'; import PickerItemComponent from '../item/PickerItem'; import {ScrollContentOffsetContext} from '../contexts/ScrollContentOffsetContext'; import {PickerItemHeightContext} from '../contexts/PickerItemHeightContext'; @@ -52,6 +58,7 @@ export type PickerProps> = { scrollEventThrottle?: number; + _enableSyncScrollAfterScrollEnd?: boolean; _onScrollStart?: () => void; _onScrollEnd?: () => void; }; @@ -76,7 +83,10 @@ const defaultRenderList: RenderList = (props) => { return ; }; -const useValueIndex = (data: ReadonlyArray>, value: any) => { +export const useValueIndex = ( + data: ReadonlyArray>, + value: any, +) => { return useMemo(() => { const index = data.findIndex((x) => x.value === value); return index >= 0 ? index : 0; @@ -108,13 +118,19 @@ const Picker = >({ overlayItemStyle, contentContainerStyle, + _enableSyncScrollAfterScrollEnd = true, _onScrollStart, _onScrollEnd, ...restProps }: PickerProps) => { const valueIndex = useValueIndex(data, value); + const initialIndex = useInit(() => valueIndex); - const offsetY = useRef(new Animated.Value(valueIndex * itemHeight)).current; + const offsetY = useMemo( + () => new Animated.Value(valueIndex * itemHeight), + // eslint-disable-next-line react-hooks/exhaustive-deps + [readOnly], // when scrollEnabled changes, the events stop coming. Re-creating + ); const listRef = useRef(null); const touching = useBoolean(false); @@ -163,6 +179,7 @@ const Picker = >({ extraValues, activeIndexRef, touching: touching.value, + enableSyncScrollAfterScrollEnd: _enableSyncScrollAfterScrollEnd, }); const onScrollEnd = useStableCallback(() => { diff --git a/src/base/picker/hooks/useSyncScrollEffect.ts b/src/base/picker/hooks/useSyncScrollEffect.ts index 0f89c0a..7d0cef2 100644 --- a/src/base/picker/hooks/useSyncScrollEffect.ts +++ b/src/base/picker/hooks/useSyncScrollEffect.ts @@ -1,4 +1,4 @@ -import {type RefObject, useEffect, useRef} from 'react'; +import {type RefObject, useRef} from 'react'; import {useStableCallback} from '@rozhkov/react-useful-hooks'; import {useEffectWithDynamicDepsLength} from '@utils/react'; import type {ListMethods} from '../../types'; @@ -10,6 +10,7 @@ const useSyncScrollEffect = ({ extraValues = [], activeIndexRef, touching, + enableSyncScrollAfterScrollEnd, }: { listRef: RefObject; value: unknown; @@ -17,6 +18,7 @@ const useSyncScrollEffect = ({ extraValues: unknown[] | undefined; activeIndexRef: RefObject; touching: boolean; + enableSyncScrollAfterScrollEnd: boolean; }) => { const syncScroll = useStableCallback(() => { if ( @@ -30,17 +32,15 @@ const useSyncScrollEffect = ({ listRef.current.scrollToIndex({index: valueIndex, animated: true}); }); - useEffect(() => { - syncScroll(); - }, [valueIndex]); // eslint-disable-line react-hooks/exhaustive-deps - + const timeoutId = useRef(undefined); useEffectWithDynamicDepsLength(() => { - syncScroll(); - }, extraValues); + clearTimeout(timeoutId.current); + // fix: loops between two values. We are making a small delay so that the value in other places can be updated for verification. + timeoutId.current = setTimeout(syncScroll, 0); + }, [valueIndex, enableSyncScrollAfterScrollEnd, ...extraValues]); - const timeoutId = useRef(undefined); const onScrollEnd = useStableCallback(() => { - if (value !== undefined) { + if (enableSyncScrollAfterScrollEnd && value !== undefined) { clearTimeout(timeoutId.current); timeoutId.current = setTimeout(syncScroll, 0); } diff --git a/src/base/types.ts b/src/base/types.ts index dbe56de..67074bf 100644 --- a/src/base/types.ts +++ b/src/base/types.ts @@ -77,7 +77,17 @@ export type RenderOverlay = ( ) => React.ReactElement | null; // events -export type ValueChangingEvent = {item: ItemT; index: number}; -export type ValueChangedEvent = {item: ItemT; index: number}; -export type OnValueChanging = (event: ValueChangingEvent) => void; -export type OnValueChanged = (event: ValueChangedEvent) => void; +export type ValueChangingEvent> = { + item: ItemT; + index: number; +}; +export type ValueChangedEvent> = { + item: ItemT; + index: number; +}; +export type OnValueChanging> = ( + event: ValueChangingEvent, +) => void; +export type OnValueChanged> = ( + event: ValueChangedEvent, +) => void; From 465e3b2f3fbaf45ec6770e40116811a3fcba95a7 Mon Sep 17 00:00:00 2001 From: rozhkovs Date: Sun, 24 Aug 2025 14:35:05 +0700 Subject: [PATCH 03/14] chore: update lefthook.yml --- lefthook.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/lefthook.yml b/lefthook.yml index 065a491..d872bf0 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -2,13 +2,16 @@ pre-commit: parallel: true commands: lint: - files: git diff --name-only @{push} - glob: "*.{js,ts,jsx,tsx}" - run: npx eslint {files} + run: yarn lint:check types: - files: git diff --name-only @{push} - glob: "*.{js,ts, jsx, tsx}" - run: npx tsc --noEmit + run: yarn tsc:check +pre-push: + parallel: true + commands: + lint: + run: yarn lint:check + types: + run: yarn tsc:check commit-msg: parallel: true commands: From 359bb78ff312bc9ce0e7c5672837a2fbcd54e1a3 Mon Sep 17 00:00:00 2001 From: rozhkovs Date: Sun, 24 Aug 2025 14:42:30 +0700 Subject: [PATCH 04/14] fix: withScrollStartEndEvent --- src/utils/scrolling/withScrollStartEndEvent.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/utils/scrolling/withScrollStartEndEvent.tsx b/src/utils/scrolling/withScrollStartEndEvent.tsx index b11731d..adf5492 100644 --- a/src/utils/scrolling/withScrollStartEndEvent.tsx +++ b/src/utils/scrolling/withScrollStartEndEvent.tsx @@ -102,8 +102,14 @@ const withScrollStartEndEvent = ( useEffect(() => { const sub = scrollOffset.addListener(() => { - maybeCallOnScrollStart(); - onScrollEnd.clear(); + if (!isOnScrollStartCalledRef.current) { + // If this condition is met, then we assume that no events were triggered, + // and there was a change in the content that offset shifted to a smaller side + maybeCallOnScrollStart(); + onScrollEnd(); + } else { + onScrollEnd.clear(); + } }); return () => { scrollOffset.removeListener(sub); From 219265c3d842c37fcfbd53a39bf5b8bd5de57ad5 Mon Sep 17 00:00:00 2001 From: rozhkovs Date: Sun, 24 Aug 2025 14:43:27 +0700 Subject: [PATCH 05/14] refactor: hoc/virtualized --- src/hoc/virtualized/VirtualizedList.tsx | 10 +++++----- src/hoc/virtualized/withVirtualized.tsx | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/hoc/virtualized/VirtualizedList.tsx b/src/hoc/virtualized/VirtualizedList.tsx index f254f23..c15eb1d 100644 --- a/src/hoc/virtualized/VirtualizedList.tsx +++ b/src/hoc/virtualized/VirtualizedList.tsx @@ -1,18 +1,18 @@ import React, { - ForwardedRef, + type ForwardedRef, + type RefObject, forwardRef, memo, - RefObject, useCallback, useMemo, } from 'react'; import { + type FlatListProps, + type StyleProp, + type ViewStyle, Animated, FlatList, - FlatListProps, - StyleProp, StyleSheet, - type ViewStyle, } from 'react-native'; import {withScrollStartEndEvent} from '@utils/scrolling'; import type { diff --git a/src/hoc/virtualized/withVirtualized.tsx b/src/hoc/virtualized/withVirtualized.tsx index 864aaf5..8c717a6 100644 --- a/src/hoc/virtualized/withVirtualized.tsx +++ b/src/hoc/virtualized/withVirtualized.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type {PickerProps, RenderList} from '@implementation/base'; -import Picker, {PickerItem} from '@implementation/base'; +import Picker, {type PickerItem} from '@implementation/base'; import type {AdditionalProps} from './VirtualizedList'; import VirtualizedList from './VirtualizedList'; From 894502e3f1f8fd8de6d7fa543dcc06d4fd4c15de Mon Sep 17 00:00:00 2001 From: rozhkovs Date: Sun, 24 Aug 2025 14:43:51 +0700 Subject: [PATCH 06/14] feat: add "picker-control" --- local-namespace-config.js | 1 + package.json | 4 +- src/picker-control/create-control.ts | 221 ++++++++++++++++++ src/picker-control/index.ts | 12 + src/picker-control/usePickerControl.ts | 48 ++++ .../usePickerControlSubscriber.ts | 91 ++++++++ src/picker-control/withPickerControl.tsx | 108 +++++++++ tsconfig.json | 1 + 8 files changed, 485 insertions(+), 1 deletion(-) create mode 100644 src/picker-control/create-control.ts create mode 100644 src/picker-control/index.ts create mode 100644 src/picker-control/usePickerControl.ts create mode 100644 src/picker-control/usePickerControlSubscriber.ts create mode 100644 src/picker-control/withPickerControl.tsx diff --git a/local-namespace-config.js b/local-namespace-config.js index 7f993af..2ccf2c3 100644 --- a/local-namespace-config.js +++ b/local-namespace-config.js @@ -1,6 +1,7 @@ module.exports = { '@implementation/virtualized': './src/hoc/virtualized/index', '@implementation/base': './src/base/index', + '@implementation/picker-control': './src/picker-control/index', '@utils/react': './src/utils/react/index', '@utils/math': './src/utils/math/index', '@utils/debounce': './src/utils/debounce/index', diff --git a/package.json b/package.json index 58c2700..b0984fc 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,9 @@ "react-native": ">=0.71.6" }, "dependencies": { - "@rozhkov/react-useful-hooks": "^1.0.10" + "@rozhkov/react-useful-hooks": "^1.0.10", + "date-fns": "^4.1.0", + "nanoevents": "^9.1.0" }, "devDependencies": { "@babel/cli": "^7.21.5", diff --git a/src/picker-control/create-control.ts b/src/picker-control/create-control.ts new file mode 100644 index 0000000..65e81b1 --- /dev/null +++ b/src/picker-control/create-control.ts @@ -0,0 +1,221 @@ +import type { + PickerItem, + ValueChangedEvent, + ValueChangingEvent, +} from '@implementation/base'; +import {createNanoEvents, type Unsubscribe} from 'nanoevents'; + +type PickerName = string; +export type BaseControlConfig = Record}>; + +export type ControlEvents = { + onValueChanged: (event: { + pickers: NonNullable; + }) => void; + onValueChanging: (event: { + pickers: NonNullable; + }) => void; +}; + +type SubscriberEvents = { + // emit from subscriber + onValueChanged: (event: ValueChangedEvent>) => void; + onValueChanging: (event: ValueChangingEvent>) => void; + onNewPropValue: (event: {item: PickerItem}) => void; + onScrollStart: () => void; + onScrollEnd: () => void; + + // emit to subscriber + onNewExtraValues: () => void; + onAllScrollEnd: () => void; +}; + +type SubId = string; +let nextSubId = 1; +const getNewSubId = (): SubId => `${++nextSubId}`; + +export type ControlSubscriber = { + getExtraValues: () => unknown[]; + getEveryIsStopped: () => boolean; + omitOnValueChanged: (event: ValueChangedEvent>) => void; + omitOnValueChanging: (event: ValueChangingEvent>) => void; + omitOnNewPropValue: (event: {item: PickerItem}) => void; + omitOnScrollStart: () => void; + omitOnScrollEnd: () => void; + onNewExtraValues: (cb: () => void) => Unsubscribe; + onAllScrollEnd: (cb: () => void) => Unsubscribe; + + disconnect: () => void; +}; + +export type Control = { + _connect: (info: { + pickerName: PickerName; + item: PickerItem; + }) => ControlSubscriber; + + _on: ( + event: NameT, + callback: ControlEvents[NameT], + ) => Unsubscribe; + + // It's used to simplify typing from outside, in the future it makes sense to make better typing inside + __SAVED_TYPE_CONFIG__?: ConfigT; +}; + +export const createControl = < + ConfigT extends BaseControlConfig, +>(): Control => { + const controlEmitter = createNanoEvents(); + + const subscribers: Record< + SubId, + { + pickerName: string; + item: PickerItem; + isStopped: boolean; + emitter: ReturnType>; + } + > = {}; + + const getEveryIsStopped = () => { + return Object.values(subscribers).every((s) => s.isStopped); + }; + + const notifyChangedExtraValues = (notifierPickerId: string) => { + Object.keys(subscribers).forEach((pickerId) => { + if (pickerId === notifierPickerId) { + return; + } + const sub = subscribers[pickerId]!; + sub.emitter.emit('onNewExtraValues'); + }); + }; + + return { + _on: (event, callback) => { + return controlEmitter.on(event, callback); + }, + _connect: ({pickerName, item}) => { + if (__DEV__) { + const isExisted = Object.values(subscribers).some( + (s) => s.pickerName === pickerName, + ); + if (isExisted) { + throw new Error( + `It is not possible to register 2 pickers with the same name "${pickerName}"`, + ); + } + } + + const subEmitter = createNanoEvents(); + const subId = getNewSubId(); + subscribers[subId] = { + pickerName, + item, + isStopped: true, + emitter: subEmitter, + }; + + const getEventPickers = () => { + const result: Record< + string, + BaseControlConfig[keyof BaseControlConfig] + > = {}; + Object.entries(subscribers).forEach(([_, data]) => { + result[data.pickerName as string] = {item: data.item}; + }); + return result as ConfigT; + }; + + const disconnect = () => { + delete subscribers[subId]; + notifyChangedExtraValues(subId); + }; + + subEmitter.on('onNewPropValue', (event) => { + if (!subscribers[subId]) { + return; + } + + subscribers[subId]!.item = event.item; + notifyChangedExtraValues(subId); + }); + subEmitter.on('onValueChanged', (event) => { + if (!subscribers[subId]) { + return; + } + + subscribers[subId]!.item = event.item; + + const isAllStopped = getEveryIsStopped(); + if (isAllStopped) { + Object.keys(subscribers).forEach((pickerId) => { + const sub = subscribers[pickerId]!; + sub.emitter.emit('onAllScrollEnd'); + }); + + controlEmitter.emit('onValueChanged', {pickers: getEventPickers()}); + } + }); + subEmitter.on('onValueChanging', (event) => { + if (!subscribers[subId]) { + return; + } + + subscribers[subId]!.item = event.item; + + controlEmitter.emit('onValueChanging', {pickers: getEventPickers()}); + }); + subEmitter.on('onScrollStart', () => { + if (!subscribers[subId]) { + return; + } + + subscribers[subId]!.isStopped = false; + }); + subEmitter.on('onScrollEnd', () => { + if (!subscribers[subId]) { + return; + } + + subscribers[subId]!.isStopped = true; + }); + + // TODO change omit -> emit + return { + getExtraValues: () => { + return Object.keys(subscribers) + .filter((id) => id !== subId) + .map((id) => subscribers[id]!.item.value); + }, + getEveryIsStopped, + + omitOnNewPropValue: (...args) => { + subEmitter.emit('onNewPropValue', ...args); + }, + omitOnValueChanged: (...args) => { + subEmitter.emit('onValueChanged', ...args); + }, + omitOnValueChanging: (...args) => { + subEmitter.emit('onValueChanging', ...args); + }, + omitOnScrollStart: () => { + subEmitter.emit('onScrollStart'); + }, + omitOnScrollEnd: () => { + subEmitter.emit('onScrollEnd'); + }, + + onNewExtraValues: (callback) => { + return subEmitter.on('onNewExtraValues', callback); + }, + onAllScrollEnd: (callback) => { + return subEmitter.on('onAllScrollEnd', callback); + }, + + disconnect, + } satisfies ControlSubscriber; + }, + } satisfies Control; +}; diff --git a/src/picker-control/index.ts b/src/picker-control/index.ts new file mode 100644 index 0000000..5b240b6 --- /dev/null +++ b/src/picker-control/index.ts @@ -0,0 +1,12 @@ +export {type Control as PickerControl} from './create-control'; + +export { + usePickerControl, + useOnPickerValueChangingEffect, + useOnPickerValueChangedEffect, +} from './usePickerControl'; + +export { + withPickerControl, + type WithPickerControlProps, +} from './withPickerControl'; diff --git a/src/picker-control/usePickerControl.ts b/src/picker-control/usePickerControl.ts new file mode 100644 index 0000000..369d25c --- /dev/null +++ b/src/picker-control/usePickerControl.ts @@ -0,0 +1,48 @@ +import {useEffect} from 'react'; +import {useInit, useStableCallback} from '@rozhkov/react-useful-hooks'; +import { + type BaseControlConfig, + type Control, + type ControlEvents, + createControl, +} from './create-control'; + +export const usePickerControl = < + Config extends BaseControlConfig = BaseControlConfig, +>() => { + return useInit(() => createControl()); +}; + +export const useOnPickerValueChangedEffect = ( + control: ControlT, + effect: ControlEvents['onValueChanged'], +) => { + const effectStable = useStableCallback(effect); + + useEffect(() => { + const unsubscribe = control._on( + 'onValueChanged', + effectStable as ControlEvents['onValueChanged'], + ); + return () => { + unsubscribe(); + }; + }, [control]); // eslint-disable-line react-hooks/exhaustive-deps +}; + +export const useOnPickerValueChangingEffect = ( + control: ControlT, + effect: ControlEvents['onValueChanging'], +) => { + const effectStable = useStableCallback(effect); + + useEffect(() => { + const unsubscribe = control._on( + 'onValueChanging', + effectStable as ControlEvents['onValueChanged'], + ); + return () => { + unsubscribe(); + }; + }, [control]); // eslint-disable-line react-hooks/exhaustive-deps +}; diff --git a/src/picker-control/usePickerControlSubscriber.ts b/src/picker-control/usePickerControlSubscriber.ts new file mode 100644 index 0000000..cfec4a2 --- /dev/null +++ b/src/picker-control/usePickerControlSubscriber.ts @@ -0,0 +1,91 @@ +import {useEffect, useState} from 'react'; +import {useStableCallback} from '@rozhkov/react-useful-hooks'; +import type {PickerItem} from '@implementation/base'; +import type {Control, ControlSubscriber} from './create-control'; + +const useConnectSub = ({ + control, + pickerName, + currentItem, +}: { + control: Control; + pickerName: string; + currentItem: PickerItem; +}) => { + const [subscriber, setSubscriber] = useState(null); + + useEffect(() => { + const sub = control._connect({pickerName, item: currentItem}); + setSubscriber(sub); + + return () => { + sub.disconnect(); + }; + }, [control, pickerName]); // eslint-disable-line react-hooks/exhaustive-deps + + return subscriber; +}; + +export const usePickerControlSubscriber = ({ + control, + pickerName, + currentItem, +}: { + control: Control; + pickerName: string; + currentItem: PickerItem; +}) => { + const subscriber = useConnectSub({ + control, + pickerName, + currentItem, + }); + + const [extraValues, setExtraValues] = useState([]); + const [enableSyncScrollAfterScrollEnd, setEnableSyncScrollAfterScrollEnd] = + useState(true); + + const onScrollStart = useStableCallback(() => { + setEnableSyncScrollAfterScrollEnd(false); + subscriber?.omitOnScrollStart(); + }); + const onScrollEnd = useStableCallback(subscriber?.omitOnScrollEnd); + const omitOnValueChanged = useStableCallback(subscriber?.omitOnValueChanged); + const omitOnValueChanging = useStableCallback( + subscriber?.omitOnValueChanging, + ); + + useEffect(() => { + if (!subscriber) { + return; + } + + setExtraValues(subscriber.getExtraValues()); + const unsubscribeNewExtraValues = subscriber.onNewExtraValues(() => { + setExtraValues(subscriber.getExtraValues()); + }); + + setEnableSyncScrollAfterScrollEnd(subscriber.getEveryIsStopped()); + const unsubscribeAllScrollEnd = subscriber.onAllScrollEnd(() => { + setEnableSyncScrollAfterScrollEnd(subscriber.getEveryIsStopped()); + }); + + return () => { + unsubscribeNewExtraValues(); + unsubscribeAllScrollEnd(); + }; + }, [subscriber]); + + useEffect(() => { + subscriber?.omitOnNewPropValue({item: currentItem}); + }, [currentItem, subscriber]); + + return { + extraValues, + enableSyncScrollAfterScrollEnd, + onScrollStart, + onScrollEnd, + omitOnValueChanged, + omitOnValueChanging, + }; +}; diff --git a/src/picker-control/withPickerControl.tsx b/src/picker-control/withPickerControl.tsx new file mode 100644 index 0000000..e05e7f3 --- /dev/null +++ b/src/picker-control/withPickerControl.tsx @@ -0,0 +1,108 @@ +import type {Control} from './create-control'; +import React, { + type ComponentRef, + type ComponentType, + forwardRef, + memo, +} from 'react'; +import {useStableCallback} from '@rozhkov/react-useful-hooks'; +import {usePickerControlSubscriber} from './usePickerControlSubscriber'; +import { + type OnValueChanged, + type OnValueChanging, + type PickerItem, + useValueIndex, +} from '@implementation/base'; + +type RequiredPickerProps = { + data: ReadonlyArray>; + value?: unknown; + extraValues?: unknown[]; + onValueChanging?: OnValueChanging>; + onValueChanged?: OnValueChanged>; + _enableSyncScrollAfterScrollEnd?: boolean; + _onScrollStart?: () => void; + _onScrollEnd?: () => void; +}; + +export type WithPickerControlProps = + PickerPropsT & { + pickerName: string; + control: Control; + }; + +export const withPickerControl = ( + PickerComponent: ComponentType, +) => { + const WrappedPicker = ( + { + pickerName, + control, + data, + value, + // extraValues TODO pass to props + onValueChanging: onValueChangingProp, + onValueChanged: onValueChangedProp, + _onScrollStart: onScrollStartProp, + _onScrollEnd: onScrollEndProp, + ...restProps + }: WithPickerControlProps, + forwardedRef: any, + ) => { + const valueIndex = useValueIndex(data, value); + const currentItem = data[valueIndex]!; + + const subscriber = usePickerControlSubscriber({ + control, + pickerName, + currentItem, + }); + + const onValueChangingStable = useStableCallback< + OnValueChanging> + >((event) => { + subscriber.omitOnValueChanging(event); + onValueChangingProp?.(event); + }); + const onValueChangedStable = useStableCallback< + OnValueChanged> + >((event) => { + subscriber.omitOnValueChanged(event); + onValueChangedProp?.(event); + }); + const onScrollStartStable = useStableCallback(() => { + subscriber.onScrollStart(); + onScrollStartProp?.(); + }); + const onScrollEndStable = useStableCallback(() => { + subscriber.onScrollEnd(); + onScrollEndProp?.(); + }); + + return ( + + ); + }; + + WrappedPicker.displayName = `withPickerControl(${PickerComponent.displayName})`; + + return memo( + forwardRef< + ComponentRef>, + WithPickerControlProps + >(WrappedPicker as any), + ); +}; diff --git a/tsconfig.json b/tsconfig.json index dfdbf38..23e886e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "paths": { "@quidone/react-native-wheel-picker": ["./src/index"], "@implementation/base": ["./src/base/index"], + "@implementation/picker-control": ["./src/picker-control/index"], "@implementation/virtualized": ["./src/hoc/virtualized/index"], "@utils/react": ["./src/utils/react/index"], "@utils/math": ["./src/utils/math/index"], From 120c5839d2bf6d5c30ccd6c8d10f8df09cc3d102 Mon Sep 17 00:00:00 2001 From: rozhkovs Date: Sun, 24 Aug 2025 14:44:10 +0700 Subject: [PATCH 07/14] feat: add DatePicker --- src/date/DatePicker.tsx | 100 ++++++++++++++++ src/date/DatePickerCommonPropsProvider.tsx | 113 ++++++++++++++++++ src/date/DatePickerContainer.tsx | 45 +++++++ src/date/DatePickerDate.tsx | 51 ++++++++ src/date/DatePickerLocaleProvider.tsx | 51 ++++++++ src/date/DatePickerMonth.tsx | 51 ++++++++ src/date/DatePickerValueProvider.tsx | 129 +++++++++++++++++++++ src/date/DatePickerYear.tsx | 51 ++++++++ src/date/date.ts | 115 ++++++++++++++++++ src/date/index.ts | 5 + src/date/useOverlayItemStyle.ts | 37 ++++++ src/index.tsx | 18 ++- 12 files changed, 764 insertions(+), 2 deletions(-) create mode 100644 src/date/DatePicker.tsx create mode 100644 src/date/DatePickerCommonPropsProvider.tsx create mode 100644 src/date/DatePickerContainer.tsx create mode 100644 src/date/DatePickerDate.tsx create mode 100644 src/date/DatePickerLocaleProvider.tsx create mode 100644 src/date/DatePickerMonth.tsx create mode 100644 src/date/DatePickerValueProvider.tsx create mode 100644 src/date/DatePickerYear.tsx create mode 100644 src/date/date.ts create mode 100644 src/date/index.ts create mode 100644 src/date/useOverlayItemStyle.ts diff --git a/src/date/DatePicker.tsx b/src/date/DatePicker.tsx new file mode 100644 index 0000000..3cab681 --- /dev/null +++ b/src/date/DatePicker.tsx @@ -0,0 +1,100 @@ +import React, {type ReactNode} from 'react'; +import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; +import DatePickerValueProvider from './DatePickerValueProvider'; +import DatePickerDates from './DatePickerDate'; +import DatePickerMonth from './DatePickerMonth'; +import DatePickerYear from './DatePickerYear'; +import DatePickerContainer, {type DateNodeType} from './DatePickerContainer'; +import type {OnlyDateFormat} from './date'; +import DatePickerLocaleProvider from './DatePickerLocaleProvider'; +import DatePickerCommonPropsProvider from './DatePickerCommonPropsProvider'; + +type DatePickerProps = { + date: OnlyDateFormat; + onDateChanged: (event: {date: OnlyDateFormat}) => void; + minDate?: OnlyDateFormat; + maxDate?: OnlyDateFormat; + locale?: string; + + renderDate?: () => ReactNode; + renderMonth?: () => ReactNode; + renderYear?: () => ReactNode; + children?: (props: { + dateNodes: {node: ReactNode; type: DateNodeType}[]; + }) => ReactNode; + + // region common props for all child wheel pickers + itemHeight?: number; + visibleItemCount?: number; + readOnly?: boolean; + enableScrollByTapOnItem?: boolean; + scrollEventThrottle?: number; + pickerStyle?: StyleProp; + itemTextStyle?: StyleProp; + overlayItemStyle?: StyleProp; + contentContainerStyle?: StyleProp; + // endregion +}; + +const DatePickerComponent = ({ + date, + onDateChanged, + minDate, + maxDate, + locale, + renderDate, + renderMonth, + renderYear, + children = ({dateNodes}) => <>{dateNodes.map((dateNode) => dateNode.node)}, + + // region common props for all child wheel pickers + itemHeight, + visibleItemCount, + readOnly, + enableScrollByTapOnItem, + scrollEventThrottle, + pickerStyle, + itemTextStyle, + overlayItemStyle, + contentContainerStyle, +}: // endregion +DatePickerProps) => { + return ( + + + + + {children} + + + + + ); +}; + +DatePickerComponent.displayName = 'DatePicker'; + +export const DatePicker = Object.assign(DatePickerComponent, { + Date: DatePickerDates, + Month: DatePickerMonth, + Year: DatePickerYear, +}); diff --git a/src/date/DatePickerCommonPropsProvider.tsx b/src/date/DatePickerCommonPropsProvider.tsx new file mode 100644 index 0000000..5fb08ed --- /dev/null +++ b/src/date/DatePickerCommonPropsProvider.tsx @@ -0,0 +1,113 @@ +import React, { + type ComponentType, + createContext, + type ForwardedRef, + forwardRef, + memo, + type PropsWithChildren, + useContext, +} from 'react'; +import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; +import {useMemoObject} from '@rozhkov/react-useful-hooks'; +import type {PickerProps} from '@implementation/base'; + +type ContextValue = { + itemHeight: number | undefined; + visibleItemCount: number | undefined; + readOnly: boolean | undefined; + enableScrollByTapOnItem: boolean | undefined; + scrollEventThrottle: number | undefined; + pickerStyle: StyleProp | undefined; + itemTextStyle: StyleProp | undefined; + overlayItemStyle: StyleProp | undefined; + contentContainerStyle: StyleProp | undefined; +}; + +const DatePickerCommonPropsContext = createContext( + undefined, +); + +type DatePickerCommonPropsProviderProps = PropsWithChildren; + +const DatePickerCommonPropsProvider = ({ + children, + ...restProps +}: DatePickerCommonPropsProviderProps) => { + const memoizedValue = useMemoObject(restProps); + + return ( + + {children} + + ); +}; + +export default DatePickerCommonPropsProvider; + +const useDatePickerCommonProps = () => { + const value = useContext(DatePickerCommonPropsContext); + if (value === undefined) { + throw new Error( + 'useDatePickerCommonProps must be called from within DatePickerCommonPropsContext.Provider!', + ); + } + return useContext(DatePickerCommonPropsContext)!; +}; + +type PickedWheelPickerProps = Pick< + PickerProps, + Exclude | 'style' +>; + +export const withCommonProps = ( + WheelPickerComponent: ComponentType, +) => { + const WrappedWheelPicker = ( + { + style: pickerStyleProp, + contentContainerStyle: contentContainerStyleProp, + itemTextStyle: itemTextStyleProp, + overlayItemStyle: overlayItemStyleProp, + ...restProps + }: PickedWheelPickerProps, + forwardedRef: ForwardedRef, + ) => { + const { + pickerStyle: pickerStyleCommon, + contentContainerStyle: contentContainerStyleCommon, + itemTextStyle: itemTextStyleCommon, + overlayItemStyle: overlayItemStyleCommon, + ...restCommonProps + } = useDatePickerCommonProps(); + + const style = useMemoObject([pickerStyleCommon, pickerStyleProp]); + const contentContainerStyle = useMemoObject([ + contentContainerStyleCommon, + contentContainerStyleProp, + ]); + const itemTextStyle = useMemoObject([ + itemTextStyleCommon, + itemTextStyleProp, + ]); + const overlayItemStyle = useMemoObject([ + overlayItemStyleCommon, + overlayItemStyleProp, + ]); + + return ( + + ); + }; + + WrappedWheelPicker.displayName = `withDateCommonProps(${WheelPickerComponent.displayName})`; + + return memo(forwardRef(WrappedWheelPicker)) as typeof WheelPickerComponent; +}; diff --git a/src/date/DatePickerContainer.tsx b/src/date/DatePickerContainer.tsx new file mode 100644 index 0000000..9df1086 --- /dev/null +++ b/src/date/DatePickerContainer.tsx @@ -0,0 +1,45 @@ +import React, {Fragment, type ReactNode} from 'react'; +import {View} from 'react-native'; +import DatePickerDate from './DatePickerDate'; +import DatePickerMonth from './DatePickerMonth'; +import DatePickerYear from './DatePickerYear'; +import {useDatePickerLocale} from './DatePickerLocaleProvider'; + +export type DateNodeType = 'date' | 'month' | 'year'; + +type DatePickerContainerProps = { + renderDate?: () => ReactNode; + renderMonth?: () => ReactNode; + renderYear?: () => ReactNode; + + children: (props: { + dateNodes: {node: ReactNode; type: DateNodeType}[]; + }) => ReactNode; +}; + +const DatePickerContainer = ({ + renderDate = () => , + renderMonth = () => , + renderYear = () => , + children, +}: DatePickerContainerProps) => { + const localeData = useDatePickerLocale(); + const typeToRenderMap: Record ReactNode> = { + date: renderDate, + month: renderMonth, + year: renderYear, + }; + + return ( + + {children({ + dateNodes: localeData.sortedDateUnitTypes.map((type) => ({ + type, + node: {typeToRenderMap[type]()}, + })), + })} + + ); +}; + +export default DatePickerContainer; diff --git a/src/date/DatePickerDate.tsx b/src/date/DatePickerDate.tsx new file mode 100644 index 0000000..1c0f071 --- /dev/null +++ b/src/date/DatePickerDate.tsx @@ -0,0 +1,51 @@ +import React, {memo, useMemo} from 'react'; +import {getDaysInMonth} from 'date-fns'; +import Picker, {type PickerProps} from '@implementation/base'; +import {withPickerControl} from '@implementation/picker-control'; +import {useDateContext} from './DatePickerValueProvider'; +import {useOverlayItemStyle} from './useOverlayItemStyle'; +import {useDatePickerLocale} from './DatePickerLocaleProvider'; +import {withCommonProps} from './DatePickerCommonPropsProvider'; + +const HocPicker = withCommonProps(withPickerControl(Picker)); + +export type DatePickerDateProps = Omit< + PickerProps<{value: number}>, + 'value' | 'data' +>; + +const DatePickerDate = ({ + width = 60, + overlayItemStyle: overlayItemStyleProp, + ...restProps +}: DatePickerDateProps) => { + const localeData = useDatePickerLocale(); + const dateContext = useDateContext(); + const value = dateContext.value; + const daysInMount = getDaysInMonth(new Date(value.year, value.month)); + const data = useMemo(() => { + return [...Array(daysInMount).keys()].map((index) => ({ + value: index + 1, + })); + }, [daysInMount]); + + const overlayItemStyle = useOverlayItemStyle({ + curUnit: 'date', + unitPositions: localeData.sortedDateUnitTypes, + propStyle: overlayItemStyleProp, + }); + + return ( + + ); +}; + +export default memo(DatePickerDate); diff --git a/src/date/DatePickerLocaleProvider.tsx b/src/date/DatePickerLocaleProvider.tsx new file mode 100644 index 0000000..614bc5a --- /dev/null +++ b/src/date/DatePickerLocaleProvider.tsx @@ -0,0 +1,51 @@ +import React, { + createContext, + type PropsWithChildren, + useContext, + useMemo, +} from 'react'; +import {type DateUnitType, DateUtils} from './date'; + +type ContextValue = { + locale: string; + sortedDateUnitTypes: DateUnitType[]; + monthLongNames: string[]; +}; + +const DatePickerLocaleContext = createContext( + undefined, +); + +type DatePickerLocaleProviderProps = PropsWithChildren<{locale?: string}>; + +const DatePickerLocaleProvider = ({ + locale = 'en', + children, +}: DatePickerLocaleProviderProps) => { + const value = useMemo( + () => ({ + locale, + monthLongNames: DateUtils.getLocalizedMonthNames(locale), + sortedDateUnitTypes: DateUtils.getSortedDateUnitPositions(locale), + }), + [locale], + ); + + return ( + + {children} + + ); +}; + +export default DatePickerLocaleProvider; + +export const useDatePickerLocale = () => { + const value = useContext(DatePickerLocaleContext); + if (value === undefined) { + throw new Error( + 'useDatePickerLocale must be called from within DatePickerLocaleContext.Provider!', + ); + } + return useContext(DatePickerLocaleContext)!; +}; diff --git a/src/date/DatePickerMonth.tsx b/src/date/DatePickerMonth.tsx new file mode 100644 index 0000000..ed1d6a5 --- /dev/null +++ b/src/date/DatePickerMonth.tsx @@ -0,0 +1,51 @@ +import React, {memo, useMemo} from 'react'; +import Picker, {type PickerProps} from '@implementation/base'; +import {useDateContext} from './DatePickerValueProvider'; +import {useOverlayItemStyle} from './useOverlayItemStyle'; +import {useDatePickerLocale} from './DatePickerLocaleProvider'; +import {withCommonProps} from './DatePickerCommonPropsProvider'; +import {DateUtils} from './date'; +import {withPickerControl} from '@implementation/picker-control'; + +const HocPicker = withCommonProps(withPickerControl(Picker)); + +export type DatePickerMonthProps = Omit< + PickerProps<{value: number}>, + 'value' | 'data' +>; + +const DatePickerMonth = ({ + width = 120, + overlayItemStyle: overlayItemStyleProp, + ...restProps +}: DatePickerMonthProps) => { + const localeData = useDatePickerLocale(); + const dateContext = useDateContext(); + const value = dateContext.value; + const data = useMemo(() => { + return [...Array(DateUtils.MONTH_COUNT).keys()].map((index) => ({ + value: index, + label: localeData.monthLongNames[index], + })); + }, [localeData.monthLongNames]); + + const overlayItemStyle = useOverlayItemStyle({ + curUnit: 'month', + unitPositions: localeData.sortedDateUnitTypes, + propStyle: overlayItemStyleProp, + }); + + return ( + + ); +}; + +export default memo(DatePickerMonth); diff --git a/src/date/DatePickerValueProvider.tsx b/src/date/DatePickerValueProvider.tsx new file mode 100644 index 0000000..563f0bb --- /dev/null +++ b/src/date/DatePickerValueProvider.tsx @@ -0,0 +1,129 @@ +import React, { + createContext, + type PropsWithChildren, + useContext, + useMemo, +} from 'react'; +import {getDaysInMonth, isSameDay} from 'date-fns'; +import type {PickerItem} from '@implementation/base'; +import { + type PickerControl, + useOnPickerValueChangedEffect, + usePickerControl, +} from '@implementation/picker-control'; +import { + type DateLocale, + DateUtils, + type OnlyDateFormat, + type OnlyDateUnits, +} from './date'; + +type ContextValue = { + pickerControl: PickerControl; + value: OnlyDateUnits; + max: Date; + min: Date; +}; + +type ControlPickersMap = { + year: {item: PickerItem}; + month: {item: PickerItem}; + date: {item: PickerItem}; +}; + +const DatePickerContext = createContext(undefined); + +type DatePickerValueProviderProps = PropsWithChildren<{ + date: OnlyDateFormat; + locale?: DateLocale; + minDate?: OnlyDateFormat; + maxDate?: OnlyDateFormat; + onDateChanged: (event: {date: OnlyDateFormat}) => void; +}>; + +const DatePickerValueProvider = ({ + date, + maxDate, + minDate, + onDateChanged, + children, +}: DatePickerValueProviderProps) => { + const {min, max} = useMemo(() => { + const now = new Date(); + + const getMaxDefault = () => { + const year = now.getFullYear() + 100; + const month = 11; + return new Date(year, month, DateUtils.getDaysInMonth(year, month)); + }; + const getMinDefault = () => new Date(now.getFullYear() - 100, 0, 1); + + return { + max: maxDate ? new Date(maxDate) : getMaxDefault(), + min: minDate ? new Date(minDate) : getMinDefault(), + }; + }, [maxDate, minDate]); + + const pickerControl = usePickerControl(); + + useOnPickerValueChangedEffect(pickerControl, (event) => { + const nextUnits = { + year: event.pickers.year.item.value, + month: event.pickers.month.item.value, + date: event.pickers.date.item.value, + }; + + const daysInCurMonth = getDaysInMonth( + new Date(nextUnits.year, nextUnits.month), + ); + if (daysInCurMonth < nextUnits.date) { + nextUnits.date = daysInCurMonth; + } + + const curDateObj = new Date(date); + const dateObj = new Date(nextUnits.year, nextUnits.month, nextUnits.date); + const normalizedDateObj = DateUtils.withBoundaries(dateObj, min, max); + + if (isSameDay(curDateObj, normalizedDateObj)) { + return; + } + + onDateChanged?.({ + date: DateUtils.toOnlyDateFormat({ + year: normalizedDateObj.getFullYear(), + month: normalizedDateObj.getMonth(), + date: normalizedDateObj.getDate(), + }), + }); + }); + + const value = useMemo( + () => ({ + pickerControl, + value: DateUtils.toUnits( + DateUtils.withBoundaries(new Date(date), min, max), + ), + max, + min, + }), + [pickerControl, date, max, min], + ); + + return ( + + {children} + + ); +}; + +export default DatePickerValueProvider; + +export const useDateContext = () => { + const value = useContext(DatePickerContext); + if (value === undefined) { + throw new Error( + 'useDateContext must be called from within DatePicker.Provider!', + ); + } + return useContext(DatePickerContext)!; +}; diff --git a/src/date/DatePickerYear.tsx b/src/date/DatePickerYear.tsx new file mode 100644 index 0000000..b35afea --- /dev/null +++ b/src/date/DatePickerYear.tsx @@ -0,0 +1,51 @@ +import React, {memo, useMemo} from 'react'; +import Picker, {type PickerProps} from '@implementation/base'; +import {useDateContext} from './DatePickerValueProvider'; +import {useOverlayItemStyle} from './useOverlayItemStyle'; +import {useDatePickerLocale} from './DatePickerLocaleProvider'; +import {withCommonProps} from './DatePickerCommonPropsProvider'; +import {withPickerControl} from '@implementation/picker-control'; + +const HocPicker = withCommonProps(withPickerControl(Picker)); + +export type DatePickerYearProps = Omit< + PickerProps<{value: number}>, + 'value' | 'data' +>; + +const DatePickerYear = ({ + width = 100, + overlayItemStyle: overlayItemStyleProp, + ...restProps +}: DatePickerYearProps) => { + const localeData = useDatePickerLocale(); + const dateContext = useDateContext(); + const value = dateContext.value; + const data = useMemo(() => { + const startYear = dateContext.min.getFullYear(); + const endYear = dateContext.max.getFullYear(); + return Array.from({length: endYear - startYear + 1}, (_, index) => ({ + value: startYear + index, + })); + }, [dateContext.max, dateContext.min]); + + const overlayItemStyle = useOverlayItemStyle({ + curUnit: 'year', + unitPositions: localeData.sortedDateUnitTypes, + propStyle: overlayItemStyleProp, + }); + + return ( + + ); +}; + +export default memo(DatePickerYear); diff --git a/src/date/date.ts b/src/date/date.ts new file mode 100644 index 0000000..246f678 --- /dev/null +++ b/src/date/date.ts @@ -0,0 +1,115 @@ +import {isAfter, isBefore, isWithinInterval, startOfDay} from 'date-fns'; + +export type OnlyDateFormat = string; // YYYY-MM-DD +export type OnlyDateUnits = {date: number; month: number; year: number}; + +const withBoundaries = (date: Date, min: Date, max: Date) => { + if (isBefore(date, min)) { + date = min; + } else if (isAfter(date, max)) { + date = max; + } + return date; +}; + +const toUnits = (date: OnlyDateFormat | Date): OnlyDateUnits => { + let dateObj = new Date(date); + + return { + year: dateObj.getFullYear(), + month: dateObj.getMonth(), + date: dateObj.getDate(), + }; +}; + +const inRange = (units: OnlyDateUnits, start: Date, end: Date) => { + return isWithinInterval(toDate(units), { + start: startOfDay(start), + end: startOfDay(end), + }); +}; + +const toDate = (units: OnlyDateUnits) => { + return new Date(units.year, units.month, units.date); +}; + +const toOnlyDateFormat = (units: OnlyDateUnits): OnlyDateFormat => { + const date = new Date(units.year, units.month, units.date); + const year = date.getFullYear(); + const month = date.getMonth() + 1; // getMonth() returns 0-11 + const day = date.getDate(); + + // Pad single digit month and day with leading zeros + const monthFormatted = month < 10 ? `0${month}` : month; + const dayFormatted = day < 10 ? `0${day}` : day; + + return `${year}-${monthFormatted}-${dayFormatted}`; +}; + +const getDaysInMonth = (year: number, month: number): number => { + return new Date(year, month + 1, 0).getDate(); +}; + +export type DateUnitType = 'date' | 'month' | 'year'; + +export type DateLocale = { + locale: string; +}; + +const getSortedDateUnitPositions = (locale: string) => { + const formatter = new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + }); + const parts = formatter.formatToParts(new Date()); + const orderMap: Record = { + day: 'date', + month: 'month', + year: 'year', + }; + + // Создаем массив с правильным порядком элементов + const order: DateUnitType[] = []; + parts.forEach((part) => { + // part - это объект {type: string, value: string} + if (part.type in orderMap) { + order.push(orderMap[part.type as keyof typeof orderMap]!); + } + }); + + return order; +}; + +const getLocalizedMonthNames = (locale: string): string[] => { + const monthNames: string[] = []; + for (let monthIndex = 0; monthIndex < DateUtils.MONTH_COUNT; monthIndex++) { + const date = new Date(2025, monthIndex, 1); // Создаем дату для каждого месяца + const formatter = new Intl.DateTimeFormat(locale, {month: 'long'}); + const monthName = formatter.format(date); + monthNames.push(monthName); + } + return monthNames; +}; + +const isFirstUnitPosition = (list: DateUnitType[], search: DateUnitType) => { + return list[0] === search; +}; +const isLastUnitPosition = (list: DateUnitType[], search: DateUnitType) => { + return list.at(-1) === search; +}; + +export const DateUtils = { + MONTH_COUNT: 12, + + toUnits, + toDate, + toOnlyDateFormat, + inRange, + withBoundaries, + getDaysInMonth, + getSortedDateUnitPositions, + getLocalizedMonthNames, + isFirstUnitPosition, + isLastUnitPosition, +}; diff --git a/src/date/index.ts b/src/date/index.ts new file mode 100644 index 0000000..d1cd035 --- /dev/null +++ b/src/date/index.ts @@ -0,0 +1,5 @@ +export {DatePicker} from './DatePicker'; +export {type DatePickerDateProps} from './DatePickerDate'; +export {type DatePickerMonthProps} from './DatePickerMonth'; +export {type DatePickerYearProps} from './DatePickerYear'; +export {useDatePickerLocale} from './DatePickerLocaleProvider'; diff --git a/src/date/useOverlayItemStyle.ts b/src/date/useOverlayItemStyle.ts new file mode 100644 index 0000000..e5803fa --- /dev/null +++ b/src/date/useOverlayItemStyle.ts @@ -0,0 +1,37 @@ +import {useMemo} from 'react'; +import {type StyleProp, StyleSheet, type ViewStyle} from 'react-native'; +import {type DateUnitType, DateUtils} from './date'; + +export const useOverlayItemStyle = ({ + curUnit, + unitPositions, + propStyle, +}: { + unitPositions: DateUnitType[]; + curUnit: DateUnitType; + propStyle: StyleProp; +}) => { + return useMemo(() => { + if (DateUtils.isFirstUnitPosition(unitPositions, curUnit)) { + return [dateStyles.leftItemOverlay, propStyle]; + } else if (DateUtils.isLastUnitPosition(unitPositions, curUnit)) { + return [dateStyles.rightItemOverlay, propStyle]; + } else { + return [dateStyles.zeroBorderRadius, propStyle]; + } + }, [curUnit, propStyle, unitPositions]); +}; + +export const dateStyles = StyleSheet.create({ + leftItemOverlay: { + borderRadius: 0, + borderTopLeftRadius: 8, + borderBottomLeftRadius: 8, + }, + rightItemOverlay: { + borderRadius: 0, + borderTopRightRadius: 8, + borderBottomRightRadius: 8, + }, + zeroBorderRadius: {borderRadius: 0}, +}); diff --git a/src/index.tsx b/src/index.tsx index 31559c1..cbdd1ab 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -19,11 +19,25 @@ export { useScrollContentOffset, } from '@implementation/base'; -export {PickerProps as WheelPickerProps} from '@implementation/base'; +export {type PickerProps as WheelPickerProps} from '@implementation/base'; import {default as WheelPicker} from '@implementation/base'; export default WheelPicker; export { withVirtualized, - WithVirtualizedProps, + type WithVirtualizedProps, } from '@implementation/virtualized'; + +export { + usePickerControl, + withPickerControl, + useOnPickerValueChangedEffect, + useOnPickerValueChangingEffect, +} from '@implementation/picker-control'; + +export { + type DatePickerYearProps, + type DatePickerMonthProps, + type DatePickerDateProps, + DatePicker, +} from './date'; From 7927d80f393c5d81b1eaf91f5f596ce0176c3c0f Mon Sep 17 00:00:00 2001 From: rozhkovs Date: Sun, 24 Aug 2025 14:46:35 +0700 Subject: [PATCH 08/14] chore: upgrade typescript --- package.json | 2 +- tsconfig.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index b0984fc..6297d4f 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "react-native-builder-bob": "^0.20.0", "react-test-renderer": "18.2.0", "release-it": "^15.0.0", - "typescript": "^4.5.2" + "typescript": "5.6.3" }, "resolutions": { "@types/react": "^18.2.0" diff --git a/tsconfig.json b/tsconfig.json index 23e886e..bbf8058 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,7 @@ "allowUnreachableCode": false, "allowUnusedLabels": false, "esModuleInterop": true, - "importsNotUsedAsValues": "error", + "verbatimModuleSyntax": true, "forceConsistentCasingInFileNames": true, "jsx": "react", "lib": ["esnext"], From 03377c89f90a2d4cf6ffa30438079bc0a6b33be2 Mon Sep 17 00:00:00 2001 From: rozhkovs Date: Sun, 24 Aug 2025 14:48:19 +0700 Subject: [PATCH 09/14] chore: update snapshots --- src/__tests__/__snapshots__/index.test.tsx.snap | 1 + src/__tests__/index.test.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/__tests__/__snapshots__/index.test.tsx.snap b/src/__tests__/__snapshots__/index.test.tsx.snap index 92c2f17..beecf49 100644 --- a/src/__tests__/__snapshots__/index.test.tsx.snap +++ b/src/__tests__/__snapshots__/index.test.tsx.snap @@ -37,6 +37,7 @@ exports[`WheelPicker should match snapshot 1`] = ` onMomentumScrollBegin={[Function]} onMomentumScrollEnd={[Function]} onScroll={[Function]} + onScrollBeginDrag={[Function]} onScrollEndDrag={[Function]} onTouchCancel={[Function]} onTouchEnd={[Function]} diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index 8d79b3f..4edffc6 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -8,6 +8,7 @@ describe('WheelPicker', () => { .create( , ) From 32f67661e21b13411c73ae9648b3bdb32126c153 Mon Sep 17 00:00:00 2001 From: rozhkovs Date: Sun, 24 Aug 2025 14:48:38 +0700 Subject: [PATCH 10/14] chore: update yarn.lock --- yarn.lock | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/yarn.lock b/yarn.lock index b67cbb1..5ec5568 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2622,6 +2622,7 @@ __metadata: "@types/react-test-renderer": "npm:^18" babel-plugin-module-resolver: "npm:^4.1.0" commitlint: "npm:^17.0.2" + date-fns: "npm:^4.1.0" del-cli: "npm:^5.0.0" eslint: "npm:^8.4.1" eslint-config-prettier: "npm:^8.5.0" @@ -2629,6 +2630,7 @@ __metadata: eslint-plugin-prettier: "npm:^4.0.0" jest: "npm:^28.1.1" metro-react-native-babel-preset: "npm:^0.77.0" + nanoevents: "npm:^9.1.0" pod-install: "npm:^0.1.0" prettier: "npm:^2.0.5" react: "npm:18.3.1" @@ -2636,7 +2638,7 @@ __metadata: react-native-builder-bob: "npm:^0.20.0" react-test-renderer: "npm:18.2.0" release-it: "npm:^15.0.0" - typescript: "npm:^4.5.2" + typescript: "npm:5.6.3" peerDependencies: react: ">=16.8" react-native: ">=0.71.6" @@ -5026,6 +5028,13 @@ __metadata: languageName: node linkType: hard +"date-fns@npm:^4.1.0": + version: 4.1.0 + resolution: "date-fns@npm:4.1.0" + checksum: 10/d5f6e9de5bbc52310f786099e18609289ed5e30af60a71e0646784c8185ddd1d0eebcf7c96b7faaaefc4a8366f3a3a4244d099b6d0866ee2bec80d1361e64342 + languageName: node + linkType: hard + "dateformat@npm:^3.0.0": version: 3.0.3 resolution: "dateformat@npm:3.0.3" @@ -9635,6 +9644,13 @@ __metadata: languageName: node linkType: hard +"nanoevents@npm:^9.1.0": + version: 9.1.0 + resolution: "nanoevents@npm:9.1.0" + checksum: 10/77abbc15c4efc15518a0075b604e5db045e5d6184f4d08892c771928a84f53b1dd46138902213f66e8a9989e6c930168ca6441b80ff9098aec83c01a782bd42b + languageName: node + linkType: hard + "natural-compare-lite@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare-lite@npm:1.4.0" @@ -12576,13 +12592,13 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^4.5.2": - version: 4.9.5 - resolution: "typescript@npm:4.9.5" +"typescript@npm:5.6.3": + version: 5.6.3 + resolution: "typescript@npm:5.6.3" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10/458f7220ab11e0fc191514cc41be1707645ec9a8c2d609448a448e18c522cef9646f58728f6811185a4c35613dacdf6c98cf8965c88b3541d0288c47291e4300 + checksum: 10/c328e418e124b500908781d9f7b9b93cf08b66bf5936d94332b463822eea2f4e62973bfb3b8a745fdc038785cb66cf59d1092bac3ec2ac6a3e5854687f7833f1 languageName: node linkType: hard @@ -12596,13 +12612,13 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^4.5.2#optional!builtin": - version: 4.9.5 - resolution: "typescript@patch:typescript@npm%3A4.9.5#optional!builtin::version=4.9.5&hash=289587" +"typescript@patch:typescript@npm%3A5.6.3#optional!builtin": + version: 5.6.3 + resolution: "typescript@patch:typescript@npm%3A5.6.3#optional!builtin::version=5.6.3&hash=8c6c40" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10/5659316360b5cc2d6f5931b346401fa534107b68b60179cf14970e27978f0936c1d5c46f4b5b8175f8cba0430f522b3ce355b4b724c0ea36ce6c0347fab25afd + checksum: 10/00504c01ee42d470c23495426af07512e25e6546bce7e24572e72a9ca2e6b2e9bea63de4286c3cfea644874da1467dcfca23f4f98f7caf20f8b03c0213bb6837 languageName: node linkType: hard From 71723820125b44c157029c20bbe63807cb864bbc Mon Sep 17 00:00:00 2001 From: rozhkovs Date: Sun, 24 Aug 2025 15:01:48 +0700 Subject: [PATCH 11/14] chore: update example --- example/package.json | 25 +- example/src/App.tsx | 43 +- .../SimplePickerBlockExample.tsx | 42 - .../src/components/example-blocks/index.ts | 3 - example/src/picker-config/index.ts | 3 - .../picker-config/withExamplePickerConfig.tsx | 63 - example/src/snack/contants.ts | 2 + example/src/snack/get-random-number.ts | 7 + example/src/snack/index.ts | 2 + .../NativeFeedbackProvider.tsx | 38 + .../src/snack/native-api-provider/index.ts | 4 + example/src/snack/navigator/RootNavigator.tsx | 24 + example/src/snack/navigator/index.ts | 1 + .../PickerPropsChangerPanel.tsx} | 94 +- .../PickerPropsChangerProvider.tsx} | 30 +- example/src/snack/props-changer/index.ts | 3 + .../snack/props-changer/withPropsChanger.tsx | 82 + .../ControlSimpleUsage.tsx | 138 + .../screens/control-simple-usage/index.ts | 1 + .../CustomizedPickerScreen.tsx} | 66 +- .../screens/customized-picker}/Overlay.tsx | 0 .../screens/customized-picker}/PickerItem.tsx | 0 .../PickerItemContainer.tsx | 4 +- .../snack/screens/customized-picker/index.ts | 1 + .../screens/customized-picker}/types.ts | 0 example/src/snack/screens/main/MainScreen.tsx | 55 + example/src/snack/screens/main/index.ts | 1 + .../screens/simple-date-picker/LocaleRow.tsx | 44 + .../SimpleDatePickerScreen.tsx | 82 + .../snack/screens/simple-date-picker/index.ts | 1 + .../SimplePickerAndIOSPickerScreen.tsx} | 37 +- .../simple-picker-and-ios-picker/index.ts | 1 + .../simple-picker/SimplePickerScreen.tsx | 57 + .../src/snack/screens/simple-picker/index.ts | 1 + .../DatePickerBottomSheet.tsx | 114 + .../WithBottomSheetScreen.tsx | 36 + .../snack/screens/with-bottom-sheet/index.ts | 1 + .../base => snack/ui-base}/Box.tsx | 0 .../base => snack/ui-base}/Divider.tsx | 0 .../base => snack/ui-base}/Header.tsx | 0 .../ui-base}/ListItemCheckBox.tsx | 2 +- .../ui-base/PickerScreenViewContainer.tsx | 21 + .../base => snack/ui-base}/index.ts | 1 + example/tsconfig.json | 1 + example/yarn.lock | 2515 +++++++++-------- 45 files changed, 2177 insertions(+), 1469 deletions(-) delete mode 100644 example/src/components/example-blocks/SimplePickerBlockExample.tsx delete mode 100644 example/src/components/example-blocks/index.ts delete mode 100644 example/src/picker-config/index.ts delete mode 100644 example/src/picker-config/withExamplePickerConfig.tsx create mode 100644 example/src/snack/contants.ts create mode 100644 example/src/snack/get-random-number.ts create mode 100644 example/src/snack/index.ts create mode 100644 example/src/snack/native-api-provider/NativeFeedbackProvider.tsx create mode 100644 example/src/snack/native-api-provider/index.ts create mode 100644 example/src/snack/navigator/RootNavigator.tsx create mode 100644 example/src/snack/navigator/index.ts rename example/src/{picker-config/PickerConfigPanel.tsx => snack/props-changer/PickerPropsChangerPanel.tsx} (55%) rename example/src/{picker-config/PickerConfigProvider.tsx => snack/props-changer/PickerPropsChangerProvider.tsx} (73%) create mode 100644 example/src/snack/props-changer/index.ts create mode 100644 example/src/snack/props-changer/withPropsChanger.tsx create mode 100644 example/src/snack/screens/control-simple-usage/ControlSimpleUsage.tsx create mode 100644 example/src/snack/screens/control-simple-usage/index.ts rename example/src/{components/example-blocks/AvatarCustomizedPickerBlockExample/index.tsx => snack/screens/customized-picker/CustomizedPickerScreen.tsx} (57%) rename example/src/{components/example-blocks/AvatarCustomizedPickerBlockExample => snack/screens/customized-picker}/Overlay.tsx (100%) rename example/src/{components/example-blocks/AvatarCustomizedPickerBlockExample => snack/screens/customized-picker}/PickerItem.tsx (100%) rename example/src/{components/example-blocks/AvatarCustomizedPickerBlockExample => snack/screens/customized-picker}/PickerItemContainer.tsx (96%) create mode 100644 example/src/snack/screens/customized-picker/index.ts rename example/src/{components/example-blocks/AvatarCustomizedPickerBlockExample => snack/screens/customized-picker}/types.ts (100%) create mode 100644 example/src/snack/screens/main/MainScreen.tsx create mode 100644 example/src/snack/screens/main/index.ts create mode 100644 example/src/snack/screens/simple-date-picker/LocaleRow.tsx create mode 100644 example/src/snack/screens/simple-date-picker/SimpleDatePickerScreen.tsx create mode 100644 example/src/snack/screens/simple-date-picker/index.ts rename example/src/{components/example-blocks/CompareWithNativeIOSBlockExample.tsx => snack/screens/simple-picker-and-ios-picker/SimplePickerAndIOSPickerScreen.tsx} (79%) create mode 100644 example/src/snack/screens/simple-picker-and-ios-picker/index.ts create mode 100644 example/src/snack/screens/simple-picker/SimplePickerScreen.tsx create mode 100644 example/src/snack/screens/simple-picker/index.ts create mode 100644 example/src/snack/screens/with-bottom-sheet/DatePickerBottomSheet.tsx create mode 100644 example/src/snack/screens/with-bottom-sheet/WithBottomSheetScreen.tsx create mode 100644 example/src/snack/screens/with-bottom-sheet/index.ts rename example/src/{components/base => snack/ui-base}/Box.tsx (100%) rename example/src/{components/base => snack/ui-base}/Divider.tsx (100%) rename example/src/{components/base => snack/ui-base}/Header.tsx (100%) rename example/src/{components/base => snack/ui-base}/ListItemCheckBox.tsx (98%) create mode 100644 example/src/snack/ui-base/PickerScreenViewContainer.tsx rename example/src/{components/base => snack/ui-base}/index.ts (70%) diff --git a/example/package.json b/example/package.json index a8c5fca..579f1d3 100644 --- a/example/package.json +++ b/example/package.json @@ -10,19 +10,26 @@ "dependencies": { "@expo/ngrok": "^4.1.1", "@faker-js/faker": "^8.0.2", + "@gorhom/bottom-sheet": "^5", "@quidone/react-native-wheel-picker-feedback": "^2.0.0", - "@react-native-picker/picker": "2.9.0", + "@react-native-picker/picker": "2.11.1", + "@react-navigation/elements": "^2.6.3", + "@react-navigation/native": "^7.1.17", + "@react-navigation/native-stack": "^7.3.25", "@rozhkov/react-useful-hooks": "^1.0.9", - "expo": "^52.0.41", - "expo-splash-screen": "~0.29.22", - "expo-status-bar": "~2.0.1", - "react": "18.3.1", - "react-dom": "18.3.1", - "react-native": "0.76.7", + "expo": "^53.0.20", + "expo-splash-screen": "~0.30.10", + "expo-status-bar": "~2.2.3", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-native": "0.79.5", "react-native-builder-bob": "^0.38.4", "react-native-elements": "^4.0.0-rc.2", - "react-native-safe-area-context": "4.12.0", - "react-native-web": "~0.19.10" + "react-native-gesture-handler": "~2.24.0", + "react-native-reanimated": "~3.17.4", + "react-native-safe-area-context": "5.4.0", + "react-native-screens": "~4.11.1", + "react-native-web": "^0.20.0" }, "devDependencies": { "@babel/core": "^7.24.0", diff --git a/example/src/App.tsx b/example/src/App.tsx index 2ddc59c..45cd5af 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,38 +1,19 @@ import * as React from 'react'; -import {ScrollView, StyleSheet} from 'react-native'; -import {PickerConfigProvider} from './picker-config'; -import {Box} from './components/base'; -import { - AvatarCustomizedPickerBlockExample, - CompareWithNativeIOSBlockExample, - SimplePickerBlockExample, -} from './components/example-blocks'; +import WheelPickerFeedback from '@quidone/react-native-wheel-picker-feedback'; +import {NativeFeedbackProvider, RootNavigation} from './snack'; +import {GestureHandlerRootView} from 'react-native-gesture-handler'; +import {BottomSheetModalProvider} from '@gorhom/bottom-sheet'; const App = () => { return ( - - - - - - + + + + + + + ); }; -const styles = StyleSheet.create({ - contentContainer: { - paddingVertical: 60, - paddingHorizontal: 20, - alignItems: 'center', - }, -}); - -const Providers = () => { - return ( - - - - ); -}; - -export default Providers; +export default App; diff --git a/example/src/components/example-blocks/SimplePickerBlockExample.tsx b/example/src/components/example-blocks/SimplePickerBlockExample.tsx deleted file mode 100644 index ec1d066..0000000 --- a/example/src/components/example-blocks/SimplePickerBlockExample.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React, {useCallback, useState} from 'react'; -import WheelPicker, { - PickerItem, - type ValueChangedEvent, -} from '@quidone/react-native-wheel-picker'; -import {useInit} from '@rozhkov/react-useful-hooks'; -import {withExamplePickerConfig, PickerConfigPanel} from '../../picker-config'; -import {Header} from '../base'; - -const ExampleWheelPicker = withExamplePickerConfig(WheelPicker); -const createPickerItem = (index: number): PickerItem => ({ - value: index, - label: index.toString(), -}); - -const SimplePicker = () => { - const data = useInit(() => [...Array(100).keys()].map(createPickerItem)); - const [value, setValue] = useState(0); - - const onValueChanged = useCallback( - ({item: {value: val}}: ValueChangedEvent>) => { - setValue(val); - }, - [], - ); - - return ( - <> -
- - - - ); -}; - -export default SimplePicker; diff --git a/example/src/components/example-blocks/index.ts b/example/src/components/example-blocks/index.ts deleted file mode 100644 index f60bb39..0000000 --- a/example/src/components/example-blocks/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export {default as SimplePickerBlockExample} from './SimplePickerBlockExample'; -export {default as AvatarCustomizedPickerBlockExample} from './AvatarCustomizedPickerBlockExample'; -export {default as CompareWithNativeIOSBlockExample} from './CompareWithNativeIOSBlockExample'; diff --git a/example/src/picker-config/index.ts b/example/src/picker-config/index.ts deleted file mode 100644 index 95cd215..0000000 --- a/example/src/picker-config/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export {default as PickerConfigProvider} from './PickerConfigProvider'; -export {default as withExamplePickerConfig} from './withExamplePickerConfig'; -export {default as PickerConfigPanel} from './PickerConfigPanel'; diff --git a/example/src/picker-config/withExamplePickerConfig.tsx b/example/src/picker-config/withExamplePickerConfig.tsx deleted file mode 100644 index 1dfd91c..0000000 --- a/example/src/picker-config/withExamplePickerConfig.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React, {FC, memo, useCallback, useMemo} from 'react'; -import type { - default as WheelPicker, - OnValueChanging, - WheelPickerProps, -} from '@quidone/react-native-wheel-picker'; -import {usePickerConfig} from './PickerConfigProvider'; -import WheelPickerFeedback from '@quidone/react-native-wheel-picker-feedback'; -import {withVirtualized} from '@quidone/react-native-wheel-picker'; - -const useCallFeedback = () => { - const {enabledSound, enabledImpact} = usePickerConfig(); - return useMemo(() => { - switch (true) { - case enabledSound && enabledImpact: return WheelPickerFeedback.triggerSoundAndImpact; // eslint-disable-line prettier/prettier - case enabledSound: return WheelPickerFeedback.triggerSound; // eslint-disable-line prettier/prettier - case enabledImpact: return WheelPickerFeedback.triggerImpact; // eslint-disable-line prettier/prettier - default: return () => {}; // eslint-disable-line prettier/prettier - } - }, [enabledImpact, enabledSound]); -}; - -const withExamplePickerConfig = ( - WrappedComponent: FC>, -) => { - const Wrapper = ({ - onValueChanging: onValueChangingProp, - ...restProps - }: WheelPickerProps) => { - const {enabledVirtualized, readOnly, visibleItemCount} = usePickerConfig(); - const callFeedback = useCallFeedback(); - - const onValueChanging = useCallback>( - (...args) => { - callFeedback(); - onValueChangingProp?.(...args); - }, - [callFeedback, onValueChangingProp], - ); - - const ResultComponent = useMemo(() => { - if (!enabledVirtualized) { - return WrappedComponent; - } - return withVirtualized(WrappedComponent as any); - }, [enabledVirtualized]); - - return ( - - ); - }; - - Wrapper.displayName = `withExamplePickerConfig(${WrappedComponent.displayName})`; - - return memo(Wrapper) as typeof WheelPicker; -}; - -export default withExamplePickerConfig; diff --git a/example/src/snack/contants.ts b/example/src/snack/contants.ts new file mode 100644 index 0000000..77b671a --- /dev/null +++ b/example/src/snack/contants.ts @@ -0,0 +1,2 @@ +export const WP_FEEDBACK_GITHUB_URL = + 'https://github.com/quidone/react-native-wheel-picker-feedback'; diff --git a/example/src/snack/get-random-number.ts b/example/src/snack/get-random-number.ts new file mode 100644 index 0000000..7424cd6 --- /dev/null +++ b/example/src/snack/get-random-number.ts @@ -0,0 +1,7 @@ +export const getRandomNumber = (currentValue: number, dataCount: number) => { + let randomValue; + do { + randomValue = Math.floor(Math.random() * dataCount); + } while (randomValue === currentValue); + return randomValue; +}; diff --git a/example/src/snack/index.ts b/example/src/snack/index.ts new file mode 100644 index 0000000..4836a61 --- /dev/null +++ b/example/src/snack/index.ts @@ -0,0 +1,2 @@ +export {RootNavigation} from './navigator'; +export {NativeFeedbackProvider} from './native-api-provider'; diff --git a/example/src/snack/native-api-provider/NativeFeedbackProvider.tsx b/example/src/snack/native-api-provider/NativeFeedbackProvider.tsx new file mode 100644 index 0000000..5de27ac --- /dev/null +++ b/example/src/snack/native-api-provider/NativeFeedbackProvider.tsx @@ -0,0 +1,38 @@ +import React, {createContext, type PropsWithChildren, useContext} from 'react'; + +const NativeFeedbackModuleContext = createContext< + NativeFeedbackModule | 'NOT_SUPPORT_IN_SNACK' +>('NOT_SUPPORT_IN_SNACK'); + +type NativeFeedbackModule = { + triggerImpact: () => void; + triggerSound: () => void; + triggerSoundAndImpact: () => void; +}; + +type NativeFeedbackProviderProps = PropsWithChildren<{ + module: NativeFeedbackModule | 'NOT_SUPPORT_IN_SNACK'; +}>; + +const NativeFeedbackProvider = ({ + module, + children, +}: NativeFeedbackProviderProps) => { + return ( + + {children} + + ); +}; + +export default NativeFeedbackProvider; + +export const useNativeFeedbackModule = () => { + const value = useContext(NativeFeedbackModuleContext); + if (value === undefined) { + throw new Error( + 'useNativeFeedbackModule must be called from within NativeFeedbackProvider!', + ); + } + return useContext(NativeFeedbackModuleContext)!; +}; diff --git a/example/src/snack/native-api-provider/index.ts b/example/src/snack/native-api-provider/index.ts new file mode 100644 index 0000000..9ba76a6 --- /dev/null +++ b/example/src/snack/native-api-provider/index.ts @@ -0,0 +1,4 @@ +export { + default as NativeFeedbackProvider, + useNativeFeedbackModule, +} from './NativeFeedbackProvider'; diff --git a/example/src/snack/navigator/RootNavigator.tsx b/example/src/snack/navigator/RootNavigator.tsx new file mode 100644 index 0000000..6966634 --- /dev/null +++ b/example/src/snack/navigator/RootNavigator.tsx @@ -0,0 +1,24 @@ +import {createStaticNavigation} from '@react-navigation/native'; +import {createNativeStackNavigator} from '@react-navigation/native-stack'; +import {SimplePickerScreen} from '../screens/simple-picker'; +import {MainScreen} from '../screens/main'; +import {SimplePickerAndIOSPickerScreen} from '../screens/simple-picker-and-ios-picker'; +import {CustomizedPickerScreen} from '../screens/customized-picker'; +import {SimpleDatePickerScreen} from '../screens/simple-date-picker'; +import {ControlSimpleUsage} from '../screens/control-simple-usage'; +import {WithBottomSheetScreen} from '../screens/with-bottom-sheet'; + +// @ts-ignore +const RootStackNavigator = createNativeStackNavigator({ + screens: { + Main: MainScreen, + SimplePicker: SimplePickerScreen, + SimplePickerAndIOSPicker: SimplePickerAndIOSPickerScreen, + CustomizedPicker: CustomizedPickerScreen, + SimpleDatePicker: SimpleDatePickerScreen, + ControlSimpleUsage: ControlSimpleUsage, + WithBottomSheet: WithBottomSheetScreen, + }, +}); + +export const RootNavigation = createStaticNavigation(RootStackNavigator); diff --git a/example/src/snack/navigator/index.ts b/example/src/snack/navigator/index.ts new file mode 100644 index 0000000..7d199b8 --- /dev/null +++ b/example/src/snack/navigator/index.ts @@ -0,0 +1 @@ +export {RootNavigation} from './RootNavigator'; diff --git a/example/src/picker-config/PickerConfigPanel.tsx b/example/src/snack/props-changer/PickerPropsChangerPanel.tsx similarity index 55% rename from example/src/picker-config/PickerConfigPanel.tsx rename to example/src/snack/props-changer/PickerPropsChangerPanel.tsx index 21a88d7..c534521 100644 --- a/example/src/picker-config/PickerConfigPanel.tsx +++ b/example/src/snack/props-changer/PickerPropsChangerPanel.tsx @@ -1,54 +1,94 @@ import React, {memo} from 'react'; -import {Linking, StyleSheet, Text, TouchableOpacity, View} from 'react-native'; -import {ListItemCheckBox, Divider} from '../components/base'; -import {usePickerConfig} from './PickerConfigProvider'; -import {WP_FEEDBACK_GITHUB_URL} from '../contants'; +import { + Linking, + type StyleProp, + StyleSheet, + Text, + TouchableOpacity, + View, + type ViewStyle, +} from 'react-native'; +import {ListItemCheckBox, Divider} from '../ui-base'; import {ButtonGroup} from 'react-native-elements'; import {useInit} from '@rozhkov/react-useful-hooks'; +import {usePickerPropsChanger} from './PickerPropsChangerProvider'; +import {WP_FEEDBACK_GITHUB_URL} from '../contants'; + +type PickerPropsChangerPanelProps = { + style?: StyleProp; + hideSound?: boolean; + hideImpact?: boolean; + hideVirtualized?: boolean; +}; -const PickerConfigPanel = () => { +const PickerPropsChangerPanel = ({ + style, + hideImpact, + hideSound, + hideVirtualized, +}: PickerPropsChangerPanelProps) => { const { enabledVirtualized, enabledSound, enabledImpact, readOnly, + enableScrollByTapOnItem, visibleItemCount, toggleVirtualized, toggleSound, toggleImpact, toggleReadOnly, + toggleScrollByTapOnItem, changeVisibleItemCount, - } = usePickerConfig(); + } = usePickerPropsChanger(); const visibleItemCounts = useInit(() => ['1', '3', '5', '7', '9']); return ( - - - - - - - + + {!hideSound && ( + <> + + + + )} + {!hideImpact && ( + <> + + + + )} + {!hideVirtualized && ( + <> + + + + )} + + Visible Count: void; toggleImpact: () => void; toggleVirtualized: () => void; + toggleScrollByTapOnItem: () => void; toggleReadOnly: () => void; changeVisibleItemCount: (count: number) => void; } & PickerConfig; @@ -34,23 +36,26 @@ const alertNotAvailableFeedback = () => { ); }; -const PickerConfigProvider = ({children}: PropsWithChildren) => { +const PickerPropsChangerProvider = ({children}: PropsWithChildren) => { + const nativeFeedbackModule = useNativeFeedbackModule(); + const [config, setConfig] = useState(() => ({ enabledSound: false, enabledImpact: false, enabledVirtualized: false, + enableScrollByTapOnItem: true, readOnly: false, visibleItemCount: 5, })); const toggleSound = useStableCallback(() => { - if (IS_EXPO_SNACK) { + if (nativeFeedbackModule === 'NOT_SUPPORT_IN_SNACK') { alertNotAvailableFeedback(); return; } setConfig((prev) => ({...prev, enabledSound: !prev.enabledSound})); }); const toggleImpact = useStableCallback(() => { - if (IS_EXPO_SNACK) { + if (nativeFeedbackModule === 'NOT_SUPPORT_IN_SNACK') { alertNotAvailableFeedback(); return; } @@ -68,6 +73,12 @@ const PickerConfigProvider = ({children}: PropsWithChildren) => { readOnly: !prev.readOnly, })); }); + const toggleScrollByTapOnItem = useStableCallback(() => { + setConfig((prev) => ({ + ...prev, + enableScrollByTapOnItem: !prev.enableScrollByTapOnItem, + })); + }); const changeVisibleItemCount = useStableCallback( (count: 1 | 3 | 5 | 7 | 9 | number) => { setConfig((prev) => ({ @@ -82,6 +93,7 @@ const PickerConfigProvider = ({children}: PropsWithChildren) => { toggleSound, toggleImpact, toggleVirtualized, + toggleScrollByTapOnItem, toggleReadOnly, changeVisibleItemCount, }); @@ -89,13 +101,13 @@ const PickerConfigProvider = ({children}: PropsWithChildren) => { return {children}; }; -export default PickerConfigProvider; +export default PickerPropsChangerProvider; -export const usePickerConfig = () => { +export const usePickerPropsChanger = () => { const value = useContext(Context); if (value === undefined) { throw new Error( - `usePickerConfig must be called from within PickerConfigProvider!`, + `usePickerPropsChanger must be called from within PickerPropsChangerProvider!`, ); } return value; diff --git a/example/src/snack/props-changer/index.ts b/example/src/snack/props-changer/index.ts new file mode 100644 index 0000000..bad27ae --- /dev/null +++ b/example/src/snack/props-changer/index.ts @@ -0,0 +1,3 @@ +export {default as withPropsChanger} from './withPropsChanger'; +export {default as PickerPropsChangerPanel} from './PickerPropsChangerPanel'; +export {default as PickerPropsChangerProvider} from './PickerPropsChangerProvider'; diff --git a/example/src/snack/props-changer/withPropsChanger.tsx b/example/src/snack/props-changer/withPropsChanger.tsx new file mode 100644 index 0000000..d100d2c --- /dev/null +++ b/example/src/snack/props-changer/withPropsChanger.tsx @@ -0,0 +1,82 @@ +import React, {type FC, memo, useCallback, useMemo} from 'react'; +import type { + OnValueChanging, + WheelPickerProps, +} from '@quidone/react-native-wheel-picker'; +import {withVirtualized} from '@quidone/react-native-wheel-picker'; +import {usePickerPropsChanger} from './PickerPropsChangerProvider'; +import {useNativeFeedbackModule} from '../native-api-provider'; + +const useCallFeedback = () => { + const nativeFeedbackModule = useNativeFeedbackModule(); + const {enabledSound, enabledImpact} = usePickerPropsChanger(); + return useMemo(() => { + if (nativeFeedbackModule === 'NOT_SUPPORT_IN_SNACK') { + return () => {}; + } + switch (true) { + case enabledSound && enabledImpact: return nativeFeedbackModule.triggerSoundAndImpact; // eslint-disable-line prettier/prettier + case enabledSound: return nativeFeedbackModule.triggerSound; // eslint-disable-line prettier/prettier + case enabledImpact: return nativeFeedbackModule.triggerImpact; // eslint-disable-line prettier/prettier + default: return () => {}; // eslint-disable-line prettier/prettier + } + }, [enabledImpact, enabledSound, nativeFeedbackModule]); +}; + +const withPropsChanger = < + PropsT extends Pick< + WheelPickerProps, + | 'enableScrollByTapOnItem' + | 'visibleItemCount' + | 'readOnly' + | 'onValueChanged' + | 'onValueChanging' + >, +>( + AnyPicker: FC, +) => { + const WrappedPicker = ({ + onValueChanging: onValueChangingProp, + ...restProps + }: PropsT) => { + const { + enabledVirtualized, + readOnly, + visibleItemCount, + enableScrollByTapOnItem, + } = usePickerPropsChanger(); + const callFeedback = useCallFeedback(); + + const onValueChanging = useCallback>( + (...args) => { + callFeedback(); + onValueChangingProp?.(...args); + }, + [callFeedback, onValueChangingProp], + ); + + const ResultComponent = useMemo(() => { + if (!enabledVirtualized) { + return AnyPicker; + } + return withVirtualized(AnyPicker as any); + }, [enabledVirtualized]); + + return ( + // @ts-ignore + + ); + }; + + WrappedPicker.displayName = `withPropsChanger(${AnyPicker.displayName})`; + + return memo(WrappedPicker) as unknown as typeof AnyPicker; +}; + +export default withPropsChanger; diff --git a/example/src/snack/screens/control-simple-usage/ControlSimpleUsage.tsx b/example/src/snack/screens/control-simple-usage/ControlSimpleUsage.tsx new file mode 100644 index 0000000..6d33377 --- /dev/null +++ b/example/src/snack/screens/control-simple-usage/ControlSimpleUsage.tsx @@ -0,0 +1,138 @@ +import React, {memo, useMemo, useState} from 'react'; +import {StyleSheet, View} from 'react-native'; +import WheelPicker, { + type PickerItem, + useOnPickerValueChangedEffect, + useOnPickerValueChangingEffect, + usePickerControl, + withPickerControl, +} from '@quidone/react-native-wheel-picker'; +import {Button, Text} from 'react-native-elements'; +import {getRandomNumber} from '../../get-random-number'; + +const PickerWithControl = withPickerControl(WheelPicker); + +const generateNumbersArray = (length: number): {value: number}[] => { + return Array.from({length: length}, (_, index) => ({value: index})); +}; + +type ControlPickersMap = { + value1: {item: PickerItem}; + value2: {item: PickerItem}; +}; + +const ControlSimpleUsage = () => { + const [dataCount, setDataCount] = useState(100); + const data = useMemo(() => generateNumbersArray(dataCount), [dataCount]); + const [value, setValue] = useState({value1: 0, value2: 0}); + + const pickerControl = usePickerControl(); + + useOnPickerValueChangedEffect(pickerControl, (event) => { + console.log('[useOnPickerValueChangedEffect]', event); + setValue({ + value1: event.pickers.value1.item.value, + value2: event.pickers.value2.item.value, + }); + }); + + useOnPickerValueChangingEffect(pickerControl, (event) => { + const curValue = { + value1: event.pickers.value1.item.value, + value2: event.pickers.value2.item.value, + }; + console.log('[useOnPickerValueChangingEffect]', curValue, event); + }); + + // Обработчики нажатия на кнопки + const handleRandomValue1 = () => { + const randomValue1 = getRandomNumber(value.value1, dataCount); + setValue({...value, value1: randomValue1}); + }; + + const handleRandomValue2 = () => { + const randomValue2 = getRandomNumber(value.value2, dataCount); + setValue({...value, value2: randomValue2}); + }; + + const handleRandomBothValues = () => { + const randomValue1 = getRandomNumber(value.value1, dataCount); + const randomValue2 = getRandomNumber(value.value2, dataCount); + setValue({value1: randomValue1, value2: randomValue2}); + }; + + const changeDataCount = (count: number) => { + setDataCount(count); + setValue({value1: 0, value2: 0}); + }; + + return ( + + + + + + + value: {value.value1}:{value.value2} + + + +