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();
+ });
+});