diff --git a/apps/monk-test-app/src/views/CameraView/CameraView.tsx b/apps/monk-test-app/src/views/CameraView/CameraView.tsx index f3fd21b37..e6cf99eb9 100644 --- a/apps/monk-test-app/src/views/CameraView/CameraView.tsx +++ b/apps/monk-test-app/src/views/CameraView/CameraView.tsx @@ -1,6 +1,5 @@ import { Camera, - CameraFacingMode, CameraResolution, CompressionFormat, MonkPicture, @@ -12,7 +11,6 @@ import './CameraView.css'; export function CameraView() { const [state] = useState({ - facingMode: CameraFacingMode.ENVIRONMENT, resolution: CameraResolution.UHD_4K, compressionFormat: CompressionFormat.JPEG, quality: '0.8', @@ -27,7 +25,6 @@ export function CameraView() {
{setValue(newValue)} + + return ; +} +``` +### Props +| Prop | Type | Description | Required | Default Value | +|----------------|---------------------------|--------------------------------------------------------------------------------------------------------------|----------|----------------------| +| min | number | The minimum value of the slider. | | `0` | +| max | number | The maximum value of the slider. | | `100` | +| value | number | The current value of the slider. | | `(max - min) / 2` | +| primaryColor | ColorProp | The name or hexcode used for the thumb/knob border. | | `'primary'` | +| secondaryColor | ColorProp | The name or hexcode used for the progress bar. | | `'primary'` | +| tertiaryColor | ColorProp | The name or hexcode used for the track bar background. | | `'secondary-xlight'` | +| disabled | boolean | Boolean indicating if the slider is disabled or not. | | `false` | +| step | number | The increment value of the slider. | | `1` | +| onChange | `(value: number) => void` | Callback function invoked when the slider value changes. | | | +| style | CSSProperties | This property allows custom CSS styles for the slider. `width` sets slider width but `height` has no effect. | | | + +--- + ## Spinner ### Description A simple spinner component that displays a loading spinner. diff --git a/packages/public/common-ui-web/src/components/Slider/Slider.styles.ts b/packages/public/common-ui-web/src/components/Slider/Slider.styles.ts new file mode 100644 index 000000000..11704d643 --- /dev/null +++ b/packages/public/common-ui-web/src/components/Slider/Slider.styles.ts @@ -0,0 +1,53 @@ +import { Styles } from '@monkvision/types'; + +export const styles: Styles = { + sliderStyle: { + position: 'relative', + display: 'flex', + width: '129px', + height: '25px', + background: 'transparent', + cursor: 'pointer', + margin: '20px', + alignItems: 'center', + }, + trackBarStyle: { + position: 'absolute', + width: '100%', + height: '3px', + borderRadius: '5px', + }, + thumbStyle: { + position: 'absolute', + top: '50%', + transform: 'translate(-50%, -50%)', + background: 'white', + width: '22px', + height: '22px', + borderRadius: '50%', + border: 'solid 3px', + }, + thumbSmall: { + width: '11px', + height: '11px', + }, + progressBarStyle: { + position: 'absolute', + height: '3px', + borderRadius: '5px', + }, + sliderDisabled: { + opacity: 0.37, + cursor: 'default', + }, + hoverStyle: { + background: 'transparent', + border: 'none', + width: '25px', + height: '25px', + }, + hovered: { + border: 'solid 15px', + opacity: '15%', + }, +}; diff --git a/packages/public/common-ui-web/src/components/Slider/Slider.tsx b/packages/public/common-ui-web/src/components/Slider/Slider.tsx new file mode 100644 index 000000000..679a49f4e --- /dev/null +++ b/packages/public/common-ui-web/src/components/Slider/Slider.tsx @@ -0,0 +1,67 @@ +import { useRef } from 'react'; +import { useInteractiveStatus } from '@monkvision/common'; +import { SliderProps, useSliderStyle, useSlider } from './hooks'; + +/** + * A Slider component that allows users to select a value within a specified range by dragging along a horizontal track. + */ +export function Slider({ + min = 0, + max = 100, + value = (max - min) / 2, + primaryColor = 'primary', + secondaryColor = 'primary', + tertiaryColor = 'secondary-xlight', + disabled = false, + step = 1, + onChange, + style, +}: SliderProps) { + const sliderRef = useRef(null); + const { thumbPosition, handleStart, isDragging } = useSlider({ + sliderRef, + value, + min, + max, + step: step > 0 ? step : 1, + disabled, + onChange, + }); + const { status, eventHandlers } = useInteractiveStatus({ + disabled, + }); + const { sliderStyle, thumbStyle, progressBarStyle, trackBarStyle, hoverThumbStyle } = + useSliderStyle({ + primaryColor, + secondaryColor, + tertiaryColor, + style, + status, + }); + + return ( +
+
+
+
+
+
+ ); +} diff --git a/packages/public/common-ui-web/src/components/Slider/hooks/index.ts b/packages/public/common-ui-web/src/components/Slider/hooks/index.ts new file mode 100644 index 000000000..69ac509f3 --- /dev/null +++ b/packages/public/common-ui-web/src/components/Slider/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useSlider'; +export * from './useSliderStyle'; diff --git a/packages/public/common-ui-web/src/components/Slider/hooks/useSlider.ts b/packages/public/common-ui-web/src/components/Slider/hooks/useSlider.ts new file mode 100644 index 000000000..21d2311a3 --- /dev/null +++ b/packages/public/common-ui-web/src/components/Slider/hooks/useSlider.ts @@ -0,0 +1,118 @@ +import { useState, RefObject, useLayoutEffect } from 'react'; + +function getFirstThumbPosition(min: number, max: number, value: number): number { + if (value > max) { + return 100; + } + if (value < min) { + return 0; + } + return ((value - min) / (max - min)) * 100; +} + +function getThumbPosition( + event: MouseEvent | TouchEvent, + sliderRef: RefObject, + step: number, + min: number, + max: number, +): number { + if (!sliderRef.current) { + return 0; + } + const sliderElement = sliderRef.current; + const sliderRect = sliderElement.getBoundingClientRect(); + const offsetX = Math.max( + 0, + Math.min( + sliderRect.width, + (event instanceof MouseEvent ? event.clientX : event.touches[0].clientX) - sliderRect.left, + ), + ); + const positionPercentage = (offsetX / sliderRect.width) * 100; + const stepPercentage = 100 / ((max - min) / step); + return Math.round(positionPercentage / stepPercentage) * stepPercentage; +} + +function getNewSliderValue( + roundedPercentage: number, + max: number, + min: number, + step: number, +): number { + let multiplier = 1; + if (!Number.isInteger(step)) { + const nbDigitAfterDot = step.toString().split('.')[1].length; + multiplier = 10 ** nbDigitAfterDot; + } + return Math.round(((max - min) * (roundedPercentage / 100) + min) * multiplier) / multiplier; +} + +export interface UseSliderParams { + sliderRef: RefObject; + value: number; + min: number; + max: number; + step: number; + disabled: boolean; + onChange?: (value: number) => void; +} + +export function useSlider({ + sliderRef, + value, + min, + max, + step, + disabled, + onChange, +}: UseSliderParams) { + const [thumbPosition, setThumbPosition] = useState(getFirstThumbPosition(min, max, value)); + const [isDragging, setIsDragging] = useState(false); + + const handleStart = () => { + setIsDragging(true); + }; + + const handleEnd = () => { + setIsDragging(false); + }; + + const handleMove = (event: MouseEvent | TouchEvent) => { + event.preventDefault(); + if (!disabled && max > min && onChange) { + const roundedPercentage = getThumbPosition(event, sliderRef, step, min, max); + setThumbPosition(roundedPercentage); + + const newValue = getNewSliderValue(roundedPercentage, max, min, step); + onChange(newValue); + } + }; + + useLayoutEffect(() => { + if (!isDragging) { + return () => {}; + } + document.addEventListener('mousemove', handleMove); + document.addEventListener('touchmove', handleMove, { passive: false }); + + return () => { + document.removeEventListener('mousemove', handleMove); + document.removeEventListener('touchmove', handleMove); + }; + }, [isDragging]); + + useLayoutEffect(() => { + document.addEventListener('mouseup', handleEnd); + document.addEventListener('touchend', handleEnd); + sliderRef?.current?.addEventListener('click', handleMove); + + return () => { + document.removeEventListener('mouseup', handleEnd); + document.removeEventListener('touchend', handleEnd); + sliderRef?.current?.removeEventListener('click', handleMove); + }; + }, []); + + return { thumbPosition, handleStart, isDragging }; +} diff --git a/packages/public/common-ui-web/src/components/Slider/hooks/useSliderStyle.ts b/packages/public/common-ui-web/src/components/Slider/hooks/useSliderStyle.ts new file mode 100644 index 000000000..8acab62bb --- /dev/null +++ b/packages/public/common-ui-web/src/components/Slider/hooks/useSliderStyle.ts @@ -0,0 +1,128 @@ +import { CSSProperties, useMemo } from 'react'; +import { ColorProp, InteractiveStatus } from '@monkvision/types'; +import { useMonkTheme } from '@monkvision/common'; +import { styles } from '../Slider.styles'; + +/** + * Props that the Slider component can accept. + */ +export interface SliderProps { + /** + * The minimum value of the slider. + * + * @default 0 + */ + min?: number; + + /** + * The maximum value of the slider. + * + * @default 100 + */ + max?: number; + + /** + * The current value of the slider. + * + * @default (max - min) / 2 + */ + value?: number; + + /** + * The primary color used for the thumb/knob border . + * + * @default 'primary' + */ + primaryColor?: ColorProp; + + /** + * The secondary color used for the progress bar, growing depending on the value / thumb position. + * + * @default 'primary' + */ + secondaryColor?: ColorProp; + + /** + * The tertiary color used for the track bar background of the slider. + * + * @default 'secondary-xlight' + */ + tertiaryColor?: ColorProp; + + /** + * Determines if the slider is disabled. + * + * @default false + */ + disabled?: boolean; + + /** + * The increment value of the slider. + * + * @default 1 + */ + step?: number; + + /** + * Callback function invoked when the slider value changes. + * @param value - The new value of the slider. + */ + onChange?: (value: number) => void; + + /** + * Optional styling: `style` property allows custom CSS styles for the slider. + * `width` sets slider width; `height` has no effect. + */ + style?: CSSProperties; +} + +type SliderStylesParams = Required< + Pick +> & { + status: InteractiveStatus; + style?: CSSProperties; +}; + +/** + * Custom hook for generating styles for the Slider component based on provided parameters. + * @param params - Parameters for generating Slider styles. + * @returns SliderStyles object containing CSS properties for various slider elements. + */ +export function useSliderStyle(params: SliderStylesParams) { + const { utils } = useMonkTheme(); + const { primary, secondary, tertiary } = { + primary: utils.getColor(params.primaryColor), + secondary: utils.getColor(params.secondaryColor), + tertiary: utils.getColor(params.tertiaryColor), + }; + + return useMemo(() => { + return { + sliderStyle: { + ...styles['sliderStyle'], + ...params.style, + ...(params.status === InteractiveStatus.DISABLED ? styles['sliderDisabled'] : {}), + }, + trackBarStyle: { + ...styles['trackBarStyle'], + background: tertiary, + }, + thumbStyle: { + ...styles['thumbStyle'], + ...(params.status === InteractiveStatus.DISABLED ? { cursor: 'default' } : {}), + borderColor: primary, + }, + progressBarStyle: { + ...styles['progressBarStyle'], + background: secondary, + }, + hoverThumbStyle: { + ...styles['thumbStyle'], + ...styles['hoverStyle'], + ...([InteractiveStatus.HOVERED, InteractiveStatus.ACTIVE].includes(params.status) + ? styles['hovered'] + : {}), + }, + }; + }, [params, utils]); +} diff --git a/packages/public/common-ui-web/src/components/Slider/index.ts b/packages/public/common-ui-web/src/components/Slider/index.ts new file mode 100644 index 000000000..ea4be8e36 --- /dev/null +++ b/packages/public/common-ui-web/src/components/Slider/index.ts @@ -0,0 +1,2 @@ +export { Slider } from './Slider'; +export { type SliderProps } from './hooks/useSliderStyle'; diff --git a/packages/public/common-ui-web/src/components/index.ts b/packages/public/common-ui-web/src/components/index.ts index 57b05ab8e..057f4f940 100644 --- a/packages/public/common-ui-web/src/components/index.ts +++ b/packages/public/common-ui-web/src/components/index.ts @@ -6,3 +6,4 @@ export * from './SightOverlay'; export * from './SwitchButton'; export * from './FullscreenModal'; export * from './BackdropDialog'; +export * from './Slider'; diff --git a/packages/public/common-ui-web/test/components/Slider.test.tsx b/packages/public/common-ui-web/test/components/Slider.test.tsx new file mode 100644 index 000000000..b70610390 --- /dev/null +++ b/packages/public/common-ui-web/test/components/Slider.test.tsx @@ -0,0 +1,156 @@ +jest.mock('@monkvision/common'); + +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { InteractiveStatus } from '@monkvision/types'; +import * as common from '@monkvision/common'; +import { Slider } from '../../src'; + +jest.spyOn(common, 'useInteractiveStatus').mockImplementation(() => ({ + status: InteractiveStatus.DEFAULT, + eventHandlers: { + onMouseLeave: jest.fn(), + onMouseEnter: jest.fn(), + onMouseDown: jest.fn(), + onMouseUp: jest.fn(), + }, +})); + +describe('Slider component', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should display the slider without any props', () => { + const { unmount } = render(); + + expect(screen.getByTestId('slider')).toBeInTheDocument(); + expect(screen.getByTestId('track')).toBeInTheDocument(); + expect(screen.getByTestId('value-track')).toBeInTheDocument(); + expect(screen.getByTestId('thumb')).toBeInTheDocument(); + expect(screen.getByTestId('hover-thumb')).toBeInTheDocument(); + + const sliderEl = screen.getByTestId('slider'); + expect(sliderEl.style.opacity).toEqual(''); + expect(sliderEl.style.cursor).toEqual('pointer'); + + unmount(); + }); + + it('should not be clickable and have opacity ON when disabled is true', () => { + jest.spyOn(common, 'useInteractiveStatus').mockImplementationOnce(() => ({ + status: InteractiveStatus.DISABLED, + eventHandlers: { + onMouseLeave: jest.fn(), + onMouseEnter: jest.fn(), + onMouseDown: jest.fn(), + onMouseUp: jest.fn(), + }, + })); + const onChange = jest.fn(); + + const { unmount } = render(); + const sliderEl = screen.getByTestId('slider'); + expect(sliderEl.style.opacity).not.toBeNull(); + expect(sliderEl.style.cursor).toEqual('default'); + + fireEvent.mouseDown(sliderEl); + fireEvent.click(sliderEl); + fireEvent.mouseMove(sliderEl, { clientX: 100 }); + fireEvent.mouseUp(sliderEl); + expect(onChange).not.toHaveBeenCalled(); + + unmount(); + }); + + it('should display the thumb in the most right when value is greater than max', () => { + const { unmount } = render(); + const thumbEl = screen.getByTestId('thumb'); + expect(thumbEl.style.left).toEqual('100%'); + + unmount(); + }); + + it('should display the thumb in the most left when value is less than min', () => { + const { unmount } = render(); + const thumbEl = screen.getByTestId('thumb'); + expect(thumbEl.style.left).toEqual('0%'); + + unmount(); + }); + + it('should call the onChange callback when clicked on and moved on', () => { + const onChange = jest.fn(); + + const { unmount } = render(); + const sliderEl = screen.getByTestId('slider'); + fireEvent.mouseDown(sliderEl); + fireEvent.click(sliderEl); + fireEvent.mouseMove(sliderEl, { clientX: 100 }); + fireEvent.mouseUp(sliderEl); + expect(onChange).toHaveBeenCalledTimes(2); + + unmount(); + }); + + it('should display and change left CSSProperty when mouseMove event is trigger', () => { + const onChange = jest.fn(); + const { unmount } = render(); + + const sliderEl = screen.getByTestId('slider'); + jest.spyOn(sliderEl, 'getBoundingClientRect').mockImplementation(() => ({ + x: 0, + y: 0, + width: 100, + height: 100, + top: 0, + left: 0, + bottom: 100, + right: 100, + toJSON: () => ({}), + })); + const thumbEl = screen.getByTestId('thumb'); + let thumbElInitialPos = thumbEl.style.left; + + expect(thumbEl.style.cursor).toEqual('grab'); + fireEvent.mouseDown(thumbEl); + expect(thumbEl.style.cursor).toEqual('grabbing'); + + fireEvent.click(thumbEl, { clientX: 10 }); + expect(thumbEl.style.left).not.toEqual(thumbElInitialPos); + + thumbElInitialPos = thumbEl.style.left; + fireEvent.mouseMove(thumbEl, { clientX: 100 }); + expect(thumbEl.style.left).not.toEqual(thumbElInitialPos); + + fireEvent.mouseUp(thumbEl); + expect(thumbEl.style.cursor).toEqual('grab'); + + unmount(); + }); + + it('should have float percentage when step passed as props is a float number', () => { + const onChange = jest.fn(); + + const { unmount } = render(); + + const sliderEl = screen.getByTestId('slider'); + jest.spyOn(sliderEl, 'getBoundingClientRect').mockImplementation(() => ({ + x: 0, + y: 0, + width: 1000, + height: 1000, + top: 0, + left: 0, + bottom: 100, + right: 100, + toJSON: () => ({}), + })); + const thumbEl = screen.getByTestId('thumb'); + fireEvent.mouseDown(thumbEl); + fireEvent.click(thumbEl, { clientX: 57 }); + expect(thumbEl.style.left.toString().includes('.')).toBe(true); + + unmount(); + }); +});