diff --git a/docs/src/pages/components/bottom-navigation/FixedBottomNavigation.js b/docs/src/pages/components/bottom-navigation/FixedBottomNavigation.js new file mode 100644 index 00000000000000..4d55bbf80ec97d --- /dev/null +++ b/docs/src/pages/components/bottom-navigation/FixedBottomNavigation.js @@ -0,0 +1,121 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import CssBaseline from '@material-ui/core/CssBaseline'; +import BottomNavigation from '@material-ui/core/BottomNavigation'; +import BottomNavigationAction from '@material-ui/core/BottomNavigationAction'; +import RestoreIcon from '@material-ui/icons/Restore'; +import FavoriteIcon from '@material-ui/icons/Favorite'; +import ArchiveIcon from '@material-ui/icons/Archive'; +import Paper from '@material-ui/core/Paper'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemAvatar from '@material-ui/core/ListItemAvatar'; +import ListItemText from '@material-ui/core/ListItemText'; +import Avatar from '@material-ui/core/Avatar'; + +const useStyles = makeStyles({ + root: { + paddingBottom: 56, + }, + bottomNav: { + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + }, +}); + +function refreshMessages() { + const getRandomInt = (max) => Math.floor(Math.random() * Math.floor(max)); + + return Array.from(new Array(50)).map( + () => messageExamples[getRandomInt(messageExamples.length)], + ); +} + +export default function FixedBottomNavigation() { + const classes = useStyles(); + const [value, setValue] = React.useState(0); + const ref = React.useRef(null); + const [messages, setMessages] = React.useState(() => refreshMessages()); + + React.useEffect(() => { + ref.current.ownerDocument.body.scrollTop = 0; + setMessages(refreshMessages()); + }, [value, setMessages]); + + return ( +
+ + + {messages.map(({ primary, secondary, person }, index) => ( + + + + + + + ))} + + + { + setValue(newValue); + }} + > + } /> + } /> + } /> + + +
+ ); +} + +const messageExamples = [ + { + primary: 'Brunch this week?', + secondary: + "I'll be in the neighbourhood this week. Let's grab a bite to eat", + person: '/static/images/avatar/5.jpg', + }, + { + primary: 'Birthday Gift', + secondary: `Do you have a suggestion for a good present for John on his work + anniversary. I am really confused & would love your thoughts on it.`, + person: '/static/images/avatar/1.jpg', + }, + { + primary: 'Recipe to try', + secondary: + 'I am try out this new BBQ recipe, I think this might be amazing', + person: '/static/images/avatar/2.jpg', + }, + { + primary: 'Yes!', + secondary: 'I have the tickets to the ReactConf for this year.', + person: '/static/images/avatar/3.jpg', + }, + { + primary: "Doctor's Appointment", + secondary: + 'My appointment for the doctor was rescheduled for next Saturday.', + person: '/static/images/avatar/4.jpg', + }, + { + primary: 'Discussion', + secondary: `Menus that are generated by the bottom app bar (such as a bottom + navigation drawer or overflow menu) open as bottom sheets at a higher elevation + than the bar.`, + person: '/static/images/avatar/5.jpg', + }, + { + primary: 'Summer BBQ', + secondary: `Who wants to have a cookout this weekend? I just got some furniture + for my backyard and would love to fire up the grill.`, + person: '/static/images/avatar/1.jpg', + }, +]; diff --git a/docs/src/pages/components/bottom-navigation/FixedBottomNavigation.tsx b/docs/src/pages/components/bottom-navigation/FixedBottomNavigation.tsx new file mode 100644 index 00000000000000..75a677c288b18b --- /dev/null +++ b/docs/src/pages/components/bottom-navigation/FixedBottomNavigation.tsx @@ -0,0 +1,128 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import CssBaseline from '@material-ui/core/CssBaseline'; +import BottomNavigation from '@material-ui/core/BottomNavigation'; +import BottomNavigationAction from '@material-ui/core/BottomNavigationAction'; +import RestoreIcon from '@material-ui/icons/Restore'; +import FavoriteIcon from '@material-ui/icons/Favorite'; +import ArchiveIcon from '@material-ui/icons/Archive'; +import Paper from '@material-ui/core/Paper'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemAvatar from '@material-ui/core/ListItemAvatar'; +import ListItemText from '@material-ui/core/ListItemText'; +import Avatar from '@material-ui/core/Avatar'; + +const useStyles = makeStyles({ + root: { + paddingBottom: 56, + }, + bottomNav: { + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + }, +}); + +function refreshMessages(): MessageExample[] { + const getRandomInt = (max: number) => + Math.floor(Math.random() * Math.floor(max)); + + return Array.from(new Array(50)).map( + () => messageExamples[getRandomInt(messageExamples.length)], + ); +} + +export default function FixedBottomNavigation() { + const classes = useStyles(); + const [value, setValue] = React.useState(0); + const ref = React.useRef(null); + const [messages, setMessages] = React.useState(() => refreshMessages()); + + React.useEffect(() => { + (ref.current as HTMLDivElement).ownerDocument.body.scrollTop = 0; + setMessages(refreshMessages()); + }, [value, setMessages]); + + return ( +
+ + + {messages.map(({ primary, secondary, person }, index) => ( + + + + + + + ))} + + + { + setValue(newValue); + }} + > + } /> + } /> + } /> + + +
+ ); +} + +interface MessageExample { + primary: string; + secondary: string; + person: string; +} + +const messageExamples: MessageExample[] = [ + { + primary: 'Brunch this week?', + secondary: + "I'll be in the neighbourhood this week. Let's grab a bite to eat", + person: '/static/images/avatar/5.jpg', + }, + { + primary: 'Birthday Gift', + secondary: `Do you have a suggestion for a good present for John on his work + anniversary. I am really confused & would love your thoughts on it.`, + person: '/static/images/avatar/1.jpg', + }, + { + primary: 'Recipe to try', + secondary: + 'I am try out this new BBQ recipe, I think this might be amazing', + person: '/static/images/avatar/2.jpg', + }, + { + primary: 'Yes!', + secondary: 'I have the tickets to the ReactConf for this year.', + person: '/static/images/avatar/3.jpg', + }, + { + primary: "Doctor's Appointment", + secondary: + 'My appointment for the doctor was rescheduled for next Saturday.', + person: '/static/images/avatar/4.jpg', + }, + { + primary: 'Discussion', + secondary: `Menus that are generated by the bottom app bar (such as a bottom + navigation drawer or overflow menu) open as bottom sheets at a higher elevation + than the bar.`, + person: '/static/images/avatar/5.jpg', + }, + { + primary: 'Summer BBQ', + secondary: `Who wants to have a cookout this weekend? I just got some furniture + for my backyard and would love to fire up the grill.`, + person: '/static/images/avatar/1.jpg', + }, +]; diff --git a/docs/src/pages/components/bottom-navigation/SimpleBottomNavigation.js b/docs/src/pages/components/bottom-navigation/SimpleBottomNavigation.js index 845c7fea7e7ea1..30664ed92d2f83 100644 --- a/docs/src/pages/components/bottom-navigation/SimpleBottomNavigation.js +++ b/docs/src/pages/components/bottom-navigation/SimpleBottomNavigation.js @@ -17,17 +17,18 @@ export default function SimpleBottomNavigation() { const [value, setValue] = React.useState(0); return ( - { - setValue(newValue); - }} - showLabels - className={classes.root} - > - } /> - } /> - } /> - +
+ { + setValue(newValue); + }} + > + } /> + } /> + } /> + +
); } diff --git a/docs/src/pages/components/bottom-navigation/SimpleBottomNavigation.tsx b/docs/src/pages/components/bottom-navigation/SimpleBottomNavigation.tsx index 845c7fea7e7ea1..30664ed92d2f83 100644 --- a/docs/src/pages/components/bottom-navigation/SimpleBottomNavigation.tsx +++ b/docs/src/pages/components/bottom-navigation/SimpleBottomNavigation.tsx @@ -17,17 +17,18 @@ export default function SimpleBottomNavigation() { const [value, setValue] = React.useState(0); return ( - { - setValue(newValue); - }} - showLabels - className={classes.root} - > - } /> - } /> - } /> - +
+ { + setValue(newValue); + }} + > + } /> + } /> + } /> + +
); } diff --git a/docs/src/pages/components/bottom-navigation/bottom-navigation.md b/docs/src/pages/components/bottom-navigation/bottom-navigation.md index 7a8c39e8bf44ee..aadbc0e2c46bc5 100644 --- a/docs/src/pages/components/bottom-navigation/bottom-navigation.md +++ b/docs/src/pages/components/bottom-navigation/bottom-navigation.md @@ -24,3 +24,9 @@ When there are only **three** actions, display both icons and text labels at all If there are **four** or **five** actions, display inactive views as icons only. {{"demo": "pages/components/bottom-navigation/LabelBottomNavigation.js", "bg": true}} + +## Fixed positioning + +This demo keeps bottom navigation fixed to the bottom, no matter the amount of content on-screen. + +{{"demo": "pages/components/bottom-navigation/FixedBottomNavigation.js", "bg": true, "iframe": true, "maxWidth": 600}} diff --git a/packages/material-ui/src/BottomNavigationAction/BottomNavigationAction.js b/packages/material-ui/src/BottomNavigationAction/BottomNavigationAction.js index a1956c844d451c..589272cb81a11f 100644 --- a/packages/material-ui/src/BottomNavigationAction/BottomNavigationAction.js +++ b/packages/material-ui/src/BottomNavigationAction/BottomNavigationAction.js @@ -60,6 +60,8 @@ const BottomNavigationAction = React.forwardRef(function BottomNavigationAction( icon, label, onChange, + onTouchStart, + onTouchEnd, onClick, // eslint-disable-next-line react/prop-types -- private, always overridden by BottomNavigation selected, @@ -68,7 +70,52 @@ const BottomNavigationAction = React.forwardRef(function BottomNavigationAction( ...other } = props; + const touchStartPos = React.useRef(); + const touchTimer = React.useRef(); + + React.useEffect(() => { + return () => { + clearTimeout(touchTimer.current); + }; + }, [touchTimer]); + + const handleTouchStart = (event) => { + if (onTouchStart) { + onTouchStart(event); + } + + const { clientX, clientY } = event.touches[0]; + + touchStartPos.current = { + clientX, + clientY, + }; + }; + + const handleTouchEnd = (event) => { + if (onTouchEnd) onTouchEnd(event); + + const target = event.target; + const { clientX, clientY } = event.changedTouches[0]; + + if ( + Math.abs(clientX - touchStartPos.current.clientX) < 10 && + Math.abs(clientY - touchStartPos.current.clientY) < 10 + ) { + touchTimer.current = setTimeout(() => { + // Simulate the native tap behavior on mobile. + // On the web, a tap won't trigger a click if a container is scrolling. + // + // Note that the synthetic behavior won't trigger a native nor + // it will trigger a click at all on iOS. + target.dispatchEvent(new Event('click', { bubbles: true })); + }, 10); + } + }; + const handleChange = (event) => { + clearTimeout(touchTimer.current); + if (onChange) { onChange(event, value); } @@ -91,6 +138,8 @@ const BottomNavigationAction = React.forwardRef(function BottomNavigationAction( )} focusRipple onClick={handleChange} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} {...other} > @@ -142,6 +191,14 @@ BottomNavigationAction.propTypes = { * @ignore */ onClick: PropTypes.func, + /** + * @ignore + */ + onTouchEnd: PropTypes.func, + /** + * @ignore + */ + onTouchStart: PropTypes.func, /** * If `true`, the `BottomNavigationAction` will show its label. * By default, only the selected `BottomNavigationAction` diff --git a/packages/material-ui/src/BottomNavigationAction/BottomNavigationAction.test.js b/packages/material-ui/src/BottomNavigationAction/BottomNavigationAction.test.js index 647b9a3dc91ce6..21981d291330da 100644 --- a/packages/material-ui/src/BottomNavigationAction/BottomNavigationAction.test.js +++ b/packages/material-ui/src/BottomNavigationAction/BottomNavigationAction.test.js @@ -1,12 +1,13 @@ import * as React from 'react'; import { expect } from 'chai'; -import { spy } from 'sinon'; +import { spy, useFakeTimers } from 'sinon'; import { getClasses, createMount, describeConformance, createClientRender, within, + fireEvent, } from 'test/utils'; import ButtonBase from '../ButtonBase'; import BottomNavigationAction from './BottomNavigationAction'; @@ -80,4 +81,170 @@ describe('', () => { expect(handleClick.callCount).to.equal(1); }); }); + + describe('touch functionality', () => { + before(function test() { + // Only run in supported browsers + if (typeof Touch === 'undefined') { + this.skip(); + } + }); + + let clock; + + beforeEach(() => { + clock = useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + it('should fire onClick on touch tap', () => { + const handleClick = spy(); + + // Need disableTouchRipple to avoid ripple missing act (async setState after touchEnd) + const { container } = render( + , + ); + + fireEvent.touchStart(container.firstChild, { + touches: [ + new Touch({ + identifier: 1, + target: container, + clientX: 42, + clientY: 42, + }), + ], + }); + + fireEvent.touchEnd(container.firstChild, { + changedTouches: [ + new Touch({ + identifier: 1, + target: container, + clientX: 42, + clientY: 42, + }), + ], + }); + + clock.tick(15); + + expect(handleClick.callCount).to.equal(1); + }); + + it('should not fire onClick twice on touch tap', () => { + const handleClick = spy(); + + // Need disableTouchRipple to avoid ripple missing act (async setState after touchEnd) + const { getByRole, container } = render( + , + ); + + fireEvent.touchStart(container.firstChild, { + touches: [ + new Touch({ + identifier: 1, + target: container, + clientX: 42, + clientY: 42, + }), + ], + }); + + fireEvent.touchEnd(container.firstChild, { + changedTouches: [ + new Touch({ + identifier: 1, + target: container, + clientX: 42, + clientY: 42, + }), + ], + }); + + getByRole('button').click(); + + clock.tick(15); + + expect(handleClick.callCount).to.equal(1); + }); + + it('should not fire onClick if swiping', () => { + const handleClick = spy(); + + // Need disableTouchRipple to avoid ripple missing act (async setState after touchEnd) + const { container } = render( + , + ); + + fireEvent.touchStart(container.firstChild, { + touches: [ + new Touch({ + identifier: 1, + target: container, + clientX: 42, + clientY: 42, + }), + ], + }); + + fireEvent.touchEnd(container.firstChild, { + changedTouches: [ + new Touch({ + identifier: 1, + target: container, + clientX: 84, + clientY: 84, + }), + ], + }); + + clock.tick(10); + + expect(handleClick.callCount).to.equal(0); + }); + + it('should forward onTouchStart and onTouchEnd events', () => { + const handleTouchStart = spy(); + const handleTouchEnd = spy(); + + // Need disableTouchRipple to avoid ripple missing act (async setState after touchEnd). + const { container } = render( + , + ); + + fireEvent.touchStart(container.firstChild, { + touches: [ + new Touch({ + identifier: 1, + target: container, + clientX: 42, + clientY: 42, + }), + ], + }); + + expect(handleTouchStart.callCount).to.be.equals(1); + + fireEvent.touchEnd(container.firstChild, { + changedTouches: [ + new Touch({ + identifier: 1, + target: container, + clientX: 84, + clientY: 84, + }), + ], + }); + + expect(handleTouchEnd.callCount).to.be.equals(1); + }); + }); });