diff --git a/.eslintignore b/.eslintignore index edefeeeebadd18..8da77316e6795e 100644 --- a/.eslintignore +++ b/.eslintignore @@ -18,6 +18,7 @@ /packages/material-ui-icons/legacy /packages/material-ui-icons/src /packages/material-ui-icons/templateSvgIcon.js +/packages/material-ui-utils/macros/__fixtures__/ # Ignore fixtures /packages/typescript-to-proptypes/test/*/* /tmp diff --git a/packages/material-ui-utils/macros/MuiError.macro.d.ts b/packages/material-ui-utils/macros/MuiError.macro.d.ts new file mode 100644 index 00000000000000..53d894b2241058 --- /dev/null +++ b/packages/material-ui-utils/macros/MuiError.macro.d.ts @@ -0,0 +1,3 @@ +export default class MuiError { + constructor(message: string); +} diff --git a/packages/material-ui-utils/macros/MuiError.macro.js b/packages/material-ui-utils/macros/MuiError.macro.js index a91c70c8c105ff..c0e2f13b357fda 100644 --- a/packages/material-ui-utils/macros/MuiError.macro.js +++ b/packages/material-ui-utils/macros/MuiError.macro.js @@ -1,6 +1,7 @@ const { createMacro, MacroError } = require('babel-plugin-macros'); const helperModuleImports = require('@babel/helper-module-imports'); const fs = require('fs'); +const path = require('path'); function invertObject(object) { const inverted = {}; @@ -11,9 +12,13 @@ function invertObject(object) { } /** + * Supported imports: + * 1. bare specifier e.g. `'@material-ui/utils/macros/MuiError.macro'` + * 2. relative import from `packages/material-ui-utils/src` e.g. `'../macros/MuiError.macro'` + * * @param {import('babel-plugin-macros').MacroParams} param0 */ -function muiError({ references, babel, config }) { +function muiError({ references, babel, config, source }) { const { errorCodesPath = {}, missingError = 'annotate' } = config; const errorCodes = JSON.parse(fs.readFileSync(errorCodesPath, { encoding: 'utf8' })); const errorCodesLookup = invertObject(errorCodes); @@ -125,13 +130,33 @@ function muiError({ references, babel, config }) { errorCode = parseInt(errorCode, 10); if (formatMuiErrorMessageIdentifier === null) { - // Outputs: - // import { formatMuiErrorMessage } from '@material-ui/utils'; - formatMuiErrorMessageIdentifier = helperModuleImports.addNamed( - babelPath, - 'formatMuiErrorMessage', - '@material-ui/utils', - ); + const isBareImportSourceIdentifier = source.startsWith('@material-ui/utils'); + if (isBareImportSourceIdentifier) { + // Input: import MuiError from '@material-ui/utils/macros/MuiError.macro' + // Outputs: + // import { formatMuiErrorMessage } from '@material-ui/utils'; + formatMuiErrorMessageIdentifier = helperModuleImports.addNamed( + babelPath, + 'formatMuiErrorMessage', + '@material-ui/utils', + ); + } else { + const normalizedRelativeImport = path.normalize( + source.replace('../macros/MuiError.macro', './formatMuiErrorMessage'), + ); + // 'formatMuiErrorMessage' implies './formatMuiErrorMessage' for fs paths but not for import specifiers. + const formatMuiErrorMessageImportSource = normalizedRelativeImport.startsWith('.') + ? normalizedRelativeImport + : `./${normalizedRelativeImport}`; + // Input: import MuiError from '../macros/MuiError.macro' + // Outputs: + // import formatMuiErrorMessage from './formatMuiErrorMessage'; + formatMuiErrorMessageIdentifier = helperModuleImports.addDefault( + babelPath, + formatMuiErrorMessageImportSource, + { nameHint: 'formatMuiErrorMessage' }, + ); + } } // Outputs: diff --git a/packages/material-ui-utils/macros/MuiError.macro.test.js b/packages/material-ui-utils/macros/MuiError.macro.test.js index d5bdf637a533e8..e503820c75e6cb 100644 --- a/packages/material-ui-utils/macros/MuiError.macro.test.js +++ b/packages/material-ui-utils/macros/MuiError.macro.test.js @@ -95,5 +95,13 @@ pluginTester({ }, }, }, + { + title: 'relative-import', + pluginOptions: { + muiError: { errorCodesPath: path.join(fixturePath, 'relative-import', 'error-codes.json') }, + }, + fixture: path.join(fixturePath, 'relative-import', 'input.js'), + output: readOutputFixtureSync('relative-import', 'output.js'), + }, ], }); diff --git a/packages/material-ui-utils/macros/__fixtures__/relative-import/error-codes.json b/packages/material-ui-utils/macros/__fixtures__/relative-import/error-codes.json new file mode 100644 index 00000000000000..94a681317bf508 --- /dev/null +++ b/packages/material-ui-utils/macros/__fixtures__/relative-import/error-codes.json @@ -0,0 +1,3 @@ +{ + "1": "Material-UI: Expected valid input target.\nDid you use `inputComponent`?" +} diff --git a/packages/material-ui-utils/macros/__fixtures__/relative-import/input.js b/packages/material-ui-utils/macros/__fixtures__/relative-import/input.js new file mode 100644 index 00000000000000..5def863cce3505 --- /dev/null +++ b/packages/material-ui-utils/macros/__fixtures__/relative-import/input.js @@ -0,0 +1,3 @@ +import MuiError from '../../../macros/MuiError.macro'; + +throw new MuiError('Material-UI: Expected valid input target.\n' + 'Did you use `inputComponent`?'); diff --git a/packages/material-ui-utils/macros/__fixtures__/relative-import/output.js b/packages/material-ui-utils/macros/__fixtures__/relative-import/output.js new file mode 100644 index 00000000000000..f2786cea115007 --- /dev/null +++ b/packages/material-ui-utils/macros/__fixtures__/relative-import/output.js @@ -0,0 +1,7 @@ +import _formatMuiErrorMessage from '../../formatMuiErrorMessage'; +throw new Error( + process.env.NODE_ENV !== 'production' + ? `Material-UI: Expected valid input target. +Did you use \`inputComponent\`?` + : _formatMuiErrorMessage(1), +); diff --git a/packages/material-ui-utils/src/capitalize.d.ts b/packages/material-ui-utils/src/capitalize.d.ts new file mode 100644 index 00000000000000..3fc792144be532 --- /dev/null +++ b/packages/material-ui-utils/src/capitalize.d.ts @@ -0,0 +1 @@ +export default function capitalize(string: string): string; diff --git a/packages/material-ui-utils/src/capitalize.js b/packages/material-ui-utils/src/capitalize.js new file mode 100644 index 00000000000000..86acc05961b330 --- /dev/null +++ b/packages/material-ui-utils/src/capitalize.js @@ -0,0 +1,12 @@ +import MuiError from '../macros/MuiError.macro'; +// It should to be noted that this function isn't equivalent to `text-transform: capitalize`. +// +// A strict capitalization should uppercase the first letter of each word in the sentence. +// We only handle the first word. +export default function capitalize(string) { + if (typeof string !== 'string') { + throw new MuiError('Material-UI: `capitalize(string)` expects a string argument.'); + } + + return string.charAt(0).toUpperCase() + string.slice(1); +} diff --git a/packages/material-ui/src/utils/capitalize.test.js b/packages/material-ui-utils/src/capitalize.test.js similarity index 100% rename from packages/material-ui/src/utils/capitalize.test.js rename to packages/material-ui-utils/src/capitalize.test.js diff --git a/packages/material-ui-utils/src/createChainedFunction.d.ts b/packages/material-ui-utils/src/createChainedFunction.d.ts new file mode 100644 index 00000000000000..53d4a3c3d378a2 --- /dev/null +++ b/packages/material-ui-utils/src/createChainedFunction.d.ts @@ -0,0 +1,5 @@ +export type ChainedFunction = ((...args: any[]) => void) | undefined | null; + +export default function createChainedFunction( + ...funcs: ChainedFunction[] +): (...args: any[]) => never; diff --git a/packages/material-ui-utils/src/createChainedFunction.js b/packages/material-ui-utils/src/createChainedFunction.js new file mode 100644 index 00000000000000..4e72e2d4afeee4 --- /dev/null +++ b/packages/material-ui-utils/src/createChainedFunction.js @@ -0,0 +1,31 @@ +/** + * Safe chained function. + * + * Will only create a new function if needed, + * otherwise will pass back existing functions or null. + * @param {function} functions to chain + * @returns {function|null} + */ +export default function createChainedFunction(...funcs) { + return funcs.reduce( + (acc, func) => { + if (func == null) { + return acc; + } + + if (process.env.NODE_ENV !== 'production') { + if (typeof func !== 'function') { + console.error( + 'Material-UI: Invalid argument type - must only provide functions, undefined, or null.', + ); + } + } + + return function chainedFunction(...args) { + acc.apply(this, args); + func.apply(this, args); + }; + }, + () => {}, + ); +} diff --git a/packages/material-ui-utils/src/debounce.d.ts b/packages/material-ui-utils/src/debounce.d.ts new file mode 100644 index 00000000000000..ead5017e3d59cc --- /dev/null +++ b/packages/material-ui-utils/src/debounce.d.ts @@ -0,0 +1,8 @@ +export interface Cancelable { + clear(): void; +} + +export default function debounce any>( + func: T, + wait?: number +): T & Cancelable; diff --git a/packages/material-ui-utils/src/debounce.js b/packages/material-ui-utils/src/debounce.js new file mode 100644 index 00000000000000..7b47300636d936 --- /dev/null +++ b/packages/material-ui-utils/src/debounce.js @@ -0,0 +1,18 @@ +// Corresponds to 10 frames at 60 Hz. +// A few bytes payload overhead when lodash/debounce is ~3 kB and debounce ~300 B. +export default function debounce(func, wait = 166) { + let timeout; + function debounced(...args) { + const later = () => { + func.apply(this, args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + } + + debounced.clear = () => { + clearTimeout(timeout); + }; + + return debounced; +} diff --git a/packages/material-ui/src/utils/debounce.test.js b/packages/material-ui-utils/src/debounce.test.js similarity index 100% rename from packages/material-ui/src/utils/debounce.test.js rename to packages/material-ui-utils/src/debounce.test.js diff --git a/packages/material-ui-utils/src/deprecatedPropType.d.ts b/packages/material-ui-utils/src/deprecatedPropType.d.ts new file mode 100644 index 00000000000000..b21c9a984992e4 --- /dev/null +++ b/packages/material-ui-utils/src/deprecatedPropType.d.ts @@ -0,0 +1 @@ +export default function deprecatedPropType(validator: T, reason: string): T; diff --git a/packages/material-ui-utils/src/deprecatedPropType.js b/packages/material-ui-utils/src/deprecatedPropType.js new file mode 100644 index 00000000000000..66b0caed3b0185 --- /dev/null +++ b/packages/material-ui-utils/src/deprecatedPropType.js @@ -0,0 +1,19 @@ +export default function deprecatedPropType(validator, reason) { + if (process.env.NODE_ENV === 'production') { + return () => null; + } + + return (props, propName, componentName, location, propFullName) => { + const componentNameSafe = componentName || '<>'; + const propFullNameSafe = propFullName || propName; + + if (typeof props[propName] !== 'undefined') { + return new Error( + `The ${location} \`${propFullNameSafe}\` of ` + + `\`${componentNameSafe}\` is deprecated. ${reason}`, + ); + } + + return null; + }; +} diff --git a/packages/material-ui/src/utils/deprecatedPropType.test.js b/packages/material-ui-utils/src/deprecatedPropType.test.js similarity index 100% rename from packages/material-ui/src/utils/deprecatedPropType.test.js rename to packages/material-ui-utils/src/deprecatedPropType.test.js diff --git a/packages/material-ui-utils/src/getScrollbarSize.ts b/packages/material-ui-utils/src/getScrollbarSize.ts new file mode 100644 index 00000000000000..33c787300c6d37 --- /dev/null +++ b/packages/material-ui-utils/src/getScrollbarSize.ts @@ -0,0 +1,16 @@ +// A change of the browser zoom change the scrollbar size. +// Credit https://github.com/twbs/bootstrap/blob/3ffe3a5d82f6f561b82ff78d82b32a7d14aed558/js/src/modal.js#L512-L519 +export default function getScrollbarSize(doc: Document): number { + const scrollDiv = doc.createElement('div'); + scrollDiv.style.width = '99px'; + scrollDiv.style.height = '99px'; + scrollDiv.style.position = 'absolute'; + scrollDiv.style.top = '-9999px'; + scrollDiv.style.overflow = 'scroll'; + + doc.body.appendChild(scrollDiv); + const scrollbarSize = scrollDiv.offsetWidth - scrollDiv.clientWidth; + doc.body.removeChild(scrollDiv); + + return scrollbarSize; +} diff --git a/packages/material-ui-utils/src/index.ts b/packages/material-ui-utils/src/index.ts index 03b77400974236..e3cd7c13caadb3 100644 --- a/packages/material-ui-utils/src/index.ts +++ b/packages/material-ui-utils/src/index.ts @@ -8,3 +8,24 @@ export { default as getDisplayName } from './getDisplayName'; export { default as HTMLElementType } from './HTMLElementType'; export { default as ponyfillGlobal } from './ponyfillGlobal'; export { default as refType } from './refType'; +export { default as unstable_capitalize } from './capitalize'; +export { default as unstable_createChainedFunction } from './createChainedFunction'; +export { default as unstable_debounce } from './debounce'; +export { default as unstable_deprecatedPropType } from './deprecatedPropType'; +export { default as unstable_isMuiElement } from './isMuiElement'; +export { default as unstable_ownerDocument } from './ownerDocument'; +export { default as unstable_ownerWindow } from './ownerWindow'; +export { default as unstable_requirePropFactory } from './requirePropFactory'; +export { default as unstable_setRef } from './setRef'; +export { default as unstable_useEnhancedEffect } from './useEnhancedEffect'; +export { default as unstable_useId } from './useId'; +export { default as unstable_unsupportedProp } from './unsupportedProp'; +export { default as unstable_useControlled } from './useControlled'; +export { default as unstable_useEventCallback } from './useEventCallback'; +export { default as unstable_useForkRef } from './useForkRef'; +export { default as unstable_useIsFocusVisible } from './useIsFocusVisible'; +export { default as unstable_getScrollbarSize } from './getScrollbarSize'; +export { + detectScrollType as unstable_detectScrollType, + getNormalizedScrollLeft as unstable_getNormalizedScrollLeft, +} from './scrollLeft'; diff --git a/packages/material-ui-utils/src/isMuiElement.d.ts b/packages/material-ui-utils/src/isMuiElement.d.ts new file mode 100644 index 00000000000000..b0c8bd5d15391c --- /dev/null +++ b/packages/material-ui-utils/src/isMuiElement.d.ts @@ -0,0 +1,35 @@ +import * as React from 'react'; + +export type ClassNameMap = Record; + +export interface StyledComponentProps { + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial>; + innerRef?: React.Ref; +} + +/** + * Helper type for conform (describeConformance) components that are decorated with `withStyles + * However, we don't declare classes on this type. + * It is recommended to declare them manually with an interface so that each class can have a separate JSDOC. + */ +export type StandardProps = Omit & + // each component declares it's classes in a separate interface for proper JSDOC + StyledComponentProps & { + ref?: C extends { ref?: infer RefType } ? RefType : React.Ref; + // TODO: Remove implicit props. Up to each component. + className?: string; + style?: React.CSSProperties; + }; + +export type NamedMuiComponent = React.ComponentType & { muiName: string }; + +export interface NamedMuiElement { + type: NamedMuiComponent; + props: StandardProps<{}>; + key: string | number | null; +} + +export default function isMuiElement(element: any, muiNames: string[]): element is NamedMuiElement; diff --git a/packages/material-ui-utils/src/isMuiElement.js b/packages/material-ui-utils/src/isMuiElement.js new file mode 100644 index 00000000000000..e64372aa6ae18d --- /dev/null +++ b/packages/material-ui-utils/src/isMuiElement.js @@ -0,0 +1,5 @@ +import * as React from 'react'; + +export default function isMuiElement(element, muiNames) { + return React.isValidElement(element) && muiNames.indexOf(element.type.muiName) !== -1; +} diff --git a/packages/material-ui-utils/src/ownerDocument.ts b/packages/material-ui-utils/src/ownerDocument.ts new file mode 100644 index 00000000000000..eed9b51474ccbf --- /dev/null +++ b/packages/material-ui-utils/src/ownerDocument.ts @@ -0,0 +1,3 @@ +export default function ownerDocument(node: Node | undefined): Document { + return (node && node.ownerDocument) || document; +} diff --git a/packages/material-ui-utils/src/ownerWindow.ts b/packages/material-ui-utils/src/ownerWindow.ts new file mode 100644 index 00000000000000..b1b796370bcc3d --- /dev/null +++ b/packages/material-ui-utils/src/ownerWindow.ts @@ -0,0 +1,6 @@ +import ownerDocument from './ownerDocument'; + +export default function ownerWindow(node: Node | undefined): Window { + const doc = ownerDocument(node); + return doc.defaultView || window; +} diff --git a/packages/material-ui-utils/src/requirePropFactory.d.ts b/packages/material-ui-utils/src/requirePropFactory.d.ts new file mode 100644 index 00000000000000..63ae30db9f2831 --- /dev/null +++ b/packages/material-ui-utils/src/requirePropFactory.d.ts @@ -0,0 +1 @@ +export default function requirePropFactory(componentNameInError: string): any; diff --git a/packages/material-ui-utils/src/requirePropFactory.js b/packages/material-ui-utils/src/requirePropFactory.js new file mode 100644 index 00000000000000..4d6707aab111be --- /dev/null +++ b/packages/material-ui-utils/src/requirePropFactory.js @@ -0,0 +1,25 @@ +export default function requirePropFactory(componentNameInError) { + if (process.env.NODE_ENV === 'production') { + return () => null; + } + + const requireProp = (requiredProp) => ( + props, + propName, + componentName, + location, + propFullName, + ) => { + const propFullNameSafe = propFullName || propName; + + if (typeof props[propName] !== 'undefined' && !props[requiredProp]) { + return new Error( + `The prop \`${propFullNameSafe}\` of ` + + `\`${componentNameInError}\` must be used on \`${requiredProp}\`.`, + ); + } + + return null; + }; + return requireProp; +} diff --git a/packages/material-ui/src/utils/requirePropFactory.test.js b/packages/material-ui-utils/src/requirePropFactory.test.js similarity index 100% rename from packages/material-ui/src/utils/requirePropFactory.test.js rename to packages/material-ui-utils/src/requirePropFactory.test.js diff --git a/packages/material-ui-utils/src/scrollLeft.d.ts b/packages/material-ui-utils/src/scrollLeft.d.ts new file mode 100644 index 00000000000000..cd6227f302cf45 --- /dev/null +++ b/packages/material-ui-utils/src/scrollLeft.d.ts @@ -0,0 +1,2 @@ +export function detectScrollType(): string; +export function getNormalizedScrollLeft(element: HTMLElement, direction: string): number; diff --git a/packages/material-ui-utils/src/scrollLeft.js b/packages/material-ui-utils/src/scrollLeft.js new file mode 100644 index 00000000000000..e9998373024ca4 --- /dev/null +++ b/packages/material-ui-utils/src/scrollLeft.js @@ -0,0 +1,77 @@ +// Source from https://github.com/alitaheri/normalize-scroll-left +let cachedType; + +/** + * Based on the jquery plugin https://github.com/othree/jquery.rtl-scroll-type + * + * Types of scrollLeft, assuming scrollWidth=100 and direction is rtl. + * + * Type | <- Most Left | Most Right -> | Initial + * ---------------- | ------------ | ------------- | ------- + * default | 0 | 100 | 100 + * negative (spec*) | -100 | 0 | 0 + * reverse | 100 | 0 | 0 + * + * Edge 85: default + * Safari 14: negative + * Chrome 85: negative + * Firefox 81: negative + * IE11: reverse + * + * spec* https://drafts.csswg.org/cssom-view/#dom-window-scroll + */ +export function detectScrollType() { + if (cachedType) { + return cachedType; + } + + const dummy = document.createElement('div'); + const container = document.createElement('div'); + container.style.width = '10px'; + container.style.height = '1px'; + dummy.appendChild(container); + dummy.dir = 'rtl'; + dummy.style.fontSize = '14px'; + dummy.style.width = '4px'; + dummy.style.height = '1px'; + dummy.style.position = 'absolute'; + dummy.style.top = '-1000px'; + dummy.style.overflow = 'scroll'; + + document.body.appendChild(dummy); + + cachedType = 'reverse'; + + if (dummy.scrollLeft > 0) { + cachedType = 'default'; + } else { + dummy.scrollLeft = 1; + if (dummy.scrollLeft === 0) { + cachedType = 'negative'; + } + } + + document.body.removeChild(dummy); + return cachedType; +} + +// Based on https://stackoverflow.com/a/24394376 +export function getNormalizedScrollLeft(element, direction) { + const scrollLeft = element.scrollLeft; + + // Perform the calculations only when direction is rtl to avoid messing up the ltr behavior + if (direction !== 'rtl') { + return scrollLeft; + } + + const type = detectScrollType(); + + switch (type) { + case 'negative': + return element.scrollWidth - element.clientWidth + scrollLeft; + case 'reverse': + return element.scrollWidth - element.clientWidth - scrollLeft; + default: + return scrollLeft; + } +} diff --git a/packages/material-ui/src/utils/setRef.spec.tsx b/packages/material-ui-utils/src/setRef.spec.tsx similarity index 100% rename from packages/material-ui/src/utils/setRef.spec.tsx rename to packages/material-ui-utils/src/setRef.spec.tsx diff --git a/packages/material-ui-utils/src/setRef.ts b/packages/material-ui-utils/src/setRef.ts new file mode 100644 index 00000000000000..54cbca739cce0d --- /dev/null +++ b/packages/material-ui-utils/src/setRef.ts @@ -0,0 +1,25 @@ +import * as React from 'react'; + +/** + * TODO v5: consider making it private + * + * passes {value} to {ref} + * + * WARNING: Be sure to only call this inside a callback that is passed as a ref. + * Otherwise, make sure to cleanup the previous {ref} if it changes. See + * https://github.com/mui-org/material-ui/issues/13539 + * + * Useful if you want to expose the ref of an inner component to the public API + * while still using it inside the component. + * @param ref A ref callback or ref object. If anything falsy, this is a no-op. + */ +export default function setRef( + ref: React.MutableRefObject | ((instance: T | null) => void) | null | undefined, + value: T | null, +): void { + if (typeof ref === 'function') { + ref(value); + } else if (ref) { + ref.current = value; + } +} diff --git a/packages/material-ui-utils/src/unsupportedProp.d.ts b/packages/material-ui-utils/src/unsupportedProp.d.ts new file mode 100644 index 00000000000000..e08c9a729fccab --- /dev/null +++ b/packages/material-ui-utils/src/unsupportedProp.d.ts @@ -0,0 +1,7 @@ +export default function unsupportedProp( + props: { [key: string]: any }, + propName: string, + componentName: string, + location: string, + propFullName: string +): Error | null; diff --git a/packages/material-ui-utils/src/unsupportedProp.js b/packages/material-ui-utils/src/unsupportedProp.js new file mode 100644 index 00000000000000..925bca3ce9eacd --- /dev/null +++ b/packages/material-ui-utils/src/unsupportedProp.js @@ -0,0 +1,13 @@ +export default function unsupportedProp(props, propName, componentName, location, propFullName) { + if (process.env.NODE_ENV === 'production') { + return null; + } + + const propFullNameSafe = propFullName || propName; + + if (typeof props[propName] !== 'undefined') { + return new Error(`The prop \`${propFullNameSafe}\` is not supported. Please remove it.`); + } + + return null; +} diff --git a/packages/material-ui/src/utils/unsupportedProp.test.js b/packages/material-ui-utils/src/unsupportedProp.test.js similarity index 100% rename from packages/material-ui/src/utils/unsupportedProp.test.js rename to packages/material-ui-utils/src/unsupportedProp.test.js diff --git a/packages/material-ui-utils/src/useControlled.d.ts b/packages/material-ui-utils/src/useControlled.d.ts new file mode 100644 index 00000000000000..6d994c30b77d5e --- /dev/null +++ b/packages/material-ui-utils/src/useControlled.d.ts @@ -0,0 +1,22 @@ +export interface UseControlledProps { + /** + * Holds the component value when it's controlled. + */ + controlled: T | undefined; + /** + * The default value when uncontrolled. + */ + default: T | undefined; + /** + * The component name displayed in warnings. + */ + name: string; + /** + * The name of the state variable displayed in warnings. + */ + state?: string; +} + +export default function useControlled( + props: UseControlledProps +): [T, (newValue: T) => void]; diff --git a/packages/material-ui-utils/src/useControlled.js b/packages/material-ui-utils/src/useControlled.js new file mode 100644 index 00000000000000..dae83eeb89d686 --- /dev/null +++ b/packages/material-ui-utils/src/useControlled.js @@ -0,0 +1,49 @@ +/* eslint-disable react-hooks/rules-of-hooks, react-hooks/exhaustive-deps */ +import * as React from 'react'; + +export default function useControlled({ controlled, default: defaultProp, name, state = 'value' }) { + // isControlled is ignored in the hook dependency lists as it should never change. + const { current: isControlled } = React.useRef(controlled !== undefined); + const [valueState, setValue] = React.useState(defaultProp); + const value = isControlled ? controlled : valueState; + + if (process.env.NODE_ENV !== 'production') { + React.useEffect(() => { + if (isControlled !== (controlled !== undefined)) { + console.error( + [ + `Material-UI: A component is changing the ${ + isControlled ? '' : 'un' + }controlled ${state} state of ${name} to be ${isControlled ? 'un' : ''}controlled.`, + 'Elements should not switch from uncontrolled to controlled (or vice versa).', + `Decide between using a controlled or uncontrolled ${name} ` + + 'element for the lifetime of the component.', + "The nature of the state is determined during the first render. It's considered controlled if the value is not `undefined`.", + 'More info: https://fb.me/react-controlled-components', + ].join('\n'), + ); + } + }, [state, name, controlled]); + + const { current: defaultValue } = React.useRef(defaultProp); + + React.useEffect(() => { + if (!isControlled && defaultValue !== defaultProp) { + console.error( + [ + `Material-UI: A component is changing the default ${state} state of an uncontrolled ${name} after being initialized. ` + + `To suppress this warning opt to use a controlled ${name}.`, + ].join('\n'), + ); + } + }, [JSON.stringify(defaultProp)]); + } + + const setValueIfUncontrolled = React.useCallback((newValue) => { + if (!isControlled) { + setValue(newValue); + } + }, []); + + return [value, setValueIfUncontrolled]; +} diff --git a/packages/material-ui/src/utils/useControlled.test.js b/packages/material-ui-utils/src/useControlled.test.js similarity index 100% rename from packages/material-ui/src/utils/useControlled.test.js rename to packages/material-ui-utils/src/useControlled.test.js diff --git a/packages/material-ui-utils/src/useEnhancedEffect.d.ts b/packages/material-ui-utils/src/useEnhancedEffect.d.ts new file mode 100644 index 00000000000000..9d060a00b0230c --- /dev/null +++ b/packages/material-ui-utils/src/useEnhancedEffect.d.ts @@ -0,0 +1,6 @@ +import * as React from 'react'; + +export default function useEnhancedEffect( + effect: React.EffectCallback, + deps?: React.DependencyList +): void; diff --git a/packages/material-ui-utils/src/useEnhancedEffect.js b/packages/material-ui-utils/src/useEnhancedEffect.js new file mode 100644 index 00000000000000..5856a3922f4ab9 --- /dev/null +++ b/packages/material-ui-utils/src/useEnhancedEffect.js @@ -0,0 +1,5 @@ +import * as React from 'react'; + +const useEnhancedEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; + +export default useEnhancedEffect; diff --git a/packages/material-ui-utils/src/useEventCallback.d.ts b/packages/material-ui-utils/src/useEventCallback.d.ts new file mode 100644 index 00000000000000..81f39f979486f0 --- /dev/null +++ b/packages/material-ui-utils/src/useEventCallback.d.ts @@ -0,0 +1 @@ +export default function useEventCallback any>(func: T): T; diff --git a/packages/material-ui-utils/src/useEventCallback.js b/packages/material-ui-utils/src/useEventCallback.js new file mode 100644 index 00000000000000..7ff34781cd75cd --- /dev/null +++ b/packages/material-ui-utils/src/useEventCallback.js @@ -0,0 +1,14 @@ +import * as React from 'react'; +import useEnhancedEffect from './useEnhancedEffect'; + +/** + * https://github.com/facebook/react/issues/14099#issuecomment-440013892 + * @param {function} fn + */ +export default function useEventCallback(fn) { + const ref = React.useRef(fn); + useEnhancedEffect(() => { + ref.current = fn; + }); + return React.useCallback((...args) => (0, ref.current)(...args), []); +} diff --git a/packages/material-ui-utils/src/useForkRef.d.ts b/packages/material-ui-utils/src/useForkRef.d.ts new file mode 100644 index 00000000000000..877be87b037a71 --- /dev/null +++ b/packages/material-ui-utils/src/useForkRef.d.ts @@ -0,0 +1,3 @@ +import * as React from 'react'; + +export default function useForkRef(refA: React.Ref, refB: React.Ref): React.Ref; diff --git a/packages/material-ui-utils/src/useForkRef.js b/packages/material-ui-utils/src/useForkRef.js new file mode 100644 index 00000000000000..673e0f0a8b7f9e --- /dev/null +++ b/packages/material-ui-utils/src/useForkRef.js @@ -0,0 +1,19 @@ +import * as React from 'react'; +import setRef from './setRef'; + +export default function useForkRef(refA, refB) { + /** + * This will create a new function if the ref props change and are defined. + * This means react will call the old forkRef with `null` and the new forkRef + * with the ref. Cleanup naturally emerges from this behavior. + */ + return React.useMemo(() => { + if (refA == null && refB == null) { + return null; + } + return (refValue) => { + setRef(refA, refValue); + setRef(refB, refValue); + }; + }, [refA, refB]); +} diff --git a/packages/material-ui-utils/src/useId.d.ts b/packages/material-ui-utils/src/useId.d.ts new file mode 100644 index 00000000000000..06368a53d01428 --- /dev/null +++ b/packages/material-ui-utils/src/useId.d.ts @@ -0,0 +1 @@ +export default function useId(idOverride?: string): string; diff --git a/packages/material-ui-utils/src/useId.js b/packages/material-ui-utils/src/useId.js new file mode 100644 index 00000000000000..546ee8c7be4fbf --- /dev/null +++ b/packages/material-ui-utils/src/useId.js @@ -0,0 +1,15 @@ +import * as React from 'react'; + +export default function useId(idOverride) { + const [defaultId, setDefaultId] = React.useState(idOverride); + const id = idOverride || defaultId; + React.useEffect(() => { + if (defaultId == null) { + // Fallback to this default id when possible. + // Use the random value for client-side rendering only. + // We can't use it server-side. + setDefaultId(`mui-${Math.round(Math.random() * 1e5)}`); + } + }, [defaultId]); + return id; +} diff --git a/packages/material-ui/src/utils/useId.test.js b/packages/material-ui-utils/src/useId.test.js similarity index 100% rename from packages/material-ui/src/utils/useId.test.js rename to packages/material-ui-utils/src/useId.test.js diff --git a/packages/material-ui-utils/src/useIsFocusVisible.d.ts b/packages/material-ui-utils/src/useIsFocusVisible.d.ts new file mode 100644 index 00000000000000..bc64b8b201deb7 --- /dev/null +++ b/packages/material-ui-utils/src/useIsFocusVisible.d.ts @@ -0,0 +1,8 @@ +import * as React from 'react'; + +export default function useIsFocusVisible(): { + isFocusVisibleRef: React.MutableRefObject; + onBlur: (event: React.FocusEvent) => void; + onFocus: (event: React.FocusEvent) => void; + ref: React.Ref; +}; diff --git a/packages/material-ui-utils/src/useIsFocusVisible.js b/packages/material-ui-utils/src/useIsFocusVisible.js new file mode 100644 index 00000000000000..3b87da3db5cb94 --- /dev/null +++ b/packages/material-ui-utils/src/useIsFocusVisible.js @@ -0,0 +1,167 @@ +// based on https://github.com/WICG/focus-visible/blob/v4.1.5/src/focus-visible.js +import * as React from 'react'; + +let hadKeyboardEvent = true; +let hadFocusVisibleRecently = false; +let hadFocusVisibleRecentlyTimeout = null; + +const inputTypesWhitelist = { + text: true, + search: true, + url: true, + tel: true, + email: true, + password: true, + number: true, + date: true, + month: true, + week: true, + time: true, + datetime: true, + 'datetime-local': true, +}; + +/** + * Computes whether the given element should automatically trigger the + * `focus-visible` class being added, i.e. whether it should always match + * `:focus-visible` when focused. + * @param {Element} node + * @returns {boolean} + */ +function focusTriggersKeyboardModality(node) { + const { type, tagName } = node; + + if (tagName === 'INPUT' && inputTypesWhitelist[type] && !node.readOnly) { + return true; + } + + if (tagName === 'TEXTAREA' && !node.readOnly) { + return true; + } + + if (node.isContentEditable) { + return true; + } + + return false; +} + +/** + * Keep track of our keyboard modality state with `hadKeyboardEvent`. + * If the most recent user interaction was via the keyboard; + * and the key press did not include a meta, alt/option, or control key; + * then the modality is keyboard. Otherwise, the modality is not keyboard. + * @param {KeyboardEvent} event + */ +function handleKeyDown(event) { + if (event.metaKey || event.altKey || event.ctrlKey) { + return; + } + hadKeyboardEvent = true; +} + +/** + * If at any point a user clicks with a pointing device, ensure that we change + * the modality away from keyboard. + * This avoids the situation where a user presses a key on an already focused + * element, and then clicks on a different element, focusing it with a + * pointing device, while we still think we're in keyboard modality. + */ +function handlePointerDown() { + hadKeyboardEvent = false; +} + +function handleVisibilityChange() { + if (this.visibilityState === 'hidden') { + // If the tab becomes active again, the browser will handle calling focus + // on the element (Safari actually calls it twice). + // If this tab change caused a blur on an element with focus-visible, + // re-apply the class when the user switches back to the tab. + if (hadFocusVisibleRecently) { + hadKeyboardEvent = true; + } + } +} + +function prepare(doc) { + doc.addEventListener('keydown', handleKeyDown, true); + doc.addEventListener('mousedown', handlePointerDown, true); + doc.addEventListener('pointerdown', handlePointerDown, true); + doc.addEventListener('touchstart', handlePointerDown, true); + doc.addEventListener('visibilitychange', handleVisibilityChange, true); +} + +export function teardown(doc) { + doc.removeEventListener('keydown', handleKeyDown, true); + doc.removeEventListener('mousedown', handlePointerDown, true); + doc.removeEventListener('pointerdown', handlePointerDown, true); + doc.removeEventListener('touchstart', handlePointerDown, true); + doc.removeEventListener('visibilitychange', handleVisibilityChange, true); +} + +function isFocusVisible(event) { + const { target } = event; + try { + return target.matches(':focus-visible'); + } catch (error) { + // Browsers not implementing :focus-visible will throw a SyntaxError. + // We use our own heuristic for those browsers. + // Rethrow might be better if it's not the expected error but do we really + // want to crash if focus-visible malfunctioned? + } + + // No need for validFocusTarget check. The user does that by attaching it to + // focusable events only. + return hadKeyboardEvent || focusTriggersKeyboardModality(target); +} + +export default function useIsFocusVisible() { + const ref = React.useCallback((node) => { + if (node != null) { + prepare(node.ownerDocument); + } + }, []); + + const isFocusVisibleRef = React.useRef(false); + + /** + * Should be called if a blur event is fired + */ + function handleBlurVisible() { + // checking against potential state variable does not suffice if we focus and blur synchronously. + // React wouldn't have time to trigger a re-render so `focusVisible` would be stale. + // Ideally we would adjust `isFocusVisible(event)` to look at `relatedTarget` for blur events. + // This doesn't work in IE11 due to https://github.com/facebook/react/issues/3751 + // TODO: check again if React releases their internal changes to focus event handling (https://github.com/facebook/react/pull/19186). + if (isFocusVisibleRef.current) { + // To detect a tab/window switch, we look for a blur event followed + // rapidly by a visibility change. + // If we don't see a visibility change within 100ms, it's probably a + // regular focus change. + hadFocusVisibleRecently = true; + window.clearTimeout(hadFocusVisibleRecentlyTimeout); + hadFocusVisibleRecentlyTimeout = window.setTimeout(() => { + hadFocusVisibleRecently = false; + }, 100); + + isFocusVisibleRef.current = false; + + return true; + } + + return false; + } + + /** + * Should be called if a blur event is fired + */ + function handleFocusVisible(event) { + if (isFocusVisible(event)) { + isFocusVisibleRef.current = true; + return true; + } + return false; + } + + return { isFocusVisibleRef, onFocus: handleFocusVisible, onBlur: handleBlurVisible, ref }; +} diff --git a/packages/material-ui/src/utils/useIsFocusVisible.test.js b/packages/material-ui-utils/src/useIsFocusVisible.test.js similarity index 100% rename from packages/material-ui/src/utils/useIsFocusVisible.test.js rename to packages/material-ui-utils/src/useIsFocusVisible.test.js diff --git a/packages/material-ui/src/utils/capitalize.d.ts b/packages/material-ui/src/utils/capitalize.d.ts index 3fc792144be532..dd825d99c21660 100644 --- a/packages/material-ui/src/utils/capitalize.d.ts +++ b/packages/material-ui/src/utils/capitalize.d.ts @@ -1 +1,3 @@ -export default function capitalize(string: string): string; +import { unstable_capitalize as capitalize } from '@material-ui/utils'; + +export default capitalize; diff --git a/packages/material-ui/src/utils/capitalize.js b/packages/material-ui/src/utils/capitalize.js index 4c87d3020595a1..dd825d99c21660 100644 --- a/packages/material-ui/src/utils/capitalize.js +++ b/packages/material-ui/src/utils/capitalize.js @@ -1,12 +1,3 @@ -import MuiError from '@material-ui/utils/macros/MuiError.macro'; -// It should to be noted that this function isn't equivalent to `text-transform: capitalize`. -// -// A strict capitalization should uppercase the first letter of each word a the sentence. -// We only handle the first word. -export default function capitalize(string) { - if (typeof string !== 'string') { - throw new MuiError('Material-UI: capitalize(string) expects a string argument.'); - } +import { unstable_capitalize as capitalize } from '@material-ui/utils'; - return string.charAt(0).toUpperCase() + string.slice(1); -} +export default capitalize; diff --git a/packages/material-ui/src/utils/createChainedFunction.d.ts b/packages/material-ui/src/utils/createChainedFunction.d.ts index 53d4a3c3d378a2..5dfc830559ec32 100644 --- a/packages/material-ui/src/utils/createChainedFunction.d.ts +++ b/packages/material-ui/src/utils/createChainedFunction.d.ts @@ -1,5 +1,3 @@ -export type ChainedFunction = ((...args: any[]) => void) | undefined | null; +import { unstable_createChainedFunction as createChainedFunction } from '@material-ui/utils'; -export default function createChainedFunction( - ...funcs: ChainedFunction[] -): (...args: any[]) => never; +export default createChainedFunction; diff --git a/packages/material-ui/src/utils/createChainedFunction.js b/packages/material-ui/src/utils/createChainedFunction.js index a023586cc3ea88..5dfc830559ec32 100644 --- a/packages/material-ui/src/utils/createChainedFunction.js +++ b/packages/material-ui/src/utils/createChainedFunction.js @@ -1,31 +1,3 @@ -/** - * Safe chained function - * - * Will only create a new function if needed, - * otherwise will pass back existing functions or null. - * @param {function} functions to chain - * @returns {function|null} - */ -export default function createChainedFunction(...funcs) { - return funcs.reduce( - (acc, func) => { - if (func == null) { - return acc; - } +import { unstable_createChainedFunction as createChainedFunction } from '@material-ui/utils'; - if (process.env.NODE_ENV !== 'production') { - if (typeof func !== 'function') { - console.error( - 'Material-UI: Invalid Argument Type, must only provide functions, undefined, or null.', - ); - } - } - - return function chainedFunction(...args) { - acc.apply(this, args); - func.apply(this, args); - }; - }, - () => {}, - ); -} +export default createChainedFunction; diff --git a/packages/material-ui/src/utils/debounce.d.ts b/packages/material-ui/src/utils/debounce.d.ts index ead5017e3d59cc..2ca0d8f37f512c 100644 --- a/packages/material-ui/src/utils/debounce.d.ts +++ b/packages/material-ui/src/utils/debounce.d.ts @@ -1,8 +1,3 @@ -export interface Cancelable { - clear(): void; -} +import { unstable_debounce as debounce } from '@material-ui/utils'; -export default function debounce any>( - func: T, - wait?: number -): T & Cancelable; +export default debounce; diff --git a/packages/material-ui/src/utils/debounce.js b/packages/material-ui/src/utils/debounce.js index 7b47300636d936..2ca0d8f37f512c 100644 --- a/packages/material-ui/src/utils/debounce.js +++ b/packages/material-ui/src/utils/debounce.js @@ -1,18 +1,3 @@ -// Corresponds to 10 frames at 60 Hz. -// A few bytes payload overhead when lodash/debounce is ~3 kB and debounce ~300 B. -export default function debounce(func, wait = 166) { - let timeout; - function debounced(...args) { - const later = () => { - func.apply(this, args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - } +import { unstable_debounce as debounce } from '@material-ui/utils'; - debounced.clear = () => { - clearTimeout(timeout); - }; - - return debounced; -} +export default debounce; diff --git a/packages/material-ui/src/utils/deprecatedPropType.d.ts b/packages/material-ui/src/utils/deprecatedPropType.d.ts index b21c9a984992e4..9dca74eb4e75b2 100644 --- a/packages/material-ui/src/utils/deprecatedPropType.d.ts +++ b/packages/material-ui/src/utils/deprecatedPropType.d.ts @@ -1 +1,3 @@ -export default function deprecatedPropType(validator: T, reason: string): T; +import { unstable_deprecatedPropType as deprecatedPropType } from '@material-ui/utils'; + +export default deprecatedPropType; diff --git a/packages/material-ui/src/utils/deprecatedPropType.js b/packages/material-ui/src/utils/deprecatedPropType.js index 66b0caed3b0185..9dca74eb4e75b2 100644 --- a/packages/material-ui/src/utils/deprecatedPropType.js +++ b/packages/material-ui/src/utils/deprecatedPropType.js @@ -1,19 +1,3 @@ -export default function deprecatedPropType(validator, reason) { - if (process.env.NODE_ENV === 'production') { - return () => null; - } +import { unstable_deprecatedPropType as deprecatedPropType } from '@material-ui/utils'; - return (props, propName, componentName, location, propFullName) => { - const componentNameSafe = componentName || '<>'; - const propFullNameSafe = propFullName || propName; - - if (typeof props[propName] !== 'undefined') { - return new Error( - `The ${location} \`${propFullNameSafe}\` of ` + - `\`${componentNameSafe}\` is deprecated. ${reason}`, - ); - } - - return null; - }; -} +export default deprecatedPropType; diff --git a/packages/material-ui/src/utils/getScrollbarSize.ts b/packages/material-ui/src/utils/getScrollbarSize.ts index 33c787300c6d37..9edfe93d11cf9d 100644 --- a/packages/material-ui/src/utils/getScrollbarSize.ts +++ b/packages/material-ui/src/utils/getScrollbarSize.ts @@ -1,16 +1,3 @@ -// A change of the browser zoom change the scrollbar size. -// Credit https://github.com/twbs/bootstrap/blob/3ffe3a5d82f6f561b82ff78d82b32a7d14aed558/js/src/modal.js#L512-L519 -export default function getScrollbarSize(doc: Document): number { - const scrollDiv = doc.createElement('div'); - scrollDiv.style.width = '99px'; - scrollDiv.style.height = '99px'; - scrollDiv.style.position = 'absolute'; - scrollDiv.style.top = '-9999px'; - scrollDiv.style.overflow = 'scroll'; +import { unstable_getScrollbarSize as getScrollbarSize } from '@material-ui/utils'; - doc.body.appendChild(scrollDiv); - const scrollbarSize = scrollDiv.offsetWidth - scrollDiv.clientWidth; - doc.body.removeChild(scrollDiv); - - return scrollbarSize; -} +export default getScrollbarSize; diff --git a/packages/material-ui/src/utils/isMuiElement.d.ts b/packages/material-ui/src/utils/isMuiElement.d.ts index e15f13bdaf60fc..5ffef839cb3c8a 100644 --- a/packages/material-ui/src/utils/isMuiElement.d.ts +++ b/packages/material-ui/src/utils/isMuiElement.d.ts @@ -1,12 +1,3 @@ -import * as React from 'react'; -import { InternalStandardProps as StandardProps } from '..'; +import { unstable_isMuiElement as isMuiElement } from '@material-ui/utils'; -export type NamedMuiComponent = React.ComponentType & { muiName: string }; - -export interface NamedMuiElement { - type: NamedMuiComponent; - props: StandardProps<{}>; - key: string | number | null; -} - -export default function isMuiElement(element: any, muiNames: string[]): element is NamedMuiElement; +export default isMuiElement; diff --git a/packages/material-ui/src/utils/isMuiElement.js b/packages/material-ui/src/utils/isMuiElement.js index e64372aa6ae18d..5ffef839cb3c8a 100644 --- a/packages/material-ui/src/utils/isMuiElement.js +++ b/packages/material-ui/src/utils/isMuiElement.js @@ -1,5 +1,3 @@ -import * as React from 'react'; +import { unstable_isMuiElement as isMuiElement } from '@material-ui/utils'; -export default function isMuiElement(element, muiNames) { - return React.isValidElement(element) && muiNames.indexOf(element.type.muiName) !== -1; -} +export default isMuiElement; diff --git a/packages/material-ui/src/utils/ownerDocument.ts b/packages/material-ui/src/utils/ownerDocument.ts index eed9b51474ccbf..75df5785ed3d43 100644 --- a/packages/material-ui/src/utils/ownerDocument.ts +++ b/packages/material-ui/src/utils/ownerDocument.ts @@ -1,3 +1,3 @@ -export default function ownerDocument(node: Node | undefined): Document { - return (node && node.ownerDocument) || document; -} +import { unstable_ownerDocument as ownerDocument } from '@material-ui/utils'; + +export default ownerDocument; diff --git a/packages/material-ui/src/utils/ownerWindow.ts b/packages/material-ui/src/utils/ownerWindow.ts index b1b796370bcc3d..548c997dc8708f 100644 --- a/packages/material-ui/src/utils/ownerWindow.ts +++ b/packages/material-ui/src/utils/ownerWindow.ts @@ -1,6 +1,3 @@ -import ownerDocument from './ownerDocument'; +import { unstable_ownerWindow as ownerWindow } from '@material-ui/utils'; -export default function ownerWindow(node: Node | undefined): Window { - const doc = ownerDocument(node); - return doc.defaultView || window; -} +export default ownerWindow; diff --git a/packages/material-ui/src/utils/requirePropFactory.d.ts b/packages/material-ui/src/utils/requirePropFactory.d.ts index 63ae30db9f2831..b93c715ed7c449 100644 --- a/packages/material-ui/src/utils/requirePropFactory.d.ts +++ b/packages/material-ui/src/utils/requirePropFactory.d.ts @@ -1 +1,3 @@ -export default function requirePropFactory(componentNameInError: string): any; +import { unstable_requirePropFactory as requirePropFactory } from '@material-ui/utils'; + +export default requirePropFactory; diff --git a/packages/material-ui/src/utils/requirePropFactory.js b/packages/material-ui/src/utils/requirePropFactory.js index 4d6707aab111be..b93c715ed7c449 100644 --- a/packages/material-ui/src/utils/requirePropFactory.js +++ b/packages/material-ui/src/utils/requirePropFactory.js @@ -1,25 +1,3 @@ -export default function requirePropFactory(componentNameInError) { - if (process.env.NODE_ENV === 'production') { - return () => null; - } +import { unstable_requirePropFactory as requirePropFactory } from '@material-ui/utils'; - const requireProp = (requiredProp) => ( - props, - propName, - componentName, - location, - propFullName, - ) => { - const propFullNameSafe = propFullName || propName; - - if (typeof props[propName] !== 'undefined' && !props[requiredProp]) { - return new Error( - `The prop \`${propFullNameSafe}\` of ` + - `\`${componentNameInError}\` must be used on \`${requiredProp}\`.`, - ); - } - - return null; - }; - return requireProp; -} +export default requirePropFactory; diff --git a/packages/material-ui/src/utils/scrollLeft.js b/packages/material-ui/src/utils/scrollLeft.js index e9998373024ca4..4e09588843158a 100644 --- a/packages/material-ui/src/utils/scrollLeft.js +++ b/packages/material-ui/src/utils/scrollLeft.js @@ -1,77 +1,4 @@ -// Source from https://github.com/alitaheri/normalize-scroll-left -let cachedType; - -/** - * Based on the jquery plugin https://github.com/othree/jquery.rtl-scroll-type - * - * Types of scrollLeft, assuming scrollWidth=100 and direction is rtl. - * - * Type | <- Most Left | Most Right -> | Initial - * ---------------- | ------------ | ------------- | ------- - * default | 0 | 100 | 100 - * negative (spec*) | -100 | 0 | 0 - * reverse | 100 | 0 | 0 - * - * Edge 85: default - * Safari 14: negative - * Chrome 85: negative - * Firefox 81: negative - * IE11: reverse - * - * spec* https://drafts.csswg.org/cssom-view/#dom-window-scroll - */ -export function detectScrollType() { - if (cachedType) { - return cachedType; - } - - const dummy = document.createElement('div'); - const container = document.createElement('div'); - container.style.width = '10px'; - container.style.height = '1px'; - dummy.appendChild(container); - dummy.dir = 'rtl'; - dummy.style.fontSize = '14px'; - dummy.style.width = '4px'; - dummy.style.height = '1px'; - dummy.style.position = 'absolute'; - dummy.style.top = '-1000px'; - dummy.style.overflow = 'scroll'; - - document.body.appendChild(dummy); - - cachedType = 'reverse'; - - if (dummy.scrollLeft > 0) { - cachedType = 'default'; - } else { - dummy.scrollLeft = 1; - if (dummy.scrollLeft === 0) { - cachedType = 'negative'; - } - } - - document.body.removeChild(dummy); - return cachedType; -} - -// Based on https://stackoverflow.com/a/24394376 -export function getNormalizedScrollLeft(element, direction) { - const scrollLeft = element.scrollLeft; - - // Perform the calculations only when direction is rtl to avoid messing up the ltr behavior - if (direction !== 'rtl') { - return scrollLeft; - } - - const type = detectScrollType(); - - switch (type) { - case 'negative': - return element.scrollWidth - element.clientWidth + scrollLeft; - case 'reverse': - return element.scrollWidth - element.clientWidth - scrollLeft; - default: - return scrollLeft; - } -} +export { + unstable_detectScrollType as detectScrollType, + unstable_getNormalizedScrollLeft as getNormalizedScrollLeft, +} from '@material-ui/utils'; diff --git a/packages/material-ui/src/utils/setRef.ts b/packages/material-ui/src/utils/setRef.ts index 29ebef0d3527c7..e5ad3e7b2336b4 100644 --- a/packages/material-ui/src/utils/setRef.ts +++ b/packages/material-ui/src/utils/setRef.ts @@ -1,25 +1,3 @@ -import * as React from 'react'; +import { unstable_setRef as setRef } from '@material-ui/utils'; -/** - * TODO v5: consider to make it private - * - * passes {value} to {ref} - * - * WARNING: Be sure to only call this inside a callback that is passed as a ref. - * Otherwise make sure to cleanup previous {ref} if it changes. See - * https://github.com/mui-org/material-ui/issues/13539 - * - * useful if you want to expose the ref of an inner component to the public api - * while still using it inside the component - * @param ref a ref callback or ref object if anything falsy this is a no-op - */ -export default function setRef( - ref: React.MutableRefObject | ((instance: T | null) => void) | null | undefined, - value: T | null, -): void { - if (typeof ref === 'function') { - ref(value); - } else if (ref) { - ref.current = value; - } -} +export default setRef; diff --git a/packages/material-ui/src/utils/unsupportedProp.d.ts b/packages/material-ui/src/utils/unsupportedProp.d.ts index e08c9a729fccab..9bfe899838ee3c 100644 --- a/packages/material-ui/src/utils/unsupportedProp.d.ts +++ b/packages/material-ui/src/utils/unsupportedProp.d.ts @@ -1,7 +1,3 @@ -export default function unsupportedProp( - props: { [key: string]: any }, - propName: string, - componentName: string, - location: string, - propFullName: string -): Error | null; +import { unstable_unsupportedProp as unsupportedProp } from '@material-ui/utils'; + +export default unsupportedProp; diff --git a/packages/material-ui/src/utils/unsupportedProp.js b/packages/material-ui/src/utils/unsupportedProp.js index 925bca3ce9eacd..9bfe899838ee3c 100644 --- a/packages/material-ui/src/utils/unsupportedProp.js +++ b/packages/material-ui/src/utils/unsupportedProp.js @@ -1,13 +1,3 @@ -export default function unsupportedProp(props, propName, componentName, location, propFullName) { - if (process.env.NODE_ENV === 'production') { - return null; - } +import { unstable_unsupportedProp as unsupportedProp } from '@material-ui/utils'; - const propFullNameSafe = propFullName || propName; - - if (typeof props[propName] !== 'undefined') { - return new Error(`The prop \`${propFullNameSafe}\` is not supported. Please remove it.`); - } - - return null; -} +export default unsupportedProp; diff --git a/packages/material-ui/src/utils/useControlled.d.ts b/packages/material-ui/src/utils/useControlled.d.ts index 87e785ae289356..75b0a1cb095dbf 100644 --- a/packages/material-ui/src/utils/useControlled.d.ts +++ b/packages/material-ui/src/utils/useControlled.d.ts @@ -1,22 +1,3 @@ -export interface UseControlledProps { - /** - * This prop contains the component value when it's controlled. - */ - controlled: T | undefined; - /** - * The default value when uncontrolled. - */ - default: T | undefined; - /** - * The component name displayed in warnings. - */ - name: string; - /** - * The name of the state variable displayed in warnings. - */ - state?: string; -} +import { unstable_useControlled as useControlled } from '@material-ui/utils'; -export default function useControlled( - props: UseControlledProps -): [T, (newValue: T) => void]; +export default useControlled; diff --git a/packages/material-ui/src/utils/useControlled.js b/packages/material-ui/src/utils/useControlled.js index 3eb0b699910936..75b0a1cb095dbf 100644 --- a/packages/material-ui/src/utils/useControlled.js +++ b/packages/material-ui/src/utils/useControlled.js @@ -1,49 +1,3 @@ -/* eslint-disable react-hooks/rules-of-hooks, react-hooks/exhaustive-deps */ -import * as React from 'react'; +import { unstable_useControlled as useControlled } from '@material-ui/utils'; -export default function useControlled({ controlled, default: defaultProp, name, state = 'value' }) { - // isControlled is ignored in the hook dependency lists as it should never change. - const { current: isControlled } = React.useRef(controlled !== undefined); - const [valueState, setValue] = React.useState(defaultProp); - const value = isControlled ? controlled : valueState; - - if (process.env.NODE_ENV !== 'production') { - React.useEffect(() => { - if (isControlled !== (controlled !== undefined)) { - console.error( - [ - `Material-UI: A component is changing the ${ - isControlled ? '' : 'un' - }controlled ${state} state of ${name} to be ${isControlled ? 'un' : ''}controlled.`, - 'Elements should not switch from uncontrolled to controlled (or vice versa).', - `Decide between using a controlled or uncontrolled ${name} ` + - 'element for the lifetime of the component.', - "The nature of the state is determined during the first render, it's considered controlled if the value is not `undefined`.", - 'More info: https://fb.me/react-controlled-components', - ].join('\n'), - ); - } - }, [state, name, controlled]); - - const { current: defaultValue } = React.useRef(defaultProp); - - React.useEffect(() => { - if (!isControlled && defaultValue !== defaultProp) { - console.error( - [ - `Material-UI: A component is changing the default ${state} state of an uncontrolled ${name} after being initialized. ` + - `To suppress this warning opt to use a controlled ${name}.`, - ].join('\n'), - ); - } - }, [JSON.stringify(defaultProp)]); - } - - const setValueIfUncontrolled = React.useCallback((newValue) => { - if (!isControlled) { - setValue(newValue); - } - }, []); - - return [value, setValueIfUncontrolled]; -} +export default useControlled; diff --git a/packages/material-ui/src/utils/useEnhancedEffect.d.ts b/packages/material-ui/src/utils/useEnhancedEffect.d.ts index 9d060a00b0230c..2d24ca475df1ad 100644 --- a/packages/material-ui/src/utils/useEnhancedEffect.d.ts +++ b/packages/material-ui/src/utils/useEnhancedEffect.d.ts @@ -1,6 +1,3 @@ -import * as React from 'react'; +import { unstable_useEnhancedEffect as useEnhancedEffect } from '@material-ui/utils'; -export default function useEnhancedEffect( - effect: React.EffectCallback, - deps?: React.DependencyList -): void; +export default useEnhancedEffect; diff --git a/packages/material-ui/src/utils/useEnhancedEffect.js b/packages/material-ui/src/utils/useEnhancedEffect.js index beefea01128179..2d24ca475df1ad 100644 --- a/packages/material-ui/src/utils/useEnhancedEffect.js +++ b/packages/material-ui/src/utils/useEnhancedEffect.js @@ -1,8 +1,3 @@ -import * as React from 'react'; +import { unstable_useEnhancedEffect as useEnhancedEffect } from '@material-ui/utils'; -const useEnhancedEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; - -/** - * Private module reserved for @material-ui packages. - */ export default useEnhancedEffect; diff --git a/packages/material-ui/src/utils/useEventCallback.d.ts b/packages/material-ui/src/utils/useEventCallback.d.ts index 81f39f979486f0..3e334fcb9bd9e5 100644 --- a/packages/material-ui/src/utils/useEventCallback.d.ts +++ b/packages/material-ui/src/utils/useEventCallback.d.ts @@ -1 +1,3 @@ -export default function useEventCallback any>(func: T): T; +import { unstable_useEventCallback as useEventCallback } from '@material-ui/utils'; + +export default useEventCallback; diff --git a/packages/material-ui/src/utils/useEventCallback.js b/packages/material-ui/src/utils/useEventCallback.js index 7ff34781cd75cd..3e334fcb9bd9e5 100644 --- a/packages/material-ui/src/utils/useEventCallback.js +++ b/packages/material-ui/src/utils/useEventCallback.js @@ -1,14 +1,3 @@ -import * as React from 'react'; -import useEnhancedEffect from './useEnhancedEffect'; +import { unstable_useEventCallback as useEventCallback } from '@material-ui/utils'; -/** - * https://github.com/facebook/react/issues/14099#issuecomment-440013892 - * @param {function} fn - */ -export default function useEventCallback(fn) { - const ref = React.useRef(fn); - useEnhancedEffect(() => { - ref.current = fn; - }); - return React.useCallback((...args) => (0, ref.current)(...args), []); -} +export default useEventCallback; diff --git a/packages/material-ui/src/utils/useForkRef.d.ts b/packages/material-ui/src/utils/useForkRef.d.ts index 877be87b037a71..981d58b14e59ad 100644 --- a/packages/material-ui/src/utils/useForkRef.d.ts +++ b/packages/material-ui/src/utils/useForkRef.d.ts @@ -1,3 +1,3 @@ -import * as React from 'react'; +import { unstable_useForkRef as useForkRef } from '@material-ui/utils'; -export default function useForkRef(refA: React.Ref, refB: React.Ref): React.Ref; +export default useForkRef; diff --git a/packages/material-ui/src/utils/useForkRef.js b/packages/material-ui/src/utils/useForkRef.js index 5e67bbaf0b421e..981d58b14e59ad 100644 --- a/packages/material-ui/src/utils/useForkRef.js +++ b/packages/material-ui/src/utils/useForkRef.js @@ -1,19 +1,3 @@ -import * as React from 'react'; -import setRef from './setRef'; +import { unstable_useForkRef as useForkRef } from '@material-ui/utils'; -export default function useForkRef(refA, refB) { - /** - * This will create a new function if the ref props change and are defined. - * This means react will call the old forkRef with `null` and the new forkRef - * with the ref. Cleanup naturally emerges from this behavior - */ - return React.useMemo(() => { - if (refA == null && refB == null) { - return null; - } - return (refValue) => { - setRef(refA, refValue); - setRef(refB, refValue); - }; - }, [refA, refB]); -} +export default useForkRef; diff --git a/packages/material-ui/src/utils/useId.d.ts b/packages/material-ui/src/utils/useId.d.ts index 06368a53d01428..0b6bcab4d378a2 100644 --- a/packages/material-ui/src/utils/useId.d.ts +++ b/packages/material-ui/src/utils/useId.d.ts @@ -1 +1,3 @@ -export default function useId(idOverride?: string): string; +import { unstable_useId as useId } from '@material-ui/utils'; + +export default useId; diff --git a/packages/material-ui/src/utils/useId.js b/packages/material-ui/src/utils/useId.js index 16ab5c9eda794c..0b6bcab4d378a2 100644 --- a/packages/material-ui/src/utils/useId.js +++ b/packages/material-ui/src/utils/useId.js @@ -1,18 +1,3 @@ -import * as React from 'react'; +import { unstable_useId as useId } from '@material-ui/utils'; -/** - * Private module reserved for @material-ui packages. - */ -export default function useId(idOverride) { - const [defaultId, setDefaultId] = React.useState(idOverride); - const id = idOverride || defaultId; - React.useEffect(() => { - if (defaultId == null) { - // Fallback to this default id when possible. - // Use the random value for client-side rendering only. - // We can't use it server-side. - setDefaultId(`mui-${Math.round(Math.random() * 1e5)}`); - } - }, [defaultId]); - return id; -} +export default useId; diff --git a/packages/material-ui/src/utils/useIsFocusVisible.d.ts b/packages/material-ui/src/utils/useIsFocusVisible.d.ts index bc64b8b201deb7..055b994c4c4058 100644 --- a/packages/material-ui/src/utils/useIsFocusVisible.d.ts +++ b/packages/material-ui/src/utils/useIsFocusVisible.d.ts @@ -1,8 +1,3 @@ -import * as React from 'react'; +import { unstable_useIsFocusVisible as useIsFocusVisible } from '@material-ui/utils'; -export default function useIsFocusVisible(): { - isFocusVisibleRef: React.MutableRefObject; - onBlur: (event: React.FocusEvent) => void; - onFocus: (event: React.FocusEvent) => void; - ref: React.Ref; -}; +export default useIsFocusVisible; diff --git a/packages/material-ui/src/utils/useIsFocusVisible.js b/packages/material-ui/src/utils/useIsFocusVisible.js index 7b01d9d9eb982e..055b994c4c4058 100644 --- a/packages/material-ui/src/utils/useIsFocusVisible.js +++ b/packages/material-ui/src/utils/useIsFocusVisible.js @@ -1,167 +1,3 @@ -// based on https://github.com/WICG/focus-visible/blob/v4.1.5/src/focus-visible.js -import * as React from 'react'; +import { unstable_useIsFocusVisible as useIsFocusVisible } from '@material-ui/utils'; -let hadKeyboardEvent = true; -let hadFocusVisibleRecently = false; -let hadFocusVisibleRecentlyTimeout = null; - -const inputTypesWhitelist = { - text: true, - search: true, - url: true, - tel: true, - email: true, - password: true, - number: true, - date: true, - month: true, - week: true, - time: true, - datetime: true, - 'datetime-local': true, -}; - -/** - * Computes whether the given element should automatically trigger the - * `focus-visible` class being added, i.e. whether it should always match - * `:focus-visible` when focused. - * @param {Element} node - * @returns {boolean} - */ -function focusTriggersKeyboardModality(node) { - const { type, tagName } = node; - - if (tagName === 'INPUT' && inputTypesWhitelist[type] && !node.readOnly) { - return true; - } - - if (tagName === 'TEXTAREA' && !node.readOnly) { - return true; - } - - if (node.isContentEditable) { - return true; - } - - return false; -} - -/** - * Keep track of our keyboard modality state with `hadKeyboardEvent`. - * If the most recent user interaction was via the keyboard; - * and the key press did not include a meta, alt/option, or control key; - * then the modality is keyboard. Otherwise, the modality is not keyboard. - * @param {KeyboardEvent} event - */ -function handleKeyDown(event) { - if (event.metaKey || event.altKey || event.ctrlKey) { - return; - } - hadKeyboardEvent = true; -} - -/** - * If at any point a user clicks with a pointing device, ensure that we change - * the modality away from keyboard. - * This avoids the situation where a user presses a key on an already focused - * element, and then clicks on a different element, focusing it with a - * pointing device, while we still think we're in keyboard modality. - */ -function handlePointerDown() { - hadKeyboardEvent = false; -} - -function handleVisibilityChange() { - if (this.visibilityState === 'hidden') { - // If the tab becomes active again, the browser will handle calling focus - // on the element (Safari actually calls it twice). - // If this tab change caused a blur on an element with focus-visible, - // re-apply the class when the user switches back to the tab. - if (hadFocusVisibleRecently) { - hadKeyboardEvent = true; - } - } -} - -function prepare(doc) { - doc.addEventListener('keydown', handleKeyDown, true); - doc.addEventListener('mousedown', handlePointerDown, true); - doc.addEventListener('pointerdown', handlePointerDown, true); - doc.addEventListener('touchstart', handlePointerDown, true); - doc.addEventListener('visibilitychange', handleVisibilityChange, true); -} - -export function teardown(doc) { - doc.removeEventListener('keydown', handleKeyDown, true); - doc.removeEventListener('mousedown', handlePointerDown, true); - doc.removeEventListener('pointerdown', handlePointerDown, true); - doc.removeEventListener('touchstart', handlePointerDown, true); - doc.removeEventListener('visibilitychange', handleVisibilityChange, true); -} - -function isFocusVisible(event) { - const { target } = event; - try { - return target.matches(':focus-visible'); - } catch (error) { - // browsers not implementing :focus-visible will throw a SyntaxError - // we use our own heuristic for those browsers - // rethrow might be better if it's not the expected error but do we really - // want to crash if focus-visible malfunctioned? - } - - // no need for validFocusTarget check. the user does that by attaching it to - // focusable events only - return hadKeyboardEvent || focusTriggersKeyboardModality(target); -} - -export default function useIsFocusVisible() { - const ref = React.useCallback((node) => { - if (node != null) { - prepare(node.ownerDocument); - } - }, []); - - const isFocusVisibleRef = React.useRef(false); - - /** - * Should be called if a blur event is fired - */ - function handleBlurVisible() { - // checking against potential state variable does not suffice if we focus and blur synchronously. - // React wouldn't have time to trigger a re-render so `focusVisible` would be stale. - // Ideally we would adjust `isFocusVisible(event)` to look at `relatedTarget` for blur events. - // This doesn't work in IE11 due to https://github.com/facebook/react/issues/3751 - // TODO: check again if React releases their internal changes to focus event handling (https://github.com/facebook/react/pull/19186). - if (isFocusVisibleRef.current) { - // To detect a tab/window switch, we look for a blur event followed - // rapidly by a visibility change. - // If we don't see a visibility change within 100ms, it's probably a - // regular focus change. - hadFocusVisibleRecently = true; - window.clearTimeout(hadFocusVisibleRecentlyTimeout); - hadFocusVisibleRecentlyTimeout = window.setTimeout(() => { - hadFocusVisibleRecently = false; - }, 100); - - isFocusVisibleRef.current = false; - - return true; - } - - return false; - } - - /** - * Should be called if a blur event is fired - */ - function handleFocusVisible(event) { - if (isFocusVisible(event)) { - isFocusVisibleRef.current = true; - return true; - } - return false; - } - - return { isFocusVisibleRef, onFocus: handleFocusVisible, onBlur: handleBlurVisible, ref }; -} +export default useIsFocusVisible;