diff --git a/docs/data/material/components/slider/SliderMaterialYouPlayground.js b/docs/data/material/components/slider/SliderMaterialYouPlayground.js new file mode 100644 index 00000000000000..8fe59e7952f3ea --- /dev/null +++ b/docs/data/material/components/slider/SliderMaterialYouPlayground.js @@ -0,0 +1,49 @@ +import * as React from 'react'; +import MaterialYouUsageDemo from 'docs/src/modules/components/MaterialYouUsageDemo'; +import Slider from '@mui/material-next/Slider'; +import Box from '@mui/material/Box'; + +export default function ButtonUsage() { + return ( + ( + + Hello world + + )} + /> + ); +} diff --git a/docs/data/material/components/slider/slider.md b/docs/data/material/components/slider/slider.md index 8840884b7c7723..e68394834d7672 100644 --- a/docs/data/material/components/slider/slider.md +++ b/docs/data/material/components/slider/slider.md @@ -158,3 +158,16 @@ You can solve the issue with: left: calc(-50% - 4px); } ``` + +## Experimental API + +### Material You version + +The default Slider component follows the Material Design 2 specs. +For the Material Design 3 ([Material You](https://m3.material.io/)) version, you can use the new experimental `@mui/material-next` package: + +```js +import Slider from '@mui/material-next/Slider'; +``` + +{{"demo": "SliderMaterialYouPlayground.js", "hideToolbar": true}} diff --git a/docs/pages/experiments/md3/index.tsx b/docs/pages/experiments/md3/index.tsx index 6916f9d02b6ccd..cb784397491ab5 100644 --- a/docs/pages/experiments/md3/index.tsx +++ b/docs/pages/experiments/md3/index.tsx @@ -280,7 +280,9 @@ const customPalette = { neutral: { '0': '#000000', '10': '#201a1c', + '17': '#2e282a', '20': '#352f30', + '22': '#393335', '30': '#4c4546', '40': '#645c5e', '50': '#7e7577', @@ -288,6 +290,7 @@ const customPalette = { '70': '#b3a9aa', '80': '#cfc4c5', '90': '#ebe0e1', + '92': '#f1e5e6', '95': '#faeef0', '99': '#fffbff', '100': '#ffffff', diff --git a/packages/mui-material-next/src/Slider/Slider.spec.tsx b/packages/mui-material-next/src/Slider/Slider.spec.tsx new file mode 100644 index 00000000000000..eaeb4e7bcbc73c --- /dev/null +++ b/packages/mui-material-next/src/Slider/Slider.spec.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import Slider from '@mui/material-next/Slider'; +import { SliderOwnerState } from './Slider.types'; + +function testOnChange() { + function handleSliderChange(event: Event, value: unknown) {} + function handleSliderChangeCommitted(event: React.SyntheticEvent | Event, value: unknown) {} + ; + + function handleElementChange(event: React.ChangeEvent) {} + ; +} + +; + +// slotProps as object + 'onMouseDown event triggered' }, + input: { disabled: true }, + mark: { onClick: () => 'clicked' }, + markLabel: { className: 'markLabel' }, + rail: { className: 'rail' }, + thumb: { className: 'thumb' }, + valueLabel: { valueLabelDisplay: 'auto' }, + }} +/>; + +// slotProps as function + ({ + className: color === 'primary' ? 'root_primary' : 'root_secondary', + }), + input: ({ size }: SliderOwnerState) => ({ disabled: size === 'medium' }), + mark: ({ marked }: SliderOwnerState) => ({ + className: marked ? 'marked' : '', + }), + markLabel: ({ max }: SliderOwnerState) => ({ + className: max === 99 ? 'red' : 'normal', + }), + rail: ({ dragging }: SliderOwnerState) => ({ + className: dragging ? 'rail' : '', + }), + thumb: ({ orientation }: SliderOwnerState) => ({ + className: orientation === 'vertical' ? 'thumb_vertical' : '', + }), + }} +/>; diff --git a/packages/mui-material-next/src/Slider/Slider.test.js b/packages/mui-material-next/src/Slider/Slider.test.js new file mode 100644 index 00000000000000..b125abfebdddbc --- /dev/null +++ b/packages/mui-material-next/src/Slider/Slider.test.js @@ -0,0 +1,1440 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { spy, stub } from 'sinon'; +import { expect } from 'chai'; +import { describeConformance, act, createRenderer, fireEvent, screen } from 'test/utils'; +import { CssVarsProvider, extendTheme } from '@mui/material-next/styles'; +import BaseSlider from '@mui/base/Slider'; +import Slider, { sliderClasses as classes } from '@mui/material-next/Slider'; + +function createTouches(touches) { + return { + changedTouches: touches.map( + (touch) => + new Touch({ + target: document.body, + ...touch, + }), + ), + }; +} + +describe('', () => { + before(function beforeHook() { + // only run in supported browsers + if (typeof Touch === 'undefined') { + this.skip(); + } + }); + + let originalMatchmedia; + + beforeEach(() => { + originalMatchmedia = window.matchMedia; + window.matchMedia = () => ({ + addListener: () => {}, + removeListener: () => {}, + }); + }); + afterEach(() => { + window.matchMedia = originalMatchmedia; + }); + + const { render } = createRenderer(); + + describeConformance( + , + () => ({ + classes, + inheritComponent: 'span', + render, + refInstanceof: window.HTMLSpanElement, + muiName: 'MuiSlider', + testDeepOverrides: { slotName: 'thumb', slotClassName: classes.thumb }, + testVariantProps: { color: 'primary', orientation: 'vertical', size: 'small' }, + testStateOverrides: { prop: 'color', value: 'secondary', styleKey: 'colorSecondary' }, + ThemeProvider: CssVarsProvider, + createTheme: extendTheme, + slots: { + root: { + expectedClassName: classes.root, + }, + thumb: { + expectedClassName: classes.thumb, + }, + track: { + expectedClassName: classes.track, + }, + rail: { + expectedClassName: classes.rail, + }, + input: { + expectedClassName: classes.input, + }, + mark: { + expectedClassName: classes.mark, + }, + markLabel: { + expectedClassName: classes.markLabel, + }, + }, + skip: [ + 'componentsProp', + 'slotPropsCallback', // not supported yet + ], + }), + ); + + it('should call handlers', () => { + const handleChange = spy(); + const handleChangeCommitted = spy(); + + const { container, getByRole } = render( + , + ); + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + left: 0, + })); + const slider = getByRole('slider'); + + fireEvent.mouseDown(container.firstChild, { + buttons: 1, + clientX: 10, + }); + fireEvent.mouseUp(container.firstChild, { + buttons: 1, + clientX: 10, + }); + + expect(handleChange.callCount).to.equal(1); + expect(handleChange.args[0][1]).to.equal(10); + expect(handleChangeCommitted.callCount).to.equal(1); + expect(handleChangeCommitted.args[0][1]).to.equal(10); + + act(() => { + slider.focus(); + }); + fireEvent.change(slider, { target: { value: 23 } }); + expect(handleChange.callCount).to.equal(2); + expect(handleChangeCommitted.callCount).to.equal(2); + }); + + it('should only listen to changes from the same touchpoint', () => { + const handleChange = spy(); + const handleChangeCommitted = spy(); + const { container } = render( + , + ); + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + fireEvent.touchStart(container.firstChild, createTouches([{ identifier: 1, clientX: 0 }])); + expect(handleChange.callCount).to.equal(0); + expect(handleChangeCommitted.callCount).to.equal(0); + + fireEvent.touchStart(document.body, createTouches([{ identifier: 2, clientX: 40 }])); + expect(handleChange.callCount).to.equal(0); + expect(handleChangeCommitted.callCount).to.equal(0); + + fireEvent.touchMove(document.body, createTouches([{ identifier: 1, clientX: 1 }])); + expect(handleChange.callCount).to.equal(1); + expect(handleChangeCommitted.callCount).to.equal(0); + + fireEvent.touchMove(document.body, createTouches([{ identifier: 2, clientX: 41 }])); + expect(handleChange.callCount).to.equal(1); + expect(handleChangeCommitted.callCount).to.equal(0); + + fireEvent.touchEnd(document.body, createTouches([{ identifier: 1, clientX: 2 }])); + expect(handleChange.callCount).to.equal(1); + expect(handleChangeCommitted.callCount).to.equal(1); + }); + + it('should hedge against a dropped mouseup event', () => { + const handleChange = spy(); + const { container } = render(); + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + left: 0, + })); + + fireEvent.mouseDown(container.firstChild, { + buttons: 1, + clientX: 1, + }); + expect(handleChange.callCount).to.equal(1); + expect(handleChange.args[0][1]).to.equal(1); + + fireEvent.mouseMove(document.body, { + buttons: 1, + clientX: 10, + }); + expect(handleChange.callCount).to.equal(2); + expect(handleChange.args[1][1]).to.equal(10); + + fireEvent.mouseMove(document.body, { + buttons: 0, + clientX: 11, + }); + // The mouse's button was released, stop the dragging session. + expect(handleChange.callCount).to.equal(2); + }); + + it('should only fire onChange when the value changes', () => { + const handleChange = spy(); + const { container } = render(); + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + left: 0, + })); + + fireEvent.mouseDown(container.firstChild, { + buttons: 1, + clientX: 21, + }); + + fireEvent.mouseMove(document.body, { + buttons: 1, + clientX: 22, + }); + // Sometimes another event with the same position is fired by the browser. + fireEvent.mouseMove(document.body, { + buttons: 1, + clientX: 22, + }); + + expect(handleChange.callCount).to.equal(2); + expect(handleChange.args[0][1]).to.deep.equal(21); + expect(handleChange.args[1][1]).to.deep.equal(22); + }); + + describe('prop: classes', () => { + it('adds custom classes to the component', () => { + const selectedClasses = ['root', 'rail', 'track', 'mark']; + const customClasses = selectedClasses.reduce((acc, curr) => { + acc[curr] = `custom-${curr}`; + return acc; + }, {}); + + const { container } = render( + , + ); + + expect(container.firstChild).to.have.class(classes.root); + expect(container.firstChild).to.have.class('custom-root'); + selectedClasses.slice(1).forEach((className, index) => { + expect(container.firstChild.children[index]).to.have.class(`custom-${className}`); + }); + }); + }); + + describe('prop: orientation', () => { + it('should render with the vertical classes', () => { + const { container, getByRole } = render(); + expect(container.firstChild).to.have.class(classes.vertical); + expect(getByRole('slider')).to.have.attribute('aria-orientation', 'vertical'); + }); + + it('should report the right position', () => { + const handleChange = spy(); + const { container } = render( + , + ); + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 10, + height: 100, + bottom: 100, + left: 0, + })); + + fireEvent.touchStart( + container.firstChild, + createTouches([{ identifier: 1, clientX: 0, clientY: 20 }]), + ); + fireEvent.touchMove( + document.body, + createTouches([{ identifier: 1, clientX: 0, clientY: 22 }]), + ); + + expect(handleChange.callCount).to.equal(2); + expect(handleChange.args[0][1]).to.equal(80); + expect(handleChange.args[1][1]).to.equal(78); + }); + }); + + describe('range', () => { + it('should support keyboard', () => { + const { getAllByRole } = render(); + const [slider1, slider2] = getAllByRole('slider'); + + act(() => { + slider1.focus(); + }); + fireEvent.change(slider1, { target: { value: '21' } }); + + expect(slider1.getAttribute('aria-valuenow')).to.equal('21'); + expect(slider2.getAttribute('aria-valuenow')).to.equal('30'); + + act(() => { + slider2.focus(); + fireEvent.change(slider2, { target: { value: '31' } }); + }); + + expect(slider1.getAttribute('aria-valuenow')).to.equal('21'); + expect(slider2.getAttribute('aria-valuenow')).to.equal('31'); + + act(() => { + slider1.focus(); + }); + fireEvent.change(slider1, { target: { value: '31' } }); + + expect(slider1.getAttribute('aria-valuenow')).to.equal('31'); + expect(slider2.getAttribute('aria-valuenow')).to.equal('31'); + expect(document.activeElement).to.have.attribute('data-index', '0'); + + act(() => { + slider1.focus(); + }); + fireEvent.change(slider1, { target: { value: '32' } }); + + expect(slider1.getAttribute('aria-valuenow')).to.equal('31'); + expect(slider2.getAttribute('aria-valuenow')).to.equal('32'); + expect(document.activeElement).to.have.attribute('data-index', '1'); + }); + + it('should focus the slider when dragging', () => { + const { getByRole, getByTestId, container } = render( + , + ); + const slider = getByRole('slider'); + const thumb = getByTestId('thumb'); + + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + left: 0, + })); + + fireEvent.mouseDown(thumb, { + buttons: 1, + clientX: 1, + }); + + expect(slider).toHaveFocus(); + }); + + it('should support touch events', () => { + const handleChange = spy(); + const { container } = render(); + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + fireEvent.touchStart(container.firstChild, createTouches([{ identifier: 1, clientX: 20 }])); + + fireEvent.touchMove(document.body, createTouches([{ identifier: 1, clientX: 21 }])); + + fireEvent.touchEnd(document.body, createTouches([{ identifier: 1, clientX: 21 }])); + + fireEvent.touchStart(container.firstChild, createTouches([{ identifier: 1, clientX: 21 }])); + + fireEvent.touchMove(document.body, createTouches([{ identifier: 1, clientX: 22 }])); + + fireEvent.touchEnd(document.body, createTouches([{ identifier: 1, clientX: 22 }])); + + fireEvent.touchStart(container.firstChild, createTouches([{ identifier: 1, clientX: 22 }])); + + fireEvent.touchMove(document.body, createTouches([{ identifier: 1, clientX: 22.1 }])); + + fireEvent.touchEnd(document.body, createTouches([{ identifier: 1, clientX: 22.1 }])); + + expect(handleChange.callCount).to.equal(2); + expect(handleChange.args[0][1]).to.deep.equal([21, 30]); + expect(handleChange.args[1][1]).to.deep.equal([22, 30]); + }); + + it('should not react to right clicks', () => { + const handleChange = spy(); + const { getByRole } = render( + , + ); + const thumb = getByRole('slider'); + fireEvent.mouseDown(thumb, { button: 2 }); + expect(handleChange.callCount).to.equal(0); + }); + }); + + it('should not break when initial value is out of range', () => { + const { container } = render(); + + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + fireEvent.touchStart( + container.firstChild, + createTouches([{ identifier: 1, clientX: 100, clientY: 0 }]), + ); + + fireEvent.touchMove(document.body, createTouches([{ identifier: 1, clientX: 20, clientY: 0 }])); + }); + + it('focuses the thumb on when touching', () => { + const { getByRole } = render(); + const thumb = getByRole('slider'); + + fireEvent.touchStart(thumb, createTouches([{ identifier: 1, clientX: 0, clientY: 0 }])); + + expect(thumb).toHaveFocus(); + }); + + describe('prop: step', () => { + it('should handle a null step', () => { + const { getByRole, container } = render( + , + ); + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + const slider = getByRole('slider'); + + fireEvent.touchStart( + container.firstChild, + createTouches([{ identifier: 1, clientX: 21, clientY: 0 }]), + ); + expect(slider).to.have.attribute('aria-valuenow', '20'); + + fireEvent.change(slider, { + target: { + value: 21, + }, + }); + expect(slider).to.have.attribute('aria-valuenow', '30'); + + fireEvent.change(slider, { + target: { + value: 29, + }, + }); + expect(slider).to.have.attribute('aria-valuenow', '20'); + }); + + it('change events with non integer numbers should work', () => { + const { getByRole } = render( + , + ); + const slider = getByRole('slider'); + act(() => { + slider.focus(); + }); + + fireEvent.change(slider, { target: { value: '51.1' } }); + expect(slider).to.have.attribute('aria-valuenow', '51.1'); + + fireEvent.change(slider, { target: { value: '0.00000005' } }); + expect(slider).to.have.attribute('aria-valuenow', '5e-8'); + + fireEvent.change(slider, { target: { value: '1e-7' } }); + expect(slider).to.have.attribute('aria-valuenow', '1e-7'); + }); + + it('should round value to step precision', () => { + const { getByRole, container } = render( + , + ); + const slider = getByRole('slider'); + + act(() => { + slider.focus(); + }); + + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + act(() => { + slider.focus(); + }); + + expect(slider).to.have.attribute('aria-valuenow', '0.2'); + + fireEvent.touchStart( + container.firstChild, + createTouches([{ identifier: 1, clientX: 20, clientY: 0 }]), + ); + + fireEvent.touchMove( + document.body, + createTouches([{ identifier: 1, clientX: 80, clientY: 0 }]), + ); + expect(slider).to.have.attribute('aria-valuenow', '0.8'); + + fireEvent.touchMove( + document.body, + createTouches([{ identifier: 1, clientX: 40, clientY: 0 }]), + ); + expect(slider).to.have.attribute('aria-valuenow', '0.4'); + }); + + it('should not fail to round value to step precision when step is very small', () => { + const { getByRole, container } = render( + , + ); + const slider = getByRole('slider'); + + act(() => { + slider.focus(); + }); + + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + act(() => { + slider.focus(); + }); + + expect(slider).to.have.attribute('aria-valuenow', '2e-8'); + + fireEvent.touchStart( + container.firstChild, + createTouches([{ identifier: 1, clientX: 20, clientY: 0 }]), + ); + + fireEvent.touchMove( + document.body, + createTouches([{ identifier: 1, clientX: 80, clientY: 0 }]), + ); + expect(slider).to.have.attribute('aria-valuenow', '8e-8'); + }); + + it('should not fail to round value to step precision when step is very small and negative', () => { + const { getByRole, container } = render( + , + ); + const slider = getByRole('slider'); + + act(() => { + slider.focus(); + }); + + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + act(() => { + slider.focus(); + }); + + expect(slider).to.have.attribute('aria-valuenow', '-2e-8'); + + fireEvent.touchStart( + container.firstChild, + createTouches([{ identifier: 1, clientX: 80, clientY: 0 }]), + ); + + fireEvent.touchMove( + document.body, + createTouches([{ identifier: 1, clientX: 20, clientY: 0 }]), + ); + expect(slider).to.have.attribute('aria-valuenow', '-8e-8'); + }); + }); + + describe('prop: disabled', () => { + it('should render the disabled classes', () => { + const { container, getByRole } = render(); + expect(container.firstChild).to.have.class(classes.disabled); + expect(getByRole('slider')).not.to.have.attribute('tabIndex'); + }); + + it('should not respond to drag events after becoming disabled', function test() { + // TODO: Don't skip once a fix for https://github.com/jsdom/jsdom/issues/3029 is released. + if (/jsdom/.test(window.navigator.userAgent)) { + this.skip(); + } + + const { getByRole, setProps, container } = render(); + + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + fireEvent.touchStart( + container.firstChild, + createTouches([{ identifier: 1, clientX: 21, clientY: 0 }]), + ); + + const thumb = getByRole('slider'); + + expect(thumb).to.have.attribute('aria-valuenow', '21'); + expect(thumb).toHaveFocus(); + + setProps({ disabled: true }); + expect(thumb).not.toHaveFocus(); + expect(thumb).not.to.have.class(classes.active); + + fireEvent.touchMove( + container.firstChild, + createTouches([{ identifier: 1, clientX: 30, clientY: 0 }]), + ); + + expect(thumb).to.have.attribute('aria-valuenow', '21'); + }); + + it('is not focused (visibly) after becoming disabled', function test() { + // TODO: Don't skip once a fix for https://github.com/jsdom/jsdom/issues/3029 is released. + if (/jsdom/.test(window.navigator.userAgent)) { + this.skip(); + } + + const { getByRole, setProps } = render(); + + const thumb = getByRole('slider'); + act(() => { + thumb.focus(); + }); + setProps({ disabled: true }); + expect(thumb).not.toHaveFocus(); + expect(thumb).not.to.have.class(classes.focusVisible); + }); + + it('should be customizable in the theme', () => { + const theme = extendTheme({ + components: { + MuiSlider: { + styleOverrides: { + root: { + [`&.${classes.disabled}`]: { + mixBlendMode: 'darken', + }, + }, + }, + }, + }, + }); + + const { container } = render( + + + , + ); + expect(container.firstChild).to.toHaveComputedStyle({ + mixBlendMode: 'darken', + }); + }); + }); + + describe('prop: track', () => { + it('should render the track classes for false', () => { + const { container } = render(); + expect(container.firstChild).to.have.class(classes.trackFalse); + }); + + it('should render the track classes for inverted', () => { + const { container } = render(); + expect(container.firstChild).to.have.class(classes.trackInverted); + }); + }); + + describe('aria-valuenow', () => { + it('should update the aria-valuenow', () => { + const { getByRole } = render(); + const slider = getByRole('slider'); + act(() => { + slider.focus(); + }); + + fireEvent.change(slider, { target: { value: 51 } }); + expect(slider).to.have.attribute('aria-valuenow', '51'); + + fireEvent.change(slider, { target: { value: 52 } }); + expect(slider).to.have.attribute('aria-valuenow', '52'); + }); + }); + + describe('prop: min', () => { + it('should set the min and aria-valuemin on the input', () => { + const min = 150; + const { getByRole } = render(); + const slider = getByRole('slider'); + + expect(slider).to.have.attribute('aria-valuemin', String(min)); + expect(slider).to.have.attribute('min', String(min)); + }); + + it('should use min as the step origin', () => { + const min = 150; + const { getByRole } = render(); + const slider = getByRole('slider'); + act(() => { + slider.focus(); + }); + + expect(slider).to.have.attribute('aria-valuenow', String(min)); + }); + + it('should not go less than the min', () => { + const min = 150; + const { getByRole } = render(); + const slider = getByRole('slider'); + act(() => { + slider.focus(); + }); + + fireEvent.change(slider, { target: { value: String(min - 100) } }); + expect(slider).to.have.attribute('aria-valuenow', String(min)); + }); + }); + describe('prop: max', () => { + it('should set the max and aria-valuemax on the input', () => { + const max = 750; + const { getByRole } = render(); + const slider = getByRole('slider'); + + expect(slider).to.have.attribute('aria-valuemax', String(max)); + expect(slider).to.have.attribute('max', String(max)); + }); + + it('should not go more than the max', () => { + const max = 750; + const { getByRole } = render(); + const slider = getByRole('slider'); + act(() => { + slider.focus(); + }); + + fireEvent.change(slider, { target: { value: String(max + 100) } }); + expect(slider).to.have.attribute('aria-valuenow', String(max)); + }); + + it('should reach right edge value', () => { + const { getByRole, container } = render( + , + ); + + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + const thumb = getByRole('slider'); + act(() => { + thumb.focus(); + }); + + expect(thumb).to.have.attribute('aria-valuenow', '90'); + + fireEvent.touchStart( + container.firstChild, + createTouches([{ identifier: 1, clientX: 20, clientY: 0 }]), + ); + + fireEvent.touchMove( + document.body, + createTouches([{ identifier: 1, clientX: 100, clientY: 0 }]), + ); + expect(thumb).to.have.attribute('aria-valuenow', '106'); + + fireEvent.touchMove( + document.body, + createTouches([{ identifier: 1, clientX: 200, clientY: 0 }]), + ); + expect(thumb).to.have.attribute('aria-valuenow', '108'); + + fireEvent.touchMove( + document.body, + createTouches([{ identifier: 1, clientX: 50, clientY: 0 }]), + ); + expect(thumb).to.have.attribute('aria-valuenow', '56'); + + fireEvent.touchMove( + document.body, + createTouches([{ identifier: 1, clientX: -100, clientY: 0 }]), + ); + expect(thumb).to.have.attribute('aria-valuenow', '6'); + }); + }); + + describe('prop: valueLabelDisplay', () => { + it('should always display the value label according to on and off', () => { + const { setProps } = render( + , + ); + expect(document.querySelector(`.${classes.valueLabelOpen}`)).not.to.equal(null); + + setProps({ + valueLabelDisplay: 'off', + }); + + expect(document.querySelector(`.${classes.valueLabelOpen}`)).to.equal(null); + }); + + it('should display the value label only on hover for auto', () => { + const { getByTestId } = render( + , + ); + const thumb = getByTestId('thumb'); + expect(document.querySelector(`.${classes.valueLabelOpen}`)).to.equal(null); + + fireEvent.mouseOver(thumb); + + expect(document.querySelector(`.${classes.valueLabelOpen}`)).not.to.equal(null); + }); + + it('should be respected when using custom value label', () => { + function ValueLabelComponent(props) { + const { value, open } = props; + return ( + + {value} + + ); + } + ValueLabelComponent.propTypes = { value: PropTypes.number }; + + const { setProps } = render( + , + ); + + expect(screen.queryByTestId('value-label')).to.have.class('open'); + + setProps({ + valueLabelDisplay: 'off', + }); + + expect(screen.queryByTestId('value-label')).to.equal(null); + }); + }); + + describe('markActive state', () => { + function getActives(container) { + return Array.from(container.querySelectorAll(`.${classes.mark}`)).map((node) => + node.classList.contains(classes.markActive), + ); + } + + it('sets the marks active that are `within` the value', () => { + const marks = [{ value: 5 }, { value: 10 }, { value: 15 }]; + + const { container: container1 } = render( + , + ); + expect(getActives(container1)).to.deep.equal([true, true, false]); + + const { container: container2 } = render( + , + ); + expect(getActives(container2)).to.deep.equal([false, true, false]); + }); + + it('uses closed intervals for the within check', () => { + const { container: container1 } = render( + , + ); + expect(getActives(container1)).to.deep.equal([true, true, true]); + + const { container: container2 } = render( + , + ); + expect(getActives(container2)).to.deep.equal([true, true, false]); + }); + + it('should support inverted track', () => { + const marks = [{ value: 5 }, { value: 10 }, { value: 15 }]; + + const { container: container1 } = render( + , + ); + expect(getActives(container1)).to.deep.equal([false, false, true]); + + const { container: container2 } = render( + , + ); + expect(getActives(container2)).to.deep.equal([true, false, true]); + }); + }); + + it('should forward mouseDown', () => { + const handleMouseDown = spy(); + const { container } = render(); + fireEvent.mouseDown(container.firstChild); + expect(handleMouseDown.callCount).to.equal(1); + }); + + describe('rtl', () => { + it('should add direction css', () => { + const { getByRole } = render( + + + , + ); + const thumb = getByRole('slider'); + act(() => { + thumb.focus(); + }); + + expect(thumb.style.direction).to.equal('rtl'); + }); + + it('should handle RTL', () => { + const handleChange = spy(); + const { container, getByTestId } = render( + + + , + ); + const thumb = getByTestId('thumb'); + expect(thumb.style.right).to.equal('30%'); + + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + fireEvent.touchStart( + container.firstChild, + createTouches([{ identifier: 1, clientX: 20, clientY: 0 }]), + ); + + fireEvent.touchMove( + document.body, + createTouches([{ identifier: 1, clientX: 22, clientY: 0 }]), + ); + + expect(handleChange.callCount).to.equal(2); + expect(handleChange.args[0][1]).to.equal(80); + expect(handleChange.args[1][1]).to.equal(78); + }); + }); + + describe('warnings', () => { + beforeEach(() => { + PropTypes.resetWarningCache(); + }); + + it('should warn if aria-valuetext is provided', () => { + expect(() => { + PropTypes.checkPropTypes( + Slider.propTypes, + { classes: {}, value: [20, 50], 'aria-valuetext': 'hot' }, + 'prop', + 'MockedSlider', + ); + }).toErrorDev('MUI: You need to use the `getAriaValueText` prop instead of'); + }); + + it('should warn if aria-label is provided', () => { + expect(() => { + PropTypes.checkPropTypes( + Slider.propTypes, + { classes: {}, value: [20, 50], 'aria-label': 'hot' }, + 'prop', + 'MockedSlider', + ); + }).toErrorDev('MUI: You need to use the `getAriaLabel` prop instead of'); + }); + + it('should warn when switching from controlled to uncontrolled', () => { + const { setProps } = render(); + + expect(() => { + setProps({ value: undefined }); + }).toErrorDev( + 'MUI: A component is changing the controlled value state of Slider to be uncontrolled.', + ); + }); + + it('should warn when switching between uncontrolled to controlled', () => { + const { setProps } = render(); + + expect(() => { + setProps({ value: [20, 50] }); + }).toErrorDev( + 'MUI: A component is changing the uncontrolled value state of Slider to be controlled.', + ); + }); + }); + + it('should support getAriaValueText', () => { + const getAriaValueText = (value) => `${value}°C`; + const { getAllByRole } = render( + , + ); + const sliders = getAllByRole('slider'); + + expect(sliders[0]).to.have.attribute('aria-valuetext', '20°C'); + expect(sliders[1]).to.have.attribute('aria-valuetext', '50°C'); + }); + + it('should support getAriaLabel', () => { + const getAriaLabel = (index) => `Label ${index}`; + const { getAllByRole } = render(); + const sliders = getAllByRole('slider'); + + expect(sliders[0]).to.have.attribute('aria-label', 'Label 0'); + expect(sliders[1]).to.have.attribute('aria-label', 'Label 1'); + }); + + it('should allow customization of the marks', () => { + const { container } = render( + , + ); + expect(container.querySelectorAll(`.${classes.markLabel}`).length).to.equal(3); + expect(container.querySelectorAll(`.${classes.mark}`).length).to.equal(3); + expect(container.querySelectorAll(`.${classes.markLabel}[data-index="2"]`).length).to.equal(1); + expect(container.querySelectorAll(`.${classes.mark}[data-index="2"]`).length).to.equal(1); + }); + + it('should correctly display mark labels when ranges slider have the same start and end', () => { + const getMarks = (value) => value.map((val) => ({ value: val, label: val })); + + const { container, setProps } = render( + , + ); + expect(container.querySelectorAll(`.${classes.markLabel}`).length).to.equal(2); + + setProps({ value: [40, 60], marks: getMarks([40, 60]) }); + expect(container.querySelectorAll(`.${classes.markLabel}`).length).to.equal(2); + }); + + it('should pass "name" and "value" as part of the event.target for onChange', () => { + const handleChange = stub().callsFake((event) => event.target); + const { getByRole } = render( + , + ); + const slider = getByRole('slider'); + + act(() => { + slider.focus(); + }); + fireEvent.change(slider, { + target: { + value: 4, + }, + }); + + expect(handleChange.callCount).to.equal(1); + const target = handleChange.firstCall.returnValue; + expect(target).to.deep.equal({ + name: 'change-testing', + value: 4, + }); + }); + + describe('prop: ValueLabelComponent', () => { + it('receives the formatted value', () => { + function ValueLabelComponent(props) { + const { value } = props; + return {value}; + } + ValueLabelComponent.propTypes = { value: PropTypes.string }; + + const { getByTestId } = render( + n.toString(2)} + />, + ); + + expect(getByTestId('value-label')).to.have.text('1010'); + }); + }); + + it('should not override the event.target on touch events', () => { + const handleChange = spy(); + const handleNativeEvent = spy(); + const handleEvent = spy(); + function Test() { + React.useEffect(() => { + document.addEventListener('touchstart', handleNativeEvent); + return () => { + document.removeEventListener('touchstart', handleNativeEvent); + }; + }); + + return ( +
+ +
+ ); + } + + render(); + const slider = screen.getByTestId('slider'); + + stub(slider, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + fireEvent.touchStart(slider, createTouches([{ identifier: 1, clientX: 0 }])); + + expect(handleChange.callCount).to.equal(0); + expect(handleNativeEvent.callCount).to.equal(1); + expect(handleNativeEvent.firstCall.args[0]).to.have.property('target', slider); + expect(handleEvent.callCount).to.equal(1); + expect(handleEvent.firstCall.args[0]).to.have.property('target', slider); + }); + + it('should not override the event.target on mouse events', () => { + const handleChange = spy(); + const handleNativeEvent = spy(); + const handleEvent = spy(); + function Test() { + React.useEffect(() => { + document.addEventListener('mousedown', handleNativeEvent); + return () => { + document.removeEventListener('mousedown', handleNativeEvent); + }; + }); + + return ( +
+ +
+ ); + } + render(); + const slider = screen.getByTestId('slider'); + + stub(slider, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + fireEvent.mouseDown(slider); + + expect(handleChange.callCount).to.equal(0); + expect(handleNativeEvent.callCount).to.equal(1); + expect(handleNativeEvent.firstCall.args[0]).to.have.property('target', slider); + expect(handleEvent.callCount).to.equal(1); + expect(handleEvent.firstCall.args[0]).to.have.property('target', slider); + }); + + describe('dragging state', () => { + it('should not apply class name for click modality', () => { + const { container } = render(); + + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + fireEvent.touchStart( + container.firstChild, + createTouches([{ identifier: 1, clientX: 20, clientY: 0 }]), + ); + fireEvent.touchMove( + document.body, + createTouches([{ identifier: 1, clientX: 21, clientY: 0 }]), + ); + expect(container.firstChild).not.to.have.class(classes.dragging); + fireEvent.touchEnd(document.body, createTouches([{ identifier: 1 }])); + }); + + it('should apply class name for dragging modality', () => { + const { container } = render(); + + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + fireEvent.touchStart( + container.firstChild, + createTouches([{ identifier: 1, clientX: 20, clientY: 0 }]), + ); + fireEvent.touchMove( + document.body, + createTouches([{ identifier: 1, clientX: 200, clientY: 0 }]), + ); + fireEvent.touchMove( + document.body, + createTouches([{ identifier: 1, clientX: 200, clientY: 0 }]), + ); + + expect(container.firstChild).not.to.have.class(classes.dragging); + + fireEvent.touchMove( + document.body, + createTouches([{ identifier: 1, clientX: 200, clientY: 0 }]), + ); + + expect(container.firstChild).to.have.class(classes.dragging); + fireEvent.touchEnd(document.body, createTouches([{ identifier: 1 }])); + expect(container.firstChild).not.to.have.class(classes.dragging); + }); + }); + + it('should remove the slider from the tab sequence', () => { + render(); + expect(screen.getByRole('slider')).to.have.property('tabIndex', -1); + }); + + describe('prop: disableSwap', () => { + it('should bound the value when using the keyboard', () => { + const handleChange = spy(); + const { getAllByRole } = render( + , + ); + const [slider1, slider2] = getAllByRole('slider'); + + act(() => { + slider1.focus(); + }); + fireEvent.change(slider2, { target: { value: '19' } }); + expect(handleChange.args[0][1]).to.deep.equal([20, 20]); + expect(document.activeElement).to.have.attribute('data-index', '1'); + }); + + it('should bound the value when using the mouse', () => { + const handleChange = spy(); + const { container } = render( + , + ); + + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + fireEvent.touchStart( + container.firstChild, + createTouches([{ identifier: 1, clientX: 35, clientY: 0 }]), + ); + fireEvent.touchMove( + document.body, + createTouches([{ identifier: 1, clientX: 19, clientY: 0 }]), + ); + expect(handleChange.args[0][1]).to.deep.equal([20, 35]); + expect(handleChange.args[1][1]).to.deep.equal([20, 20]); + expect(document.activeElement).to.have.attribute('data-index', '1'); + }); + + it('should bound the value when moving the first behind the second', () => { + const handleChange = spy(); + const { container } = render( + , + ); + + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + fireEvent.touchStart( + container.firstChild, + createTouches([{ identifier: 1, clientX: 15, clientY: 0 }]), + ); + fireEvent.touchMove( + document.body, + createTouches([{ identifier: 1, clientX: 40, clientY: 0 }]), + ); + expect(handleChange.args[0][1]).to.deep.equal([15, 30]); + expect(handleChange.args[1][1]).to.deep.equal([30, 30]); + expect(document.activeElement).to.have.attribute('data-index', '0'); + }); + }); + + describe('prop: size', () => { + it('should render default slider', () => { + render(); + + const root = document.querySelector(`.${classes.root}`); + const thumb = document.querySelector(`.${classes.thumb}`); + expect(root).not.to.have.class(classes.sizeSmall); + expect(thumb).not.to.have.class(classes.thumbSizeSmall); + }); + + it('should render small slider', () => { + render(); + + const root = document.querySelector(`.${classes.root}`); + const thumb = document.querySelector(`.${classes.thumb}`); + expect(root).to.have.class(classes.sizeSmall); + expect(thumb).to.have.class(classes.thumbSizeSmall); + }); + }); + + describe('prop: slots', () => { + it('should render custom components if specified', () => { + // ARRANGE + const dataTestId = 'slider-input-testid'; + const name = 'custom-input'; + function CustomInput({ ownerState, ...props }) { + return ; + } + + // ACT + const { getByTestId } = render(); + + // ASSERT + expect(getByTestId(dataTestId).name).to.equal(name); + }); + }); + + describe('prop: slotProps', () => { + it('should forward the props to their respective components', () => { + // ARRANGE + const dataTestId = 'slider-input-testid'; + const id = 'slider-input-id'; + + // ACT + const { getByTestId } = render( + , + ); + + // ASSERT + expect(getByTestId(dataTestId).id).to.equal(id); + }); + }); + + it('marked slider should be customizable in the theme', function test() { + if (/jsdom/.test(window.navigator.userAgent)) { + this.skip(); + } + + const theme = extendTheme({ + components: { + MuiSlider: { + styleOverrides: { + marked: { + marginTop: 40, + marginBottom: 0, + }, + }, + }, + }, + }); + + const { container } = render( + + + , + ); + + expect(container.querySelector(`.${classes.marked}`)).toHaveComputedStyle({ + marginTop: '40px', + marginBottom: '0px', + }); + }); + + it('active marks should be customizable in theme', function test() { + if (/jsdom/.test(window.navigator.userAgent)) { + this.skip(); + } + + const theme = extendTheme({ + components: { + MuiSlider: { + styleOverrides: { + markActive: { + height: '10px', + width: '10px', + }, + }, + }, + }, + }); + + const { container } = render( + + + , + ); + + expect(container.querySelector(`.${classes.markActive}`)).toHaveComputedStyle({ + height: '10px', + width: '10px', + }); + }); +}); diff --git a/packages/mui-material-next/src/Slider/Slider.tsx b/packages/mui-material-next/src/Slider/Slider.tsx new file mode 100644 index 00000000000000..f1af39db48a471 --- /dev/null +++ b/packages/mui-material-next/src/Slider/Slider.tsx @@ -0,0 +1,1033 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { chainPropTypes, unstable_capitalize as capitalize } from '@mui/utils'; +import { + isHostComponent, + useSlotProps, + unstable_composeClasses as composeClasses, +} from '@mui/base'; +import useSlider, { valueToPercent } from '@mui/base/useSlider'; +import { shouldForwardProp } from '@mui/system'; +import useThemeProps from '../styles/useThemeProps'; +import styled from '../styles/styled'; +import useTheme from '../styles/useTheme'; +import shouldSpreadAdditionalProps from '../utils/shouldSpreadAdditionalProps'; +import SliderValueLabel from './SliderValueLabel'; +import sliderClasses, { getSliderUtilityClass } from './sliderClasses'; +import { SliderOwnerState, SliderTypeMap, SliderProps } from './Slider.types'; +import { MD3ColorSchemeTokens, MD3State } from '../styles'; + +function Identity(x: Type): Type { + return x; +} + +const SliderRoot = styled('span', { + name: 'MuiSlider', + slot: 'Root', + overridesResolver: (props, styles) => { + const { ownerState } = props; + + return [ + styles.root, + styles[`color${capitalize(ownerState.color || 'primary')}`], + ownerState.size !== 'medium' && styles[`size${capitalize(ownerState.size)}`], + ownerState.marked && styles.marked, + ownerState.orientation === 'vertical' && styles.vertical, + ownerState.track === 'inverted' && styles.trackInverted, + ownerState.track === false && styles.trackFalse, + ]; + }, +})<{ ownerState: SliderOwnerState }>(({ theme: { vars: tokens }, ownerState }) => ({ + borderRadius: tokens.sys.shape.corner.full, + boxSizing: 'content-box', + display: 'inline-block', + position: 'relative', + cursor: 'pointer', + touchAction: 'none', + color: tokens.sys.color[ownerState.color || 'primary'], + WebkitTapHighlightColor: 'transparent', + ...(ownerState.orientation === 'horizontal' && { + height: 4, + width: '100%', + // 40px touch target + padding: '18px 0', + ...(ownerState.size === 'small' && { + height: 2, + }), + ...(ownerState.marked && { + marginBottom: 20, + }), + }), + ...(ownerState.orientation === 'vertical' && { + height: '100%', + width: 4, + padding: '0 18px', + ...(ownerState.size === 'small' && { + width: 2, + }), + ...(ownerState.marked && { + marginRight: 44, + }), + }), + '@media print': { + colorAdjust: 'exact', + }, + [`&.${sliderClasses.disabled}`]: { + pointerEvents: 'none', + cursor: 'default', + color: tokens.sys.color.outline, + }, + [`&.${sliderClasses.dragging}`]: { + [`& .${sliderClasses.thumb}, & .${sliderClasses.track}`]: { + transition: 'none', + }, + }, +})); + +SliderRoot.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the d.ts file and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.node, +}; + +export { SliderRoot }; + +const SliderRail = styled('span', { + name: 'MuiSlider', + slot: 'Rail', + overridesResolver: (props, styles) => styles.rail, +})<{ ownerState: SliderOwnerState }>(({ theme: { vars: tokens }, ownerState }) => ({ + display: 'block', + position: 'absolute', + borderRadius: 'inherit', + backgroundColor: tokens.sys.color.surfaceContainerHighest, + boxShadow: tokens.sys.elevation[0], + ...(ownerState.orientation === 'horizontal' && { + width: '100%', + height: 'inherit', + top: '50%', + transform: 'translateY(-50%)', + }), + ...(ownerState.orientation === 'vertical' && { + height: '100%', + width: 'inherit', + left: '50%', + transform: 'translateX(-50%)', + }), + ...(ownerState.track === 'inverted' && { + backgroundColor: ownerState.disabled ? tokens.sys.color.outline : 'currentColor', + }), +})); + +SliderRail.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the d.ts file and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.node, +}; + +export { SliderRail }; + +const SliderTrack = styled('span', { + name: 'MuiSlider', + slot: 'Track', + overridesResolver: (props, styles) => styles.track, +})<{ ownerState: SliderOwnerState }>(({ theme, ownerState }) => { + const { vars: tokens } = theme; + + return { + display: 'block', + position: 'absolute', + borderRadius: 'inherit', + backgroundColor: 'currentColor', + boxShadow: tokens.sys.elevation[0], + transition: theme.transitions.create(['left', 'width', 'bottom', 'height'], { + duration: theme.transitions.duration.shortest, + }), + ...(ownerState.orientation === 'horizontal' && { + height: 'inherit', + top: '50%', + transform: 'translateY(-50%)', + }), + ...(ownerState.orientation === 'vertical' && { + width: 'inherit', + left: '50%', + transform: 'translateX(-50%)', + }), + ...(ownerState.track === false && { + display: 'none', + }), + ...(ownerState.track === 'inverted' && { + backgroundColor: tokens.sys.color.surfaceContainerHighest, + }), + }; +}); + +SliderTrack.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the d.ts file and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.node, +}; + +export { SliderTrack }; + +const SliderThumb = styled('span', { + name: 'MuiSlider', + slot: 'Thumb', + overridesResolver: (props, styles) => { + const { ownerState } = props; + return [ + styles.thumb, + styles[`thumbColor${capitalize(ownerState.color || 'primary')}`], + ownerState.size !== 'medium' && styles[`thumbSize${capitalize(ownerState.size)}`], + ]; + }, +})<{ ownerState: SliderOwnerState }>(({ theme, ownerState }) => { + const { vars: tokens } = theme; + + function getBoxShadow(state: keyof MD3State) { + return `0px 0px 0px 10px rgba(${tokens.sys.color.primaryChannel} / ${tokens.sys.state[state].stateLayerOpacity})`; + } + + return { + position: 'absolute', + width: 20, + height: 20, + boxSizing: 'border-box', + borderRadius: tokens.sys.shape.corner.full, + outline: 0, + backgroundColor: 'currentColor', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + transition: theme.transitions.create(['box-shadow', 'left', 'bottom'], { + duration: theme.transitions.duration.shortest, + }), + ...(ownerState.size === 'small' && { + width: 12, + height: 12, + }), + ...(ownerState.orientation === 'horizontal' && { + top: '50%', + transform: 'translate(-50%, -50%)', + }), + ...(ownerState.orientation === 'vertical' && { + left: '50%', + transform: 'translate(-50%, 50%)', + }), + '&:before': { + position: 'absolute', + content: '""', + borderRadius: 'inherit', + width: '100%', + height: '100%', + boxShadow: tokens.sys.elevation[1], + ...(ownerState.size === 'small' && { + boxShadow: 'none', + }), + }, + '&::after': { + position: 'absolute', + content: '""', + borderRadius: '50%', + // 40px is the hit target + width: 40, + height: 40, + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + }, + [`&:hover`]: { + boxShadow: getBoxShadow('hover'), + '@media (hover: none)': { + boxShadow: 'none', + }, + }, + [`&.${sliderClasses.focusVisible}`]: { + boxShadow: getBoxShadow('focus'), + }, + [`&.${sliderClasses.active}`]: { + boxShadow: getBoxShadow('pressed'), + }, + [`&.${sliderClasses.disabled}`]: { + boxShadow: 'none', + '&:before': { + boxShadow: tokens.sys.elevation[0], + }, + }, + }; +}); + +SliderThumb.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the d.ts file and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.node, +}; + +export { SliderThumb }; + +const StyledSliderValueLabel = styled(SliderValueLabel, { + name: 'MuiSlider', + slot: 'ValueLabel', + overridesResolver: (props, styles) => styles.valueLabel, +})<{ ownerState: SliderOwnerState }>(({ theme, ownerState }) => { + const { vars: tokens } = theme; + const letterSpacing = `${ + theme.sys.typescale.label.medium.tracking / theme.sys.typescale.label.medium.size + }rem`; + + return { + zIndex: 1, + whiteSpace: 'nowrap', + fontFamily: tokens.sys.typescale.label.medium.family, + lineHeight: `calc(${tokens.sys.typescale.label.large.lineHeight} / ${tokens.sys.typescale.label.medium.size})`, + fontWeight: tokens.sys.typescale.label.medium.weight, + fontSize: theme.typography.pxToRem(theme.sys.typescale.label.medium.size), + letterSpacing, + transition: theme.transitions.create(['transform'], { + duration: theme.transitions.duration.shortest, + }), + position: 'absolute', + backgroundColor: tokens.sys.color[ownerState.color || 'primary'], + boxShadow: tokens.sys.elevation[0], + borderRadius: '50% 50% 50% 0', + color: + tokens.sys.color[ + `on${capitalize(ownerState.color || 'primary')}` as keyof MD3ColorSchemeTokens + ], + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 28, + height: 28, + [`& .${sliderClasses.valueLabelLabel}`]: { + // compensates letter spacing being added only on the right side + paddingLeft: letterSpacing, + }, + ...(ownerState.orientation === 'horizontal' && { + top: ownerState.size === 'small' ? -32 : -36, + [`&.${sliderClasses.valueLabel}`]: { + transform: 'translateY(50%) rotate(-45deg) scale(0)', + }, + [`& .${sliderClasses.valueLabelCircle}`]: { + transform: 'rotate(45deg)', + }, + [`&.${sliderClasses.valueLabelOpen}`]: { + transform: 'translateY(0) rotate(-45deg) scale(1)', + }, + }), + ...(ownerState.orientation === 'vertical' && { + left: ownerState.size === 'small' ? -32 : -36, + [`&.${sliderClasses.valueLabel}`]: { + transform: 'translateX(50%) rotate(225deg) scale(0)', + }, + [`& .${sliderClasses.valueLabelCircle}`]: { + transform: 'rotate(-225deg)', + }, + [`&.${sliderClasses.valueLabelOpen}`]: { + transform: 'translateX(0) rotate(225deg) scale(1)', + }, + }), + ...(ownerState.size === 'small' && { + fontSize: theme.typography.pxToRem(theme.sys.typescale.label.small.size), + width: 24, + height: 24, + }), + ...(ownerState.disabled && { + backgroundColor: tokens.sys.color.outline, + color: tokens.sys.color.surface, + }), + }; +}); + +StyledSliderValueLabel.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the d.ts file and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.element, +}; + +export { StyledSliderValueLabel as SliderValueLabel }; + +const SliderMark = styled('span', { + name: 'MuiSlider', + slot: 'Mark', + shouldForwardProp: (prop) => shouldForwardProp(prop) && prop !== 'markActive', + overridesResolver: (props, styles) => { + const { markActive } = props; + + return [styles.mark, markActive && styles.markActive]; + }, +})<{ ownerState: SliderOwnerState; markActive: boolean }>( + ({ theme: { vars: tokens }, ownerState, markActive }) => ({ + position: 'absolute', + width: 2, + height: 2, + borderRadius: tokens.sys.shape.corner.full, + backgroundColor: tokens.sys.color.onSurfaceVariant, + opacity: 0.38, + ...(ownerState.orientation === 'horizontal' && { + top: '50%', + transform: 'translate(-1px, -50%)', + }), + ...(ownerState.orientation === 'vertical' && { + left: '50%', + transform: 'translate(-50%, 1px)', + }), + ...(markActive && { + backgroundColor: + tokens.sys.color[ + `on${capitalize(ownerState.color || 'primary')}` as keyof MD3ColorSchemeTokens + ], + }), + }), +); + +SliderMark.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the d.ts file and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.node, +}; + +export { SliderMark }; + +const SliderMarkLabel = styled('span', { + name: 'MuiSlider', + slot: 'MarkLabel', + shouldForwardProp: (prop) => shouldForwardProp(prop) && prop !== 'markLabelActive', + overridesResolver: (props, styles) => styles.markLabel, +})<{ ownerState: SliderOwnerState; markLabelActive: boolean }>( + ({ theme, ownerState, markLabelActive }) => { + const { vars: tokens } = theme; + + return { + fontFamily: tokens.sys.typescale.label.medium.family, + lineHeight: `calc(${tokens.sys.typescale.label.medium.lineHeight} / ${tokens.sys.typescale.label.medium.size})`, + fontWeight: tokens.sys.typescale.label.medium.weight, + fontSize: theme.typography.pxToRem(theme.sys.typescale.label.medium.size), + letterSpacing: tokens.sys.typescale.label.medium.tracking, + color: tokens.sys.color.onSurfaceVariant, + position: 'absolute', + whiteSpace: 'nowrap', + ...(ownerState.orientation === 'horizontal' && { + top: 36, + transform: 'translateX(-50%)', + '@media (pointer: coarse)': { + top: 44, + }, + }), + ...(ownerState.orientation === 'vertical' && { + left: 36, + transform: 'translateY(50%)', + '@media (pointer: coarse)': { + left: 44, + }, + }), + ...(markLabelActive && { + color: tokens.sys.color.onSurface, + }), + }; + }, +); + +SliderMarkLabel.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the d.ts file and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.node, +}; + +export { SliderMarkLabel }; + +const useUtilityClasses = (ownerState: SliderOwnerState) => { + const { disabled, dragging, marked, orientation, track, classes, color, size } = ownerState; + + const slots = { + root: [ + 'root', + disabled && 'disabled', + dragging && 'dragging', + marked && 'marked', + orientation === 'vertical' && 'vertical', + track === 'inverted' && 'trackInverted', + track === false && 'trackFalse', + color && `color${capitalize(color)}`, + size && `size${capitalize(size)}`, + ], + rail: ['rail'], + track: ['track'], + mark: ['mark'], + markActive: ['markActive'], + markLabel: ['markLabel'], + markLabelActive: ['markLabelActive'], + valueLabel: ['valueLabel'], + thumb: [ + 'thumb', + disabled && 'disabled', + size && `thumbSize${capitalize(size)}`, + color && `thumbColor${capitalize(color)}`, + ], + active: ['active'], + disabled: ['disabled'], + focusVisible: ['focusVisible'], + }; + + return composeClasses(slots, getSliderUtilityClass, classes); +}; + +const Forward = ({ children }: { children: React.ReactElement }) => children; + +const Slider = React.forwardRef(function Slider< + BaseComponentType extends React.ElementType = SliderTypeMap['defaultComponent'], +>(inProps: SliderProps, ref: React.ForwardedRef) { + const props = useThemeProps({ + props: inProps, + name: 'MuiSlider', + }); + + const theme = useTheme(); + const isRtl = theme.direction === 'rtl'; + + const { + 'aria-label': ariaLabel, + 'aria-valuetext': ariaValuetext, + 'aria-labelledby': ariaLabelledby, + component = 'span', + color = 'primary', + classes: classesProp, + className, + disableSwap = false, + disabled = false, + getAriaLabel, + getAriaValueText, + marks: marksProp = false, + max = 100, + min = 0, + name, + onChange, + onChangeCommitted, + orientation = 'horizontal', + size = 'medium', + step = 1, + scale = Identity, + slotProps = {}, + slots = {}, + tabIndex, + track = 'normal', + value: valueProp, + valueLabelDisplay = 'off', + valueLabelFormat = Identity, + ...other + } = props; + + const propsWithDefaultValues = { + ...props, + isRtl, + max, + min, + classes: classesProp, + disabled, + disableSwap, + orientation, + marks: marksProp, + color, + size, + step, + scale, + track, + valueLabelDisplay, + valueLabelFormat, + } as Partial; + + const { + axisProps, + getRootProps, + getHiddenInputProps, + getThumbProps, + open, + active, + axis, + focusedThumbIndex, + range, + dragging, + marks, + values, + trackOffset, + trackLeap, + } = useSlider({ ...propsWithDefaultValues, rootRef: ref }); + + const ownerState: SliderOwnerState = { + ...propsWithDefaultValues, + marked: marks.length > 0 && marks.some((mark) => mark.label), + dragging, + focusedThumbIndex, + }; + + const classes = useUtilityClasses(ownerState); + + const RootSlot = slots.root ?? SliderRoot; + const RailSlot = slots.rail ?? SliderRail; + const TrackSlot = slots.track ?? SliderTrack; + const ThumbSlot = slots.thumb ?? SliderThumb; + const ValueLabelSlot = slots.valueLabel ?? StyledSliderValueLabel; + const MarkSlot = slots.mark ?? SliderMark; + const MarkLabelSlot = slots.markLabel ?? SliderMarkLabel; + const InputSlot = slots.input ?? 'input'; + + const rootProps = useSlotProps({ + elementType: RootSlot, + getSlotProps: getRootProps, + externalSlotProps: slotProps.root, + externalForwardedProps: other, + additionalProps: { + ...(shouldSpreadAdditionalProps(RootSlot) && { + as: component, + }), + }, + ownerState, + className: [classes.root, className], + }); + + const railProps = useSlotProps({ + elementType: RailSlot, + externalSlotProps: slotProps.rail, + ownerState, + className: classes.rail, + }); + + const trackProps = useSlotProps({ + elementType: TrackSlot, + externalSlotProps: slotProps.track, + additionalProps: { + style: { + ...axisProps[axis].offset(trackOffset), + ...axisProps[axis].leap(trackLeap), + }, + }, + ownerState, + className: classes.track, + }); + + const thumbProps = useSlotProps({ + elementType: ThumbSlot, + getSlotProps: getThumbProps, + externalSlotProps: slotProps.thumb, + ownerState, + className: classes.thumb, + }); + + const valueLabelProps = useSlotProps({ + elementType: ValueLabelSlot, + externalSlotProps: slotProps.valueLabel, + ownerState, + className: classes.valueLabel, + }); + + const markProps = useSlotProps({ + elementType: MarkSlot, + externalSlotProps: slotProps.mark, + ownerState, + className: classes.mark, + }); + + const markLabelProps = useSlotProps({ + elementType: MarkLabelSlot, + externalSlotProps: slotProps.markLabel, + ownerState, + className: classes.markLabel, + }); + + const inputSliderProps = useSlotProps({ + elementType: InputSlot, + getSlotProps: getHiddenInputProps, + externalSlotProps: slotProps.input, + ownerState, + }); + + return ( + + + + {marks + .filter((mark) => mark.value >= min && mark.value <= max) + .map((mark, index) => { + const percent = valueToPercent(mark.value, min, max); + const style = axisProps[axis].offset(percent); + + let markActive; + if (track === false) { + markActive = values.indexOf(mark.value) !== -1; + } else { + markActive = + (track === 'normal' && + (range + ? mark.value >= values[0] && mark.value <= values[values.length - 1] + : mark.value <= values[0])) || + (track === 'inverted' && + (range + ? mark.value <= values[0] || mark.value >= values[values.length - 1] + : mark.value >= values[0])); + } + + return ( + + + {mark.label != null ? ( + + {mark.label} + + ) : null} + + ); + })} + {values.map((value, index) => { + const percent = valueToPercent(value, min, max); + const style = axisProps[axis].offset(percent); + + const ValueLabelComponent = valueLabelDisplay === 'off' ? Forward : ValueLabelSlot; + + return ( + /* TODO v6: Change component structure. It will help in avoiding the complicated React.cloneElement API added in SliderValueLabel component. Should be: Thumb -> Input, ValueLabel. Follow Joy UI's Slider structure. */ + + + + + + ); + })} + + ); +}); + +Slider.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * The label of the slider. + */ + 'aria-label': chainPropTypes(PropTypes.string, (props) => { + const range = Array.isArray(props.value || props.defaultValue); + + if (range && props['aria-label'] != null) { + return new Error( + 'MUI: You need to use the `getAriaLabel` prop instead of `aria-label` when using a range slider.', + ); + } + + return null; + }), + /** + * The id of the element containing a label for the slider. + */ + 'aria-labelledby': PropTypes.string, + /** + * A string value that provides a user-friendly name for the current value of the slider. + */ + 'aria-valuetext': chainPropTypes(PropTypes.string, (props) => { + const range = Array.isArray(props.value || props.defaultValue); + + if (range && props['aria-valuetext'] != null) { + return new Error( + 'MUI: You need to use the `getAriaValueText` prop instead of `aria-valuetext` when using a range slider.', + ); + } + + return null; + }), + /** + * Override or extend the styles applied to the component. + */ + classes: PropTypes.object, + /** + * @ignore + */ + className: PropTypes.string, + /** + * The color of the component. + * It supports both default and custom theme colors, which can be added as shown in the + * [palette customization guide](https://mui.com/material-ui/customization/palette/#adding-new-colors). + * @default 'primary' + */ + color: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + PropTypes.oneOf(['primary', 'secondary']), + PropTypes.string, + ]), + /** + * If `true`, the component is disabled. + * @default false + */ + disabled: PropTypes.bool, + /** + * If `true`, the active thumb doesn't swap when moving pointer over a thumb while dragging another thumb. + * @default false + */ + disableSwap: PropTypes.bool, + /** + * Accepts a function which returns a string value that provides a user-friendly name for the thumb labels of the slider. + * This is important for screen reader users. + * @param {number} index The thumb label's index to format. + * @returns {string} + */ + getAriaLabel: PropTypes.func, + /** + * Accepts a function which returns a string value that provides a user-friendly name for the current value of the slider. + * This is important for screen reader users. + * @param {number} value The thumb label's value to format. + * @param {number} index The thumb label's index to format. + * @returns {string} + */ + getAriaValueText: PropTypes.func, + /** + * Marks indicate predetermined values to which the user can move the slider. + * If `true` the marks are spaced according the value of the `step` prop. + * If an array, it should contain objects with `value` and an optional `label` keys. + * @default false + */ + marks: PropTypes.oneOfType([ + PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.node, + value: PropTypes.number.isRequired, + }), + ), + PropTypes.bool, + ]), + /** + * The maximum allowed value of the slider. + * Should not be equal to min. + * @default 100 + */ + max: PropTypes.number, + /** + * The minimum allowed value of the slider. + * Should not be equal to max. + * @default 0 + */ + min: PropTypes.number, + /** + * Name attribute of the hidden `input` element. + */ + name: PropTypes.string, + /** + * Callback function that is fired when the slider's value changed. + * + * @param {Event} event The event source of the callback. + * You can pull out the new value by accessing `event.target.value` (any). + * **Warning**: This is a generic event, not a change event. + * @param {number | number[]} value The new value. + * @param {number} activeThumb Index of the currently moved thumb. + */ + onChange: PropTypes.func, + /** + * Callback function that is fired when the `mouseup` is triggered. + * + * @param {React.SyntheticEvent | Event} event The event source of the callback. **Warning**: This is a generic event, not a change event. + * @param {number | number[]} value The new value. + */ + onChangeCommitted: PropTypes.func, + /** + * The component orientation. + * @default 'horizontal' + */ + orientation: PropTypes.oneOf(['horizontal', 'vertical']), + /** + * A transformation function, to change the scale of the slider. + * @param {any} x + * @returns {any} + * @default function Identity(x) { + * return x; + * } + */ + scale: PropTypes.func, + /** + * The size of the slider. + * @default 'medium' + */ + size: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + PropTypes.oneOf(['small', 'medium']), + PropTypes.string, + ]), + /** + * The props used for each slot inside the Slider. + * @default {} + */ + slotProps: PropTypes.shape({ + input: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + mark: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + markLabel: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + rail: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + thumb: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + track: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + valueLabel: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({ + children: PropTypes.element, + className: PropTypes.string, + open: PropTypes.bool, + style: PropTypes.object, + value: PropTypes.number, + valueLabelDisplay: PropTypes.oneOf(['auto', 'off', 'on']), + }), + ]), + }), + /** + * The components used for each slot inside the Slider. + * Either a string to use a HTML element or a component. + * @default {} + */ + slots: PropTypes.shape({ + input: PropTypes.elementType, + mark: PropTypes.elementType, + markLabel: PropTypes.elementType, + rail: PropTypes.elementType, + root: PropTypes.elementType, + thumb: PropTypes.elementType, + track: PropTypes.elementType, + valueLabel: PropTypes.elementType, + }), + /** + * The granularity with which the slider can step through values. (A "discrete" slider.) + * The `min` prop serves as the origin for the valid values. + * We recommend (max - min) to be evenly divisible by the step. + * + * When step is `null`, the thumb can only be slid onto marks provided with the `marks` prop. + * @default 1 + */ + step: PropTypes.number, + /** + * Tab index attribute of the hidden `input` element. + */ + tabIndex: PropTypes.number, + /** + * The track presentation: + * + * - `normal` the track will render a bar representing the slider value. + * - `inverted` the track will render a bar representing the remaining slider value. + * - `false` the track will render without a bar. + * @default 'normal' + */ + track: PropTypes.oneOf(['inverted', 'normal', false]), + /** + * The value of the slider. + * For ranged sliders, provide an array with two values. + */ + value: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]), + /** + * Controls when the value label is displayed: + * + * - `auto` the value label will display when the thumb is hovered or focused. + * - `on` will display persistently. + * - `off` will never display. + * @default 'off' + */ + valueLabelDisplay: PropTypes.oneOf(['auto', 'off', 'on']), + /** + * The format function the value label's value. + * + * When a function is provided, it should have the following signature: + * + * - {number} value The value label's value to format + * - {number} index The value label's index to format + * @param {any} x + * @returns {any} + * @default function Identity(x) { + * return x; + * } + */ + valueLabelFormat: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), +} as any; + +export default Slider; diff --git a/packages/mui-material-next/src/Slider/Slider.types.ts b/packages/mui-material-next/src/Slider/Slider.types.ts new file mode 100644 index 00000000000000..d8779b79317bf3 --- /dev/null +++ b/packages/mui-material-next/src/Slider/Slider.types.ts @@ -0,0 +1,269 @@ +import * as React from 'react'; +import { SlotComponentProps } from '@mui/base'; +import { Mark } from '@mui/base/useSlider'; +import { SxProps } from '@mui/system'; +import { OverridableStringUnion, OverrideProps, OverridableComponent } from '@mui/types'; +import { Theme } from '../styles'; +import SliderValueLabelComponent from './SliderValueLabel'; +import { SliderClasses } from './sliderClasses'; + +export interface SliderPropsColorOverrides {} + +export interface SliderPropsSizeOverrides {} + +export interface SliderSlotPropsOverrides {} + +export interface SliderOwnerState extends SliderProps { + dragging: boolean; + marked: boolean; + focusedThumbIndex: number; +} + +export interface SliderTypeMap { + props: P & { + /** + * The label of the slider. + */ + 'aria-label'?: string; + /** + * The id of the element containing a label for the slider. + */ + 'aria-labelledby'?: string; + /** + * A string value that provides a user-friendly name for the current value of the slider. + */ + 'aria-valuetext'?: string; + /** + * The color of the component. + * It supports both default and custom theme colors, which can be added as shown in the + * [palette customization guide](https://mui.com/material-ui/customization/palette/#adding-new-colors). + * @default 'primary' + */ + color?: OverridableStringUnion<'primary' | 'secondary', SliderPropsColorOverrides>; + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial; + /** + * @ignore + */ + className?: string; + /** + * The default value. Use when the component is not controlled. + */ + defaultValue?: number | number[]; + /** + * If `true`, the component is disabled. + * @default false + */ + disabled?: boolean; + /** + * If `true`, the active thumb doesn't swap when moving pointer over a thumb while dragging another thumb. + * @default false + */ + disableSwap?: boolean; + /** + * Accepts a function which returns a string value that provides a user-friendly name for the thumb labels of the slider. + * This is important for screen reader users. + * @param {number} index The thumb label's index to format. + * @returns {string} + */ + getAriaLabel?: (index: number) => string; + /** + * Accepts a function which returns a string value that provides a user-friendly name for the current value of the slider. + * This is important for screen reader users. + * @param {number} value The thumb label's value to format. + * @param {number} index The thumb label's index to format. + * @returns {string} + */ + getAriaValueText?: (value: number, index: number) => string; + /** + * Marks indicate predetermined values to which the user can move the slider. + * If `true` the marks are spaced according the value of the `step` prop. + * If an array, it should contain objects with `value` and an optional `label` keys. + * @default false + */ + marks?: boolean | Mark[]; + /** + * The maximum allowed value of the slider. + * Should not be equal to min. + * @default 100 + */ + max?: number; + /** + * The minimum allowed value of the slider. + * Should not be equal to max. + * @default 0 + */ + min?: number; + /** + * Name attribute of the hidden `input` element. + */ + name?: string; + /** + * Callback function that is fired when the slider's value changed. + * + * @param {Event} event The event source of the callback. + * You can pull out the new value by accessing `event.target.value` (any). + * **Warning**: This is a generic event, not a change event. + * @param {number | number[]} value The new value. + * @param {number} activeThumb Index of the currently moved thumb. + */ + onChange?: (event: Event, value: number | number[], activeThumb: number) => void; + /** + * Callback function that is fired when the `mouseup` is triggered. + * + * @param {React.SyntheticEvent | Event} event The event source of the callback. **Warning**: This is a generic event, not a change event. + * @param {number | number[]} value The new value. + */ + onChangeCommitted?: (event: React.SyntheticEvent | Event, value: number | number[]) => void; + /** + * The component orientation. + * @default 'horizontal' + */ + orientation?: 'horizontal' | 'vertical'; + /** + * A transformation function, to change the scale of the slider. + * @param {any} x + * @returns {any} + * @default function Identity(x) { + * return x; + * } + */ + scale?: (value: number) => number; + /** + * The size of the slider. + * @default 'medium' + */ + size?: OverridableStringUnion<'small' | 'medium', SliderPropsSizeOverrides>; + /** + * The props used for each slot inside the Slider. + * @default {} + */ + slotProps?: { + root?: SlotComponentProps<'span', SliderSlotPropsOverrides, SliderOwnerState>; + track?: SlotComponentProps<'span', SliderSlotPropsOverrides, SliderOwnerState>; + rail?: SlotComponentProps<'span', SliderSlotPropsOverrides, SliderOwnerState>; + thumb?: SlotComponentProps<'span', SliderSlotPropsOverrides, SliderOwnerState>; + mark?: SlotComponentProps<'span', SliderSlotPropsOverrides, SliderOwnerState>; + markLabel?: SlotComponentProps<'span', SliderSlotPropsOverrides, SliderOwnerState>; + valueLabel?: SlotComponentProps< + typeof SliderValueLabelComponent, + SliderSlotPropsOverrides, + SliderOwnerState + >; + input?: SlotComponentProps<'input', SliderSlotPropsOverrides, SliderOwnerState>; + }; + /** + * The components used for each slot inside the Slider. + * Either a string to use a HTML element or a component. + * @default {} + */ + slots?: { + root?: React.ElementType; + track?: React.ElementType; + rail?: React.ElementType; + thumb?: React.ElementType; + mark?: React.ElementType; + markLabel?: React.ElementType; + valueLabel?: React.ElementType; + input?: React.ElementType; + }; + /** + * The granularity with which the slider can step through values. (A "discrete" slider.) + * The `min` prop serves as the origin for the valid values. + * We recommend (max - min) to be evenly divisible by the step. + * + * When step is `null`, the thumb can only be slid onto marks provided with the `marks` prop. + * @default 1 + */ + step?: number | null; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps; + /** + * Tab index attribute of the hidden `input` element. + */ + tabIndex?: number; + /** + * The track presentation: + * + * - `normal` the track will render a bar representing the slider value. + * - `inverted` the track will render a bar representing the remaining slider value. + * - `false` the track will render without a bar. + * @default 'normal' + */ + track?: 'normal' | false | 'inverted'; + /** + * The value of the slider. + * For ranged sliders, provide an array with two values. + */ + value?: number | number[]; + /** + * Controls when the value label is displayed: + * + * - `auto` the value label will display when the thumb is hovered or focused. + * - `on` will display persistently. + * - `off` will never display. + * @default 'off' + */ + valueLabelDisplay?: 'on' | 'auto' | 'off'; + /** + * The format function the value label's value. + * + * When a function is provided, it should have the following signature: + * + * - {number} value The value label's value to format + * - {number} index The value label's index to format + * @param {any} x + * @returns {any} + * @default function Identity(x) { + * return x; + * } + */ + valueLabelFormat?: string | ((value: number, index: number) => React.ReactNode); + }; + defaultComponent: D; +} + +export interface SliderValueLabelProps extends React.HTMLAttributes { + children: React.ReactElement; + index: number; + open: boolean; + value: number; +} + +type SliderRootProps = NonNullable['root']; +type SliderMarkProps = NonNullable['mark']; +type SliderMarkLabelProps = NonNullable['markLabel']; +type SliderRailProps = NonNullable['rail']; +type SliderTrackProps = NonNullable['track']; +type SliderThumbProps = NonNullable['thumb']; + +export declare const SliderRoot: React.FC; +export declare const SliderMark: React.FC; +export declare const SliderMarkLabel: React.FC; +export declare const SliderRail: React.FC; +export declare const SliderTrack: React.FC; +export declare const SliderThumb: React.FC; +export declare const SliderValueLabel: React.FC; + +/** + * + * Demos: + * + * - [Slider](https://mui.com/material-ui/react-slider/) + * + * API: + * + * - [Slider API](https://mui.com/material-ui/api/slider/) + */ +declare const Slider: OverridableComponent; + +export type SliderProps< + D extends React.ElementType = SliderTypeMap['defaultComponent'], + P = {}, +> = OverrideProps, D>; + +export default Slider; diff --git a/packages/mui-material-next/src/Slider/SliderValueLabel.tsx b/packages/mui-material-next/src/Slider/SliderValueLabel.tsx new file mode 100644 index 00000000000000..d5f1364b87f0fe --- /dev/null +++ b/packages/mui-material-next/src/Slider/SliderValueLabel.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { SliderValueLabelProps } from './SliderValueLabel.types'; +import sliderClasses from './sliderClasses'; + +const useValueLabelClasses = (props: SliderValueLabelProps) => { + const { open } = props; + + const utilityClasses = { + offset: clsx({ + [sliderClasses.valueLabelOpen]: open, + }), + circle: sliderClasses.valueLabelCircle, + label: sliderClasses.valueLabelLabel, + }; + + return utilityClasses; +}; + +/** + * @ignore - internal component. + */ +export default function SliderValueLabel(props: SliderValueLabelProps) { + const { children, className, value } = props; + const classes = useValueLabelClasses(props); + + if (!children) { + return null; + } + + return React.cloneElement( + children, + { + className: clsx(children.props.className), + }, + + {children.props.children} + + + {value} + + + , + ); +} + +SliderValueLabel.propTypes = { + children: PropTypes.element.isRequired, + className: PropTypes.string, + value: PropTypes.node, +}; diff --git a/packages/mui-material-next/src/Slider/SliderValueLabel.types.ts b/packages/mui-material-next/src/Slider/SliderValueLabel.types.ts new file mode 100644 index 00000000000000..68fe865e14d10f --- /dev/null +++ b/packages/mui-material-next/src/Slider/SliderValueLabel.types.ts @@ -0,0 +1,23 @@ +export interface SliderValueLabelProps { + children?: React.ReactElement; + className?: string; + style?: React.CSSProperties; + /** + * If `true`, the value label is visible. + */ + open: boolean; + /** + * The value of the slider. + * For ranged sliders, provide an array with two values. + */ + value: number; + /** + * Controls when the value label is displayed: + * + * - `auto` the value label will display when the thumb is hovered or focused. + * - `on` will display persistently. + * - `off` will never display. + * @default 'off' + */ + valueLabelDisplay?: 'on' | 'auto' | 'off'; +} diff --git a/packages/mui-material-next/src/Slider/index.ts b/packages/mui-material-next/src/Slider/index.ts new file mode 100644 index 00000000000000..ac3427028230ed --- /dev/null +++ b/packages/mui-material-next/src/Slider/index.ts @@ -0,0 +1,5 @@ +export { default } from './Slider'; +export * from './Slider'; + +export { default as sliderClasses } from './sliderClasses'; +export * from './sliderClasses'; diff --git a/packages/mui-material-next/src/Slider/sliderClasses.ts b/packages/mui-material-next/src/Slider/sliderClasses.ts new file mode 100644 index 00000000000000..8c469853eef7a7 --- /dev/null +++ b/packages/mui-material-next/src/Slider/sliderClasses.ts @@ -0,0 +1,96 @@ +import { + unstable_generateUtilityClasses as generateUtilityClasses, + unstable_generateUtilityClass as generateUtilityClass, +} from '@mui/utils'; + +export interface SliderClasses { + /** Styles applied to the root element. */ + root: string; + /** Styles applied to the root element if `color="primary"`. */ + colorPrimary: string; + /** Styles applied to the root element if `color="secondary"`. */ + colorSecondary: string; + /** Styles applied to the root element if `marks` is provided with at least one label. */ + marked: string; + /** Styles applied to the root element if `orientation="vertical"`. */ + vertical: string; + /** State class applied to the root and thumb element if `disabled={true}`. */ + disabled: string; + /** State class applied to the root if a thumb is being dragged. */ + dragging: string; + /** Styles applied to the rail element. */ + rail: string; + /** Styles applied to the track element. */ + track: string; + /** Styles applied to the root element if `track={false}`. */ + trackFalse: string; + /** Styles applied to the root element if `track="inverted"`. */ + trackInverted: string; + /** Styles applied to the thumb element. */ + thumb: string; + /** State class applied to the thumb element if it's active. */ + active: string; + /** State class applied to the thumb element if keyboard focused. */ + focusVisible: string; + /** Styles applied to the mark element. */ + mark: string; + /** Styles applied to the mark element if active (depending on the value). */ + markActive: string; + /** Styles applied to the mark label element. */ + markLabel: string; + /** Styles applied to the mark label element if active (depending on the value). */ + markLabelActive: string; + /** Styles applied to the root element if `size="small"`. */ + sizeSmall: string; + /** Styles applied to the thumb element if `color="primary"`. */ + thumbColorPrimary: string; + /** Styles applied to the thumb element if `color="secondary"`. */ + thumbColorSecondary: string; + /** Styles applied to the thumb element if `size="small"`. */ + thumbSizeSmall: string; + /** Styles applied to the thumb label element. */ + valueLabel: string; + /** Styles applied to the thumb label element if it's open. */ + valueLabelOpen: string; + /** Styles applied to the thumb label's circle element. */ + valueLabelCircle: string; + /** Styles applied to the thumb label's label element. */ + valueLabelLabel: string; +} + +export type SliderClassKey = keyof SliderClasses; + +export function getSliderUtilityClass(slot: string): string { + return generateUtilityClass('MuiSlider', slot); +} + +const sliderClasses: SliderClasses = generateUtilityClasses('MuiSlider', [ + 'root', + 'active', + 'colorPrimary', + 'colorSecondary', + 'disabled', + 'dragging', + 'focusVisible', + 'mark', + 'markActive', + 'marked', + 'markLabel', + 'markLabelActive', + 'rail', + 'sizeSmall', + 'thumb', + 'thumbColorPrimary', + 'thumbColorSecondary', + 'track', + 'trackInverted', + 'trackFalse', + 'thumbSizeSmall', + 'valueLabel', + 'valueLabelOpen', + 'valueLabelCircle', + 'valueLabelLabel', + 'vertical', +]); + +export default sliderClasses; diff --git a/packages/mui-material-next/src/styles/Theme.types.ts b/packages/mui-material-next/src/styles/Theme.types.ts index 03d4fe0799d82c..1054d25c53b401 100644 --- a/packages/mui-material-next/src/styles/Theme.types.ts +++ b/packages/mui-material-next/src/styles/Theme.types.ts @@ -21,11 +21,17 @@ export interface MD3Tones { 99: string; 100: string; } + +export interface MD3NeutralTones extends MD3Tones { + 17: string; + 22: string; + 92: string; +} export interface MD3Palettes { primary: MD3Tones; secondary: MD3Tones; tertiary: MD3Tones; - neutral: MD3Tones; + neutral: MD3NeutralTones; neutralVariant: MD3Tones; error: MD3Tones; common: { @@ -62,6 +68,8 @@ export interface MD3ColorSchemeTokens { onSurface: string; surfaceVariant: string; onSurfaceVariant: string; + surfaceContainerHigh: string; + surfaceContainerHighest: string; inverseSurface: string; inverseOnSurface: string; @@ -108,10 +116,16 @@ export interface TypescaleValue { small: { family: string; weight: string; + lineHeight: number; + size: number; + tracking: number; }; medium: { family: string; weight: string; + lineHeight: number; + size: number; + tracking: number; }; large: { family: string; diff --git a/packages/mui-material-next/src/styles/createDarkColorScheme.ts b/packages/mui-material-next/src/styles/createDarkColorScheme.ts index 55ffd8e5707df4..8c9146bb157384 100644 --- a/packages/mui-material-next/src/styles/createDarkColorScheme.ts +++ b/packages/mui-material-next/src/styles/createDarkColorScheme.ts @@ -23,6 +23,8 @@ const createDarkColorScheme = ( onSurface: getCssVar('ref-palette-neutral-90', palette.neutral[90]), surfaceVariant: getCssVar('ref-palette-neutralVariant-30', palette.neutralVariant[30]), surface: getCssVar('ref-palette-neutral-10', palette.neutral[10]), + surfaceContainerHigh: getCssVar('ref-palette-neutral-17', palette.neutral[17]), + surfaceContainerHighest: getCssVar('ref-palette-neutral-22', palette.neutral[22]), onSecondaryContainer: getCssVar('ref-palette-secondary-90', palette.secondary[90]), onSecondary: getCssVar('ref-palette-secondary-20', palette.secondary[20]), secondaryContainer: getCssVar('ref-palette-secondary-30', palette.secondary[30]), diff --git a/packages/mui-material-next/src/styles/createLightColorScheme.ts b/packages/mui-material-next/src/styles/createLightColorScheme.ts index b5894088a3de0e..ef15f283b3b460 100644 --- a/packages/mui-material-next/src/styles/createLightColorScheme.ts +++ b/packages/mui-material-next/src/styles/createLightColorScheme.ts @@ -23,6 +23,8 @@ const createLightColorScheme = ( onSurface: getCssVar('ref-palette-neutral-10', palette.neutral[10]), surfaceVariant: getCssVar('ref-palette-neutralVariant-90', palette.neutralVariant[90]), surface: getCssVar('ref-palette-neutral-99', palette.neutral[99]), + surfaceContainerHigh: getCssVar('ref-palette-neutral-92', palette.neutral[92]), + surfaceContainerHighest: getCssVar('ref-palette-neutral-90', palette.neutral[90]), onSecondaryContainer: getCssVar('ref-palette-secondary-10', palette.secondary[10]), onSecondary: getCssVar('ref-palette-secondary-100', palette.secondary[100]), secondaryContainer: getCssVar('ref-palette-secondary-90', palette.secondary[90]), diff --git a/packages/mui-material-next/src/styles/identifier.ts b/packages/mui-material-next/src/styles/identifier.ts new file mode 100644 index 00000000000000..93c7bbf7859970 --- /dev/null +++ b/packages/mui-material-next/src/styles/identifier.ts @@ -0,0 +1 @@ +export default '$$material'; diff --git a/packages/mui-material-next/src/styles/palette.ts b/packages/mui-material-next/src/styles/palette.ts index d5ebb888545f44..f99a4db331ba49 100644 --- a/packages/mui-material-next/src/styles/palette.ts +++ b/packages/mui-material-next/src/styles/palette.ts @@ -78,6 +78,8 @@ const mdRefPalette = { 0: '#000000', 10: '#1c1b1f', 20: '#313033', + 17: '#2b2930', + 22: '#36343b', 30: '#484649', 40: '#605d62', 50: '#787579', @@ -85,6 +87,7 @@ const mdRefPalette = { 70: '#aeaaae', 80: '#c9c5ca', 90: '#e6e1e5', + 92: '#ece6f0', 95: '#f4eff4', 99: '#fffbfe', 100: '#ffffff', diff --git a/packages/mui-material-next/src/styles/typescale.ts b/packages/mui-material-next/src/styles/typescale.ts index 89d0aca6fd0fef..055a1e942fd4ac 100644 --- a/packages/mui-material-next/src/styles/typescale.ts +++ b/packages/mui-material-next/src/styles/typescale.ts @@ -3,11 +3,15 @@ const mdSysTypescale = { small: { family: 'Roboto', weight: '500', + lineHeight: 16, + size: 11, tracking: 0.5, }, medium: { family: 'Roboto', weight: '500', + lineHeight: 16, + size: 12, tracking: 0.5, }, large: { diff --git a/packages/mui-material-next/src/styles/useTheme.ts b/packages/mui-material-next/src/styles/useTheme.ts new file mode 100644 index 00000000000000..1b697bb953a99a --- /dev/null +++ b/packages/mui-material-next/src/styles/useTheme.ts @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { useTheme as useSystemTheme } from '@mui/system'; +import defaultTheme from './defaultTheme'; +import THEME_ID from './identifier'; +import { Theme } from './Theme.types'; + +export default function useTheme(): Theme { + const theme = useSystemTheme(defaultTheme); + + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line react-hooks/rules-of-hooks + React.useDebugValue(theme); + } + + // @ts-ignore internal logic + return theme[THEME_ID] || theme; +} diff --git a/packages/mui-material-next/src/styles/useThemeProps.ts b/packages/mui-material-next/src/styles/useThemeProps.ts new file mode 100644 index 00000000000000..982a7d860daa4e --- /dev/null +++ b/packages/mui-material-next/src/styles/useThemeProps.ts @@ -0,0 +1,18 @@ +import { useThemeProps as systemUseThemeProps } from '@mui/system'; +import defaultTheme from './defaultTheme'; +import THEME_ID from './identifier'; + +export default function useThemeProps({ + props, + name, +}: { + props: T & {}; + name: string; +}) { + return systemUseThemeProps({ + props, + name, + defaultTheme: { ...defaultTheme, components: {} }, + themeId: THEME_ID, + }); +} diff --git a/packages/mui-material-next/src/utils/shouldSpreadAdditionalProps.ts b/packages/mui-material-next/src/utils/shouldSpreadAdditionalProps.ts new file mode 100644 index 00000000000000..0ac3d621438ee5 --- /dev/null +++ b/packages/mui-material-next/src/utils/shouldSpreadAdditionalProps.ts @@ -0,0 +1,7 @@ +import { isHostComponent } from '@mui/base'; + +const shouldSpreadAdditionalProps = (Slot: any) => { + return !Slot || !isHostComponent(Slot); +}; + +export default shouldSpreadAdditionalProps; diff --git a/packages/mui-material/src/styles/experimental_extendTheme.d.ts b/packages/mui-material/src/styles/experimental_extendTheme.d.ts index 8f70000a11dccc..c5315639fd53b9 100644 --- a/packages/mui-material/src/styles/experimental_extendTheme.d.ts +++ b/packages/mui-material/src/styles/experimental_extendTheme.d.ts @@ -421,6 +421,7 @@ export interface CssVarsTheme extends ColorSystem { shadows: Theme['shadows']; mixins: Theme['mixins']; zIndex: Theme['zIndex']; + direction: Theme['direction']; /** * A function to determine if the key, value should be attached as CSS Variable * `keys` is an array that represents the object path keys.