-
-
Notifications
You must be signed in to change notification settings - Fork 32.5k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[core] Include history from the @mui/base components & hooks v4
- Loading branch information
1 parent
656bde7
commit f6e0318
Showing
28 changed files
with
6,535 additions
and
0 deletions.
There are no files selected for viewing
441 changes: 441 additions & 0 deletions
441
packages/mui-base/src/ClickAwayListener/ClickAwayListener.test.js
Large diffs are not rendered by default.
Oops, something went wrong.
258 changes: 258 additions & 0 deletions
258
packages/mui-base/src/ClickAwayListener/ClickAwayListener.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,258 @@ | ||
'use client'; | ||
import * as React from 'react'; | ||
import PropTypes from 'prop-types'; | ||
import { | ||
elementAcceptingRef, | ||
exactProp, | ||
unstable_ownerDocument as ownerDocument, | ||
unstable_useForkRef as useForkRef, | ||
unstable_useEventCallback as useEventCallback, | ||
unstable_getReactNodeRef as getReactNodeRef, | ||
} from '@mui/utils'; | ||
|
||
// TODO: return `EventHandlerName extends `on${infer EventName}` ? Lowercase<EventName> : never` once generatePropTypes runs with TS 4.1 | ||
function mapEventPropToEvent( | ||
eventProp: ClickAwayMouseEventHandler | ClickAwayTouchEventHandler, | ||
): 'click' | 'mousedown' | 'mouseup' | 'touchstart' | 'touchend' | 'pointerdown' | 'pointerup' { | ||
return eventProp.substring(2).toLowerCase() as any; | ||
} | ||
|
||
function clickedRootScrollbar(event: MouseEvent, doc: Document) { | ||
return ( | ||
doc.documentElement.clientWidth < event.clientX || | ||
doc.documentElement.clientHeight < event.clientY | ||
); | ||
} | ||
|
||
type ClickAwayMouseEventHandler = | ||
| 'onClick' | ||
| 'onMouseDown' | ||
| 'onMouseUp' | ||
| 'onPointerDown' | ||
| 'onPointerUp'; | ||
type ClickAwayTouchEventHandler = 'onTouchStart' | 'onTouchEnd'; | ||
|
||
export interface ClickAwayListenerProps { | ||
/** | ||
* The wrapped element. | ||
*/ | ||
children: React.ReactElement<any>; | ||
/** | ||
* If `true`, the React tree is ignored and only the DOM tree is considered. | ||
* This prop changes how portaled elements are handled. | ||
* @default false | ||
*/ | ||
disableReactTree?: boolean; | ||
/** | ||
* The mouse event to listen to. You can disable the listener by providing `false`. | ||
* @default 'onClick' | ||
*/ | ||
mouseEvent?: ClickAwayMouseEventHandler | false; | ||
/** | ||
* Callback fired when a "click away" event is detected. | ||
*/ | ||
onClickAway: (event: MouseEvent | TouchEvent) => void; | ||
/** | ||
* The touch event to listen to. You can disable the listener by providing `false`. | ||
* @default 'onTouchEnd' | ||
*/ | ||
touchEvent?: ClickAwayTouchEventHandler | false; | ||
} | ||
|
||
/** | ||
* Listen for click events that occur somewhere in the document, outside of the element itself. | ||
* For instance, if you need to hide a menu when people click anywhere else on your page. | ||
* | ||
* Demos: | ||
* | ||
* - [Click-Away Listener](https://mui.com/base-ui/react-click-away-listener/) | ||
* | ||
* API: | ||
* | ||
* - [ClickAwayListener API](https://mui.com/base-ui/react-click-away-listener/components-api/#click-away-listener) | ||
*/ | ||
function ClickAwayListener(props: ClickAwayListenerProps): React.JSX.Element { | ||
const { | ||
children, | ||
disableReactTree = false, | ||
mouseEvent = 'onClick', | ||
onClickAway, | ||
touchEvent = 'onTouchEnd', | ||
} = props; | ||
const movedRef = React.useRef(false); | ||
const nodeRef = React.useRef<Element>(null); | ||
const activatedRef = React.useRef(false); | ||
const syntheticEventRef = React.useRef(false); | ||
|
||
React.useEffect(() => { | ||
// Ensure that this component is not "activated" synchronously. | ||
// https://github.com/facebook/react/issues/20074 | ||
setTimeout(() => { | ||
activatedRef.current = true; | ||
}, 0); | ||
return () => { | ||
activatedRef.current = false; | ||
}; | ||
}, []); | ||
|
||
const handleRef = useForkRef(getReactNodeRef(children), nodeRef); | ||
|
||
// The handler doesn't take event.defaultPrevented into account: | ||
// | ||
// event.preventDefault() is meant to stop default behaviors like | ||
// clicking a checkbox to check it, hitting a button to submit a form, | ||
// and hitting left arrow to move the cursor in a text input etc. | ||
// Only special HTML elements have these default behaviors. | ||
const handleClickAway = useEventCallback((event: MouseEvent | TouchEvent) => { | ||
// Given developers can stop the propagation of the synthetic event, | ||
// we can only be confident with a positive value. | ||
const insideReactTree = syntheticEventRef.current; | ||
syntheticEventRef.current = false; | ||
|
||
const doc = ownerDocument(nodeRef.current); | ||
|
||
// 1. IE11 support, which trigger the handleClickAway even after the unbind | ||
// 2. The child might render null. | ||
// 3. Behave like a blur listener. | ||
if ( | ||
!activatedRef.current || | ||
!nodeRef.current || | ||
('clientX' in event && clickedRootScrollbar(event, doc)) | ||
) { | ||
return; | ||
} | ||
|
||
// Do not act if user performed touchmove | ||
if (movedRef.current) { | ||
movedRef.current = false; | ||
return; | ||
} | ||
|
||
let insideDOM; | ||
|
||
// If not enough, can use https://github.com/DieterHolvoet/event-propagation-path/blob/master/propagationPath.js | ||
if (event.composedPath) { | ||
insideDOM = event.composedPath().indexOf(nodeRef.current) > -1; | ||
} else { | ||
insideDOM = | ||
!doc.documentElement.contains( | ||
// @ts-expect-error returns `false` as intended when not dispatched from a Node | ||
event.target, | ||
) || | ||
nodeRef.current.contains( | ||
// @ts-expect-error returns `false` as intended when not dispatched from a Node | ||
event.target, | ||
); | ||
} | ||
|
||
if (!insideDOM && (disableReactTree || !insideReactTree)) { | ||
onClickAway(event); | ||
} | ||
}); | ||
|
||
// Keep track of mouse/touch events that bubbled up through the portal. | ||
const createHandleSynthetic = (handlerName: string) => (event: React.SyntheticEvent) => { | ||
syntheticEventRef.current = true; | ||
|
||
const childrenPropsHandler = children.props[handlerName]; | ||
if (childrenPropsHandler) { | ||
childrenPropsHandler(event); | ||
} | ||
}; | ||
|
||
const childrenProps: { ref: React.Ref<Element> } & Pick< | ||
React.DOMAttributes<Element>, | ||
ClickAwayMouseEventHandler | ClickAwayTouchEventHandler | ||
> = { ref: handleRef }; | ||
|
||
if (touchEvent !== false) { | ||
childrenProps[touchEvent] = createHandleSynthetic(touchEvent); | ||
} | ||
|
||
React.useEffect(() => { | ||
if (touchEvent !== false) { | ||
const mappedTouchEvent = mapEventPropToEvent(touchEvent); | ||
const doc = ownerDocument(nodeRef.current); | ||
|
||
const handleTouchMove = () => { | ||
movedRef.current = true; | ||
}; | ||
|
||
doc.addEventListener(mappedTouchEvent, handleClickAway); | ||
doc.addEventListener('touchmove', handleTouchMove); | ||
|
||
return () => { | ||
doc.removeEventListener(mappedTouchEvent, handleClickAway); | ||
doc.removeEventListener('touchmove', handleTouchMove); | ||
}; | ||
} | ||
|
||
return undefined; | ||
}, [handleClickAway, touchEvent]); | ||
|
||
if (mouseEvent !== false) { | ||
childrenProps[mouseEvent] = createHandleSynthetic(mouseEvent); | ||
} | ||
|
||
React.useEffect(() => { | ||
if (mouseEvent !== false) { | ||
const mappedMouseEvent = mapEventPropToEvent(mouseEvent); | ||
const doc = ownerDocument(nodeRef.current); | ||
|
||
doc.addEventListener(mappedMouseEvent, handleClickAway); | ||
|
||
return () => { | ||
doc.removeEventListener(mappedMouseEvent, handleClickAway); | ||
}; | ||
} | ||
|
||
return undefined; | ||
}, [handleClickAway, mouseEvent]); | ||
|
||
return <React.Fragment>{React.cloneElement(children, childrenProps)}</React.Fragment>; | ||
} | ||
|
||
ClickAwayListener.propTypes /* remove-proptypes */ = { | ||
// ┌────────────────────────────── Warning ──────────────────────────────┐ | ||
// │ These PropTypes are generated from the TypeScript type definitions. │ | ||
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ | ||
// └─────────────────────────────────────────────────────────────────────┘ | ||
/** | ||
* The wrapped element. | ||
*/ | ||
children: elementAcceptingRef.isRequired, | ||
/** | ||
* If `true`, the React tree is ignored and only the DOM tree is considered. | ||
* This prop changes how portaled elements are handled. | ||
* @default false | ||
*/ | ||
disableReactTree: PropTypes.bool, | ||
/** | ||
* The mouse event to listen to. You can disable the listener by providing `false`. | ||
* @default 'onClick' | ||
*/ | ||
mouseEvent: PropTypes.oneOf([ | ||
'onClick', | ||
'onMouseDown', | ||
'onMouseUp', | ||
'onPointerDown', | ||
'onPointerUp', | ||
false, | ||
]), | ||
/** | ||
* Callback fired when a "click away" event is detected. | ||
*/ | ||
onClickAway: PropTypes.func.isRequired, | ||
/** | ||
* The touch event to listen to. You can disable the listener by providing `false`. | ||
* @default 'onTouchEnd' | ||
*/ | ||
touchEvent: PropTypes.oneOf(['onTouchEnd', 'onTouchStart', false]), | ||
} as any; | ||
|
||
if (process.env.NODE_ENV !== 'production') { | ||
// eslint-disable-next-line | ||
(ClickAwayListener as any)['propTypes' + ''] = exactProp(ClickAwayListener.propTypes); | ||
} | ||
|
||
export { ClickAwayListener }; |
Oops, something went wrong.