diff --git a/docs/src/pages/components/slider/ContinuousSlider.js b/docs/src/pages/components/slider/ContinuousSlider.js index a024382190fee6..3056c6ff9dcd6c 100644 --- a/docs/src/pages/components/slider/ContinuousSlider.js +++ b/docs/src/pages/components/slider/ContinuousSlider.js @@ -5,6 +5,8 @@ import Typography from '@material-ui/core/Typography'; import Slider from '@material-ui/core/Slider'; import VolumeDown from '@material-ui/icons/VolumeDown'; import VolumeUp from '@material-ui/icons/VolumeUp'; +import { ThemeProvider } from '@material-ui/styles'; +import styled from '@emotion/styled'; const useStyles = makeStyles({ root: { @@ -12,6 +14,11 @@ const useStyles = makeStyles({ }, }); +const CustomSlider = styled(Slider)` + background-color: pink; + border-color: green; +`; + export default function ContinuousSlider() { const classes = useStyles(); const [value, setValue] = React.useState(30); @@ -20,30 +27,63 @@ export default function ContinuousSlider() { setValue(newValue); }; + const theme = { + components: { + MuiSlider: { + // @ts-ignore MuiSlider does not support variants, this is added just for testing + variants: [ + { + props: { color: 'primary', orientation: 'vertical' }, + style: { + backgroundColor: 'green', + border: '3px solid orange', + }, + }, + ], + styleOverrides: { + root: { + background: 'red', + }, + }, + }, + }, + }; + return ( -
- - Volume - - - - - - - - - - + +
+ + Volume + + + + + + + + + + + - - - Disabled slider - - -
+ + Disabled slider + + + + Vertical primary slider + + + +
+ ); } diff --git a/docs/src/pages/components/slider/ContinuousSlider.tsx b/docs/src/pages/components/slider/ContinuousSlider.tsx index 66234230619d29..72b8a94018dfef 100644 --- a/docs/src/pages/components/slider/ContinuousSlider.tsx +++ b/docs/src/pages/components/slider/ContinuousSlider.tsx @@ -1,10 +1,12 @@ import * as React from 'react'; -import { makeStyles } from '@material-ui/core/styles'; +import { makeStyles, Theme } from '@material-ui/core/styles'; import Grid from '@material-ui/core/Grid'; import Typography from '@material-ui/core/Typography'; import Slider from '@material-ui/core/Slider'; import VolumeDown from '@material-ui/icons/VolumeDown'; import VolumeUp from '@material-ui/icons/VolumeUp'; +import { ThemeProvider } from '@material-ui/styles'; +import styled from '@emotion/styled'; const useStyles = makeStyles({ root: { @@ -12,6 +14,11 @@ const useStyles = makeStyles({ }, }); +const CustomSlider = styled(Slider)` + background-color: pink; + border-color: green; +`; + export default function ContinuousSlider() { const classes = useStyles(); const [value, setValue] = React.useState(30); @@ -23,30 +30,63 @@ export default function ContinuousSlider() { setValue(newValue as number); }; + const theme: Theme = { + components: { + MuiSlider: { + // @ts-ignore MuiSlider does not support variants, this is added just for testing + variants: [ + { + props: { color: 'primary', orientation: 'vertical' }, + style: { + backgroundColor: 'green', + border: '3px solid orange', + }, + }, + ], + styleOverrides: { + root: { + background: 'red', + }, + }, + }, + }, + }; + return ( -
- - Volume - - - - - - - - - - + +
+ + Volume + + + + + + + + + + + - - - Disabled slider - - -
+ + Disabled slider + + + + Vertical primary slider + + + +
+ ); } diff --git a/docs/src/pages/components/slider/CustomizedSlider.js b/docs/src/pages/components/slider/CustomizedSlider.js index 1ab7e7beecface..2bda386bb08fcb 100644 --- a/docs/src/pages/components/slider/CustomizedSlider.js +++ b/docs/src/pages/components/slider/CustomizedSlider.js @@ -1,9 +1,11 @@ import * as React from 'react'; import PropTypes from 'prop-types'; -import { withStyles, makeStyles } from '@material-ui/core/styles'; +import { useTheme, makeStyles } from '@material-ui/core/styles'; import Slider from '@material-ui/core/Slider'; +import styled from '@emotion/styled'; import Typography from '@material-ui/core/Typography'; import Tooltip from '@material-ui/core/Tooltip'; +import { ThemeProvider } from 'emotion-theming'; const useStyles = makeStyles((theme) => ({ root: { @@ -48,20 +50,19 @@ const marks = [ }, ]; -const IOSSlider = withStyles({ - root: { - color: '#3880ff', - height: 2, - padding: '15px 0', - }, - thumb: { +const IosSlider = styled(Slider)({ + color: '#3880ff', + height: 2, + padding: '15px 0', + '& .MuiSlider-thumb': { height: 28, width: 28, backgroundColor: '#fff', + // @ts-ignore boxShadow: iOSBoxShadow, marginTop: -14, marginLeft: -14, - '&:focus, &:hover, &$active': { + '&:focus, &:hover, &.Mui-active': { boxShadow: '0 3px 1px rgba(0,0,0,0.1),0 4px 8px rgba(0,0,0,0.3),0 0 0 1px rgba(0,0,0,0.02)', // Reset on touch devices, it doesn't add specificity @@ -70,8 +71,7 @@ const IOSSlider = withStyles({ }, }, }, - active: {}, - valueLabel: { + '& .MuiSlider-valueLabel': { left: 'calc(-50% + 12px)', top: -22, '& *': { @@ -79,71 +79,67 @@ const IOSSlider = withStyles({ color: '#000', }, }, - track: { + '& .MuiSlider-track': { height: 2, }, - rail: { + '& .MuiSlider-rail': { height: 2, opacity: 0.5, backgroundColor: '#bfbfbf', }, - mark: { + '& .MuiSlider-mark': { backgroundColor: '#bfbfbf', height: 8, width: 1, marginTop: -3, + '&.MuiSlider-markActive': { + opacity: 1, + backgroundColor: 'currentColor', + }, }, - markActive: { - opacity: 1, - backgroundColor: 'currentColor', - }, -})(Slider); +}); -const PrettoSlider = withStyles({ - root: { - color: '#52af77', - height: 8, - }, - thumb: { +const PrettoSlider = styled(Slider)({ + color: '#52af77', + height: 8, + '& .MuiSlider-thumb': { height: 24, width: 24, backgroundColor: '#fff', border: '2px solid currentColor', marginTop: -8, marginLeft: -12, - '&:focus, &:hover, &$active': { + '&:focus, &:hover, &.Mui-active': { boxShadow: 'inherit', }, }, - active: {}, - valueLabel: { + '& .MuiSlider-valueLabel': { left: 'calc(-50% + 4px)', }, - track: { + '& .MuiSlider-track': { height: 8, borderRadius: 4, }, - rail: { + '& .MuiSlider-rail': { height: 8, borderRadius: 4, }, -})(Slider); +}); -const AirbnbSlider = withStyles({ - root: { - color: '#3a8589', - height: 3, - padding: '13px 0', - }, - thumb: { +const AirbnbSlider = styled(Slider)({ + color: '#3a8589', + height: 3, + padding: '13px 0', + '& .MuiSlider-thumb': { height: 27, width: 27, backgroundColor: '#fff', border: '1px solid currentColor', marginTop: -12, marginLeft: -13, + // @ts-ignore boxShadow: '#ebebeb 0 2px 2px', - '&:focus, &:hover, &$active': { + '&:focus, &:hover, &.Mui-active': { boxShadow: '#ccc 0 2px 3px 1px', }, '& .bar': { @@ -155,16 +151,15 @@ const AirbnbSlider = withStyles({ marginRight: 1, }, }, - active: {}, - track: { + '& .MuiSlider-track': { height: 3, }, - rail: { + '& .MuiSlider-rail': { color: '#d8d8d8', opacity: 1, height: 3, }, -})(Slider); +}); function AirbnbThumbComponent(props) { return ( @@ -178,39 +173,45 @@ function AirbnbThumbComponent(props) { export default function CustomizedSlider() { const classes = useStyles(); + // For some reason the theme when styled used twice is coming from emotion, not our defulat theme + const theme = useTheme(); return ( -
- iOS - -
- pretto.fr - -
- Tooltip value label - -
- Airbnb - - index === 0 ? 'Minimum price' : 'Maximum price' - } - defaultValue={[20, 40]} - /> -
+ +
+ iOS + +
+ pretto.fr + +
+ Tooltip value label + +
+ Airbnb + + index === 0 ? 'Minimum price' : 'Maximum price' + } + defaultValue={[20, 40]} + /> +
+ ); } diff --git a/docs/src/pages/components/slider/CustomizedSlider.tsx b/docs/src/pages/components/slider/CustomizedSlider.tsx index 08c6a0dc968908..be33c7a35de9f5 100644 --- a/docs/src/pages/components/slider/CustomizedSlider.tsx +++ b/docs/src/pages/components/slider/CustomizedSlider.tsx @@ -1,13 +1,15 @@ import * as React from 'react'; import { - withStyles, + useTheme, makeStyles, Theme, createStyles, } from '@material-ui/core/styles'; import Slider from '@material-ui/core/Slider'; +import styled from '@emotion/styled'; import Typography from '@material-ui/core/Typography'; import Tooltip from '@material-ui/core/Tooltip'; +import { ThemeProvider } from 'emotion-theming'; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -54,20 +56,19 @@ const marks = [ }, ]; -const IOSSlider = withStyles({ - root: { - color: '#3880ff', - height: 2, - padding: '15px 0', - }, - thumb: { +const IosSlider = styled(Slider)({ + color: '#3880ff', + height: 2, + padding: '15px 0', + '& .MuiSlider-thumb': { height: 28, width: 28, backgroundColor: '#fff', + // @ts-ignore boxShadow: iOSBoxShadow, marginTop: -14, marginLeft: -14, - '&:focus, &:hover, &$active': { + '&:focus, &:hover, &.Mui-active': { boxShadow: '0 3px 1px rgba(0,0,0,0.1),0 4px 8px rgba(0,0,0,0.3),0 0 0 1px rgba(0,0,0,0.02)', // Reset on touch devices, it doesn't add specificity @@ -76,8 +77,8 @@ const IOSSlider = withStyles({ }, }, }, - active: {}, - valueLabel: { + + '& .MuiSlider-valueLabel': { left: 'calc(-50% + 12px)', top: -22, '& *': { @@ -85,71 +86,73 @@ const IOSSlider = withStyles({ color: '#000', }, }, - track: { + + '& .MuiSlider-track': { height: 2, }, - rail: { + + '& .MuiSlider-rail': { height: 2, opacity: 0.5, backgroundColor: '#bfbfbf', }, - mark: { + + '& .MuiSlider-mark': { backgroundColor: '#bfbfbf', height: 8, width: 1, marginTop: -3, + '&.MuiSlider-markActive': { + opacity: 1, + backgroundColor: 'currentColor', + }, }, - markActive: { - opacity: 1, - backgroundColor: 'currentColor', - }, -})(Slider); +}); -const PrettoSlider = withStyles({ - root: { - color: '#52af77', - height: 8, - }, - thumb: { +const PrettoSlider = styled(Slider)({ + color: '#52af77', + height: 8, + '& .MuiSlider-thumb': { height: 24, width: 24, backgroundColor: '#fff', border: '2px solid currentColor', marginTop: -8, marginLeft: -12, - '&:focus, &:hover, &$active': { + '&:focus, &:hover, &.Mui-active': { boxShadow: 'inherit', }, }, - active: {}, - valueLabel: { + '& .MuiSlider-valueLabel': { left: 'calc(-50% + 4px)', }, - track: { + + '& .MuiSlider-track': { height: 8, borderRadius: 4, }, - rail: { + + '& .MuiSlider-rail': { height: 8, borderRadius: 4, }, -})(Slider); +}); -const AirbnbSlider = withStyles({ - root: { - color: '#3a8589', - height: 3, - padding: '13px 0', - }, - thumb: { +const AirbnbSlider = styled(Slider)({ + color: '#3a8589', + height: 3, + padding: '13px 0', + + '& .MuiSlider-thumb': { height: 27, width: 27, backgroundColor: '#fff', border: '1px solid currentColor', marginTop: -12, marginLeft: -13, + // @ts-ignore boxShadow: '#ebebeb 0 2px 2px', - '&:focus, &:hover, &$active': { + '&:focus, &:hover, &.Mui-active': { boxShadow: '#ccc 0 2px 3px 1px', }, '& .bar': { @@ -161,17 +164,17 @@ const AirbnbSlider = withStyles({ marginRight: 1, }, }, - active: {}, - track: { + + '& .MuiSlider-track': { height: 3, }, - rail: { + + '& .MuiSlider-rail': { color: '#d8d8d8', opacity: 1, height: 3, }, -})(Slider); - +}); function AirbnbThumbComponent(props: any) { return ( @@ -184,39 +187,45 @@ function AirbnbThumbComponent(props: any) { export default function CustomizedSlider() { const classes = useStyles(); + // For some reason the theme when styled used twice is coming from emotion, not our defulat theme + const theme = useTheme(); return ( -
- iOS - -
- pretto.fr - -
- Tooltip value label - -
- Airbnb - - index === 0 ? 'Minimum price' : 'Maximum price' - } - defaultValue={[20, 40]} - /> -
+ +
+ iOS + +
+ pretto.fr + +
+ Tooltip value label + +
+ Airbnb + + index === 0 ? 'Minimum price' : 'Maximum price' + } + defaultValue={[20, 40]} + /> +
+ ); } diff --git a/docs/src/pages/components/slider/UnstyledSlider.js b/docs/src/pages/components/slider/UnstyledSlider.js new file mode 100644 index 00000000000000..f54d71fc5d492e --- /dev/null +++ b/docs/src/pages/components/slider/UnstyledSlider.js @@ -0,0 +1,62 @@ +import * as React from 'react'; +import styled from '@emotion/styled'; +import { SliderBase } from '@material-ui/core/Slider'; + +const StyledSlider = styled(SliderBase)` + height: 2px; + width: 100%; + padding: 13px 0; + display: inline-block; + position: relative; + cursor: pointer; + touch-action: none; + -webkit-tap-highlight-color: transparent; + + & .MuiSlider-rail { + display: block; + position: absolute; + width: 100%; + height: 2px; + border-radius: 1px; + background-color: currentColor; + opacity: 0.38; + } + + & .MuiSlider-track { + display: block; + position: absolute; + height: 2px; + border-radius: 1px; + background-color: currentColor; + } + + & .MuiSlider-thumb { + position: absolute; + width: 12px; + height: 12px; + margin-left: -6px; + margin-top: -5px; + box-sizing: border-box; + border-radius: 50%; + outline: 0; + background-color: currentColor; + display: flex; + align-items: center; + justify-content: center; + transition: box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + + &::after { + position: absolute; + content: ''; + border-radius: 50%; + left: -15px; + top: -15px; + right: -15px; + bottom: -15px; + } + } +`; + +export default function UnstyledSlider() { + return ; +} diff --git a/docs/src/pages/components/slider/UnstyledSlider.tsx b/docs/src/pages/components/slider/UnstyledSlider.tsx new file mode 100644 index 00000000000000..f54d71fc5d492e --- /dev/null +++ b/docs/src/pages/components/slider/UnstyledSlider.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import styled from '@emotion/styled'; +import { SliderBase } from '@material-ui/core/Slider'; + +const StyledSlider = styled(SliderBase)` + height: 2px; + width: 100%; + padding: 13px 0; + display: inline-block; + position: relative; + cursor: pointer; + touch-action: none; + -webkit-tap-highlight-color: transparent; + + & .MuiSlider-rail { + display: block; + position: absolute; + width: 100%; + height: 2px; + border-radius: 1px; + background-color: currentColor; + opacity: 0.38; + } + + & .MuiSlider-track { + display: block; + position: absolute; + height: 2px; + border-radius: 1px; + background-color: currentColor; + } + + & .MuiSlider-thumb { + position: absolute; + width: 12px; + height: 12px; + margin-left: -6px; + margin-top: -5px; + box-sizing: border-box; + border-radius: 50%; + outline: 0; + background-color: currentColor; + display: flex; + align-items: center; + justify-content: center; + transition: box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + + &::after { + position: absolute; + content: ''; + border-radius: 50%; + left: -15px; + top: -15px; + right: -15px; + bottom: -15px; + } + } +`; + +export default function UnstyledSlider() { + return ; +} diff --git a/docs/src/pages/components/slider/slider.md b/docs/src/pages/components/slider/slider.md index 5676fd903787bd..2a8d78f72500ef 100644 --- a/docs/src/pages/components/slider/slider.md +++ b/docs/src/pages/components/slider/slider.md @@ -100,6 +100,10 @@ For instance, in the following demo, the value _x_ represents the power of _10^x {{"demo": "pages/components/slider/NonLinearSlider.js"}} +## Unstyled slider + +{{"demo": "pages/components/slider/UnstyledSlider.js"}} + ## Accessibility (WAI-ARIA: https://www.w3.org/TR/wai-aria-practices/#slider) diff --git a/packages/material-ui-styles/package.json b/packages/material-ui-styles/package.json index 4e0d51e3854a8f..8380c01dff8783 100644 --- a/packages/material-ui-styles/package.json +++ b/packages/material-ui-styles/package.json @@ -38,6 +38,7 @@ "typescript": "tslint -p tsconfig.json \"{src,test}/**/*.{spec,d}.{ts,tsx}\" && tsc -p tsconfig.json" }, "peerDependencies": { + "@emotion/core": "^10.0.27", "@types/react": "^16.8.6", "react": "^16.8.0", "react-dom": "^16.8.0" @@ -49,6 +50,7 @@ }, "dependencies": { "@babel/runtime": "^7.4.4", + "@emotion/cache": "^10.0.27", "@emotion/hash": "^0.8.0", "@material-ui/types": "^5.1.0", "@material-ui/utils": "^5.0.0-alpha.8", @@ -63,7 +65,8 @@ "jss-plugin-props-sort": "^10.0.3", "jss-plugin-rule-value-function": "^10.0.3", "jss-plugin-vendor-prefixer": "^10.0.3", - "prop-types": "^15.7.2" + "prop-types": "^15.7.2", + "stylis-plugin-rtl": "^1.0.0" }, "sideEffects": false, "publishConfig": { diff --git a/packages/material-ui-styles/src/ThemeProvider/ThemeProvider.js b/packages/material-ui-styles/src/ThemeProvider/ThemeProvider.js index 2a3093f36603a2..73c06169b26f04 100644 --- a/packages/material-ui-styles/src/ThemeProvider/ThemeProvider.js +++ b/packages/material-ui-styles/src/ThemeProvider/ThemeProvider.js @@ -1,6 +1,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import { exactProp } from '@material-ui/utils'; +import { CacheProvider } from '@emotion/core'; +import createCache from '@emotion/cache'; +import rtlPlugin from 'stylis-plugin-rtl'; import ThemeContext from '../useTheme/ThemeContext'; import useTheme from '../useTheme'; import nested from './nested'; @@ -27,6 +30,20 @@ function mergeOuterLocalTheme(outerTheme, localTheme) { return { ...outerTheme, ...localTheme }; } +// Cache for the ltr version of the styles +const cacheLtr = createCache({ + key: 'mui', + stylisPlugins: [], + speedy: true, +}); + +// Cache for the rtl version of the styles +const cacheRtl = createCache({ + key: 'muirtl', + stylisPlugins: [rtlPlugin], + speedy: true, +}); + /** * This component takes a `theme` prop. * It makes the `theme` available down the React tree thanks to React context. @@ -61,7 +78,13 @@ function ThemeProvider(props) { return output; }, [localTheme, outerTheme]); - return {children}; + const rtl = theme.direction === 'rtl'; + + return ( + + {children} + + ); } ThemeProvider.propTypes = { diff --git a/packages/material-ui-styles/src/index.d.ts b/packages/material-ui-styles/src/index.d.ts index 65d11adec915fe..baf467943fe9f6 100644 --- a/packages/material-ui-styles/src/index.d.ts +++ b/packages/material-ui-styles/src/index.d.ts @@ -34,6 +34,9 @@ export * from './useTheme'; export { default as useThemeVariants } from './useThemeVariants'; export * from './useThemeVariants'; +export { default as propsToClassKey } from './propsToClassKey'; +export * from './propsToClassKey'; + export { default as withStyles } from './withStyles'; export * from './withStyles'; diff --git a/packages/material-ui-styles/src/index.js b/packages/material-ui-styles/src/index.js index dd9aac3207d387..c390bed604f19a 100644 --- a/packages/material-ui-styles/src/index.js +++ b/packages/material-ui-styles/src/index.js @@ -61,6 +61,9 @@ export * from './useTheme'; export { default as useThemeVariants } from './useThemeVariants'; export * from './useThemeVariants'; +export { default as propsToClassKey } from './propsToClassKey'; +export * from './propsToClassKey'; + export { default as withStyles } from './withStyles'; export * from './withStyles'; diff --git a/packages/material-ui/package.json b/packages/material-ui/package.json index f7aa648cc15225..5715943c27d172 100644 --- a/packages/material-ui/package.json +++ b/packages/material-ui/package.json @@ -39,7 +39,10 @@ "typescript": "tslint -p tsconfig.json \"{src,test}/**/*.{spec,d}.{ts,tsx}\" && tsc -p tsconfig.json && tsc -p tsconfig.build.json" }, "peerDependencies": { + "@emotion/core": "^10.0.27", + "@emotion/styled": "^10.0.27", "@types/react": "^16.8.6", + "emotion-theming": "^10.0.27", "react": "^16.8.0", "react-dom": "^16.8.0" }, @@ -50,6 +53,7 @@ }, "dependencies": { "@babel/runtime": "^7.4.4", + "@emotion/is-prop-valid": "^0.8.8", "@material-ui/styles": "^5.0.0-alpha.8", "@material-ui/system": "^5.0.0-alpha.6", "@material-ui/types": "^5.1.0", diff --git a/packages/material-ui/src/Slider/Slider.d.ts b/packages/material-ui/src/Slider/Slider.d.ts index 45305dabef1a9c..98db442971517b 100644 --- a/packages/material-ui/src/Slider/Slider.d.ts +++ b/packages/material-ui/src/Slider/Slider.d.ts @@ -77,6 +77,62 @@ export interface SliderTypeMap

{ * @default 'primary' */ color?: 'primary' | 'secondary'; + /** + * The components used for each slot inside the Slider. + * Either a string to use a HTML element or a component. + */ + components?: { + Root?: React.ElementType; + Track?: React.ElementType; + Rail?: React.ElementType; + Thumb?: React.ElementType; + Mark?: React.ElementType; + MarkLabel?: React.ElementType; + ValueLabel?: React.ElementType; + }; + /** + * The props used for each slot inside the Slider. + */ + componentsProps?: { + root?: { + state?: Omit['props'], 'components' | 'componentsProps'>; + as: React.ElementType; + }; + track?: { + state?: Omit['props'], 'components' | 'componentsProps'>; + as?: React.ElementType; + }; + rail?: { + state?: Omit['props'], 'components' | 'componentsProps'>; + as?: React.ElementType; + }; + thumb?: { + state?: Omit['props'], 'components' | 'componentsProps'> & { + active?: boolean; + focusVisible?: boolean; + }; + as?: React.ElementType; + }; + mark?: { + state?: Omit['props'], 'components' | 'componentsProps'> & { + markActive?: boolean; + }; + as?: React.ElementType; + }; + markLabel?: { + state?: Omit['props'], 'components' | 'componentsProps'> & { + markLabelActive?: boolean; + }; + as?: React.ElementType; + }; + valueLabel?: { + state?: Omit['props'], 'components' | 'componentsProps'> & { + index?: number; + open?: boolean; + }; + as?: React.ElementType; + }; + }; /** * The default element value. Use when the component is not controlled. */ @@ -101,6 +157,10 @@ export interface SliderTypeMap

{ * @returns {string} */ getAriaValueText?: (value: number, index: number) => string; + /** + * Indicates whether the theme context has rtl direction. It is set automatically. + */ + isRtl?: boolean; /** * Marks indicate predetermined values to which the user can move the slider. * If `true` the marks will be spaced according the value of the `step` prop. @@ -203,6 +263,7 @@ export interface SliderTypeMap

{ }; defaultComponent: D; } + /** * * Demos: @@ -222,4 +283,20 @@ export type SliderProps< P = {} > = OverrideProps, D>; +type SliderRootProps = NonNullable['root']; +type SliderMarkProps = NonNullable['mark']; +type SliderMarkLabelProps = NonNullable['markLabel']; +type SliderRailProps = NonNullable['rail']; +type SliderTrackProps = NonNullable['track']; +type SliderThumbProps = NonNullable['thumb']; +type SliderValueLabel = NonNullable['valueLabel']; + +export const SliderRoot: React.FC; +export const SliderMark: React.FC; +export const SliderMarkLabel: React.FC; +export const SliderRail: React.FC; +export const SliderTrack: React.FC; +export const SliderThumb: React.FC; +export const SliderValueLabel: React.FC; + export default Slider; diff --git a/packages/material-ui/src/Slider/Slider.js b/packages/material-ui/src/Slider/Slider.js index 2831fcb1282846..7087170000936b 100644 --- a/packages/material-ui/src/Slider/Slider.js +++ b/packages/material-ui/src/Slider/Slider.js @@ -1,153 +1,81 @@ import * as React from 'react'; -import PropTypes from 'prop-types'; -import clsx from 'clsx'; -import { chainPropTypes } from '@material-ui/utils'; -import withStyles from '../styles/withStyles'; -import useTheme from '../styles/useTheme'; +import { propsToClassKey } from '@material-ui/styles'; +import useThemeProps from '../styles/useThemeProps'; import { fade, lighten, darken } from '../styles/colorManipulator'; -import useIsFocusVisible from '../utils/useIsFocusVisible'; -import useEnhancedEffect from '../utils/useEnhancedEffect'; -import ownerDocument from '../utils/ownerDocument'; -import useEventCallback from '../utils/useEventCallback'; -import useForkRef from '../utils/useForkRef'; import capitalize from '../utils/capitalize'; -import useControlled from '../utils/useControlled'; -import ValueLabel from './ValueLabel'; +import SliderBase from './SliderBase'; +import muiStyled from '../styles/muiStyled'; -function asc(a, b) { - return a - b; -} - -function clamp(value, min, max) { - return Math.min(Math.max(min, value), max); -} - -function findClosest(values, currentValue) { - const { index: closestIndex } = values.reduce((acc, value, index) => { - const distance = Math.abs(currentValue - value); - - if (acc === null || distance < acc.distance || distance === acc.distance) { - return { - distance, - index, - }; - } - - return acc; - }, null); - return closestIndex; -} - -function trackFinger(event, touchId) { - if (touchId.current !== undefined && event.changedTouches) { - for (let i = 0; i < event.changedTouches.length; i += 1) { - const touch = event.changedTouches[i]; - if (touch.identifier === touchId.current) { - return { - x: touch.clientX, - y: touch.clientY, - }; - } - } +const overridesResolver = (props, styles, name) => { + const { + color = 'primary', + marks: marksProp = false, + max = 100, + min = 0, + orientation = 'horizontal', + step = 1, + track = 'normal', + } = props; - return false; - } + const marks = + marksProp === true && step !== null + ? [...Array(Math.floor((max - min) / step) + 1)].map((_, index) => ({ + value: min + step * index, + })) + : marksProp || []; - return { - x: event.clientX, - y: event.clientY, + const marked = marks.length > 0 && marks.some((mark) => mark.label); + + const styleOverrides = { + ...styles.root, + ...styles[`color${capitalize(color)}`], + '&.Mui-disabled': styles.disabled, + ...(marked && styles.marked), + ...(orientation === 'vertical' && styles.vertical), + ...(track === 'inverted' && styles.trackInverted), + ...(track === false && styles.trackFalse), + [`.& ${name}-rail`]: styles.rail, + [`.& ${name}-track`]: styles.track, + [`.& ${name}-mark`]: styles.mark, + [`.& ${name}-markLabel`]: styles.markLabel, + [`.& ${name}-valueLabel`]: styles.valueLabel, + [`.& ${name}-thumb`]: { + ...styles.thumb, + ...styles[`thumbColor${capitalize(color)}`], + '&.Mui-disabled': styles.disabled, + }, }; -} - -function valueToPercent(value, min, max) { - return ((value - min) * 100) / (max - min); -} -function percentToValue(percent, min, max) { - return (max - min) * percent + min; -} - -function getDecimalPrecision(num) { - // This handles the case when num is very small (0.00000001), js will turn this into 1e-8. - // When num is bigger than 1 or less than -1 it won't get converted to this notation so it's fine. - if (Math.abs(num) < 1) { - const parts = num.toExponential().split('e-'); - const matissaDecimalPart = parts[0].split('.')[1]; - return (matissaDecimalPart ? matissaDecimalPart.length : 0) + parseInt(parts[1], 10); - } - - const decimalPart = num.toString().split('.')[1]; - return decimalPart ? decimalPart.length : 0; -} - -function roundValueToStep(value, step, min) { - const nearest = Math.round((value - min) / step) * step + min; - return Number(nearest.toFixed(getDecimalPrecision(step))); -} - -function setValueIndex({ values, source, newValue, index }) { - // Performance shortcut - if (source[index] === newValue) { - return source; - } - - const output = values.slice(); - output[index] = newValue; - return output; -} - -function focusThumb({ sliderRef, activeIndex, setActive }) { - const doc = ownerDocument(sliderRef.current); - if ( - !sliderRef.current.contains(doc.activeElement) || - Number(doc.activeElement.getAttribute('data-index')) !== activeIndex - ) { - sliderRef.current.querySelector(`[role="slider"][data-index="${activeIndex}"]`).focus(); - } + return styleOverrides; +}; - if (setActive) { - setActive(activeIndex); +const variantsResolver = (props, styles, theme, name) => { + const { state = {} } = props; + let variantsStyles = {}; + if (theme && theme.components && theme.components[name] && theme.components[name].variants) { + const themeVariants = theme.components[name].variants; + themeVariants.forEach((themeVariant) => { + let isMatch = true; + Object.keys(themeVariant.props).forEach((key) => { + if (state[key] !== themeVariant.props[key]) { + isMatch = false; + } + }); + if (isMatch) { + variantsStyles = { ...variantsStyles, ...styles[propsToClassKey(themeVariant.props)] }; + } + }); } -} -const axisProps = { - horizontal: { - offset: (percent) => ({ left: `${percent}%` }), - leap: (percent) => ({ width: `${percent}%` }), - }, - 'horizontal-reverse': { - offset: (percent) => ({ right: `${percent}%` }), - leap: (percent) => ({ width: `${percent}%` }), - }, - vertical: { - offset: (percent) => ({ bottom: `${percent}%` }), - leap: (percent) => ({ height: `${percent}%` }), - }, + return variantsStyles; }; -const Identity = (x) => x; - -// TODO: remove support for Safari < 13. -// https://caniuse.com/#search=touch-action -// -// Safari, on iOS, supports touch action since v13. -// Over 80% of the iOS phones are compatible -// in August 2020. -let cachedSupportsTouchActionNone; -function doesSupportTouchActionNone() { - if (cachedSupportsTouchActionNone === undefined) { - const element = document.createElement('div'); - element.style.touchAction = 'none'; - document.body.appendChild(element); - cachedSupportsTouchActionNone = window.getComputedStyle(element).touchAction === 'none'; - element.parentElement.removeChild(element); - } - return cachedSupportsTouchActionNone; -} - -export const styles = (theme) => ({ - /* Styles applied to the root element. */ - root: { +export const SliderRoot = muiStyled( + 'div', + {}, + { muiName: 'MuiSlider', overridesResolver, variantsResolver }, +)((props) => { + return { height: 2, width: '100%', boxSizing: 'content-box', @@ -155,901 +83,218 @@ export const styles = (theme) => ({ display: 'inline-block', position: 'relative', cursor: 'pointer', - // Disable scroll capabilities. touchAction: 'none', - color: theme.palette.primary.main, + color: props.theme.palette.primary.main, WebkitTapHighlightColor: 'transparent', - '&$disabled': { + ...(props.state.color === 'secondary' && { + color: props.theme.palette.secondary.main, + }), + '&.Mui-disabled': { pointerEvents: 'none', cursor: 'default', - color: theme.palette.grey[400], + color: props.theme.palette.grey[400], }, - '&$vertical': { + ...(props.state.orientation === 'vertical' && { width: 2, height: '100%', padding: '0 13px', - }, + }), // The primary input mechanism of the device includes a pointing device of limited accuracy. '@media (pointer: coarse)': { // Reach 42px touch target, about ~8mm on screen. padding: '20px 0', - '&$vertical': { + ...(props.state.orientation === 'vertical' && { padding: '0 20px', - }, + }), }, '@media print': { colorAdjust: 'exact', }, - }, - /* Styles applied to the root element if `color="primary"`. */ - colorPrimary: { - // TODO v5: move the style here - }, - /* Styles applied to the root element if `color="secondary"`. */ - colorSecondary: { - color: theme.palette.secondary.main, - }, - /* Styles applied to the root element if `marks` is provided with at least one label. */ - marked: { - marginBottom: 20, - '&$vertical': { - marginBottom: 'auto', - marginRight: 20, - }, - }, - /* Pseudo-class applied to the root element if `orientation="vertical"`. */ - vertical: {}, - /* Pseudo-class applied to the root and thumb element if `disabled={true}`. */ - disabled: {}, - /* Styles applied to the rail element. */ - rail: { - display: 'block', - position: 'absolute', - width: '100%', - height: 2, - borderRadius: 1, - backgroundColor: 'currentColor', - opacity: 0.38, - '$vertical &': { - height: '100%', - width: 2, - }, - }, - /* Styles applied to the track element. */ - track: { - display: 'block', - position: 'absolute', - height: 2, - borderRadius: 1, - backgroundColor: 'currentColor', - '$vertical &': { - width: 2, - }, - }, - /* Styles applied to the track element if `track={false}`. */ - trackFalse: { - '& $track': { - display: 'none', - }, - }, - /* Styles applied to the track element if `track="inverted"`. */ - trackInverted: { - '& $track': { - backgroundColor: - // Same logic as the LinearProgress track color - theme.palette.type === 'light' - ? lighten(theme.palette.primary.main, 0.62) - : darken(theme.palette.primary.main, 0.5), + ...(props.state.marked && { + marginBottom: 20, + ...(props.state.orientation === 'vertical' && { + marginBottom: 'auto', + marginRight: 20, + }), + }), + + '& .MuiSlider-rail': { + display: 'block', + position: 'absolute', + width: '100%', + height: 2, + borderRadius: 1, + backgroundColor: 'currentColor', + opacity: 0.38, + ...(props.state.orientation === 'vertical' && { + height: '100%', + width: 2, + }), + ...(props.state.track === 'inverted' && { + opacity: 1, + }), }, - '& $rail': { - opacity: 1, + + '& .MuiSlider-track': { + display: 'block', + position: 'absolute', + height: 2, + borderRadius: 1, + backgroundColor: 'currentColor', + ...(props.state.orientation === 'vertical' && { + width: 2, + }), + ...(props.state.track === false && { + display: 'none', + }), + ...(props.state.track === 'inverted' && { + backgroundColor: + // Same logic as the LinearProgress track color + props.theme.palette.type === 'light' + ? lighten(props.theme.palette.primary.main, 0.62) + : darken(props.theme.palette.primary.main, 0.5), + }), }, - }, - /* Styles applied to the thumb element. */ - thumb: { - position: 'absolute', - width: 12, - height: 12, - marginLeft: -6, - marginTop: -5, - boxSizing: 'border-box', - borderRadius: '50%', - outline: 0, - backgroundColor: 'currentColor', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - transition: theme.transitions.create(['box-shadow'], { - duration: theme.transitions.duration.shortest, - }), - '&::after': { + + '& .MuiSlider-thumb': { position: 'absolute', - content: '""', + width: 12, + height: 12, + marginLeft: -6, + marginTop: -5, + boxSizing: 'border-box', borderRadius: '50%', - // reach 42px hit target (2 * 15 + thumb diameter) - left: -15, - top: -15, - right: -15, - bottom: -15, - }, - '&$focusVisible,&:hover': { - boxShadow: `0px 0px 0px 8px ${fade(theme.palette.primary.main, 0.16)}`, - '@media (hover: none)': { - boxShadow: 'none', + outline: 0, + backgroundColor: 'currentColor', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + transition: props.theme.transitions.create(['box-shadow'], { + duration: props.theme.transitions.duration.shortest, + }), + '::after': { + position: 'absolute', + content: '""', + borderRadius: '50%', + // reach 42px hit target (2 * 15 + thumb diameter) + left: -15, + top: -15, + right: -15, + bottom: -15, }, - }, - '&$active': { - boxShadow: `0px 0px 0px 14px ${fade(theme.palette.primary.main, 0.16)}`, - }, - '&$disabled': { - width: 8, - height: 8, - marginLeft: -4, - marginTop: -3, - '&:hover': { - boxShadow: 'none', + ':hover': { + boxShadow: `0px 0px 0px 8px ${fade(props.theme.palette.primary.main, 0.16)}`, + '@media (hover: none)': { + boxShadow: 'none', + }, }, - }, - '$vertical &': { - marginLeft: -5, - marginBottom: -6, - }, - '$vertical &$disabled': { - marginLeft: -3, - marginBottom: -4, - }, - }, - /* Styles applied to the thumb element if `color="primary"`. */ - thumbColorPrimary: { - // TODO v5: move the style here - }, - /* Styles applied to the thumb element if `color="secondary"`. */ - thumbColorSecondary: { - '&$focusVisible,&:hover': { - boxShadow: `0px 0px 0px 8px ${fade(theme.palette.secondary.main, 0.16)}`, - }, - '&$active': { - boxShadow: `0px 0px 0px 14px ${fade(theme.palette.secondary.main, 0.16)}`, - }, - }, - /* Pseudo-class applied to the thumb element if it's active. */ - active: {}, - /* Pseudo-class applied to the thumb element if keyboard focused. */ - focusVisible: {}, - /* Styles applied to the thumb label element. */ - valueLabel: { - // IE 11 centering bug, to remove from the customization demos once no longer supported - left: 'calc(-50% - 4px)', - }, - /* Styles applied to the mark element. */ - mark: { - position: 'absolute', - width: 2, - height: 2, - borderRadius: 1, - backgroundColor: 'currentColor', - }, - /* Styles applied to the mark element if active (depending on the value). */ - markActive: { - backgroundColor: theme.palette.background.paper, - opacity: 0.8, - }, - /* Styles applied to the mark label element. */ - markLabel: { - ...theme.typography.body2, - color: theme.palette.text.secondary, - position: 'absolute', - top: 26, - transform: 'translateX(-50%)', - whiteSpace: 'nowrap', - '$vertical &': { - top: 'auto', - left: 26, - transform: 'translateY(50%)', - }, - '@media (pointer: coarse)': { - top: 40, - '$vertical &': { - left: 31, + '&.Mui-focusVisible': { + boxShadow: `0px 0px 0px 8px ${fade(props.theme.palette.primary.main, 0.16)}`, + '@media (hover: none)': { + boxShadow: 'none', + }, + }, + '&.Mui-active': { + boxShadow: `0px 0px 0px 14px ${fade(props.theme.palette.primary.main, 0.16)}`, + }, + '&.Mui-disabled': { + width: 8, + height: 8, + marginLeft: -4, + marginTop: -3, + ':hover': { + boxShadow: 'none', + }, }, + ...(props.state.orientation === 'vertical' && { + marginLeft: -5, + marginBottom: -6, + }), + ...(props.state.orientation === 'vertical' && { + '&.Mui-disabled': { + marginLeft: -3, + marginBottom: -4, + }, + }), + ...(props.state.color === 'secondary' && { + ':hover': { + boxShadow: `0px 0px 0px 8px ${fade(props.theme.palette.secondary.main, 0.16)}`, + }, + '&.Mui-focusVisible': { + boxShadow: `0px 0px 0px 8px ${fade(props.theme.palette.secondary.main, 0.16)}`, + }, + '&.Mui-active': { + boxShadow: `0px 0px 0px 14px ${fade(props.theme.palette.secondary.main, 0.16)}`, + }, + }), }, - }, - /* Styles applied to the mark label element if active (depending on the value). */ - markLabelActive: { - color: theme.palette.text.primary, - }, -}); -const Slider = React.forwardRef(function Slider(props, ref) { - const { - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledby, - 'aria-valuetext': ariaValuetext, - classes, - className, - color = 'primary', - component: Component = 'span', - defaultValue, - disabled = false, - getAriaLabel, - getAriaValueText, - marks: marksProp = false, - max = 100, - min = 0, - name, - onChange, - onChangeCommitted, - onMouseDown, - orientation = 'horizontal', - scale = Identity, - step = 1, - ThumbComponent = 'span', - track = 'normal', - value: valueProp, - ValueLabelComponent = ValueLabel, - valueLabelDisplay = 'off', - valueLabelFormat = Identity, - ...other - } = props; - const theme = useTheme(); - const touchId = React.useRef(); - // We can't use the :active browser pseudo-classes. - // - The active state isn't triggered when clicking on the rail. - // - The active state isn't transfered when inversing a range slider. - const [active, setActive] = React.useState(-1); - const [open, setOpen] = React.useState(-1); - - const [valueDerived, setValueState] = useControlled({ - controlled: valueProp, - default: defaultValue, - name: 'Slider', - }); - - const range = Array.isArray(valueDerived); - let values = range ? valueDerived.slice().sort(asc) : [valueDerived]; - values = values.map((value) => clamp(value, min, max)); - const marks = - marksProp === true && step !== null - ? [...Array(Math.floor((max - min) / step) + 1)].map((_, index) => ({ - value: min + step * index, - })) - : marksProp || []; - - const { - isFocusVisibleRef, - onBlur: handleBlurVisible, - onFocus: handleFocusVisible, - ref: focusVisibleRef, - } = useIsFocusVisible(); - const [focusVisible, setFocusVisible] = React.useState(-1); - - const sliderRef = React.useRef(); - const handleFocusRef = useForkRef(focusVisibleRef, sliderRef); - const handleRef = useForkRef(ref, handleFocusRef); - - const handleFocus = useEventCallback((event) => { - const index = Number(event.currentTarget.getAttribute('data-index')); - handleFocusVisible(event); - if (isFocusVisibleRef.current === true) { - setFocusVisible(index); - } - setOpen(index); - }); - const handleBlur = useEventCallback((event) => { - handleBlurVisible(event); - if (isFocusVisibleRef.current === false) { - setFocusVisible(-1); - } - setOpen(-1); - }); - const handleMouseOver = useEventCallback((event) => { - const index = Number(event.currentTarget.getAttribute('data-index')); - setOpen(index); - }); - const handleMouseLeave = useEventCallback(() => { - setOpen(-1); - }); - - useEnhancedEffect(() => { - if (disabled && sliderRef.current.contains(document.activeElement)) { - // This is necessary because Firefox and Safari will keep focus - // on a disabled element: - // https://codesandbox.io/s/mui-pr-22247-forked-h151h?file=/src/App.js - document.activeElement.blur(); - } - }, [disabled]); - - if (disabled && active !== -1) { - setActive(-1); - } - if (disabled && focusVisible !== -1) { - setFocusVisible(-1); - } - - const isRtl = theme.direction === 'rtl'; - - const handleKeyDown = useEventCallback((event) => { - const index = Number(event.currentTarget.getAttribute('data-index')); - const value = values[index]; - const tenPercents = (max - min) / 10; - const marksValues = marks.map((mark) => mark.value); - const marksIndex = marksValues.indexOf(value); - let newValue; - const increaseKey = isRtl ? 'ArrowLeft' : 'ArrowRight'; - const decreaseKey = isRtl ? 'ArrowRight' : 'ArrowLeft'; - - switch (event.key) { - case 'Home': - newValue = min; - break; - case 'End': - newValue = max; - break; - case 'PageUp': - if (step) { - newValue = value + tenPercents; - } - break; - case 'PageDown': - if (step) { - newValue = value - tenPercents; - } - break; - case increaseKey: - case 'ArrowUp': - if (step) { - newValue = value + step; - } else { - newValue = marksValues[marksIndex + 1] || marksValues[marksValues.length - 1]; - } - break; - case decreaseKey: - case 'ArrowDown': - if (step) { - newValue = value - step; - } else { - newValue = marksValues[marksIndex - 1] || marksValues[0]; - } - break; - default: - return; - } - - // Prevent scroll of the page - event.preventDefault(); - - if (step) { - newValue = roundValueToStep(newValue, step, min); - } - - newValue = clamp(newValue, min, max); - - if (range) { - const previousValue = newValue; - newValue = setValueIndex({ - values, - source: valueDerived, - newValue, - index, - }).sort(asc); - focusThumb({ sliderRef, activeIndex: newValue.indexOf(previousValue) }); - } - - setValueState(newValue); - setFocusVisible(index); - - if (onChange) { - onChange(event, newValue); - } - if (onChangeCommitted) { - onChangeCommitted(event, newValue); - } - }); - - const previousIndex = React.useRef(); - let axis = orientation; - if (isRtl && orientation === 'horizontal') { - axis += '-reverse'; - } - - const getFingerNewValue = ({ finger, move = false, values: values2, source }) => { - const { current: slider } = sliderRef; - const { width, height, bottom, left } = slider.getBoundingClientRect(); - let percent; - - if (axis.indexOf('vertical') === 0) { - percent = (bottom - finger.y) / height; - } else { - percent = (finger.x - left) / width; - } - - if (axis.indexOf('-reverse') !== -1) { - percent = 1 - percent; - } - - let newValue; - newValue = percentToValue(percent, min, max); - if (step) { - newValue = roundValueToStep(newValue, step, min); - } else { - const marksValues = marks.map((mark) => mark.value); - const closestIndex = findClosest(marksValues, newValue); - newValue = marksValues[closestIndex]; - } - - newValue = clamp(newValue, min, max); - let activeIndex = 0; - - if (range) { - if (!move) { - activeIndex = findClosest(values2, newValue); - } else { - activeIndex = previousIndex.current; - } + '& .MuiSlider-valueLabel': { + // IE 11 centering bug, to remove from the customization demos once no longer supported + left: 'calc(-50% - 4px)', + }, - const previousValue = newValue; - newValue = setValueIndex({ - values: values2, - source, - newValue, - index: activeIndex, - }).sort(asc); - activeIndex = newValue.indexOf(previousValue); - previousIndex.current = activeIndex; - } + '& .MuiSlider-mark': { + position: 'absolute', + width: 2, + height: 2, + borderRadius: 1, + backgroundColor: 'currentColor', + '&.MuiSlider-markActive': { + backgroundColor: props.theme.palette.background.paper, + opacity: 0.8, + }, + }, - return { newValue, activeIndex }; + '& .MuiSlider-markLabel': { + ...props.theme.typography.body2, + color: props.theme.palette.text.secondary, + position: 'absolute', + top: 26, + transform: 'translateX(-50%)', + whiteSpace: 'nowrap', + ...(props.state.orientation === 'vertical' && { + top: 'auto', + left: 26, + transform: 'translateY(50%)', + }), + '@media (pointer: coarse)': { + top: 40, + ...(props.state.orientation === 'vertical' && { + left: 31, + }), + }, + '&.MuiSlider-markLabelActive': { + color: props.theme.palette.text.primary, + }, + }, }; +}); - const handleTouchMove = useEventCallback((nativeEvent) => { - const finger = trackFinger(nativeEvent, touchId); - - if (!finger) { - return; - } - - // Cancel move in case some other element consumed a mouseup event and it was not fired. - if (nativeEvent.type === 'mousemove' && nativeEvent.buttons === 0) { - // eslint-disable-next-line @typescript-eslint/no-use-before-define - handleTouchEnd(nativeEvent); - return; - } - - const { newValue, activeIndex } = getFingerNewValue({ - finger, - move: true, - values, - source: valueDerived, - }); - - focusThumb({ sliderRef, activeIndex, setActive }); - setValueState(newValue); - - if (onChange) { - onChange(nativeEvent, newValue); - } - }); - - const handleTouchEnd = useEventCallback((nativeEvent) => { - const finger = trackFinger(nativeEvent, touchId); - - if (!finger) { - return; - } - - const { newValue } = getFingerNewValue({ finger, values, source: valueDerived }); - - setActive(-1); - if (nativeEvent.type === 'touchend') { - setOpen(-1); - } - - if (onChangeCommitted) { - onChangeCommitted(nativeEvent, newValue); - } - - touchId.current = undefined; - - const doc = ownerDocument(sliderRef.current); - doc.removeEventListener('mousemove', handleTouchMove); - doc.removeEventListener('mouseup', handleTouchEnd); - doc.removeEventListener('touchmove', handleTouchMove); - doc.removeEventListener('touchend', handleTouchEnd); - }); - - const handleTouchStart = useEventCallback((event) => { - // If touch-action: none; is not supported we need to prevent the scroll manually. - if (!doesSupportTouchActionNone()) { - event.preventDefault(); - } - - const touch = event.changedTouches[0]; - if (touch != null) { - // A number that uniquely identifies the current finger in the touch session. - touchId.current = touch.identifier; - } - const finger = trackFinger(event, touchId); - const { newValue, activeIndex } = getFingerNewValue({ finger, values, source: valueDerived }); - focusThumb({ sliderRef, activeIndex, setActive }); - - setValueState(newValue); - - if (onChange) { - onChange(event, newValue); - } - - const doc = ownerDocument(sliderRef.current); - doc.addEventListener('touchmove', handleTouchMove); - doc.addEventListener('touchend', handleTouchEnd); - }); - - React.useEffect(() => { - const { current: slider } = sliderRef; - slider.addEventListener('touchstart', handleTouchStart, { - passive: doesSupportTouchActionNone(), - }); - - const doc = ownerDocument(slider); - - return () => { - slider.removeEventListener('touchstart', handleTouchStart, { - passive: doesSupportTouchActionNone(), - }); - - doc.removeEventListener('mousemove', handleTouchMove); - doc.removeEventListener('mouseup', handleTouchEnd); - doc.removeEventListener('touchmove', handleTouchMove); - doc.removeEventListener('touchend', handleTouchEnd); - }; - }, [handleTouchEnd, handleTouchMove, handleTouchStart]); - - React.useEffect(() => { - if (disabled) { - const doc = ownerDocument(sliderRef.current); - doc.removeEventListener('mousemove', handleTouchMove); - doc.removeEventListener('mouseup', handleTouchEnd); - doc.removeEventListener('touchmove', handleTouchMove); - doc.removeEventListener('touchend', handleTouchEnd); - } - }, [disabled, handleTouchEnd, handleTouchMove]); - - const handleMouseDown = useEventCallback((event) => { - if (onMouseDown) { - onMouseDown(event); - } - - event.preventDefault(); - const finger = trackFinger(event, touchId); - const { newValue, activeIndex } = getFingerNewValue({ finger, values, source: valueDerived }); - focusThumb({ sliderRef, activeIndex, setActive }); - - setValueState(newValue); - - if (onChange) { - onChange(event, newValue); - } - - const doc = ownerDocument(sliderRef.current); - doc.addEventListener('mousemove', handleTouchMove); - doc.addEventListener('mouseup', handleTouchEnd); - }); - - const trackOffset = valueToPercent(range ? values[0] : min, min, max); - const trackLeap = valueToPercent(values[values.length - 1], min, max) - trackOffset; - const trackStyle = { - ...axisProps[axis].offset(trackOffset), - ...axisProps[axis].leap(trackLeap), +const getComponentProps = (components, componentsProps, name) => { + const slotProps = componentsProps[name] || {}; + return { + as: components[name], + ...slotProps, }; +}; +const Slider = React.forwardRef(function Slider(inputProps, ref) { + const props = useThemeProps({ props: inputProps, name: 'MuiSlider' }); + const { components = {}, componentsProps = {}, ...other } = props; return ( - 0 && marks.some((mark) => mark.label), - [classes.vertical]: orientation === 'vertical', - [classes.trackInverted]: track === 'inverted', - [classes.trackFalse]: track === false, - }, - className, - )} - onMouseDown={handleMouseDown} + - - - - {marks.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); - - return ( - - - - ); - })} - + components={{ + Root: SliderRoot, + ...components, + }} + componentsProps={{ + root: getComponentProps(components, componentsProps, 'root'), + }} + ref={ref} + /> ); }); -Slider.propTypes = { - // ----------------------------- Warning -------------------------------- - // | These PropTypes are generated from the TypeScript type definitions | - // | To update them edit the d.ts file 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( - 'Material-UI: 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( - 'Material-UI: You need to use the `getAriaValueText` prop instead of `aria-valuetext` when using a range slider.', - ); - } - - return null; - }), - /** - * @ignore - */ - children: PropTypes.node, - /** - * Override or extend the styles applied to the component. - */ - classes: PropTypes.object, - /** - * @ignore - */ - className: PropTypes.string, - /** - * The color of the component. It supports those theme colors that make sense for this component. - * @default 'primary' - */ - color: PropTypes.oneOf(['primary', 'secondary']), - /** - * The component used for the root node. - * Either a string to use a HTML element or a component. - */ - component: PropTypes.elementType, - /** - * The default element value. Use when the component is not controlled. - */ - defaultValue: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]), - /** - * If `true`, the slider will be disabled. - * @default false - */ - disabled: PropTypes.bool, - /** - * Accepts a function which returns a string value that provides a user-friendly name for the thumb labels of the slider. - * - * @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. - * - * @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 will be 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 {object} event The event source of the callback. **Warning**: This is a generic event not a change event. - * @param {number | number[]} value The new value. - */ - onChange: PropTypes.func, - /** - * Callback function that is fired when the `mouseup` is triggered. - * - * @param {object} 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, - /** - * @ignore - */ - onMouseDown: PropTypes.func, - /** - * The slider orientation. - * @default 'horizontal' - */ - orientation: PropTypes.oneOf(['horizontal', 'vertical']), - /** - * A transformation function, to change the scale of the slider. - * @default (x) => x - */ - scale: PropTypes.func, - /** - * 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, - /** - * The component used to display the value label. - * @default 'span' - */ - ThumbComponent: PropTypes.elementType, - /** - * 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]), - /** - * The value label component. - * @default ValueLabel - */ - ValueLabelComponent: PropTypes.elementType, - /** - * 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 - * @default (x) => x - */ - valueLabelFormat: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), -}; - -export default withStyles(styles, { name: 'MuiSlider' })(Slider); +export default Slider; diff --git a/packages/material-ui/src/Slider/SliderBase.d.ts b/packages/material-ui/src/Slider/SliderBase.d.ts new file mode 100644 index 00000000000000..e823b9f45ab642 --- /dev/null +++ b/packages/material-ui/src/Slider/SliderBase.d.ts @@ -0,0 +1,6 @@ +import { OverridableComponent } from '../OverridableComponent'; +import { SliderTypeMap } from './Slider'; + +declare const SliderBase: OverridableComponent; + +export default SliderBase; diff --git a/packages/material-ui/src/Slider/SliderBase.js b/packages/material-ui/src/Slider/SliderBase.js new file mode 100644 index 00000000000000..bd3d9d2ff71db3 --- /dev/null +++ b/packages/material-ui/src/Slider/SliderBase.js @@ -0,0 +1,914 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { chainPropTypes } from '@material-ui/utils'; +import useIsFocusVisible from '../utils/useIsFocusVisible'; +import useEnhancedEffect from '../utils/useEnhancedEffect'; +import ownerDocument from '../utils/ownerDocument'; +import useEventCallback from '../utils/useEventCallback'; +import useForkRef from '../utils/useForkRef'; +import capitalize from '../utils/capitalize'; +import useControlled from '../utils/useControlled'; +import ValueLabelComponent from './ValueLabel'; + +function asc(a, b) { + return a - b; +} + +function clamp(value, min, max) { + return Math.min(Math.max(min, value), max); +} + +function findClosest(values, currentValue) { + const { index: closestIndex } = values.reduce((acc, value, index) => { + const distance = Math.abs(currentValue - value); + + if (acc === null || distance < acc.distance || distance === acc.distance) { + return { + distance, + index, + }; + } + + return acc; + }, null); + return closestIndex; +} + +function trackFinger(event, touchId) { + if (touchId.current !== undefined && event.changedTouches) { + for (let i = 0; i < event.changedTouches.length; i += 1) { + const touch = event.changedTouches[i]; + if (touch.identifier === touchId.current) { + return { + x: touch.clientX, + y: touch.clientY, + }; + } + } + + return false; + } + + return { + x: event.clientX, + y: event.clientY, + }; +} + +function valueToPercent(value, min, max) { + return ((value - min) * 100) / (max - min); +} + +function percentToValue(percent, min, max) { + return (max - min) * percent + min; +} + +function getDecimalPrecision(num) { + // This handles the case when num is very small (0.00000001), js will turn this into 1e-8. + // When num is bigger than 1 or less than -1 it won't get converted to this notation so it's fine. + if (Math.abs(num) < 1) { + const parts = num.toExponential().split('e-'); + const matissaDecimalPart = parts[0].split('.')[1]; + return (matissaDecimalPart ? matissaDecimalPart.length : 0) + parseInt(parts[1], 10); + } + + const decimalPart = num.toString().split('.')[1]; + return decimalPart ? decimalPart.length : 0; +} + +function roundValueToStep(value, step, min) { + const nearest = Math.round((value - min) / step) * step + min; + return Number(nearest.toFixed(getDecimalPrecision(step))); +} + +function setValueIndex({ values, source, newValue, index }) { + // Performance shortcut + if (source[index] === newValue) { + return source; + } + + const output = values.slice(); + output[index] = newValue; + return output; +} + +function focusThumb({ sliderRef, activeIndex, setActive }) { + const doc = ownerDocument(sliderRef.current); + if ( + !sliderRef.current.contains(doc.activeElement) || + Number(doc.activeElement.getAttribute('data-index')) !== activeIndex + ) { + sliderRef.current.querySelector(`[role="slider"][data-index="${activeIndex}"]`).focus(); + } + + if (setActive) { + setActive(activeIndex); + } +} + +const axisProps = { + horizontal: { + offset: (percent) => ({ left: `${percent}%` }), + leap: (percent) => ({ width: `${percent}%` }), + }, + 'horizontal-reverse': { + offset: (percent) => ({ right: `${percent}%` }), + leap: (percent) => ({ width: `${percent}%` }), + }, + vertical: { + offset: (percent) => ({ bottom: `${percent}%` }), + leap: (percent) => ({ height: `${percent}%` }), + }, +}; + +const Identity = (x) => x; + +// TODO: remove support for Safari < 13. +// https://caniuse.com/#search=touch-action +// +// Safari, on iOS, supports touch action since v13. +// Over 80% of the iOS phones are compatible +// in August 2020. +let cachedSupportsTouchActionNone; +function doesSupportTouchActionNone() { + if (cachedSupportsTouchActionNone === undefined) { + const element = document.createElement('div'); + element.style.touchAction = 'none'; + document.body.appendChild(element); + cachedSupportsTouchActionNone = window.getComputedStyle(element).touchAction === 'none'; + element.parentElement.removeChild(element); + } + return cachedSupportsTouchActionNone; +} + +const getUtilityClass = (name) => { + return `MuiSlider-${name}`; +}; + +const useSliderClasses = (props) => { + const { color, disabled, marked, orientation, track } = props; + + const utilityClasses = { + root: clsx(getUtilityClass('root'), getUtilityClass(`color${capitalize(color)}`), { + ['Mui-disabled']: disabled, + [getUtilityClass('marked')]: marked, + [getUtilityClass('vertical')]: orientation === 'vertical', + [getUtilityClass('trackInverted')]: track === 'inverted', + [getUtilityClass('trackFalse')]: track === false, + }), + rail: getUtilityClass('rail'), + track: getUtilityClass('track'), + mark: getUtilityClass('mark'), + markLabel: getUtilityClass('markLabel'), + valueLabel: getUtilityClass('valueLabel'), + thumb: clsx(getUtilityClass('thumb'), getUtilityClass(`thumbColor${capitalize(color)}`), { + ['Mui-disabled']: disabled, + }), + }; + + return utilityClasses; +}; + +const isComponent = (element) => typeof element !== 'string'; + +const Slider = React.forwardRef(function Slider(props, ref) { + const { + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-valuetext': ariaValuetext, + classes = {}, + className, + color = 'primary', + component: Component = 'span', + defaultValue, + disabled = false, + getAriaLabel, + getAriaValueText, + marks: marksProp = false, + max = 100, + min = 0, + name, + onChange, + onChangeCommitted, + onMouseDown, + orientation = 'horizontal', + scale = Identity, + step = 1, + track = 'normal', + value: valueProp, + valueLabelDisplay = 'off', + valueLabelFormat = Identity, + isRtl = false, + components = {}, + componentsProps = {}, + ...other + } = props; + + const touchId = React.useRef(); + // We can't use the :active browser pseudo-classes. + // - The active state isn't triggered when clicking on the rail. + // - The active state isn't transfered when inversing a range slider. + const [active, setActive] = React.useState(-1); + const [open, setOpen] = React.useState(-1); + + const [valueDerived, setValueState] = useControlled({ + controlled: valueProp, + default: defaultValue, + name: 'Slider', + }); + + const range = Array.isArray(valueDerived); + let values = range ? valueDerived.slice().sort(asc) : [valueDerived]; + values = values.map((value) => clamp(value, min, max)); + const marks = + marksProp === true && step !== null + ? [...Array(Math.floor((max - min) / step) + 1)].map((_, index) => ({ + value: min + step * index, + })) + : marksProp || []; + + const { + isFocusVisibleRef, + onBlur: handleBlurVisible, + onFocus: handleFocusVisible, + ref: focusVisibleRef, + } = useIsFocusVisible(); + const [focusVisible, setFocusVisible] = React.useState(-1); + + const sliderRef = React.useRef(); + const handleFocusRef = useForkRef(focusVisibleRef, sliderRef); + const handleRef = useForkRef(ref, handleFocusRef); + + const handleFocus = useEventCallback((event) => { + const index = Number(event.currentTarget.getAttribute('data-index')); + handleFocusVisible(event); + if (isFocusVisibleRef.current === true) { + setFocusVisible(index); + } + setOpen(index); + }); + const handleBlur = useEventCallback((event) => { + handleBlurVisible(event); + if (isFocusVisibleRef.current === false) { + setFocusVisible(-1); + } + setOpen(-1); + }); + const handleMouseOver = useEventCallback((event) => { + const index = Number(event.currentTarget.getAttribute('data-index')); + setOpen(index); + }); + const handleMouseLeave = useEventCallback(() => { + setOpen(-1); + }); + + useEnhancedEffect(() => { + if (disabled && sliderRef.current.contains(document.activeElement)) { + // This is necessary because Firefox and Safari will keep focus + // on a disabled element: + // https://codesandbox.io/s/mui-pr-22247-forked-h151h?file=/src/App.js + document.activeElement.blur(); + } + }, [disabled]); + + if (disabled && active !== -1) { + setActive(-1); + } + if (disabled && focusVisible !== -1) { + setFocusVisible(-1); + } + + const handleKeyDown = useEventCallback((event) => { + const index = Number(event.currentTarget.getAttribute('data-index')); + const value = values[index]; + const tenPercents = (max - min) / 10; + const marksValues = marks.map((mark) => mark.value); + const marksIndex = marksValues.indexOf(value); + let newValue; + const increaseKey = isRtl ? 'ArrowLeft' : 'ArrowRight'; + const decreaseKey = isRtl ? 'ArrowRight' : 'ArrowLeft'; + + switch (event.key) { + case 'Home': + newValue = min; + break; + case 'End': + newValue = max; + break; + case 'PageUp': + if (step) { + newValue = value + tenPercents; + } + break; + case 'PageDown': + if (step) { + newValue = value - tenPercents; + } + break; + case increaseKey: + case 'ArrowUp': + if (step) { + newValue = value + step; + } else { + newValue = marksValues[marksIndex + 1] || marksValues[marksValues.length - 1]; + } + break; + case decreaseKey: + case 'ArrowDown': + if (step) { + newValue = value - step; + } else { + newValue = marksValues[marksIndex - 1] || marksValues[0]; + } + break; + default: + return; + } + + // Prevent scroll of the page + event.preventDefault(); + + if (step) { + newValue = roundValueToStep(newValue, step, min); + } + + newValue = clamp(newValue, min, max); + + if (range) { + const previousValue = newValue; + newValue = setValueIndex({ + values, + source: valueDerived, + newValue, + index, + }).sort(asc); + focusThumb({ sliderRef, activeIndex: newValue.indexOf(previousValue) }); + } + + setValueState(newValue); + setFocusVisible(index); + + if (onChange) { + onChange(event, newValue); + } + if (onChangeCommitted) { + onChangeCommitted(event, newValue); + } + }); + + const previousIndex = React.useRef(); + let axis = orientation; + if (isRtl && orientation === 'horizontal') { + axis += '-reverse'; + } + + const getFingerNewValue = ({ finger, move = false, values: values2, source }) => { + const { current: slider } = sliderRef; + const { width, height, bottom, left } = slider.getBoundingClientRect(); + let percent; + + if (axis.indexOf('vertical') === 0) { + percent = (bottom - finger.y) / height; + } else { + percent = (finger.x - left) / width; + } + + if (axis.indexOf('-reverse') !== -1) { + percent = 1 - percent; + } + + let newValue; + newValue = percentToValue(percent, min, max); + if (step) { + newValue = roundValueToStep(newValue, step, min); + } else { + const marksValues = marks.map((mark) => mark.value); + const closestIndex = findClosest(marksValues, newValue); + newValue = marksValues[closestIndex]; + } + + newValue = clamp(newValue, min, max); + let activeIndex = 0; + + if (range) { + if (!move) { + activeIndex = findClosest(values2, newValue); + } else { + activeIndex = previousIndex.current; + } + + const previousValue = newValue; + newValue = setValueIndex({ + values: values2, + source, + newValue, + index: activeIndex, + }).sort(asc); + activeIndex = newValue.indexOf(previousValue); + previousIndex.current = activeIndex; + } + + return { newValue, activeIndex }; + }; + + const handleTouchMove = useEventCallback((nativeEvent) => { + const finger = trackFinger(nativeEvent, touchId); + + if (!finger) { + return; + } + + // Cancel move in case some other element consumed a mouseup event and it was not fired. + if (nativeEvent.type === 'mousemove' && nativeEvent.buttons === 0) { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + handleTouchEnd(nativeEvent); + return; + } + + const { newValue, activeIndex } = getFingerNewValue({ + finger, + move: true, + values, + source: valueDerived, + }); + + focusThumb({ sliderRef, activeIndex, setActive }); + setValueState(newValue); + + if (onChange) { + onChange(nativeEvent, newValue); + } + }); + + const handleTouchEnd = useEventCallback((nativeEvent) => { + const finger = trackFinger(nativeEvent, touchId); + + if (!finger) { + return; + } + + const { newValue } = getFingerNewValue({ finger, values, source: valueDerived }); + + setActive(-1); + if (nativeEvent.type === 'touchend') { + setOpen(-1); + } + + if (onChangeCommitted) { + onChangeCommitted(nativeEvent, newValue); + } + + touchId.current = undefined; + + const doc = ownerDocument(sliderRef.current); + doc.removeEventListener('mousemove', handleTouchMove); + doc.removeEventListener('mouseup', handleTouchEnd); + doc.removeEventListener('touchmove', handleTouchMove); + doc.removeEventListener('touchend', handleTouchEnd); + }); + + const handleTouchStart = useEventCallback((event) => { + // If touch-action: none; is not supported we need to prevent the scroll manually. + if (!doesSupportTouchActionNone()) { + event.preventDefault(); + } + + const touch = event.changedTouches[0]; + if (touch != null) { + // A number that uniquely identifies the current finger in the touch session. + touchId.current = touch.identifier; + } + const finger = trackFinger(event, touchId); + const { newValue, activeIndex } = getFingerNewValue({ finger, values, source: valueDerived }); + focusThumb({ sliderRef, activeIndex, setActive }); + + setValueState(newValue); + + if (onChange) { + onChange(event, newValue); + } + + const doc = ownerDocument(sliderRef.current); + doc.addEventListener('touchmove', handleTouchMove); + doc.addEventListener('touchend', handleTouchEnd); + }); + + React.useEffect(() => { + const { current: slider } = sliderRef; + slider.addEventListener('touchstart', handleTouchStart, { + passive: doesSupportTouchActionNone(), + }); + + const doc = ownerDocument(slider); + + return () => { + slider.removeEventListener('touchstart', handleTouchStart, { + passive: doesSupportTouchActionNone(), + }); + + doc.removeEventListener('mousemove', handleTouchMove); + doc.removeEventListener('mouseup', handleTouchEnd); + doc.removeEventListener('touchmove', handleTouchMove); + doc.removeEventListener('touchend', handleTouchEnd); + }; + }, [handleTouchEnd, handleTouchMove, handleTouchStart]); + + React.useEffect(() => { + if (disabled) { + const doc = ownerDocument(sliderRef.current); + doc.removeEventListener('mousemove', handleTouchMove); + doc.removeEventListener('mouseup', handleTouchEnd); + doc.removeEventListener('touchmove', handleTouchMove); + doc.removeEventListener('touchend', handleTouchEnd); + } + }, [disabled, handleTouchEnd, handleTouchMove]); + + const handleMouseDown = useEventCallback((event) => { + if (onMouseDown) { + onMouseDown(event); + } + + event.preventDefault(); + const finger = trackFinger(event, touchId); + const { newValue, activeIndex } = getFingerNewValue({ finger, values, source: valueDerived }); + focusThumb({ sliderRef, activeIndex, setActive }); + + setValueState(newValue); + + if (onChange) { + onChange(event, newValue); + } + + const doc = ownerDocument(sliderRef.current); + doc.addEventListener('mousemove', handleTouchMove); + doc.addEventListener('mouseup', handleTouchEnd); + }); + + const trackOffset = valueToPercent(range ? values[0] : min, min, max); + const trackLeap = valueToPercent(values[values.length - 1], min, max) - trackOffset; + const trackStyle = { + ...axisProps[axis].offset(trackOffset), + ...axisProps[axis].leap(trackLeap), + }; + + const Root = components.Root || 'span'; + const rootProps = componentsProps.root || {}; + + const Rail = components.Rail || 'span'; + const railProps = componentsProps.rail || {}; + + const Track = components.Track || 'span'; + const trackProps = componentsProps.track || {}; + + const Thumb = components.Thumb || 'span'; + const thumbProps = componentsProps.thumb || {}; + + const ValueLabel = components.ValueLabel || ValueLabelComponent; + const valueLabelProps = componentsProps.valueLabel || {}; + + const Mark = components.Mark || 'span'; + const markProps = componentsProps.mark || {}; + + const MarkLabel = components.MarkLabel || 'span'; + const markLabelProps = componentsProps.markLabel || {}; + + // all props with defaults + // consider extracting to hook an reusing the lint rule for the varints + const stateAndProps = { + ...props, + color, + disabled, + max, + min, + orientation, + scale, + step, + track, + valueLabelDisplay, + valueLabelFormat, + isRtl, + marked: marks.length > 0 && marks.some((mark) => mark.label), + }; + + const utilityClasses = useSliderClasses({ + ...stateAndProps, + classes, + }); + + return ( + + + + + {marks.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); + + return ( + + + + ); + })} + + ); +}); + +Slider.propTypes = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the d.ts file 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( + 'Material-UI: 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( + 'Material-UI: You need to use the `getAriaValueText` prop instead of `aria-valuetext` when using a range slider.', + ); + } + + return null; + }), + /** + * @ignore + */ + children: PropTypes.node, + /** + * Override or extend the styles applied to the component. + */ + classes: PropTypes.object, + /** + * @ignore + */ + className: PropTypes.string, + /** + * The color of the component. It supports those theme colors that make sense for this component. + * @default 'primary' + */ + color: PropTypes.oneOf(['primary', 'secondary']), + /** + * The component used for the root node. + * Either a string to use a HTML element or a component. + */ + component: PropTypes.elementType, + /** + * The default element value. Use when the component is not controlled. + */ + defaultValue: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]), + /** + * If `true`, the slider will be disabled. + * @default false + */ + disabled: PropTypes.bool, + /** + * Accepts a function which returns a string value that provides a user-friendly name for the thumb labels of the slider. + * + * @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. + * + * @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, + /** + * Indicates whether the theme context has rtl direction. It is set automatically. + */ + isRtl: PropTypes.bool, + /** + * Marks indicate predetermined values to which the user can move the slider. + * If `true` the marks will be 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 {object} event The event source of the callback. **Warning**: This is a generic event not a change event. + * @param {number | number[]} value The new value. + */ + onChange: PropTypes.func, + /** + * Callback function that is fired when the `mouseup` is triggered. + * + * @param {object} 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, + /** + * @ignore + */ + onMouseDown: PropTypes.func, + /** + * The slider orientation. + * @default 'horizontal' + */ + orientation: PropTypes.oneOf(['horizontal', 'vertical']), + /** + * A transformation function, to change the scale of the slider. + * @default (x) => x + */ + scale: PropTypes.func, + /** + * 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, + /** + * The component used to display the value label. + * @default 'span' + */ + ThumbComponent: PropTypes.elementType, + /** + * 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]), + /** + * The value label component. + * @default ValueLabel + */ + ValueLabelComponent: PropTypes.elementType, + /** + * 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 + * @default (x) => x + */ + valueLabelFormat: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), +}; + +export default Slider; diff --git a/packages/material-ui/src/Slider/ValueLabel.js b/packages/material-ui/src/Slider/ValueLabel.js index 528c9161d1c4fc..1e070928732602 100644 --- a/packages/material-ui/src/Slider/ValueLabel.js +++ b/packages/material-ui/src/Slider/ValueLabel.js @@ -43,6 +43,7 @@ const styles = (theme) => ({ /** * @ignore - internal component. */ +// TODO: convert to emotion function ValueLabel(props) { const { children, classes, className, open, value, valueLabelDisplay } = props; diff --git a/packages/material-ui/src/Slider/index.d.ts b/packages/material-ui/src/Slider/index.d.ts index 006f966fe2404f..e81742056288f6 100644 --- a/packages/material-ui/src/Slider/index.d.ts +++ b/packages/material-ui/src/Slider/index.d.ts @@ -1,2 +1,13 @@ export { default } from './Slider'; +export { default as SliderBase } from './SliderBase'; +export { + SliderRoot, + SliderMark, + SliderMarkLabel, + SliderRail, + SliderTrack, + SliderThumb, + SliderValueLabel, +} from './Slider'; export * from './Slider'; +export * from './SliderBase'; diff --git a/packages/material-ui/src/Slider/index.js b/packages/material-ui/src/Slider/index.js index 9898d6a85d1d01..f7405315520e87 100644 --- a/packages/material-ui/src/Slider/index.js +++ b/packages/material-ui/src/Slider/index.js @@ -1 +1,3 @@ export { default } from './Slider'; +export { default as SliderBase } from './SliderBase'; +export * from './Slider'; diff --git a/packages/material-ui/src/styles/index.d.ts b/packages/material-ui/src/styles/index.d.ts index 812f80e6b4e30a..ad3c474e1a2188 100644 --- a/packages/material-ui/src/styles/index.d.ts +++ b/packages/material-ui/src/styles/index.d.ts @@ -23,6 +23,7 @@ export { StyledComponentProps, } from './withStyles'; export { default as withTheme, WithTheme } from './withTheme'; +export { default as muiStyled } from './muiStyled'; export { default as styled, ComponentCreator, StyledProps } from './styled'; export { createGenerateClassName, diff --git a/packages/material-ui/src/styles/index.js b/packages/material-ui/src/styles/index.js index 2fb7099da2caaf..8d0f9ef14407bc 100644 --- a/packages/material-ui/src/styles/index.js +++ b/packages/material-ui/src/styles/index.js @@ -10,6 +10,7 @@ export * from './transitions'; export { default as useTheme } from './useTheme'; export { default as withStyles } from './withStyles'; export { default as withTheme } from './withTheme'; +export { default as muiStyled } from './muiStyled'; export { createGenerateClassName, jssPreset, diff --git a/packages/material-ui/src/styles/muiStyled.d.ts b/packages/material-ui/src/styles/muiStyled.d.ts new file mode 100644 index 00000000000000..b5a49f0cfb7f2b --- /dev/null +++ b/packages/material-ui/src/styles/muiStyled.d.ts @@ -0,0 +1,8 @@ +/** + * Cutom styled functionality that support mui specific config. + * + * @param options Takes an incomplete theme object and adds the missing parts. + * @returns A complete, ready to use theme object. + */ +// TODO: fix typings +export default function adaptV4Theme(component: any, config: any, muiConfig: any): React.Component; diff --git a/packages/material-ui/src/styles/muiStyled.js b/packages/material-ui/src/styles/muiStyled.js new file mode 100644 index 00000000000000..24249e1936b75f --- /dev/null +++ b/packages/material-ui/src/styles/muiStyled.js @@ -0,0 +1,61 @@ +import styled from '@emotion/styled'; +import { propsToClassKey } from '@material-ui/styles'; + +const getStyleOverrides = (name, theme) => { + let styleOverrides = {}; + + if ( + theme && + theme.components && + theme.components[name] && + theme.components[name].styleOverrides + ) { + styleOverrides = theme.components[name].styleOverrides; + } + + return styleOverrides; +}; + +const getVariantStyles = (name, theme) => { + let variants = []; + if (theme && theme.components && theme.components[name] && theme.components[name].variants) { + variants = theme.components[name].variants; + } + + const variantsStyles = {}; + + variants.forEach((definition) => { + const key = propsToClassKey(definition.props); + variantsStyles[key] = definition.style; + }); + + return variantsStyles; +}; + +const shouldForwardProp = (prop) => prop !== 'state' && prop !== 'theme'; + +const muiStyled = (el, params, muiConfig) => { + const result = styled(el, { shouldForwardProp, ...params }); + const muiFunc = (...params) => { + const name = muiConfig.muiName; + + if (muiConfig.overridesResolver) { + params.push((props) => { + const theme = props.theme || defaultTheme; + return muiConfig.overridesResolver(props, getStyleOverrides(name, theme), name); + }); + } + + if (muiConfig.variantsResolver) { + params.push((props) => { + const theme = props.theme || defaultTheme; + return muiConfig.variantsResolver(props, getVariantStyles(name, theme), theme, name); + }); + } + + return result(params); + }; + return muiFunc; +}; + +export default muiStyled; diff --git a/packages/material-ui/src/styles/useThemeProps.d.ts b/packages/material-ui/src/styles/useThemeProps.d.ts new file mode 100644 index 00000000000000..3326c8b117818d --- /dev/null +++ b/packages/material-ui/src/styles/useThemeProps.d.ts @@ -0,0 +1,15 @@ +interface ThemeWithProps { + components?: { [K in keyof Components]: { defaultProps?: Partial } }; +} + +type ThemedProps = Theme extends { + components: Record; +} + ? Props + : {}; + +export default function useThemeProps< + Theme extends ThemeWithProps, + Props, + Name extends keyof any +>(params: { props: Props; name: Name; }): Props & ThemedProps; diff --git a/packages/material-ui/src/styles/useThemeProps.js b/packages/material-ui/src/styles/useThemeProps.js new file mode 100644 index 00000000000000..3a204d062ee78b --- /dev/null +++ b/packages/material-ui/src/styles/useThemeProps.js @@ -0,0 +1,20 @@ +import useTheme from './useTheme'; +import { getThemeProps } from '@material-ui/styles'; +import defaultTheme from './defaultTheme'; + +export default function useThemeProps({ props: inputProps, name }) { + const props = Object.assign({}, inputProps); + + const contextTheme = useTheme() || defaultTheme; + + const more = getThemeProps({ theme: contextTheme, name, props }); + + const theme = more.theme || contextTheme; + const isRtl = theme.direction === 'rtl'; + + return { + theme, + isRtl, + ...more, + }; +}; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index aa404acdaf24be..8eb59cb079a61a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5791,6 +5791,11 @@ cssesc@^3.0.0: resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== +cssjanus@^1.3.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/cssjanus/-/cssjanus-1.3.2.tgz#7c23d39be92f63e1557a75c015f61d95009bd6b3" + integrity sha512-5pM/C1MIfoqhXa7k9PqSnrjj1SSZDakfyB1DZhdYyJoDUH+evGbsUg6/bpQapTJeSnYaj0rdzPUMeM33CvB0vw== + cssnano-preset-default@^4.0.7: version "4.0.7" resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz#51ec662ccfca0f88b396dcd9679cdb931be17f76" @@ -15475,6 +15480,13 @@ stylehacks@^4.0.0: postcss "^7.0.0" postcss-selector-parser "^3.0.0" +stylis-plugin-rtl@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/stylis-plugin-rtl/-/stylis-plugin-rtl-1.1.0.tgz#028d72419ccc47eeaaec684f3e192534f2c57ece" + integrity sha512-FPoSxP+gbBLJRUXDRDFNBhqy/eToquDLn7ZrjIVBRfXaZ9bunwNnDtDm2qW1EoU0c93krm1Dy+8iVmJpjRGsKw== + dependencies: + cssjanus "^1.3.0" + stylis-rule-sheet@0.0.10: version "0.0.10" resolved "https://registry.yarnpkg.com/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz#44e64a2b076643f4b52e5ff71efc04d8c3c4a430"