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);
+ });
+ });
});