From 336911c0c3c52ff6cbdc8592e2fb64f543510ccf Mon Sep 17 00:00:00 2001 From: David_LY Date: Fri, 2 Feb 2024 15:44:24 +0100 Subject: [PATCH 1/3] Created Slider component --- .../eslint-config-typescript-react/index.js | 1 + .../src/components/Slider/Slider.styles.ts | 53 ++++++ .../src/components/Slider/Slider.tsx | 67 +++++++ .../src/components/Slider/index.ts | 2 + .../src/components/Slider/useSlider.ts | 170 ++++++++++++++++++ .../src/components/Slider/useSliderStyle.ts | 135 ++++++++++++++ .../common-ui-web/src/components/index.ts | 1 + .../test/components/Slider.test.tsx | 164 +++++++++++++++++ yarn.lock | 2 +- 9 files changed, 594 insertions(+), 1 deletion(-) create mode 100644 packages/public/common-ui-web/src/components/Slider/Slider.styles.ts create mode 100644 packages/public/common-ui-web/src/components/Slider/Slider.tsx create mode 100644 packages/public/common-ui-web/src/components/Slider/index.ts create mode 100644 packages/public/common-ui-web/src/components/Slider/useSlider.ts create mode 100644 packages/public/common-ui-web/src/components/Slider/useSliderStyle.ts create mode 100644 packages/public/common-ui-web/test/components/Slider.test.tsx diff --git a/packages/private/eslint-config-typescript-react/index.js b/packages/private/eslint-config-typescript-react/index.js index 21dae1834..7ab9d62cb 100644 --- a/packages/private/eslint-config-typescript-react/index.js +++ b/packages/private/eslint-config-typescript-react/index.js @@ -16,6 +16,7 @@ module.exports = { 'jsx-a11y/media-has-caption': OFF, 'jsx-a11y/anchor-has-content': OFF, 'jsx-a11y/anchor-is-valid': OFF, + 'jsx-a11y/no-noninteractive-element-interactions': OFF, }, ignorePatterns: ['**/*.js', 'node_modules', 'dist'], overrides: [ 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..14a7d21d3 --- /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', + }, + sliderBarStyle: { + 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', + }, + trackStyle: { + 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..d3f639e75 --- /dev/null +++ b/packages/public/common-ui-web/src/components/Slider/Slider.tsx @@ -0,0 +1,67 @@ +import React, { useRef } from 'react'; +import { useInteractiveStatus } from '@monkvision/common'; +import { SliderProps, useSliderStyle } from './useSliderStyle'; +import { useSlider } from './useSlider'; + +/** + * Slider component allows users to select a value within a specified range by dragging a thumb along a horizontal track. + */ +export function Slider({ + min = 0, + max = 100, + value = (max - min) / 2, + primaryColor = 'secondary-xlight', + secondaryColor = 'primary', + tertiaryColor = 'primary', + 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, valueTrackStyle, trackStyle, hoverThumbStyle } = useSliderStyle({ + primaryColor, + secondaryColor, + tertiaryColor, + style, + status, + }); + + return ( +
+
+
+
+
+
+ ); +} 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..6a45dc55d --- /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 './useSliderStyle'; diff --git a/packages/public/common-ui-web/src/components/Slider/useSlider.ts b/packages/public/common-ui-web/src/components/Slider/useSlider.ts new file mode 100644 index 000000000..342f52c36 --- /dev/null +++ b/packages/public/common-ui-web/src/components/Slider/useSlider.ts @@ -0,0 +1,170 @@ +import React, { useState, useEffect } from 'react'; + +/** + * Computes the initial position of the slider thumb based on the given value within the specified range. + * @param min - The minimum value of the range. + * @param max - The maximum value of the range. + * @param value - The current value of the slider. + * @returns A number representing the initial position of the slider thumb, scaled between 0 and 100. + */ +function computeFirstThumbPosition(min: number, max: number, value: number) { + if (value > max) { + return 100; + } + if (value < min) { + return 0; + } + return ((value - min) / (max - min)) * 100; +} + +/** + * Computes the position of the slider thumb based on the user input event, slider dimensions, and configuration parameters. + * @param event - The mouse event or touch event triggering the position computation. + * @param sliderRef - Reference to the HTMLDivElement representing the slider. + * @param step - The step value for the slider. + * @param min - The minimum value of the slider range. + * @param max - The maximum value of the slider range. + * @returns The computed position of the slider thumb as a percentage. + */ +function computeThumbPosition( + event: MouseEvent | TouchEvent, + sliderRef: React.RefObject, + step: number, + min: number, + max: number, +): number { + if (!sliderRef.current) { + return 0; + } + + const sliderElement = sliderRef.current; + const sliderRect = sliderElement.getBoundingClientRect(); + + // Compute the x-coordinate relative to the slider element + 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; + + // Calculate the step incrementation based on the slider range and step value + const stepPercentage = 100 / ((max - min) / step); + // Round the position percentage to the nearest step incrementation + return Math.round(positionPercentage / stepPercentage) * stepPercentage; +} + +/** + * Computes the new value based on the rounded percentage position of the slider thumb, the slider's range, and step value. + * @param roundedPercentage - The rounded percentage position of the slider thumb. + * @param max - The maximum value of the slider range. + * @param min - The minimum value of the slider range. + * @param step - The step value for the slider. + * @returns The new value computed based on the slider's configuration. + */ +function computeNewValue(roundedPercentage: number, max: number, min: number, step: 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; +} + +/** + * Props accepted by the userSlider hook. + */ +export interface useSliderProps { + /** Reference to the HTMLDivElement representing the slider. */ + sliderRef: React.RefObject; + + /** The current value of the slider. */ + value: number; + + /** The minimum value of the slider range. */ + min: number; + + /** The maximum value of the slider range. */ + max: number; + + /** The step value for the slider. */ + step: number; + + /** Indicates whether the slider is disabled or not. */ + disabled: boolean; + + /** + * Callback function invoked when the slider value changes. + * @param value - The new value of the slider. + */ + onChange?: (value: number) => void; +} + +/** + * Custom hook for creating a slider component with customizable behavior. + * @param options - The configuration options for the slider component. + * @returns An object containing the current thumb position, a handler to start dragging the thumb, and a boolean indicating if the thumb is currently being dragged. + */ +export function useSlider({ + sliderRef, + value, + min, + max, + step, + disabled, + onChange, +}: useSliderProps) { + const [thumbPosition, setThumbPosition] = useState(computeFirstThumbPosition(min, max, value)); + const [isDragging, setIsDragging] = useState(false); + + const handleStart = () => { + setIsDragging(true); + }; + + const handleEnd = () => { + setIsDragging(false); + }; + + const handleMove = (event: MouseEvent | TouchEvent) => { + event.preventDefault(); + if (isDragging && !disabled && max > min && onChange) { + const roundedPercentage = computeThumbPosition(event, sliderRef, step, min, max); + setThumbPosition(roundedPercentage); + + const newValue = computeNewValue(roundedPercentage, max, min, step); + onChange(newValue); + } + }; + + const handleClick = (event: MouseEvent) => { + event.preventDefault(); + if (!disabled && max > min && onChange) { + const roundedPercentage = computeThumbPosition(event, sliderRef, step, min, max); + setThumbPosition(roundedPercentage); + + const newValue = computeNewValue(roundedPercentage, max, min, step); + onChange(newValue); + } + }; + + useEffect(() => { + document.addEventListener('mouseup', handleEnd); + document.addEventListener('mousemove', handleMove); + document.addEventListener('touchend', handleEnd); + document.addEventListener('touchmove', handleMove, { passive: false }); + sliderRef?.current?.addEventListener('click', handleClick); + + return () => { + document.removeEventListener('mouseup', handleEnd); + document.removeEventListener('mousemove', handleMove); + document.removeEventListener('touchend', handleEnd); + document.removeEventListener('touchmove', handleMove); + sliderRef?.current?.removeEventListener('click', handleClick); + }; + }, [isDragging]); + + return { thumbPosition, handleStart, isDragging }; +} diff --git a/packages/public/common-ui-web/src/components/Slider/useSliderStyle.ts b/packages/public/common-ui-web/src/components/Slider/useSliderStyle.ts new file mode 100644 index 000000000..0d1a40908 --- /dev/null +++ b/packages/public/common-ui-web/src/components/Slider/useSliderStyle.ts @@ -0,0 +1,135 @@ +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 background of the slider. + * @default 'secondary-xlight' + */ + primaryColor?: ColorProp; + + /** + * The secondary color used for the 2nd slider bar, growing depending on the value / thumb position. + * @default 'primary' + */ + secondaryColor?: ColorProp; + + /** + * The tertiary color used for the thumb/knob border . + * @default 'primary' + */ + tertiaryColor?: ColorProp; + + /** + * Determines if the slider is disabled. + * @default false + */ + disabled?: boolean; + + /** + * The increment value for 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?: CSSProperties; +} + +/** + * Parameters required for calculating the styles of the Slider component. + */ +type SliderStylesParams = Required< + Pick +> & { + status: InteractiveStatus; +}; + +/** + * Styles generated for the Slider component. + */ +export interface SliderStyles { + sliderStyle: CSSProperties; + trackStyle: CSSProperties; + thumbStyle: CSSProperties; + valueTrackStyle: CSSProperties; + hoverThumbStyle: 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): SliderStyles { + const { utils } = useMonkTheme(); + const { primary, secondary, tertiary } = useMemo( + () => ({ + primary: utils.getColor(params.primaryColor), + secondary: utils.getColor(params.secondaryColor), + tertiary: utils.getColor(params.tertiaryColor), + }), + [params, utils], + ); + + return useMemo(() => { + return { + sliderStyle: { + ...styles['sliderStyle'], + ...params.style, + ...(params.status === InteractiveStatus.DISABLED ? styles['sliderDisabled'] : {}), + }, + trackStyle: { + ...styles['sliderBarStyle'], + background: primary, + }, + thumbStyle: { + ...styles['thumbStyle'], + ...(params.status === InteractiveStatus.DISABLED ? { cursor: 'default' } : {}), + borderColor: tertiary, + }, + valueTrackStyle: { + ...styles['trackStyle'], + 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/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..b7a227594 --- /dev/null +++ b/packages/public/common-ui-web/test/components/Slider.test.tsx @@ -0,0 +1,164 @@ +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 diplay and change left CSSproperty when mouseMove event is trigeer', () => { + const onChange = jest.fn(); + HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({ + x: 0, + y: 0, + width: 100, + height: 100, + top: 0, + left: 0, + bottom: 100, + right: 100, + toJSON: () => ({}), + })); + const { unmount } = render(); + const thumbEl = screen.getByTestId('thumb'); + let thumbElInitalPos = 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(thumbElInitalPos); + + thumbElInitalPos = thumbEl.style.left; + fireEvent.mouseMove(thumbEl, { clientX: 100 }); + expect(thumbEl.style.left).not.toEqual(thumbElInitalPos); + + fireEvent.mouseUp(thumbEl); + HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({ + x: 0, + y: 0, + width: 100, + height: 100, + top: 0, + left: 0, + bottom: 100, + right: 100, + toJSON: () => ({}), + })); + 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(); + HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({ + x: 0, + y: 0, + width: 1000, + height: 100, + top: 0, + left: 0, + bottom: 100, + right: 100, + toJSON: () => ({}), + })); + + const { unmount } = render(); + + const thumbEl = screen.getByTestId('thumb'); + fireEvent.mouseDown(thumbEl); + fireEvent.click(thumbEl, { clientX: 57 }); + expect(thumbEl.style.left.toString().includes('.')).toBe(true); + + unmount(); + }); +}); diff --git a/yarn.lock b/yarn.lock index 34ac4b5d0..f8a454543 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3723,7 +3723,7 @@ __metadata: languageName: unknown linkType: soft -"@monkvision/network@4.0.0, @monkvision/network@workspace:packages/public/network": +"@monkvision/network@workspace:packages/public/network": version: 0.0.0-use.local resolution: "@monkvision/network@workspace:packages/public/network" dependencies: From 3c590ff85c26bfc95ce9c6ca8089e68dc0e4b72b Mon Sep 17 00:00:00 2001 From: David_LY Date: Tue, 6 Feb 2024 18:08:09 +0100 Subject: [PATCH 2/3] fixed PR --- .../src/components/Slider/Slider.styles.ts | 4 +- .../src/components/Slider/Slider.tsx | 29 ++--- .../src/components/Slider/useSlider.ts | 116 +++++------------- .../src/components/Slider/useSliderStyle.ts | 61 ++++----- .../test/components/Slider.test.tsx | 38 +++--- 5 files changed, 91 insertions(+), 157 deletions(-) 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 index 14a7d21d3..11704d643 100644 --- a/packages/public/common-ui-web/src/components/Slider/Slider.styles.ts +++ b/packages/public/common-ui-web/src/components/Slider/Slider.styles.ts @@ -11,7 +11,7 @@ export const styles: Styles = { margin: '20px', alignItems: 'center', }, - sliderBarStyle: { + trackBarStyle: { position: 'absolute', width: '100%', height: '3px', @@ -31,7 +31,7 @@ export const styles: Styles = { width: '11px', height: '11px', }, - trackStyle: { + progressBarStyle: { position: 'absolute', height: '3px', borderRadius: '5px', diff --git a/packages/public/common-ui-web/src/components/Slider/Slider.tsx b/packages/public/common-ui-web/src/components/Slider/Slider.tsx index d3f639e75..63fd5f35c 100644 --- a/packages/public/common-ui-web/src/components/Slider/Slider.tsx +++ b/packages/public/common-ui-web/src/components/Slider/Slider.tsx @@ -1,22 +1,22 @@ -import React, { useRef } from 'react'; +import { useRef } from 'react'; import { useInteractiveStatus } from '@monkvision/common'; import { SliderProps, useSliderStyle } from './useSliderStyle'; import { useSlider } from './useSlider'; /** - * Slider component allows users to select a value within a specified range by dragging a thumb along a horizontal track. + * 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 = 'secondary-xlight', + primaryColor = 'primary', secondaryColor = 'primary', - tertiaryColor = 'primary', + tertiaryColor = 'secondary-xlight', disabled = false, step = 1, onChange, - style = {}, + style, }: SliderProps) { const sliderRef = useRef(null); const { thumbPosition, handleStart, isDragging } = useSlider({ @@ -31,13 +31,14 @@ export function Slider({ const { status, eventHandlers } = useInteractiveStatus({ disabled, }); - const { sliderStyle, thumbStyle, valueTrackStyle, trackStyle, hoverThumbStyle } = useSliderStyle({ - primaryColor, - secondaryColor, - tertiaryColor, - style, - status, - }); + const { sliderStyle, thumbStyle, progressBarStyle, trackBarStyle, hoverThumbStyle } = + useSliderStyle({ + primaryColor, + secondaryColor, + tertiaryColor, + style, + status, + }); return (
-
-
+
+
max) { return 100; } @@ -17,18 +10,9 @@ function computeFirstThumbPosition(min: number, max: number, value: number) { return ((value - min) / (max - min)) * 100; } -/** - * Computes the position of the slider thumb based on the user input event, slider dimensions, and configuration parameters. - * @param event - The mouse event or touch event triggering the position computation. - * @param sliderRef - Reference to the HTMLDivElement representing the slider. - * @param step - The step value for the slider. - * @param min - The minimum value of the slider range. - * @param max - The maximum value of the slider range. - * @returns The computed position of the slider thumb as a percentage. - */ -function computeThumbPosition( +function getThumbPosition( event: MouseEvent | TouchEvent, - sliderRef: React.RefObject, + sliderRef: RefObject, step: number, min: number, max: number, @@ -36,11 +20,8 @@ function computeThumbPosition( if (!sliderRef.current) { return 0; } - const sliderElement = sliderRef.current; const sliderRect = sliderElement.getBoundingClientRect(); - - // Compute the x-coordinate relative to the slider element const offsetX = Math.max( 0, Math.min( @@ -48,24 +29,17 @@ function computeThumbPosition( (event instanceof MouseEvent ? event.clientX : event.touches[0].clientX) - sliderRect.left, ), ); - const positionPercentage = (offsetX / sliderRect.width) * 100; - - // Calculate the step incrementation based on the slider range and step value const stepPercentage = 100 / ((max - min) / step); - // Round the position percentage to the nearest step incrementation return Math.round(positionPercentage / stepPercentage) * stepPercentage; } -/** - * Computes the new value based on the rounded percentage position of the slider thumb, the slider's range, and step value. - * @param roundedPercentage - The rounded percentage position of the slider thumb. - * @param max - The maximum value of the slider range. - * @param min - The minimum value of the slider range. - * @param step - The step value for the slider. - * @returns The new value computed based on the slider's configuration. - */ -function computeNewValue(roundedPercentage: number, max: number, min: number, step: number) { +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; @@ -74,40 +48,16 @@ function computeNewValue(roundedPercentage: number, max: number, min: number, st return Math.round(((max - min) * (roundedPercentage / 100) + min) * multiplier) / multiplier; } -/** - * Props accepted by the userSlider hook. - */ -export interface useSliderProps { - /** Reference to the HTMLDivElement representing the slider. */ - sliderRef: React.RefObject; - - /** The current value of the slider. */ +export interface useSliderParams { + sliderRef: RefObject; value: number; - - /** The minimum value of the slider range. */ min: number; - - /** The maximum value of the slider range. */ max: number; - - /** The step value for the slider. */ step: number; - - /** Indicates whether the slider is disabled or not. */ disabled: boolean; - - /** - * Callback function invoked when the slider value changes. - * @param value - The new value of the slider. - */ onChange?: (value: number) => void; } -/** - * Custom hook for creating a slider component with customizable behavior. - * @param options - The configuration options for the slider component. - * @returns An object containing the current thumb position, a handler to start dragging the thumb, and a boolean indicating if the thumb is currently being dragged. - */ export function useSlider({ sliderRef, value, @@ -116,8 +66,8 @@ export function useSlider({ step, disabled, onChange, -}: useSliderProps) { - const [thumbPosition, setThumbPosition] = useState(computeFirstThumbPosition(min, max, value)); +}: useSliderParams) { + const [thumbPosition, setThumbPosition] = useState(getFirstThumbPosition(min, max, value)); const [isDragging, setIsDragging] = useState(false); const handleStart = () => { @@ -129,42 +79,40 @@ export function useSlider({ }; const handleMove = (event: MouseEvent | TouchEvent) => { - event.preventDefault(); - if (isDragging && !disabled && max > min && onChange) { - const roundedPercentage = computeThumbPosition(event, sliderRef, step, min, max); - setThumbPosition(roundedPercentage); - - const newValue = computeNewValue(roundedPercentage, max, min, step); - onChange(newValue); - } - }; - - const handleClick = (event: MouseEvent) => { event.preventDefault(); if (!disabled && max > min && onChange) { - const roundedPercentage = computeThumbPosition(event, sliderRef, step, min, max); + const roundedPercentage = getThumbPosition(event, sliderRef, step, min, max); setThumbPosition(roundedPercentage); - const newValue = computeNewValue(roundedPercentage, max, min, step); + const newValue = getNewSliderValue(roundedPercentage, max, min, step); onChange(newValue); } }; - useEffect(() => { - document.addEventListener('mouseup', handleEnd); + useLayoutEffect(() => { + if (!isDragging) { + return () => {}; + } document.addEventListener('mousemove', handleMove); - document.addEventListener('touchend', handleEnd); document.addEventListener('touchmove', handleMove, { passive: false }); - sliderRef?.current?.addEventListener('click', handleClick); return () => { - document.removeEventListener('mouseup', handleEnd); document.removeEventListener('mousemove', handleMove); - document.removeEventListener('touchend', handleEnd); document.removeEventListener('touchmove', handleMove); - sliderRef?.current?.removeEventListener('click', handleClick); }; }, [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/useSliderStyle.ts b/packages/public/common-ui-web/src/components/Slider/useSliderStyle.ts index 0d1a40908..6018f728b 100644 --- a/packages/public/common-ui-web/src/components/Slider/useSliderStyle.ts +++ b/packages/public/common-ui-web/src/components/Slider/useSliderStyle.ts @@ -9,48 +9,56 @@ import { styles } from './Slider.styles'; 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 background of the slider. + * The primary color used for the thumb/knob border . + * * @default 'secondary-xlight' */ primaryColor?: ColorProp; /** - * The secondary color used for the 2nd slider bar, growing depending on the value / thumb position. + * 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 thumb/knob border . + * The tertiary color used for the track bar background of the slider. + * * @default 'primary' */ tertiaryColor?: ColorProp; /** * Determines if the slider is disabled. + * * @default false */ disabled?: boolean; /** * The increment value for the slider. + * * @default 1 */ step?: number; @@ -62,46 +70,31 @@ export interface SliderProps { onChange?: (value: number) => void; /** - * Optional styling + * Optional styling: `style` property allows custom CSS styles for the slider. + * `width` sets slider width; `height` has no effect. */ style?: CSSProperties; } -/** - * Parameters required for calculating the styles of the Slider component. - */ type SliderStylesParams = Required< - Pick + Pick > & { status: InteractiveStatus; + style?: CSSProperties; }; -/** - * Styles generated for the Slider component. - */ -export interface SliderStyles { - sliderStyle: CSSProperties; - trackStyle: CSSProperties; - thumbStyle: CSSProperties; - valueTrackStyle: CSSProperties; - hoverThumbStyle: 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): SliderStyles { +export function useSliderStyle(params: SliderStylesParams) { const { utils } = useMonkTheme(); - const { primary, secondary, tertiary } = useMemo( - () => ({ - primary: utils.getColor(params.primaryColor), - secondary: utils.getColor(params.secondaryColor), - tertiary: utils.getColor(params.tertiaryColor), - }), - [params, utils], - ); + const { primary, secondary, tertiary } = { + primary: utils.getColor(params.primaryColor), + secondary: utils.getColor(params.secondaryColor), + tertiary: utils.getColor(params.tertiaryColor), + }; return useMemo(() => { return { @@ -110,17 +103,17 @@ export function useSliderStyle(params: SliderStylesParams): SliderStyles { ...params.style, ...(params.status === InteractiveStatus.DISABLED ? styles['sliderDisabled'] : {}), }, - trackStyle: { - ...styles['sliderBarStyle'], - background: primary, + trackBarStyle: { + ...styles['trackBarStyle'], + background: tertiary, }, thumbStyle: { ...styles['thumbStyle'], ...(params.status === InteractiveStatus.DISABLED ? { cursor: 'default' } : {}), - borderColor: tertiary, + borderColor: primary, }, - valueTrackStyle: { - ...styles['trackStyle'], + progressBarStyle: { + ...styles['progressBarStyle'], background: secondary, }, hoverThumbStyle: { diff --git a/packages/public/common-ui-web/test/components/Slider.test.tsx b/packages/public/common-ui-web/test/components/Slider.test.tsx index b7a227594..b70610390 100644 --- a/packages/public/common-ui-web/test/components/Slider.test.tsx +++ b/packages/public/common-ui-web/test/components/Slider.test.tsx @@ -93,9 +93,12 @@ describe('Slider component', () => { unmount(); }); - it('should diplay and change left CSSproperty when mouseMove event is trigeer', () => { + it('should display and change left CSSProperty when mouseMove event is trigger', () => { const onChange = jest.fn(); - HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({ + const { unmount } = render(); + + const sliderEl = screen.getByTestId('slider'); + jest.spyOn(sliderEl, 'getBoundingClientRect').mockImplementation(() => ({ x: 0, y: 0, width: 100, @@ -106,33 +109,21 @@ describe('Slider component', () => { right: 100, toJSON: () => ({}), })); - const { unmount } = render(); const thumbEl = screen.getByTestId('thumb'); - let thumbElInitalPos = thumbEl.style.left; + 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(thumbElInitalPos); + expect(thumbEl.style.left).not.toEqual(thumbElInitialPos); - thumbElInitalPos = thumbEl.style.left; + thumbElInitialPos = thumbEl.style.left; fireEvent.mouseMove(thumbEl, { clientX: 100 }); - expect(thumbEl.style.left).not.toEqual(thumbElInitalPos); + expect(thumbEl.style.left).not.toEqual(thumbElInitialPos); fireEvent.mouseUp(thumbEl); - HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({ - x: 0, - y: 0, - width: 100, - height: 100, - top: 0, - left: 0, - bottom: 100, - right: 100, - toJSON: () => ({}), - })); expect(thumbEl.style.cursor).toEqual('grab'); unmount(); @@ -140,20 +131,21 @@ describe('Slider component', () => { it('should have float percentage when step passed as props is a float number', () => { const onChange = jest.fn(); - HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({ + + const { unmount } = render(); + + const sliderEl = screen.getByTestId('slider'); + jest.spyOn(sliderEl, 'getBoundingClientRect').mockImplementation(() => ({ x: 0, y: 0, width: 1000, - height: 100, + height: 1000, top: 0, left: 0, bottom: 100, right: 100, toJSON: () => ({}), })); - - const { unmount } = render(); - const thumbEl = screen.getByTestId('thumb'); fireEvent.mouseDown(thumbEl); fireEvent.click(thumbEl, { clientX: 57 }); From 3f3987885faab25816344c552bcb01598d1e71c1 Mon Sep 17 00:00:00 2001 From: David_LY Date: Thu, 29 Feb 2024 19:25:23 +0100 Subject: [PATCH 3/3] Added eslint-naming-convention for interface/type/enum + fix TSDoc/Doc --- .../src/views/CameraView/CameraView.tsx | 3 -- .../private/eslint-config-typescript/index.js | 6 ++++ packages/public/common-ui-web/README.md | 33 +++++++++++++++++++ .../src/components/Slider/Slider.tsx | 3 +- .../src/components/Slider/hooks/index.ts | 2 ++ .../Slider/{ => hooks}/useSlider.ts | 4 +-- .../Slider/{ => hooks}/useSliderStyle.ts | 8 ++--- .../src/components/Slider/index.ts | 2 +- yarn.lock | 2 +- 9 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 packages/public/common-ui-web/src/components/Slider/hooks/index.ts rename packages/public/common-ui-web/src/components/Slider/{ => hooks}/useSlider.ts (98%) rename packages/public/common-ui-web/src/components/Slider/{ => hooks}/useSliderStyle.ts (97%) 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.tsx b/packages/public/common-ui-web/src/components/Slider/Slider.tsx index 63fd5f35c..679a49f4e 100644 --- a/packages/public/common-ui-web/src/components/Slider/Slider.tsx +++ b/packages/public/common-ui-web/src/components/Slider/Slider.tsx @@ -1,7 +1,6 @@ import { useRef } from 'react'; import { useInteractiveStatus } from '@monkvision/common'; -import { SliderProps, useSliderStyle } from './useSliderStyle'; -import { useSlider } from './useSlider'; +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. 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/useSlider.ts b/packages/public/common-ui-web/src/components/Slider/hooks/useSlider.ts similarity index 98% rename from packages/public/common-ui-web/src/components/Slider/useSlider.ts rename to packages/public/common-ui-web/src/components/Slider/hooks/useSlider.ts index 1fd7141bd..21d2311a3 100644 --- a/packages/public/common-ui-web/src/components/Slider/useSlider.ts +++ b/packages/public/common-ui-web/src/components/Slider/hooks/useSlider.ts @@ -48,7 +48,7 @@ function getNewSliderValue( return Math.round(((max - min) * (roundedPercentage / 100) + min) * multiplier) / multiplier; } -export interface useSliderParams { +export interface UseSliderParams { sliderRef: RefObject; value: number; min: number; @@ -66,7 +66,7 @@ export function useSlider({ step, disabled, onChange, -}: useSliderParams) { +}: UseSliderParams) { const [thumbPosition, setThumbPosition] = useState(getFirstThumbPosition(min, max, value)); const [isDragging, setIsDragging] = useState(false); diff --git a/packages/public/common-ui-web/src/components/Slider/useSliderStyle.ts b/packages/public/common-ui-web/src/components/Slider/hooks/useSliderStyle.ts similarity index 97% rename from packages/public/common-ui-web/src/components/Slider/useSliderStyle.ts rename to packages/public/common-ui-web/src/components/Slider/hooks/useSliderStyle.ts index 6018f728b..8acab62bb 100644 --- a/packages/public/common-ui-web/src/components/Slider/useSliderStyle.ts +++ b/packages/public/common-ui-web/src/components/Slider/hooks/useSliderStyle.ts @@ -1,7 +1,7 @@ import { CSSProperties, useMemo } from 'react'; import { ColorProp, InteractiveStatus } from '@monkvision/types'; import { useMonkTheme } from '@monkvision/common'; -import { styles } from './Slider.styles'; +import { styles } from '../Slider.styles'; /** * Props that the Slider component can accept. @@ -31,7 +31,7 @@ export interface SliderProps { /** * The primary color used for the thumb/knob border . * - * @default 'secondary-xlight' + * @default 'primary' */ primaryColor?: ColorProp; @@ -45,7 +45,7 @@ export interface SliderProps { /** * The tertiary color used for the track bar background of the slider. * - * @default 'primary' + * @default 'secondary-xlight' */ tertiaryColor?: ColorProp; @@ -57,7 +57,7 @@ export interface SliderProps { disabled?: boolean; /** - * The increment value for the slider. + * The increment value of the slider. * * @default 1 */ diff --git a/packages/public/common-ui-web/src/components/Slider/index.ts b/packages/public/common-ui-web/src/components/Slider/index.ts index 6a45dc55d..ea4be8e36 100644 --- a/packages/public/common-ui-web/src/components/Slider/index.ts +++ b/packages/public/common-ui-web/src/components/Slider/index.ts @@ -1,2 +1,2 @@ export { Slider } from './Slider'; -export { type SliderProps } from './useSliderStyle'; +export { type SliderProps } from './hooks/useSliderStyle'; diff --git a/yarn.lock b/yarn.lock index f8a454543..34ac4b5d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3723,7 +3723,7 @@ __metadata: languageName: unknown linkType: soft -"@monkvision/network@workspace:packages/public/network": +"@monkvision/network@4.0.0, @monkvision/network@workspace:packages/public/network": version: 0.0.0-use.local resolution: "@monkvision/network@workspace:packages/public/network" dependencies: