diff --git a/apps/docs/pages/docs/v2/Components/Inputs/slider.en-US.mdx b/apps/docs/pages/docs/v2/Components/Inputs/slider.en-US.mdx new file mode 100644 index 00000000..d3abdecb --- /dev/null +++ b/apps/docs/pages/docs/v2/Components/Inputs/slider.en-US.mdx @@ -0,0 +1,84 @@ +--- +searchable: true +--- + +import { CodeEditor } from '@components/code-editor'; +import PropsTable from "@components/docs/props-table"; + +# Slider + +Component that is based on `@react-native-community/slider` library. + +https://github.com/callstack/react-native-slider + +## Import + +```js +import { Slider } from "@ficus-ui/native"; +``` + +## Usage + +### Default + + + + + + + +`} /> + +### Change value + + { + const [value, setValue] = React.useState(0.2); + + return ( + + Slider value : {Math.round(value * 100) / 100} + + + ); +} +render()`} noInline /> + +### Custom step + +`} /> + +## Props + +Extends every `Box` props and `SliderProps` from `@react-native-community/slider` + +https://github.com/callstack/react-native-slider/blob/main/package/typings/index.d.ts + +### `colorScheme` + + +### `filledTrackColor` + + +### `min` + + +### `max` + + +### `defaultValue` + \ No newline at end of file diff --git a/apps/examples/app/components-v2/Slider.tsx b/apps/examples/app/components-v2/Slider.tsx new file mode 100644 index 00000000..a0e89eaf --- /dev/null +++ b/apps/examples/app/components-v2/Slider.tsx @@ -0,0 +1,35 @@ +import { useState } from "react"; +import { SafeAreaView } from "react-native"; + +import { Slider, Text } from '@ficus-ui/native'; +import ExampleSection from "@/src/ExampleSection"; + +const SliderComponent = () => { + const [value, setValue] = useState(0.2); + + return ( + + + Slider + + + + + + + + + + + Slider value : {Math.round(value * 100) / 100} + + + + + + + + ); +}; + +export default SliderComponent; diff --git a/apps/examples/app/items-v2.ts b/apps/examples/app/items-v2.ts index 315087d8..5b95bd0f 100644 --- a/apps/examples/app/items-v2.ts +++ b/apps/examples/app/items-v2.ts @@ -14,6 +14,7 @@ import ImageComponent from '@/app/components-v2/Image'; import PressableComponent from './components-v2/Pressable'; import DividerComponent from '@/app/components-v2/Divider'; import SpinnerComponent from '@/app/components-v2/Spinner'; +import SliderComponent from '@/app/components-v2/Slider'; type ExampleComponentType = { onScreenName: string; @@ -36,5 +37,6 @@ export const components: ExampleComponentType[] = [ { navigationPath: 'Image', onScreenName: 'Image', component: ImageComponent }, { navigationPath: 'Divider', onScreenName: 'Divider', component: DividerComponent }, { navigationPath: 'Spinner', onScreenName: 'Spinner', component: SpinnerComponent }, - { navigationPath: 'Pressable', onScreenName: 'Pressable', component: SpinnerComponent }, + { navigationPath: 'Pressable', onScreenName: 'Pressable', component: PressableComponent }, + { navigationPath: 'Slider', onScreenName: 'Slider', component: SliderComponent }, ]; diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 4ed60226..1dc98090 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -11,5 +11,6 @@ export * from './image'; export * from './pressable'; export * from './divider'; export * from './spinner'; +export * from './slider'; export { ThemeProvider } from '@ficus-ui/theme'; diff --git a/packages/components/src/slider/index.tsx b/packages/components/src/slider/index.tsx new file mode 100644 index 00000000..248a100b --- /dev/null +++ b/packages/components/src/slider/index.tsx @@ -0,0 +1,98 @@ +import { useMemo } from 'react'; + +import { ThemingProps } from '@ficus-ui/style-system'; +import { getColor, useTheme } from '@ficus-ui/theme'; + +import { + type NativeFicusProps, + ficus, + forwardRef, + useMultiStyleConfig, +} from '../system'; + +interface SliderOptions { + /** + * The minimum value of the slider. + * @default 0 + */ + min?: number; + /** + * The maximum value of the slider. + * @default 100 + */ + max?: number; + /** + * The default value of the slider. + */ + defaultValue?: number; + /** + * The color of the track. + */ + trackColor?: string; + /** + * The color of the thumb. + */ + thumbColor?: string; + /** + * The color of the filled track. + */ + filledTrackColor?: string; +} + +export interface SliderProps + extends NativeFicusProps<'Slider'>, + SliderOptions, + ThemingProps<'Slider'> { + /** + * @private + * Use `trackColor` instead. + */ + minimumTrackTintColor?: string; + /** + * @private + * Use `filledTrackColor` instead. + */ + maximumTrackTintColor?: string; +} + +export const Slider = forwardRef( + function Slider(props, ref) { + const styles = useMultiStyleConfig('Slider', props); + const { theme } = useTheme(); + const { + min, + max, + defaultValue, + trackColor, + filledTrackColor, + thumbColor, + ...rest + } = props; + + const sliderColors = useMemo( + () => ({ + minimumTrackTintColor: getColor( + trackColor ?? styles.track?.bg, + theme.colors + ), + maximumTrackTintColor: getColor( + filledTrackColor ?? styles.filledTrack?.bg, + theme.colors + ), + thumbTintColor: getColor(thumbColor ?? styles.thumb?.bg, theme.colors), + }), + [trackColor, filledTrackColor, thumbColor, styles] + ); + + return ( + + ); + } +); diff --git a/packages/components/src/slider/slider.spec.tsx b/packages/components/src/slider/slider.spec.tsx new file mode 100644 index 00000000..da692f51 --- /dev/null +++ b/packages/components/src/slider/slider.spec.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +import { fireEvent, render } from '@testing-library/react-native'; + +import { Slider } from '.'; + +describe('Slider Component', () => { + it('should render correctly', () => { + const { getByTestId } = render( + + ); + expect(getByTestId('slider')).toBeTruthy(); + }); + + it('should have correct default values', () => { + const { getByTestId } = render( + + ); + const slider = getByTestId('slider'); + + expect(slider.props.minimumValue).toBe(10); + expect(slider.props.maximumValue).toBe(90); + expect(slider.props.value).toBe(30); + }); + + it('should call onValueChange when value changes', () => { + const mockOnValueChange = jest.fn(); + const { getByTestId } = render( + + ); + + fireEvent(getByTestId('slider'), 'valueChange', 75); + + expect(mockOnValueChange).toHaveBeenCalledTimes(1); + expect(mockOnValueChange).toHaveBeenCalledWith(75); + }); + + it('should apply the correct color scheme', () => { + const { getByTestId } = render( + + ); + const slider = getByTestId('slider'); + + expect(slider.props.minimumTrackTintColor).toBe('#3182ce'); + }); + + it('should apply the correct filled track color', () => { + const { getByTestId } = render( + + ); + const slider = getByTestId('slider'); + + expect(slider.props.maximumTrackTintColor).toBe('#E53E3E'); + }); +}); diff --git a/packages/components/src/spinner/index.tsx b/packages/components/src/spinner/index.tsx index f4fdc817..3b5e72d3 100644 --- a/packages/components/src/spinner/index.tsx +++ b/packages/components/src/spinner/index.tsx @@ -8,6 +8,7 @@ export const BaseSpinner = ficus('ActivityIndicator', { excludedProps: ['color'], }); +// TODO: Add to theme for default style ? export const Spinner = forwardRef((props, ref) => { const { color = 'black', ...rest } = props; diff --git a/packages/components/src/system/base-elements.ts b/packages/components/src/system/base-elements.ts index 21923b91..b7859b6d 100644 --- a/packages/components/src/system/base-elements.ts +++ b/packages/components/src/system/base-elements.ts @@ -1,3 +1,4 @@ +import RNSlider from '@react-native-community/slider'; import { ActivityIndicator as RNActivityIndicator, Image as RNImage, @@ -25,6 +26,7 @@ export const baseRNElements = { TouchableWithoutFeedback: RNTouchableWithoutFeedback, Pressable: RNPressable, ActivityIndicator: RNActivityIndicator, + Slider: RNSlider, } as const; export type BaseRNElements = keyof typeof baseRNElements; diff --git a/packages/theme/src/components/index.ts b/packages/theme/src/components/index.ts index 67615dd8..266a7dfe 100644 --- a/packages/theme/src/components/index.ts +++ b/packages/theme/src/components/index.ts @@ -1,7 +1,10 @@ import { badgeTheme } from './badge'; +import { sliderTheme } from './slider'; export { badgeTheme as Badge } from './badge'; +export { sliderTheme as Slider } from './slider'; export const components = { Badge: badgeTheme, + Slider: sliderTheme, }; diff --git a/packages/theme/src/components/slider.ts b/packages/theme/src/components/slider.ts new file mode 100644 index 00000000..6eb67fd8 --- /dev/null +++ b/packages/theme/src/components/slider.ts @@ -0,0 +1,59 @@ +import { + createMultiStyleConfigHelpers, + defineStyle, +} from '@ficus-ui/style-system'; + +const sliderParts = ['container', 'track', 'thumb', 'filledTrack'] as const; + +const { definePartsStyle, defineMultiStyleConfig } = + createMultiStyleConfigHelpers(sliderParts); + +const baseContainerStyle = defineStyle({}); + +const baseTrackStyle = defineStyle((props) => { + const { colorScheme: c } = props; + + return { + bg: `${c}.500`, + }; +}); + +const baseFilledTrackStyle = defineStyle((props) => { + const { colorScheme: c } = props; + + if (c === 'gray') { + return { + bg: `${c}.200`, + }; + } + return { + bg: `${c}.100`, + }; +}); + +const baseThumbStyle = defineStyle({ + bg: 'white', +}); + +const baseStyle = definePartsStyle((props) => ({ + container: baseContainerStyle, + track: baseTrackStyle(props), + thumb: baseThumbStyle, + filledTrack: baseFilledTrackStyle(props), +})); + +// TODO +const sizes = { + sm: {}, + md: {}, + lg: {}, +}; + +export const sliderTheme = defineMultiStyleConfig({ + baseStyle, + sizes, + defaultProps: { + size: 'md', + colorScheme: 'gray', + }, +}); diff --git a/packages/theme/src/utilities/index.ts b/packages/theme/src/utilities/index.ts new file mode 100644 index 00000000..83c49128 --- /dev/null +++ b/packages/theme/src/utilities/index.ts @@ -0,0 +1,102 @@ +import * as React from 'react'; +import { useEffect, useRef, useState } from 'react'; + +import { Dimensions } from 'react-native'; +import { + validateHTMLColorHex, + validateHTMLColorHsl, + validateHTMLColorName, + validateHTMLColorRgb, + validateHTMLColorSpecialName, +} from 'validate-color'; + +const WINDOW = Dimensions.get('window'); + +export const WINDOW_WIDTH = WINDOW.width; +export const WINDOW_HEIGHT = WINDOW.height; + +//is the value an empty array? +export const isEmptyArray = (value?: any) => + Array.isArray(value) && value.length === 0; + +// is the given object a Function? +export const isFunction = (obj: any): obj is Function => + typeof obj === 'function'; + +// is the given object an Object? +export const isObject = (obj: any): obj is Object => + obj !== null && typeof obj === 'object'; + +// is the given object an integer? +export const isInteger = (obj: any): boolean => + String(Math.floor(Number(obj))) === obj; + +// is the given object a string? +export const isString = (obj: any): obj is string => + Object.prototype.toString.call(obj) === '[object String]'; + +// is the given object a NaN? +// eslint-disable-next-line no-self-compare +export const isNaN = (obj: any): boolean => obj !== obj; + +// Does a React component have exactly 0 children? +export const isEmptyChildren = (children: any): boolean => + React.Children.count(children) === 0; + +// is the given object/value a promise? +export const isPromise = (value: any): value is PromiseLike => + isObject(value) && isFunction(value.then); + +// is the given object/value a type of synthetic event? +export const isInputEvent = (value: any): value is React.SyntheticEvent => + value && isObject(value) && isObject(value.target); + +/** + * useState with callback + * + * @param initialState + */ +export const useStateCallback = (initialState: any) => { + const [state, setState] = useState(initialState); + const cbRef = useRef(null); // mutable ref to store current callback + + const setStateCallback = (newState: any, cb: any) => { + cbRef.current = cb; // store passed callback to ref + setState(newState); + }; + + useEffect(() => { + // cb.current is `null` on initial render, so we only execute cb on state *updates* + if (cbRef.current) { + //@ts-ignore + cbRef.current(state); + cbRef.current = null; // reset callback after execution + } + }, [state]); + + return [state, setStateCallback]; +}; + +export const isValidColor = (color: string): boolean => { + return ( + validateHTMLColorRgb(color) || + validateHTMLColorSpecialName(color) || + validateHTMLColorHex(color) || + validateHTMLColorHsl(color) || + validateHTMLColorName(color) + ); +}; + +export const getSpecificProps = (obj: T, ...keys: string[]) => + //@ts-ignore + keys.reduce((a, c) => ({ ...a, [c]: obj[c] }), {}); + +export const removeSpecificProps = ( + obj: T, + ...keys: string[] +): T => + keys.reduce((a, c) => { + //@ts-ignore + delete a[c]; + return a; + }, obj);