@linagora/twake-mui 10.4.1 → 10.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/components/BottomSheet/BottomSheet.stories.d.ts +23 -0
- package/dist/components/BottomSheet/BottomSheet.stories.js +139 -0
- package/dist/components/BottomSheet/BottomSheetHeader.d.ts +9 -0
- package/dist/components/BottomSheet/BottomSheetHeader.js +14 -0
- package/dist/components/BottomSheet/BottomSheetItem.d.ts +10 -0
- package/dist/components/BottomSheet/BottomSheetItem.js +18 -0
- package/dist/components/BottomSheet/BottomSheetTitle.d.ts +10 -0
- package/dist/components/BottomSheet/BottomSheetTitle.js +20 -0
- package/dist/components/BottomSheet/core/BottomSheetCore.d.ts +28 -0
- package/dist/components/BottomSheet/core/BottomSheetCore.js +118 -0
- package/dist/components/BottomSheet/core/helpers.d.ts +8 -0
- package/dist/components/BottomSheet/core/helpers.js +29 -0
- package/dist/components/BottomSheet/helpers.d.ts +14 -0
- package/dist/components/BottomSheet/helpers.js +95 -0
- package/dist/components/BottomSheet/index.d.ts +8 -0
- package/dist/components/BottomSheet/index.js +158 -0
- package/dist/components/BottomSheet/safeArea.d.ts +3 -0
- package/dist/components/BottomSheet/safeArea.js +10 -0
- package/dist/components/BottomSheet/styles.d.ts +25 -0
- package/dist/components/BottomSheet/styles.js +100 -0
- package/dist/components/BottomSheet/types.d.ts +77 -0
- package/dist/components/BottomSheet/types.js +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +4 -0
- package/package.json +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
# @linagora/twake-mui [10.5.0](https://github.com/linagora/twake-ui/compare/@linagora/twake-mui@10.4.1...@linagora/twake-mui@10.5.0) (2026-10-07)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **twake-mui:** Migrate BottomSheet from cozy-ui ([5144650](https://github.com/linagora/twake-ui/commit/51446500bc88f45859bfc5e8278a88347d6f91f5))
|
|
7
|
+
* **twake-mui:** Migrate BottomSheet subcomponents from cozy-ui ([006d755](https://github.com/linagora/twake-ui/commit/006d7559d13d8740b28f1e6c456372956884e7fb))
|
|
8
|
+
|
|
1
9
|
## @linagora/twake-mui [10.4.1](https://github.com/linagora/twake-ui/compare/@linagora/twake-mui@10.4.0...@linagora/twake-mui@10.4.1) (2026-10-07)
|
|
2
10
|
|
|
3
11
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
interface ExampleArgs {
|
|
3
|
+
open: boolean;
|
|
4
|
+
backdrop: boolean;
|
|
5
|
+
closable: boolean;
|
|
6
|
+
skipAnimation: boolean;
|
|
7
|
+
disablePortal: boolean;
|
|
8
|
+
withFakeToolbar: boolean;
|
|
9
|
+
withHeader: boolean;
|
|
10
|
+
withTitle: boolean;
|
|
11
|
+
withListContent: boolean;
|
|
12
|
+
longContent: boolean;
|
|
13
|
+
isOpenMin: boolean;
|
|
14
|
+
hasMinHeightOffset: boolean;
|
|
15
|
+
mediumHeight?: number;
|
|
16
|
+
mediumHeightRatio?: number;
|
|
17
|
+
offset?: number;
|
|
18
|
+
}
|
|
19
|
+
declare const meta: Meta<ExampleArgs>;
|
|
20
|
+
export default meta;
|
|
21
|
+
type Story = StoryObj<typeof meta>;
|
|
22
|
+
export declare const Default: Story;
|
|
23
|
+
export declare const Screenshot: Story;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { File, FileTypeSheet, FileTypeSlide, FileTypeText, FileTypeVideo, Icon, Right } from '@linagora/twake-icons';
|
|
2
|
+
import { Button, Checkbox, Divider, List, ListItemIcon, Radio } from '@mui/material';
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import BottomSheetHeader from './BottomSheetHeader';
|
|
5
|
+
import BottomSheetItem from './BottomSheetItem';
|
|
6
|
+
import BottomSheetTitle from './BottomSheetTitle';
|
|
7
|
+
import BottomSheet from './index';
|
|
8
|
+
import ListItemButton from '../ListItemButton';
|
|
9
|
+
import ListItemText from '../ListItemText';
|
|
10
|
+
const shortContent = 'Ada Lovelace wrote the first algorithm intended to be carried out by a machine, the Analytical Engine imagined by Charles Babbage.';
|
|
11
|
+
const longContent = Array.from({ length: 12 }, (_, index) => `${index + 1}. ${shortContent} She saw that such a machine could handle symbols as well as numbers, and could one day compose music.`).join(' ');
|
|
12
|
+
const ListContent = ({ withTitle }) => (React.createElement(BottomSheetItem, { disableGutters: true },
|
|
13
|
+
withTitle && (React.createElement(React.Fragment, null,
|
|
14
|
+
React.createElement(BottomSheetTitle, { icon: FileTypeText, label: "Title" }),
|
|
15
|
+
React.createElement(Divider, null))),
|
|
16
|
+
React.createElement(List, null,
|
|
17
|
+
React.createElement(ListItemButton, null,
|
|
18
|
+
React.createElement(ListItemIcon, null,
|
|
19
|
+
React.createElement(Icon, { icon: File })),
|
|
20
|
+
React.createElement(ListItemText, { primary: "Item with icon", secondary: "and with secondary text" })),
|
|
21
|
+
React.createElement(Divider, { variant: "inset" }),
|
|
22
|
+
React.createElement(ListItemButton, null,
|
|
23
|
+
React.createElement(ListItemIcon, null,
|
|
24
|
+
React.createElement(Checkbox, null)),
|
|
25
|
+
React.createElement(ListItemText, { primary: "Item with checkbox" })),
|
|
26
|
+
React.createElement(Divider, { variant: "inset" }),
|
|
27
|
+
React.createElement(ListItemButton, null,
|
|
28
|
+
React.createElement(ListItemIcon, null,
|
|
29
|
+
React.createElement(Radio, null)),
|
|
30
|
+
React.createElement(ListItemText, { primary: "Item with radio" })),
|
|
31
|
+
React.createElement(Divider, { variant: "inset" }),
|
|
32
|
+
React.createElement(ListItemButton, null,
|
|
33
|
+
React.createElement(ListItemIcon, null,
|
|
34
|
+
React.createElement(Icon, { icon: File })),
|
|
35
|
+
React.createElement(ListItemText, { primary: "Item with secondary action" }),
|
|
36
|
+
React.createElement(Icon, { icon: Right }))),
|
|
37
|
+
React.createElement(Divider, null),
|
|
38
|
+
React.createElement(List, null,
|
|
39
|
+
React.createElement(ListItemButton, null,
|
|
40
|
+
React.createElement(ListItemIcon, null,
|
|
41
|
+
React.createElement(Icon, { icon: FileTypeText, size: 32 })),
|
|
42
|
+
React.createElement(ListItemText, { primary: "Files" })),
|
|
43
|
+
React.createElement(ListItemButton, null,
|
|
44
|
+
React.createElement(ListItemIcon, null,
|
|
45
|
+
React.createElement(Icon, { icon: FileTypeSheet, size: 32 })),
|
|
46
|
+
React.createElement(ListItemText, { primary: "Sheets" }))),
|
|
47
|
+
React.createElement(Divider, { variant: "inset" }),
|
|
48
|
+
React.createElement(List, null,
|
|
49
|
+
React.createElement(ListItemButton, null,
|
|
50
|
+
React.createElement(ListItemIcon, null,
|
|
51
|
+
React.createElement(Icon, { icon: FileTypeSlide, size: 32 })),
|
|
52
|
+
React.createElement(ListItemText, { primary: "Slides" })),
|
|
53
|
+
React.createElement(ListItemButton, null,
|
|
54
|
+
React.createElement(ListItemIcon, null,
|
|
55
|
+
React.createElement(Icon, { icon: FileTypeVideo, size: 32 })),
|
|
56
|
+
React.createElement(ListItemText, { primary: "Videos" })))));
|
|
57
|
+
const ExampleBottomSheet = args => {
|
|
58
|
+
const [isOpen, setIsOpen] = useState(args.open);
|
|
59
|
+
const [isSecondOpen, setIsSecondOpen] = useState(false);
|
|
60
|
+
const handleClose = () => setIsOpen(false);
|
|
61
|
+
const settings = args.mediumHeight === undefined && args.mediumHeightRatio === undefined
|
|
62
|
+
? {}
|
|
63
|
+
: {
|
|
64
|
+
mediumHeight: args.mediumHeight,
|
|
65
|
+
mediumHeightRatio: args.mediumHeightRatio
|
|
66
|
+
};
|
|
67
|
+
return (React.createElement(React.Fragment, null,
|
|
68
|
+
React.createElement(Button, { variant: "outlined", onClick: () => setIsOpen(true) }, "Open BottomSheet"),
|
|
69
|
+
isOpen && (React.createElement(BottomSheet, { toolbarProps: args.withFakeToolbar ? { height: 50 } : undefined, settings: {
|
|
70
|
+
...settings,
|
|
71
|
+
isOpenMin: args.isOpenMin,
|
|
72
|
+
hasMinHeightOffset: args.hasMinHeightOffset
|
|
73
|
+
}, backdrop: args.backdrop, skipAnimation: args.skipAnimation, offset: args.offset, onClose: args.closable ? handleClose : undefined, portalProps: { disablePortal: args.disablePortal } },
|
|
74
|
+
args.withHeader && (React.createElement(BottomSheetHeader, { sx: { px: 2, pb: 2, gap: 1 } },
|
|
75
|
+
React.createElement(Button, { variant: "outlined", fullWidth: true, onClick: () => setIsSecondOpen(true) }, "Open BottomSheet"),
|
|
76
|
+
React.createElement(Button, { variant: "outlined", fullWidth: true }, "Button 2"),
|
|
77
|
+
isSecondOpen && (React.createElement(BottomSheet, { backdrop: true, onClose: () => setIsSecondOpen(false) },
|
|
78
|
+
React.createElement(BottomSheetItem, null, shortContent))))),
|
|
79
|
+
args.withListContent ? (React.createElement(ListContent, { withTitle: args.withTitle })) : (React.createElement(React.Fragment, null,
|
|
80
|
+
args.withTitle && React.createElement(BottomSheetTitle, { label: "Title" }),
|
|
81
|
+
React.createElement(BottomSheetItem, null, args.longContent ? longContent : shortContent)))))));
|
|
82
|
+
};
|
|
83
|
+
const meta = {
|
|
84
|
+
title: 'BottomSheet',
|
|
85
|
+
tags: ['autodocs'],
|
|
86
|
+
parameters: {
|
|
87
|
+
docs: {
|
|
88
|
+
description: {
|
|
89
|
+
component: 'A `backdrop` requires the sheet to be closable: check `closable` whenever `backdrop` is checked.'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
argTypes: {
|
|
94
|
+
open: { control: 'boolean' },
|
|
95
|
+
backdrop: { control: 'boolean' },
|
|
96
|
+
closable: { control: 'boolean' },
|
|
97
|
+
skipAnimation: { control: 'boolean' },
|
|
98
|
+
disablePortal: { control: 'boolean' },
|
|
99
|
+
withFakeToolbar: { control: 'boolean' },
|
|
100
|
+
withHeader: { control: 'boolean' },
|
|
101
|
+
withTitle: { control: 'boolean' },
|
|
102
|
+
withListContent: { control: 'boolean' },
|
|
103
|
+
longContent: { control: 'boolean' },
|
|
104
|
+
isOpenMin: { control: 'boolean' },
|
|
105
|
+
hasMinHeightOffset: { control: 'boolean' },
|
|
106
|
+
mediumHeight: { control: 'number' },
|
|
107
|
+
mediumHeightRatio: { control: { type: 'number', step: 0.05 } },
|
|
108
|
+
offset: { control: 'number' }
|
|
109
|
+
},
|
|
110
|
+
args: {
|
|
111
|
+
open: false,
|
|
112
|
+
backdrop: true,
|
|
113
|
+
closable: true,
|
|
114
|
+
skipAnimation: false,
|
|
115
|
+
disablePortal: false,
|
|
116
|
+
withFakeToolbar: false,
|
|
117
|
+
withHeader: true,
|
|
118
|
+
withTitle: false,
|
|
119
|
+
withListContent: false,
|
|
120
|
+
longContent: false,
|
|
121
|
+
isOpenMin: false,
|
|
122
|
+
hasMinHeightOffset: false
|
|
123
|
+
},
|
|
124
|
+
render: args => React.createElement(ExampleBottomSheet, { ...args })
|
|
125
|
+
};
|
|
126
|
+
export default meta;
|
|
127
|
+
export const Default = {};
|
|
128
|
+
// Visual Regression - the sheet open at its medium stop, with every subcomponent. Kept out of
|
|
129
|
+
// the docs page, where its open sheet would cover the whole page
|
|
130
|
+
export const Screenshot = {
|
|
131
|
+
tags: ['argos', '!autodocs'],
|
|
132
|
+
render: () => (React.createElement(BottomSheet, { backdrop: true, skipAnimation: true, settings: { mediumHeight: 450 }, onClose: () => undefined },
|
|
133
|
+
React.createElement(BottomSheetHeader, { sx: { px: 2, pb: 2, gap: 1 } },
|
|
134
|
+
React.createElement(Button, { variant: "outlined", fullWidth: true }, "Button 1"),
|
|
135
|
+
React.createElement(Button, { variant: "outlined", fullWidth: true }, "Button 2")),
|
|
136
|
+
React.createElement(BottomSheetTitle, { label: "Title" }),
|
|
137
|
+
React.createElement(BottomSheetItem, null, shortContent),
|
|
138
|
+
React.createElement(ListContent, { withTitle: true })))
|
|
139
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { BottomSheetItemProps } from './BottomSheetItem';
|
|
3
|
+
export type BottomSheetHeaderProps = Omit<BottomSheetItemProps, 'disableGutters' | 'disableElevation'>;
|
|
4
|
+
/**
|
|
5
|
+
* Content kept visible at the bottom of the sheet when it is at its lowest stop, such as action
|
|
6
|
+
* buttons. Its height and bottom padding are part of the sheet minimum height.
|
|
7
|
+
*/
|
|
8
|
+
declare const BottomSheetHeader: React.ForwardRefExoticComponent<BottomSheetHeaderProps & React.RefAttributes<HTMLDivElement>>;
|
|
9
|
+
export default BottomSheetHeader;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { styled } from '@mui/material/styles';
|
|
2
|
+
import React, { forwardRef } from 'react';
|
|
3
|
+
import BottomSheetItem from './BottomSheetItem';
|
|
4
|
+
const BottomSheetHeaderRoot = styled(BottomSheetItem)({
|
|
5
|
+
display: 'flex',
|
|
6
|
+
alignItems: 'center'
|
|
7
|
+
});
|
|
8
|
+
/**
|
|
9
|
+
* Content kept visible at the bottom of the sheet when it is at its lowest stop, such as action
|
|
10
|
+
* buttons. Its height and bottom padding are part of the sheet minimum height.
|
|
11
|
+
*/
|
|
12
|
+
const BottomSheetHeader = forwardRef((props, ref) => (React.createElement(BottomSheetHeaderRoot, { ref: ref, disableGutters: true, disableElevation: true, ...props })));
|
|
13
|
+
BottomSheetHeader.displayName = 'BottomSheetHeader';
|
|
14
|
+
export default BottomSheetHeader;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { PaperProps } from '@mui/material';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
export interface BottomSheetItemProps extends Omit<PaperProps, 'ref'> {
|
|
4
|
+
/** Removes the default padding */
|
|
5
|
+
disableGutters?: boolean;
|
|
6
|
+
/** Removes the default elevation */
|
|
7
|
+
disableElevation?: boolean;
|
|
8
|
+
}
|
|
9
|
+
declare const BottomSheetItem: React.ForwardRefExoticComponent<BottomSheetItemProps & React.RefAttributes<HTMLDivElement>>;
|
|
10
|
+
export default BottomSheetItem;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Paper } from '@mui/material';
|
|
2
|
+
import { styled } from '@mui/material/styles';
|
|
3
|
+
import React, { forwardRef } from 'react';
|
|
4
|
+
/** Flat extra shadow the theme defines past the 24 MUI elevations */
|
|
5
|
+
const ITEM_ELEVATION = 25;
|
|
6
|
+
const BottomSheetItemRoot = styled(Paper, {
|
|
7
|
+
shouldForwardProp: prop => prop !== 'disableGutters'
|
|
8
|
+
})(({ theme }) => ({
|
|
9
|
+
variants: [
|
|
10
|
+
{
|
|
11
|
+
props: { disableGutters: false },
|
|
12
|
+
style: { padding: theme.spacing(2) }
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}));
|
|
16
|
+
const BottomSheetItem = forwardRef(({ disableGutters = false, disableElevation = false, ...props }, ref) => (React.createElement(BottomSheetItemRoot, { ref: ref, elevation: disableElevation ? 0 : ITEM_ELEVATION, square: true, disableGutters: disableGutters, ...props })));
|
|
17
|
+
BottomSheetItem.displayName = 'BottomSheetItem';
|
|
18
|
+
export default BottomSheetItem;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { IconProps } from '@linagora/twake-icons';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
export interface BottomSheetTitleProps {
|
|
4
|
+
className?: string;
|
|
5
|
+
label?: string;
|
|
6
|
+
/** Shows the title as a list item with this icon */
|
|
7
|
+
icon?: IconProps['icon'];
|
|
8
|
+
}
|
|
9
|
+
declare const BottomSheetTitle: React.ForwardRefExoticComponent<BottomSheetTitleProps & React.RefAttributes<HTMLElement>>;
|
|
10
|
+
export default BottomSheetTitle;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Icon } from '@linagora/twake-icons';
|
|
2
|
+
import { List, ListItemIcon, Typography } from '@mui/material';
|
|
3
|
+
import React, { forwardRef } from 'react';
|
|
4
|
+
import BottomSheetItem from './BottomSheetItem';
|
|
5
|
+
import ListItem from '../ListItem';
|
|
6
|
+
import ListItemText from '../ListItemText';
|
|
7
|
+
const BottomSheetTitle = forwardRef(({ className, label, icon }, ref) => {
|
|
8
|
+
if (icon) {
|
|
9
|
+
return (React.createElement(BottomSheetItem, { disableGutters: true, disableElevation: true },
|
|
10
|
+
React.createElement(List, { ref: ref },
|
|
11
|
+
React.createElement(ListItem, null,
|
|
12
|
+
React.createElement(ListItemIcon, null,
|
|
13
|
+
React.createElement(Icon, { icon: icon, size: 32 })),
|
|
14
|
+
React.createElement(ListItemText, { primary: label, slotProps: { primary: { variant: 'h6', noWrap: true } } })))));
|
|
15
|
+
}
|
|
16
|
+
return (React.createElement(BottomSheetItem, { disableElevation: true },
|
|
17
|
+
React.createElement(Typography, { ref: ref, className: className, variant: "h6", align: "center" }, label)));
|
|
18
|
+
});
|
|
19
|
+
BottomSheetTitle.displayName = 'BottomSheetTitle';
|
|
20
|
+
export default BottomSheetTitle;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SpringConfig } from '@react-spring/web';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
export type SnapPointSeekerMode = 'close' | 'next';
|
|
4
|
+
export interface BottomSheetCoreProps {
|
|
5
|
+
/** Height of the sheet when closed, in px */
|
|
6
|
+
defaultHeight?: number;
|
|
7
|
+
/** Intermediate heights the sheet stops at, in px */
|
|
8
|
+
peekHeights?: number[] | null;
|
|
9
|
+
/** Allows the sheet to go full screen, otherwise it tops out at its content height */
|
|
10
|
+
fullHeight?: boolean;
|
|
11
|
+
/** Index of the stop to move the sheet to, among `[defaultHeight, ...peekHeights, fullHeight]` */
|
|
12
|
+
currentIndex?: number;
|
|
13
|
+
/** Called with the index of the stop the user moved the sheet to */
|
|
14
|
+
onIndexChange?: (index: number) => void;
|
|
15
|
+
/** Moves the sheet off screen */
|
|
16
|
+
hidden?: boolean;
|
|
17
|
+
/** Distance above the highest stop, in px, at which dragging up is cancelled */
|
|
18
|
+
threshold?: number;
|
|
19
|
+
/** Prevents dragging the sheet below its lowest stop */
|
|
20
|
+
disabledClosing?: boolean;
|
|
21
|
+
/** On release, snaps to the closest stop or to the next one in the drag direction */
|
|
22
|
+
snapPointSeekerMode?: SnapPointSeekerMode;
|
|
23
|
+
springConfig?: SpringConfig;
|
|
24
|
+
skipAnimation?: boolean;
|
|
25
|
+
className?: string;
|
|
26
|
+
children?: React.ReactNode;
|
|
27
|
+
}
|
|
28
|
+
export declare function BottomSheetCore({ defaultHeight, peekHeights, fullHeight, currentIndex, onIndexChange, hidden, threshold, disabledClosing, snapPointSeekerMode, springConfig, skipAnimation, className, children }: BottomSheetCoreProps): React.JSX.Element;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Adapted from mui-bottom-sheet
|
|
3
|
+
* Copyright (c) 2020 Harley Alexander, MIT License
|
|
4
|
+
*/
|
|
5
|
+
import { animated, config, useSpring } from '@react-spring/web';
|
|
6
|
+
import { useDrag } from '@use-gesture/react';
|
|
7
|
+
import React, { useEffect, useRef } from 'react';
|
|
8
|
+
import useMeasure from 'react-use-measure';
|
|
9
|
+
import { computeClosestStop, computeNextStop, isNodeInside } from './helpers';
|
|
10
|
+
const HIDDEN_MARGIN = 30;
|
|
11
|
+
const HORIZONTAL_DRAG_RATIO = 0.8;
|
|
12
|
+
export function BottomSheetCore({ defaultHeight = 100, peekHeights = [], fullHeight = true, currentIndex, onIndexChange, hidden = false, threshold = 70, disabledClosing = false, snapPointSeekerMode = 'close', springConfig = config.stiff, skipAnimation = false, className, children }) {
|
|
13
|
+
const computeStopPosition = (height) => window.innerHeight - height;
|
|
14
|
+
const defaultPosition = computeStopPosition(defaultHeight);
|
|
15
|
+
const stops = [defaultPosition];
|
|
16
|
+
const [measureRef, { height }] = useMeasure();
|
|
17
|
+
if (height > window.innerHeight && fullHeight) {
|
|
18
|
+
stops.push(0);
|
|
19
|
+
}
|
|
20
|
+
if (height < window.innerHeight && height > defaultHeight) {
|
|
21
|
+
stops.push(computeStopPosition(height));
|
|
22
|
+
}
|
|
23
|
+
peekHeights?.forEach(peekHeight => {
|
|
24
|
+
const position = computeStopPosition(peekHeight);
|
|
25
|
+
if (peekHeight < height &&
|
|
26
|
+
peekHeight < window.innerHeight &&
|
|
27
|
+
!stops.includes(position)) {
|
|
28
|
+
stops.push(position);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
// Positions grow from top to bottom while peek heights grow from bottom to top, so a descending
|
|
32
|
+
// sort makes stop indexes match peek height indexes
|
|
33
|
+
stops.sort((a, b) => b - a);
|
|
34
|
+
const containerRef = useRef(null);
|
|
35
|
+
const lastDirectionYRef = useRef(0);
|
|
36
|
+
const [{ y }, api] = useSpring(() => ({ y: defaultPosition }));
|
|
37
|
+
const bind = useDrag(({ last, cancel, offset: [, offsetY], delta: [deltaX, deltaY], direction: [, directionY], event }) => {
|
|
38
|
+
event.stopPropagation();
|
|
39
|
+
if (!isNodeInside(event.target, containerRef.current))
|
|
40
|
+
return;
|
|
41
|
+
if (Math.abs(deltaX) >
|
|
42
|
+
HORIZONTAL_DRAG_RATIO * Math.hypot(deltaX, deltaY)) {
|
|
43
|
+
cancel();
|
|
44
|
+
}
|
|
45
|
+
if (containerRef.current?.scrollTop)
|
|
46
|
+
return;
|
|
47
|
+
if (offsetY < 0 || offsetY < Math.min(...stops) - threshold) {
|
|
48
|
+
cancel();
|
|
49
|
+
}
|
|
50
|
+
if (disabledClosing && offsetY > Math.max(...stops)) {
|
|
51
|
+
cancel();
|
|
52
|
+
}
|
|
53
|
+
if (!last) {
|
|
54
|
+
lastDirectionYRef.current = directionY;
|
|
55
|
+
void api.start({
|
|
56
|
+
y: offsetY,
|
|
57
|
+
config: { duration: 0 },
|
|
58
|
+
immediate: skipAnimation
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// The release event resets the direction when the pointer stood still, so the direction
|
|
63
|
+
// of the last move decides which stop comes next
|
|
64
|
+
const newPosition = snapPointSeekerMode === 'next'
|
|
65
|
+
? computeNextStop(offsetY, lastDirectionYRef.current, stops)
|
|
66
|
+
: computeClosestStop(offsetY, stops);
|
|
67
|
+
void api.start({
|
|
68
|
+
y: newPosition,
|
|
69
|
+
config: springConfig,
|
|
70
|
+
immediate: skipAnimation
|
|
71
|
+
});
|
|
72
|
+
onIndexChange?.(stops.findIndex(stop => stop === newPosition));
|
|
73
|
+
}, {
|
|
74
|
+
// Once the content is scrolled, starting from that negative offset makes the sheet follow
|
|
75
|
+
// the pointer only after the content is back to its top
|
|
76
|
+
from: () => [
|
|
77
|
+
0,
|
|
78
|
+
containerRef.current?.scrollTop
|
|
79
|
+
? -containerRef.current.scrollTop + Math.min(...stops)
|
|
80
|
+
: y.goal
|
|
81
|
+
],
|
|
82
|
+
bounds: { top: 0 },
|
|
83
|
+
// Touch and mouse events let the content scroll natively, unlike pointer events
|
|
84
|
+
pointer: { touch: true, mouse: true },
|
|
85
|
+
// Arrow keys typed in the sheet content must not move the sheet
|
|
86
|
+
keys: false
|
|
87
|
+
});
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
void api.start({
|
|
90
|
+
y: hidden ? window.innerHeight + HIDDEN_MARGIN : defaultPosition,
|
|
91
|
+
config: config.gentle,
|
|
92
|
+
immediate: skipAnimation
|
|
93
|
+
});
|
|
94
|
+
}, [hidden, api, defaultPosition, skipAnimation]);
|
|
95
|
+
// Stops are recomputed on every render, so this check runs after each one
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (currentIndex !== undefined && y.goal !== stops[currentIndex]) {
|
|
98
|
+
void api.start({
|
|
99
|
+
y: stops[currentIndex],
|
|
100
|
+
config: springConfig,
|
|
101
|
+
immediate: skipAnimation
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
return (React.createElement(animated.div, { ...bind(), ref: containerRef, className: className, style: {
|
|
106
|
+
width: '100%',
|
|
107
|
+
minHeight: defaultHeight,
|
|
108
|
+
maxHeight: '100%',
|
|
109
|
+
position: 'fixed',
|
|
110
|
+
left: 0,
|
|
111
|
+
top: 0,
|
|
112
|
+
y,
|
|
113
|
+
display: y.to(positionY => positionY < window.innerHeight + HIDDEN_MARGIN ? 'block' : 'none'),
|
|
114
|
+
// Content only scrolls once the sheet reached its highest stop
|
|
115
|
+
overflowY: y.to(positionY => positionY > Math.min(...stops) ? 'hidden' : 'scroll')
|
|
116
|
+
} },
|
|
117
|
+
React.createElement("div", { ref: measureRef }, children)));
|
|
118
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function computeClosestStop(goal: number, stops: number[]): number;
|
|
2
|
+
/**
|
|
3
|
+
* Finds the stop following `goal` in the drag direction: a higher value for a positive direction,
|
|
4
|
+
* a lower or equal one otherwise. A found `0` stop (full screen) falls back to the last stop like
|
|
5
|
+
* a miss: `||` is kept over `??` to preserve that snapping.
|
|
6
|
+
*/
|
|
7
|
+
export declare function computeNextStop(goal: number, direction: number, stops: number[]): number;
|
|
8
|
+
export declare function isNodeInside(node: EventTarget | null, container: Node | null): boolean;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Snap point helpers adapted from mui-bottom-sheet
|
|
3
|
+
* Copyright (c) 2020 Harley Alexander, MIT License
|
|
4
|
+
*/
|
|
5
|
+
export function computeClosestStop(goal, stops) {
|
|
6
|
+
return stops.reduce((prev, stop) => Math.abs(stop - goal) < Math.abs(prev - goal) ? stop : prev);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Finds the stop following `goal` in the drag direction: a higher value for a positive direction,
|
|
10
|
+
* a lower or equal one otherwise. A found `0` stop (full screen) falls back to the last stop like
|
|
11
|
+
* a miss: `||` is kept over `??` to preserve that snapping.
|
|
12
|
+
*/
|
|
13
|
+
export function computeNextStop(goal, direction, stops) {
|
|
14
|
+
if (direction > 0) {
|
|
15
|
+
const ascStops = [...stops].sort((a, b) => a - b);
|
|
16
|
+
return ascStops.find(stop => stop >= goal) || ascStops[ascStops.length - 1];
|
|
17
|
+
}
|
|
18
|
+
const descStops = [...stops].sort((a, b) => b - a);
|
|
19
|
+
return descStops.find(stop => stop <= goal) || descStops[descStops.length - 1];
|
|
20
|
+
}
|
|
21
|
+
export function isNodeInside(node, container) {
|
|
22
|
+
let current = node instanceof Node ? node : null;
|
|
23
|
+
while (current?.parentNode) {
|
|
24
|
+
if (current === container)
|
|
25
|
+
return true;
|
|
26
|
+
current = current.parentNode;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { BottomPositionParams, BottomSheetToolbarProps, BottomSpacerParams, MediumHeightParams, MinHeightParams, MinimizeAndCloseParams, TopPositionParams } from './types';
|
|
3
|
+
export declare const ANIMATION_DURATION = 250;
|
|
4
|
+
export declare const computeToolbarHeight: (toolbarProps?: BottomSheetToolbarProps) => number;
|
|
5
|
+
export declare const computeMaxHeight: (toolbarProps?: BottomSheetToolbarProps) => number;
|
|
6
|
+
export declare const computeMediumHeight: ({ backdrop, maxHeight, mediumHeight, mediumHeightRatio, innerContentHeight, bottomSpacerHeight, offset }: MediumHeightParams) => number;
|
|
7
|
+
export declare const computeMinHeight: ({ isClosable, isOpenMin, headerRef, offset, actionButtonsHeight, actionButtonsBottomMargin }: MinHeightParams) => number;
|
|
8
|
+
/** Gives the header child the ref the sheet measures it with */
|
|
9
|
+
export declare const makeOverriddenChildren: (children: React.ReactNode, headerContentRef: React.Ref<HTMLDivElement>) => React.ReactNode;
|
|
10
|
+
export declare const setTopPosition: ({ snapIndex, peekHeights, isTopPosition, setIsTopPosition }: TopPositionParams) => void;
|
|
11
|
+
export declare const setBottomPosition: ({ snapIndex, isBottomPosition, setIsBottomPosition }: BottomPositionParams) => void;
|
|
12
|
+
export declare const minimizeAndClose: ({ backdrop, setCurrentIndex, setIsTopPosition, setIsBottomPosition, handleClose }: MinimizeAndCloseParams) => void;
|
|
13
|
+
export declare const computeBottomSpacer: ({ backdrop, maxHeight, innerContentHeight, toolbarProps, offset }: BottomSpacerParams) => number;
|
|
14
|
+
export declare const getCssValue: (element: Element | null, property: string) => number;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React, { cloneElement, isValidElement } from 'react';
|
|
2
|
+
import BottomSheetHeader from './BottomSheetHeader';
|
|
3
|
+
import { getSafeAreaInset } from './safeArea';
|
|
4
|
+
export const ANIMATION_DURATION = 250;
|
|
5
|
+
export const computeToolbarHeight = (toolbarProps = {}) => {
|
|
6
|
+
const { ref, height } = toolbarProps;
|
|
7
|
+
let toolbarHeight = 1;
|
|
8
|
+
if (height) {
|
|
9
|
+
toolbarHeight = height;
|
|
10
|
+
}
|
|
11
|
+
else if (ref?.current) {
|
|
12
|
+
toolbarHeight = ref.current.offsetHeight;
|
|
13
|
+
}
|
|
14
|
+
return toolbarHeight + getSafeAreaInset('top');
|
|
15
|
+
};
|
|
16
|
+
export const computeMaxHeight = (toolbarProps) => {
|
|
17
|
+
return window.innerHeight - computeToolbarHeight(toolbarProps);
|
|
18
|
+
};
|
|
19
|
+
export const computeMediumHeight = ({ backdrop, maxHeight, mediumHeight, mediumHeightRatio, innerContentHeight, bottomSpacerHeight, offset }) => {
|
|
20
|
+
const mediumHeightOrWithRatio = mediumHeight || Math.round(maxHeight * mediumHeightRatio);
|
|
21
|
+
if (backdrop) {
|
|
22
|
+
if (mediumHeightOrWithRatio < innerContentHeight) {
|
|
23
|
+
return mediumHeightOrWithRatio < maxHeight
|
|
24
|
+
? mediumHeightOrWithRatio
|
|
25
|
+
: maxHeight;
|
|
26
|
+
}
|
|
27
|
+
return innerContentHeight > maxHeight
|
|
28
|
+
? maxHeight
|
|
29
|
+
: innerContentHeight + bottomSpacerHeight;
|
|
30
|
+
}
|
|
31
|
+
if (innerContentHeight < mediumHeightOrWithRatio) {
|
|
32
|
+
return innerContentHeight + offset;
|
|
33
|
+
}
|
|
34
|
+
return mediumHeightOrWithRatio > maxHeight
|
|
35
|
+
? maxHeight
|
|
36
|
+
: mediumHeightOrWithRatio;
|
|
37
|
+
};
|
|
38
|
+
export const computeMinHeight = ({ isClosable, isOpenMin, headerRef, offset = 0, actionButtonsHeight, actionButtonsBottomMargin }) => {
|
|
39
|
+
if (isClosable && !isOpenMin)
|
|
40
|
+
return 0;
|
|
41
|
+
return ((headerRef.current?.offsetHeight ?? 0) +
|
|
42
|
+
offset +
|
|
43
|
+
actionButtonsHeight +
|
|
44
|
+
actionButtonsBottomMargin +
|
|
45
|
+
getSafeAreaInset('bottom'));
|
|
46
|
+
};
|
|
47
|
+
/** Gives the header child the ref the sheet measures it with */
|
|
48
|
+
export const makeOverriddenChildren = (children, headerContentRef) => {
|
|
49
|
+
return React.Children.map(children, child => isValidElement(child) && child.type === BottomSheetHeader
|
|
50
|
+
? cloneElement(child, {
|
|
51
|
+
ref: headerContentRef
|
|
52
|
+
})
|
|
53
|
+
: child);
|
|
54
|
+
};
|
|
55
|
+
export const setTopPosition = ({ snapIndex, peekHeights, isTopPosition, setIsTopPosition }) => {
|
|
56
|
+
const maxHeightSnapIndex = peekHeights.length - 1;
|
|
57
|
+
if (snapIndex > maxHeightSnapIndex) {
|
|
58
|
+
setIsTopPosition(true);
|
|
59
|
+
}
|
|
60
|
+
if (snapIndex === maxHeightSnapIndex && !isTopPosition) {
|
|
61
|
+
setIsTopPosition(true);
|
|
62
|
+
}
|
|
63
|
+
if (snapIndex < maxHeightSnapIndex && isTopPosition) {
|
|
64
|
+
setIsTopPosition(false);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
export const setBottomPosition = ({ snapIndex, isBottomPosition, setIsBottomPosition }) => {
|
|
68
|
+
if (snapIndex === 0 && !isBottomPosition) {
|
|
69
|
+
setIsBottomPosition(true);
|
|
70
|
+
}
|
|
71
|
+
if (snapIndex !== 0 && isBottomPosition) {
|
|
72
|
+
setIsBottomPosition(false);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
export const minimizeAndClose = ({ backdrop, setCurrentIndex, setIsTopPosition, setIsBottomPosition, handleClose }) => {
|
|
76
|
+
if (backdrop) {
|
|
77
|
+
setCurrentIndex(0);
|
|
78
|
+
setIsTopPosition(false);
|
|
79
|
+
setIsBottomPosition(true);
|
|
80
|
+
setTimeout(handleClose, ANIMATION_DURATION);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
export const computeBottomSpacer = ({ backdrop, maxHeight, innerContentHeight, toolbarProps, offset }) => {
|
|
84
|
+
// Content taller than the available height
|
|
85
|
+
if (maxHeight - innerContentHeight <= 0) {
|
|
86
|
+
return offset + computeToolbarHeight(toolbarProps);
|
|
87
|
+
}
|
|
88
|
+
// Without backdrop, the sheet must be able to open up to the top of the window
|
|
89
|
+
return backdrop ? offset : maxHeight - innerContentHeight;
|
|
90
|
+
};
|
|
91
|
+
export const getCssValue = (element, property) => {
|
|
92
|
+
return element
|
|
93
|
+
? parseFloat(getComputedStyle(element).getPropertyValue(property))
|
|
94
|
+
: 0;
|
|
95
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { BottomSheetProps } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Panel coming up from the bottom of the screen, that the user can drag between a minimum, a
|
|
5
|
+
* medium and a full height. Rendered in a portal, like dialogs.
|
|
6
|
+
*/
|
|
7
|
+
declare const BottomSheet: ({ portalProps, ...props }: BottomSheetProps) => React.JSX.Element;
|
|
8
|
+
export default BottomSheet;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Backdrop, Portal } from '@mui/material';
|
|
2
|
+
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useMutationObserver, useTimeoutWhen } from 'rooks';
|
|
4
|
+
import { ANIMATION_DURATION, computeBottomSpacer, computeMaxHeight, computeMediumHeight, computeMinHeight, getCssValue, makeOverriddenChildren, minimizeAndClose, setBottomPosition, setTopPosition } from './helpers';
|
|
5
|
+
import { BORDER_RADIUS, BottomSheetBounceSafer, BottomSheetContainer, BottomSheetHandleBar, BottomSheetIndicator, BottomSheetOffsetSafer, BottomSheetRenderSafer, BottomSheetRenderSaferFill, BottomSheetRoot, BottomSheetSpacer, BottomSheetStack } from './styles';
|
|
6
|
+
/** Shared default, so that effects depending on it do not run on every render */
|
|
7
|
+
const NO_TOOLBAR_PROPS = {};
|
|
8
|
+
const DEFAULT_MEDIUM_HEIGHT_RATIO = 0.75;
|
|
9
|
+
const SPRING_CONFIG = { tension: 165, friction: 17, clamp: true };
|
|
10
|
+
const BottomSheetContent = memo(function BottomSheetContent({ toolbarProps = NO_TOOLBAR_PROPS, settings, backdrop = false, skipAnimation, onClose, offset = 0, children }) {
|
|
11
|
+
const mediumHeight = settings?.mediumHeight ?? null;
|
|
12
|
+
const mediumHeightRatio = settings?.mediumHeightRatio ?? DEFAULT_MEDIUM_HEIGHT_RATIO;
|
|
13
|
+
const isOpenMin = settings?.isOpenMin ?? false;
|
|
14
|
+
const hasMinHeightOffset = settings?.hasMinHeightOffset ?? false;
|
|
15
|
+
const innerContentRef = useRef(null);
|
|
16
|
+
const headerRef = useRef(null);
|
|
17
|
+
const headerContentRef = useRef(null);
|
|
18
|
+
const [isTopPosition, setIsTopPosition] = useState(false);
|
|
19
|
+
const [isBottomPosition, setIsBottomPosition] = useState(false);
|
|
20
|
+
const [isHidden, setIsHidden] = useState(false);
|
|
21
|
+
const [showBackdrop, setShowBackdrop] = useState(backdrop);
|
|
22
|
+
const [peekHeights, setPeekHeights] = useState(null);
|
|
23
|
+
// Starts at the medium stop, the one the initial position is computed from
|
|
24
|
+
const [currentIndex, setCurrentIndex] = useState(1);
|
|
25
|
+
const [bottomSpacerHeight, setBottomSpacerHeight] = useState(0);
|
|
26
|
+
const [initPos, setInitPos] = useState(0);
|
|
27
|
+
const prevInitPos = useRef(0);
|
|
28
|
+
const [forceRender, setForceRender] = useState(0);
|
|
29
|
+
const hasToolbarProps = Object.keys(toolbarProps).length > 0;
|
|
30
|
+
const isClosable = !!onClose || backdrop;
|
|
31
|
+
const renderSaferHeight = initPos < prevInitPos.current ? prevInitPos.current - BORDER_RADIUS : 0;
|
|
32
|
+
const showRenderSafer = prevInitPos.current !== 0 && prevInitPos.current > initPos;
|
|
33
|
+
const overriddenChildren = makeOverriddenChildren(children, headerContentRef);
|
|
34
|
+
if (backdrop && !onClose) {
|
|
35
|
+
throw new Error('BottomSheet must have `onClose` method to work properly when setting `backdrop` to `true`');
|
|
36
|
+
}
|
|
37
|
+
const handleClose = useCallback(() => {
|
|
38
|
+
setShowBackdrop(false);
|
|
39
|
+
setIsHidden(true);
|
|
40
|
+
onClose?.();
|
|
41
|
+
}, [onClose]);
|
|
42
|
+
const handleIndexChange = (snapIndex) => {
|
|
43
|
+
setCurrentIndex(snapIndex);
|
|
44
|
+
setTopPosition({
|
|
45
|
+
snapIndex,
|
|
46
|
+
peekHeights: peekHeights ?? [],
|
|
47
|
+
isTopPosition,
|
|
48
|
+
setIsTopPosition
|
|
49
|
+
});
|
|
50
|
+
setBottomPosition({ snapIndex, isBottomPosition, setIsBottomPosition });
|
|
51
|
+
};
|
|
52
|
+
const handleBackdropClick = () => {
|
|
53
|
+
minimizeAndClose({
|
|
54
|
+
backdrop,
|
|
55
|
+
setCurrentIndex,
|
|
56
|
+
setIsTopPosition,
|
|
57
|
+
setIsBottomPosition,
|
|
58
|
+
handleClose
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
// Recomputes the stops when the content changes, after loading or navigating inside it
|
|
62
|
+
useMutationObserver(innerContentRef, () => {
|
|
63
|
+
setForceRender(value => value + 1);
|
|
64
|
+
});
|
|
65
|
+
// Prevents the pull-to-refresh of iOS Safari while dragging the sheet down
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
document.body.style.overflow = 'hidden';
|
|
68
|
+
return () => {
|
|
69
|
+
document.body.style.overflow = 'auto';
|
|
70
|
+
};
|
|
71
|
+
}, []);
|
|
72
|
+
useTimeoutWhen(() => handleClose(), ANIMATION_DURATION, isClosable && isBottomPosition);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const headerContent = headerContentRef.current;
|
|
75
|
+
const innerContentHeight = innerContentRef.current?.offsetHeight ?? 0;
|
|
76
|
+
const actionButtonsHeight = getCssValue(headerContent, 'height');
|
|
77
|
+
const actionButtonsBottomMargin = getCssValue(headerContent, 'padding-bottom');
|
|
78
|
+
const maxHeight = computeMaxHeight(toolbarProps);
|
|
79
|
+
const newBottomSpacerHeight = computeBottomSpacer({
|
|
80
|
+
backdrop,
|
|
81
|
+
maxHeight,
|
|
82
|
+
innerContentHeight,
|
|
83
|
+
toolbarProps,
|
|
84
|
+
offset
|
|
85
|
+
});
|
|
86
|
+
const minHeight = computeMinHeight({
|
|
87
|
+
isClosable,
|
|
88
|
+
isOpenMin,
|
|
89
|
+
headerRef,
|
|
90
|
+
offset: hasMinHeightOffset ? offset : 0,
|
|
91
|
+
actionButtonsHeight,
|
|
92
|
+
actionButtonsBottomMargin
|
|
93
|
+
});
|
|
94
|
+
const computedMediumHeight = isOpenMin && actionButtonsHeight > 0
|
|
95
|
+
? minHeight + 1
|
|
96
|
+
: computeMediumHeight({
|
|
97
|
+
backdrop,
|
|
98
|
+
maxHeight,
|
|
99
|
+
mediumHeight,
|
|
100
|
+
mediumHeightRatio,
|
|
101
|
+
innerContentHeight,
|
|
102
|
+
bottomSpacerHeight: newBottomSpacerHeight,
|
|
103
|
+
offset
|
|
104
|
+
});
|
|
105
|
+
const newPeekHeights = [
|
|
106
|
+
...new Set([minHeight, computedMediumHeight, maxHeight])
|
|
107
|
+
];
|
|
108
|
+
const hasPeekHeightsChanged = peekHeights?.toString() !== newPeekHeights.toString();
|
|
109
|
+
// Lowers the sheet when its stops change while it is at the top
|
|
110
|
+
const snapIndex = hasPeekHeightsChanged && isTopPosition
|
|
111
|
+
? currentIndex - 1 - (currentIndex - (newPeekHeights.length - 1))
|
|
112
|
+
: currentIndex;
|
|
113
|
+
setCurrentIndex(snapIndex);
|
|
114
|
+
setPeekHeights(newPeekHeights);
|
|
115
|
+
prevInitPos.current = initPos;
|
|
116
|
+
setInitPos(computedMediumHeight);
|
|
117
|
+
setTopPosition({
|
|
118
|
+
snapIndex,
|
|
119
|
+
peekHeights: newPeekHeights,
|
|
120
|
+
isTopPosition,
|
|
121
|
+
setIsTopPosition
|
|
122
|
+
});
|
|
123
|
+
// Lets the sheet open up to the top without stopping at the content height
|
|
124
|
+
setBottomSpacerHeight(newBottomSpacerHeight);
|
|
125
|
+
// initPos is left out on purpose: the effect needs its previous value
|
|
126
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
127
|
+
}, [
|
|
128
|
+
innerContentRef,
|
|
129
|
+
toolbarProps,
|
|
130
|
+
mediumHeightRatio,
|
|
131
|
+
mediumHeight,
|
|
132
|
+
showBackdrop,
|
|
133
|
+
backdrop,
|
|
134
|
+
isClosable,
|
|
135
|
+
offset,
|
|
136
|
+
forceRender
|
|
137
|
+
]);
|
|
138
|
+
const content = (React.createElement(React.Fragment, null,
|
|
139
|
+
showBackdrop && React.createElement(Backdrop, { open: true, onClick: handleBackdropClick }),
|
|
140
|
+
React.createElement(BottomSheetRoot, { isTopPosition: isTopPosition, hasToolbar: hasToolbarProps, peekHeights: peekHeights, defaultHeight: initPos, fullHeight: !hasToolbarProps, currentIndex: currentIndex, onIndexChange: handleIndexChange, threshold: 0, springConfig: SPRING_CONFIG, disabledClosing: !onClose, hidden: isHidden, snapPointSeekerMode: "next", skipAnimation: skipAnimation },
|
|
141
|
+
React.createElement("div", { ref: innerContentRef },
|
|
142
|
+
React.createElement(BottomSheetHandleBar, { "data-testid": "bottomSheet-header", ref: headerRef, elevation: 0, square: true },
|
|
143
|
+
React.createElement(BottomSheetIndicator, null)),
|
|
144
|
+
React.createElement(BottomSheetStack, { spacing: 1.5 }, overriddenChildren)),
|
|
145
|
+
React.createElement(BottomSheetSpacer, { spacerHeight: bottomSpacerHeight })),
|
|
146
|
+
showRenderSafer && (React.createElement(BottomSheetRenderSafer, { saferHeight: renderSaferHeight },
|
|
147
|
+
React.createElement(BottomSheetRenderSaferFill, { elevation: 0, square: true }))),
|
|
148
|
+
!isBottomPosition && React.createElement(BottomSheetBounceSafer, null),
|
|
149
|
+
Boolean(offset) && (React.createElement(BottomSheetOffsetSafer, { offset: offset, isBottomPosition: isBottomPosition }))));
|
|
150
|
+
return showBackdrop ? (React.createElement(BottomSheetContainer, null, content)) : (content);
|
|
151
|
+
});
|
|
152
|
+
/**
|
|
153
|
+
* Panel coming up from the bottom of the screen, that the user can drag between a minimum, a
|
|
154
|
+
* medium and a full height. Rendered in a portal, like dialogs.
|
|
155
|
+
*/
|
|
156
|
+
const BottomSheet = ({ portalProps, ...props }) => (React.createElement(Portal, { ...portalProps },
|
|
157
|
+
React.createElement(BottomSheetContent, { ...props })));
|
|
158
|
+
export default BottomSheet;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Returns the device safe area inset (notch, home indicator), in px */
|
|
2
|
+
export function getSafeAreaInset(side) {
|
|
3
|
+
// env() is only resolved through a style, so a probe element reads it
|
|
4
|
+
const probe = document.createElement('div');
|
|
5
|
+
probe.style.paddingTop = `env(safe-area-inset-${side})`;
|
|
6
|
+
document.body.appendChild(probe);
|
|
7
|
+
const inset = parseFloat(getComputedStyle(probe).paddingTop) || 0;
|
|
8
|
+
probe.remove();
|
|
9
|
+
return inset;
|
|
10
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Theme } from '@mui/material/styles';
|
|
2
|
+
export declare const BORDER_RADIUS = 16;
|
|
3
|
+
export declare const BottomSheetContainer: import("@emotion/styled").StyledComponent<import("@mui/system").MUIStyledCommonProps<Theme>, import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, {}>;
|
|
4
|
+
export declare const BottomSheetRoot: import("@emotion/styled").StyledComponent<import("./core/BottomSheetCore").BottomSheetCoreProps & import("@mui/system").MUIStyledCommonProps<Theme> & {
|
|
5
|
+
isTopPosition: boolean;
|
|
6
|
+
hasToolbar: boolean;
|
|
7
|
+
}, {}, {}>;
|
|
8
|
+
export declare const BottomSheetHandleBar: import("@emotion/styled").StyledComponent<import("@mui/material").PaperOwnProps & import("@mui/material/OverridableComponent").CommonProps & Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "style" | "square" | "className" | "elevation" | "children" | "classes" | "sx" | "variant"> & import("@mui/system").MUIStyledCommonProps<Theme>, {}, {}>;
|
|
9
|
+
export declare const BottomSheetIndicator: import("@emotion/styled").StyledComponent<import("@mui/system").MUIStyledCommonProps<Theme>, import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, {}>;
|
|
10
|
+
export declare const BottomSheetStack: import("@emotion/styled").StyledComponent<import("@mui/material").StackOwnProps & import("@mui/material/OverridableComponent").CommonProps & Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, keyof import("@mui/material/OverridableComponent").CommonProps | keyof import("@mui/material").StackOwnProps> & import("@mui/system").MUIStyledCommonProps<Theme>, {}, {}>;
|
|
11
|
+
export declare const BottomSheetSpacer: import("@emotion/styled").StyledComponent<import("@mui/system").MUIStyledCommonProps<Theme> & {
|
|
12
|
+
spacerHeight: number;
|
|
13
|
+
}, import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, {}>;
|
|
14
|
+
/** Covers the screen bottom while the sheet bounces up */
|
|
15
|
+
export declare const BottomSheetBounceSafer: import("@emotion/styled").StyledComponent<import("@mui/system").MUIStyledCommonProps<Theme>, import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, {}>;
|
|
16
|
+
/** Covers the offset area below the sheet */
|
|
17
|
+
export declare const BottomSheetOffsetSafer: import("@emotion/styled").StyledComponent<import("@mui/system").MUIStyledCommonProps<Theme> & {
|
|
18
|
+
offset: number;
|
|
19
|
+
isBottomPosition: boolean;
|
|
20
|
+
}, import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, {}>;
|
|
21
|
+
/** Hides the gap left under the sheet while it shrinks to a lower content height */
|
|
22
|
+
export declare const BottomSheetRenderSafer: import("@emotion/styled").StyledComponent<import("@mui/system").MUIStyledCommonProps<Theme> & {
|
|
23
|
+
saferHeight: number;
|
|
24
|
+
}, import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, {}>;
|
|
25
|
+
export declare const BottomSheetRenderSaferFill: import("@emotion/styled").StyledComponent<import("@mui/material").PaperOwnProps & import("@mui/material/OverridableComponent").CommonProps & Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "style" | "square" | "className" | "elevation" | "children" | "classes" | "sx" | "variant"> & import("@mui/system").MUIStyledCommonProps<Theme>, {}, {}>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Paper, Stack } from '@mui/material';
|
|
2
|
+
import { keyframes, styled } from '@mui/material/styles';
|
|
3
|
+
import { BottomSheetCore } from './core/BottomSheetCore';
|
|
4
|
+
import { ANIMATION_DURATION } from './helpers';
|
|
5
|
+
export const BORDER_RADIUS = 16;
|
|
6
|
+
const BOUNCE_SAFER_HEIGHT = 50;
|
|
7
|
+
const fadeIn = keyframes({ from: { opacity: 0 }, to: { opacity: 1 } });
|
|
8
|
+
const collapse = keyframes({ from: { height: '100%' }, to: { height: 0 } });
|
|
9
|
+
export const BottomSheetContainer = styled('div')(({ theme }) => ({
|
|
10
|
+
position: 'fixed',
|
|
11
|
+
zIndex: theme.zIndex.modal,
|
|
12
|
+
inset: 0
|
|
13
|
+
}));
|
|
14
|
+
export const BottomSheetRoot = styled(BottomSheetCore, {
|
|
15
|
+
shouldForwardProp: prop => prop !== 'isTopPosition' && prop !== 'hasToolbar'
|
|
16
|
+
})(({ theme }) => ({
|
|
17
|
+
borderTopLeftRadius: BORDER_RADIUS,
|
|
18
|
+
borderTopRightRadius: BORDER_RADIUS,
|
|
19
|
+
transition: 'border-radius 0.5s',
|
|
20
|
+
boxShadow: '0 -0.5px 0px 0 rgba(0, 0, 0, 0.10), 0 -2px 2px 0 rgba(0, 0, 0, 0.02), 0 -4px 4px 0 rgba(0, 0, 0, 0.02), 0 -8px 8px 0 rgba(0, 0, 0, 0.02), 0 -16px 16px 0 rgba(0, 0, 0, 0.02)',
|
|
21
|
+
backgroundColor: theme.vars.palette.background.paper,
|
|
22
|
+
zIndex: theme.zIndex.modal,
|
|
23
|
+
variants: [
|
|
24
|
+
{
|
|
25
|
+
props: { isTopPosition: true },
|
|
26
|
+
style: {
|
|
27
|
+
borderTopLeftRadius: 0,
|
|
28
|
+
borderTopRightRadius: 0,
|
|
29
|
+
boxShadow: '0 -1px 0 0 rgba(255, 255, 255, 1)'
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
props: { isTopPosition: true, hasToolbar: true },
|
|
34
|
+
style: { boxShadow: '0 0 1px 0 rgba(0, 0, 0, 0.5)' }
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}));
|
|
38
|
+
export const BottomSheetHandleBar = styled(Paper)({
|
|
39
|
+
width: '100%',
|
|
40
|
+
height: '3rem',
|
|
41
|
+
position: 'relative',
|
|
42
|
+
display: 'flex',
|
|
43
|
+
alignItems: 'center',
|
|
44
|
+
justifyContent: 'center'
|
|
45
|
+
});
|
|
46
|
+
export const BottomSheetIndicator = styled('div')(({ theme }) => ({
|
|
47
|
+
width: '4rem',
|
|
48
|
+
height: '0.25rem',
|
|
49
|
+
borderRadius: 99,
|
|
50
|
+
backgroundColor: theme.vars.palette.text.secondary
|
|
51
|
+
}));
|
|
52
|
+
export const BottomSheetStack = styled(Stack)(({ theme }) => ({
|
|
53
|
+
overflow: 'hidden',
|
|
54
|
+
backgroundColor: theme.vars.palette.background.default
|
|
55
|
+
}));
|
|
56
|
+
export const BottomSheetSpacer = styled('div', {
|
|
57
|
+
shouldForwardProp: prop => prop !== 'spacerHeight'
|
|
58
|
+
})(({ spacerHeight }) => ({ height: spacerHeight }));
|
|
59
|
+
/** Covers the screen bottom while the sheet bounces up */
|
|
60
|
+
export const BottomSheetBounceSafer = styled('div')(({ theme }) => ({
|
|
61
|
+
height: BOUNCE_SAFER_HEIGHT,
|
|
62
|
+
width: '100%',
|
|
63
|
+
position: 'fixed',
|
|
64
|
+
bottom: 0,
|
|
65
|
+
left: 0,
|
|
66
|
+
backgroundColor: theme.vars.palette.background.paper,
|
|
67
|
+
animation: `${fadeIn} ${ANIMATION_DURATION}ms ${theme.transitions.easing.easeInOut}`
|
|
68
|
+
}));
|
|
69
|
+
/** Covers the offset area below the sheet */
|
|
70
|
+
export const BottomSheetOffsetSafer = styled('div', {
|
|
71
|
+
shouldForwardProp: prop => prop !== 'offset' && prop !== 'isBottomPosition'
|
|
72
|
+
})(({ theme, offset, isBottomPosition }) => ({
|
|
73
|
+
opacity: isBottomPosition ? 0 : 1,
|
|
74
|
+
height: offset,
|
|
75
|
+
width: '100%',
|
|
76
|
+
position: 'fixed',
|
|
77
|
+
bottom: 0,
|
|
78
|
+
left: 0,
|
|
79
|
+
backgroundColor: theme.vars.palette.background.paper,
|
|
80
|
+
zIndex: theme.zIndex.modal + 10,
|
|
81
|
+
transition: `opacity ${ANIMATION_DURATION}ms`,
|
|
82
|
+
animation: `${fadeIn} ${ANIMATION_DURATION}ms ${theme.transitions.easing.easeInOut}`
|
|
83
|
+
}));
|
|
84
|
+
/** Hides the gap left under the sheet while it shrinks to a lower content height */
|
|
85
|
+
export const BottomSheetRenderSafer = styled('div', {
|
|
86
|
+
shouldForwardProp: prop => prop !== 'saferHeight'
|
|
87
|
+
})(({ saferHeight }) => ({
|
|
88
|
+
height: saferHeight,
|
|
89
|
+
width: '100%',
|
|
90
|
+
position: 'fixed',
|
|
91
|
+
bottom: 0,
|
|
92
|
+
pointerEvents: 'none'
|
|
93
|
+
}));
|
|
94
|
+
export const BottomSheetRenderSaferFill = styled(Paper)({
|
|
95
|
+
position: 'absolute',
|
|
96
|
+
bottom: 0,
|
|
97
|
+
height: 0,
|
|
98
|
+
width: '100%',
|
|
99
|
+
animation: `${collapse} 1s`
|
|
100
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { PortalProps } from '@mui/material';
|
|
2
|
+
import React, { RefObject } from 'react';
|
|
3
|
+
export type SafeAreaSide = 'top' | 'bottom';
|
|
4
|
+
export interface BottomSheetToolbarProps {
|
|
5
|
+
/** Ref of the toolbar the sheet must stay under when fully open */
|
|
6
|
+
ref?: RefObject<HTMLElement | null>;
|
|
7
|
+
/** Height of that toolbar, in px, used instead of measuring `ref` */
|
|
8
|
+
height?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface BottomSheetSettings {
|
|
11
|
+
/** Height of the middle stop, in px */
|
|
12
|
+
mediumHeight?: number | null;
|
|
13
|
+
/** Height of the middle stop, as a ratio of the available height */
|
|
14
|
+
mediumHeightRatio?: number;
|
|
15
|
+
/** Includes the offset in the minimum height */
|
|
16
|
+
hasMinHeightOffset?: boolean;
|
|
17
|
+
/** Opens the sheet at its minimum height, when it has a header */
|
|
18
|
+
isOpenMin?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface BottomSheetProps {
|
|
21
|
+
/** Toolbar the sheet must stay under when fully open */
|
|
22
|
+
toolbarProps?: BottomSheetToolbarProps;
|
|
23
|
+
settings?: BottomSheetSettings;
|
|
24
|
+
/** Adds a backdrop, which requires `onClose` */
|
|
25
|
+
backdrop?: boolean;
|
|
26
|
+
/** Removes animations */
|
|
27
|
+
skipAnimation?: boolean;
|
|
28
|
+
/** Space kept at the bottom of the sheet, in px */
|
|
29
|
+
offset?: number;
|
|
30
|
+
/** Lets the user close the sheet by swiping it down */
|
|
31
|
+
onClose?: () => void;
|
|
32
|
+
portalProps?: Omit<PortalProps, 'children'>;
|
|
33
|
+
children?: React.ReactNode;
|
|
34
|
+
}
|
|
35
|
+
export type BottomSheetContentProps = Omit<BottomSheetProps, 'portalProps'>;
|
|
36
|
+
export interface MediumHeightParams {
|
|
37
|
+
backdrop: boolean;
|
|
38
|
+
maxHeight: number;
|
|
39
|
+
mediumHeight?: number | null;
|
|
40
|
+
mediumHeightRatio: number;
|
|
41
|
+
innerContentHeight: number;
|
|
42
|
+
bottomSpacerHeight: number;
|
|
43
|
+
offset: number;
|
|
44
|
+
}
|
|
45
|
+
export interface MinHeightParams {
|
|
46
|
+
isClosable: boolean;
|
|
47
|
+
isOpenMin: boolean;
|
|
48
|
+
headerRef: RefObject<HTMLElement | null>;
|
|
49
|
+
offset?: number;
|
|
50
|
+
actionButtonsHeight: number;
|
|
51
|
+
actionButtonsBottomMargin: number;
|
|
52
|
+
}
|
|
53
|
+
export interface TopPositionParams {
|
|
54
|
+
snapIndex: number;
|
|
55
|
+
peekHeights: number[];
|
|
56
|
+
isTopPosition: boolean;
|
|
57
|
+
setIsTopPosition: (isTopPosition: boolean) => void;
|
|
58
|
+
}
|
|
59
|
+
export interface BottomPositionParams {
|
|
60
|
+
snapIndex: number;
|
|
61
|
+
isBottomPosition: boolean;
|
|
62
|
+
setIsBottomPosition: (isBottomPosition: boolean) => void;
|
|
63
|
+
}
|
|
64
|
+
export interface MinimizeAndCloseParams {
|
|
65
|
+
backdrop: boolean;
|
|
66
|
+
setCurrentIndex: (index: number) => void;
|
|
67
|
+
setIsTopPosition: (isTopPosition: boolean) => void;
|
|
68
|
+
setIsBottomPosition: (isBottomPosition: boolean) => void;
|
|
69
|
+
handleClose: () => void;
|
|
70
|
+
}
|
|
71
|
+
export interface BottomSpacerParams {
|
|
72
|
+
backdrop: boolean;
|
|
73
|
+
maxHeight: number;
|
|
74
|
+
innerContentHeight: number;
|
|
75
|
+
toolbarProps?: BottomSheetToolbarProps;
|
|
76
|
+
offset: number;
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ export { radius } from './lib/radius';
|
|
|
8
8
|
export { TwakeMuiThemeProvider } from './components/ThemeProvider';
|
|
9
9
|
export { Avatar, default as AvatarDefault } from './components/Avatar';
|
|
10
10
|
export { Button, default as ButtonDefault } from './components/Button';
|
|
11
|
+
export { default as BottomSheet } from './components/BottomSheet';
|
|
12
|
+
export { default as BottomSheetItem } from './components/BottomSheet/BottomSheetItem';
|
|
13
|
+
export { default as BottomSheetHeader } from './components/BottomSheet/BottomSheetHeader';
|
|
14
|
+
export { default as BottomSheetTitle } from './components/BottomSheet/BottomSheetTitle';
|
|
11
15
|
export { default as Chip } from './components/Chip';
|
|
12
16
|
export { Dialog, default as DialogDefault } from './components/Dialog';
|
|
13
17
|
export { ContactPopover, default as ContactPopoverDefault } from './components/ContactPopover';
|
|
@@ -49,6 +53,10 @@ export type { TwakeTheme } from './lib/theme';
|
|
|
49
53
|
export type { PaletteJson } from './lib/types';
|
|
50
54
|
export type { AvatarProps } from './components/Avatar';
|
|
51
55
|
export type { ButtonProps } from './components/Button';
|
|
56
|
+
export type { BottomSheetProps, BottomSheetSettings, BottomSheetToolbarProps } from './components/BottomSheet/types';
|
|
57
|
+
export type { BottomSheetItemProps } from './components/BottomSheet/BottomSheetItem';
|
|
58
|
+
export type { BottomSheetHeaderProps } from './components/BottomSheet/BottomSheetHeader';
|
|
59
|
+
export type { BottomSheetTitleProps } from './components/BottomSheet/BottomSheetTitle';
|
|
52
60
|
export type { ContactPopoverProps } from './components/ContactPopover';
|
|
53
61
|
export { ContactPopoverActions, ContactPopoverChatAction, ContactPopoverVideoAction, ContactPopoverCalendarAction, ContactPopoverEmailAction } from './components/ContactPopover';
|
|
54
62
|
export type { ContactPopoverActionsProps } from './components/ContactPopover/ContactPopoverActions';
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,10 @@ export { radius } from './lib/radius';
|
|
|
11
11
|
export { TwakeMuiThemeProvider } from './components/ThemeProvider';
|
|
12
12
|
export { Avatar, default as AvatarDefault } from './components/Avatar';
|
|
13
13
|
export { Button, default as ButtonDefault } from './components/Button';
|
|
14
|
+
export { default as BottomSheet } from './components/BottomSheet';
|
|
15
|
+
export { default as BottomSheetItem } from './components/BottomSheet/BottomSheetItem';
|
|
16
|
+
export { default as BottomSheetHeader } from './components/BottomSheet/BottomSheetHeader';
|
|
17
|
+
export { default as BottomSheetTitle } from './components/BottomSheet/BottomSheetTitle';
|
|
14
18
|
export { default as Chip } from './components/Chip';
|
|
15
19
|
export { Dialog, default as DialogDefault } from './components/Dialog';
|
|
16
20
|
export { ContactPopover, default as ContactPopoverDefault } from './components/ContactPopover';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linagora/twake-mui",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "10.
|
|
4
|
+
"version": "10.5.0",
|
|
5
5
|
"description": "MUI v9 theme system for Twake applications",
|
|
6
6
|
"author": "Twake",
|
|
7
7
|
"license": "MIT",
|
|
@@ -60,7 +60,10 @@
|
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@linagora/twake-css": "^1.0.0",
|
|
62
62
|
"@mui/utils": "^9.2.0",
|
|
63
|
+
"@react-spring/web": "10.1.2",
|
|
64
|
+
"@use-gesture/react": "10.3.1",
|
|
63
65
|
"classnames": "^2.5.1",
|
|
66
|
+
"react-use-measure": "2.1.7",
|
|
64
67
|
"react-virtuoso": "4.13.0",
|
|
65
68
|
"rooks": "8.4.0"
|
|
66
69
|
},
|