diff --git a/.vscode/settings.json b/.vscode/settings.json index 8a69396..e99fe1e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,4 +24,7 @@ "editor.defaultFormatter": "biomejs.biome" }, "editor.defaultFormatter": "biomejs.biome", + "[markdown]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, } diff --git a/README.md b/README.md index 59dc784..2ef0f2a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- React Modal Sheet logo + React Modal Sheet logo

@@ -10,12 +10,13 @@
npm version npm license + npm downloads

-| ![](media/1.gif) | ![](media/2.gif) | ![](media/3.gif) | ![](media/4.gif) | -| :--------------: | :--------------: | :--------------: | :--------------: | +| ![](media/example-apple-maps.gif) | ![](media/example-apple-music.gif) | ![](media/example-snap-points.gif) | ![](media/example-scrollable.gif) | +| :-------------------------------: | :--------------------------------: | :--------------------------------: | :-------------------------------: | ## đŸ“Ļ Installation @@ -31,14 +32,12 @@ The gestures and animations are handled by the excellent [Motion](https://motion npm install motion ``` -> [!IMPORTANT] -> If you are still using the old `framer-motion` package you need to upgrade to `motion` as `react-modal-sheet` is now built on top of the new `motion` package. **Version `v3.5.0` and older are compatible with `framer-motion`**. - ---
📚 Table of contents + - [What's new in v5](#whats-new-in-v5) - [Usage](#-usage) - [Props](#%EF%B8%8F-props) - [Methods and properties](#%EF%B8%8F-methods-and-properties) @@ -51,6 +50,37 @@ npm install motion --- +## 🎉 What's new in v5 + +Version 5 introduces several major improvements and breaking changes: + +### 🔄 Breaking Changes + +- **Removed `Sheet.Scroller`**: Scrolling is now handled automatically by `Sheet.Content` +- **Snap point order reversed**: Snap points now use ascending order (e.g., `[0, 0.5, 1]` instead of `[1, 0.5, 0]`) + - This aligns better with other bottom sheet libraries and makes more intuitive sense +- **Detent prop values changed**: + - `"full-height"` → `"default"` + - `"content-height"` → `"content"` + - New `"full"` detent for viewport-filling sheets + +### ✨ New Features + +- **Built-in keyboard avoidance**: Best-effort automatic virtual keyboard handling with the `avoidKeyboard` prop +- **Enhanced scroll control**: Dynamic `disableScroll` and `disableDrag` functions with scroll state +- **Improved snap point handling**: Better snap point calculation and more natural snapping between points +- **New sheet properties**: Access `height` and `yInverted` motion values via ref +- **Prevent dismissal**: New `disableDismiss` prop to prevent sheet from being closed by user gestures + +### 🔧 Migration from v4 + +1. Remove all `Sheet.Scroller` components - content is now scrollable by default +2. Reverse your snap point arrays: `[1, 0.5, 0]` → `[0, 0.5, 1]` +3. Update detent props: `detent="full-height"` → `detent="default"` +4. Review virtual keyboard handling - it's now automatic with `avoidKeyboard={true}` + +--- + ## đŸ’ģ Usage ```tsx @@ -78,34 +108,36 @@ function Example() { The `Sheet` component follows the [Compound Component pattern](https://kentcdodds.com/blog/compound-components-with-react-hooks) in order to provide a flexible yet powerful API for creating highly customizable bottom sheet components. -Since the final bottom sheet is composed from smaller building blocks (`Container`, `Content`, `Scroller`, `Header`, and `Backdrop`) you are in total control over the rendering output. So for example, if you don't want to have any backdrop in your sheet then you can just skip rendering it instead of passing some prop like `renderBackdrop={false}` to the main sheet component. Cool huh? 😎 +Since the final bottom sheet is composed from smaller building blocks (`Container`, `Content`, `Header`, and `Backdrop`) you are in total control over the rendering output. So for example, if you don't want to have any backdrop in your sheet then you can just skip rendering it instead of passing some prop like `renderBackdrop={false}` to the main sheet component. Cool huh? 😎 -Also, by constructing the sheet from smaller pieces makes it easier to apply any necessary accessibility related properties to the right components without requiring the main sheet component to be aware of them. You can read more about accessibility in the [Accessibility](#Accessibility) section. +Also, by constructing the sheet from smaller pieces makes it easier to apply any necessary accessibility related properties to the right components without requiring the main sheet component to be aware of them. You can read more about accessibility in the Accessibility section below. ## đŸŽ›ī¸ Props -| Name | Required | Default | Description | -| ----------------------- | -------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `children` | yes | | Use `Sheet.Container/Content/Header/Backdrop` to compose your bottom sheet. | -| `isOpen` | yes | | Boolean that indicates whether the sheet is open or not. | -| `onClose` | yes | | Callback fn that is called when the sheet is closed by the user. | -| `disableDrag` | no | false | Disable drag for the whole sheet. | -| `disableScrollLocking` | no | false | Disable scroll locking for the `body` element while sheet is open. Can be useful if you face issues with input elements and the iOS virtual keyboard. See related [issue](https://github.com/Temzasse/react-modal-sheet/issues/135). | -| `detent` | no | `'full-height'` | The [detent](https://developer.apple.com/design/human-interface-guidelines/components/presentation/sheets#ios-ipados) in which the sheet should be in when opened. Available values: `'full-height'` or `'content-height'`. | -| `onOpenStart` | no | | Callback fn that is called when the sheet opening animation starts. | -| `onOpenEnd` | no | | Callback fn that is called when the sheet opening animation is completed. | -| `onCloseStart` | no | | Callback fn that is called when the sheet closing animation starts. | -| `onCloseEnd` | no | | Callback fn that is called when the sheet closing animation is completed. | -| `onSnap` | no | | Callback fn that is called with the current snap point index when the sheet snaps to a new snap point. Requires `snapPoints` prop. | -| `snapPoints` | no | | Eg. `[-50, 0.5, 100, 0]` - where positive values are pixels from the bottom of the sheet and negative from the top. Values between 0-1 represent percentages, eg. `0.5` means 50% of sheet height. **Prefer using `1` for representing a snap point for a fully visible sheet.** | -| `initialSnap` | no | 0 | Initial snap point when sheet is opened (index from `snapPoints`). | -| `modalEffectRootId` | no | | The id of the element where the main app is mounted, eg. "root". Enables [iOS modal effect](#-ios-modal-view-effect). | -| `modalEffectThreshold` | no | 0 | Threshold value between 0-1 which determines when the iOS modal effect will start while dragging the sheet - `0` corresponding to the start of the drag (0% has been dragged into view) and `1` corresponding to the end of the drag (100% of the sheet is visible). | -| `tweenConfig` | no | `{ ease: 'easeOut', duration: 0.2 }` | Overrides the config for the sheet [tween](https://motion.dev/docs/react-transitions#tween) transition when the sheet is opened, closed, or snapped to a point. | -| `mountPoint` | no | `document.body` | HTML element that should be used as the mount point for the sheet. | -| `prefersReducedMotion` | no | false | Skip sheet animations (sheet instantly snaps to desired location). | -| `dragVelocityThreshold` | no | 500 | How fast the sheet must be flicked down to close. Higher values make the sheet harder to close. | -| `dragCloseThreshold` | no | 0.6 | The portion of the sheet which must be dragged off-screen before it will close. | +| Name | Required | Default | Description | +| ----------------------- | -------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `children` | yes | | Use `Sheet.Container/Content/Header/Backdrop` to compose your bottom sheet. | +| `isOpen` | yes | | Boolean that indicates whether the sheet is open or not. | +| `onClose` | yes | | Callback fn that is called when the sheet is closed by the user. | +| `avoidKeyboard` | no | true | Automatically avoid the virtual keyboard by adding bottom padding when the keyboard is open. Only works on mobile devices with [Virtual Keyboard](https://developer.mozilla.org/en-US/docs/Web/API/VirtualKeyboard_API) or [Visual Viewport](https://developer.mozilla.org/en-US/docs/Web/API/Visual_Viewport_API) API support. | +| `disableDrag` | no | false | Disable drag for the whole sheet. | +| `disableDismiss` | no | false | Disable dismissing the sheet via dragging or high velocity swipe. When enabled, the sheet can only be closed programmatically. | +| `disableScrollLocking` | no | false | Disable scroll locking for the `body` element while sheet is open. Can be useful if you face issues with input elements and the iOS virtual keyboard. See related [issue](https://github.com/Temzasse/react-modal-sheet/issues/135). | +| `detent` | no | `'default'` | The [detent](https://developer.apple.com/design/human-interface-guidelines/components/presentation/sheets#ios-ipados) in which the sheet should be in when opened. Available values: `'default'`, `'content'`, or `'full'`. | +| `onOpenStart` | no | | Callback fn that is called when the sheet opening animation starts. | +| `onOpenEnd` | no | | Callback fn that is called when the sheet opening animation is completed. | +| `onCloseStart` | no | | Callback fn that is called when the sheet closing animation starts. | +| `onCloseEnd` | no | | Callback fn that is called when the sheet closing animation is completed. | +| `onSnap` | no | | Callback fn that is called with the current snap point index when the sheet snaps to a new snap point. Requires `snapPoints` prop. | +| `snapPoints` | no | | Eg. `[0, 0.5, 100, 1]` - where positive values are pixels from the bottom of the sheet and negative from the top. Values between 0-1 represent percentages, eg. `0.5` means 50% of sheet height. **Must be in ascending order and should include 0 (closed) and 1 (fully open).** | +| `initialSnap` | no | 0 | Initial snap point when sheet is opened (index from `snapPoints`). | +| `modalEffectRootId` | no | | The id of the element where the main app is mounted, eg. "root". Enables [iOS modal effect](#-ios-modal-view-effect). | +| `modalEffectThreshold` | no | 0 | Threshold value between 0-1 which determines when the iOS modal effect will start while dragging the sheet - `0` corresponding to the start of the drag (0% has been dragged into view) and `1` corresponding to the end of the drag (100% of the sheet is visible). | +| `tweenConfig` | no | `{ ease: 'easeOut', duration: 0.2 }` | Overrides the config for the sheet [tween](https://motion.dev/docs/react-transitions#tween) transition when the sheet is opened, closed, or snapped to a point. | +| `mountPoint` | no | `document.body` | HTML element that should be used as the mount point for the sheet. | +| `prefersReducedMotion` | no | false | Skip sheet animations (sheet instantly snaps to desired location). | +| `dragVelocityThreshold` | no | 500 | How fast the sheet must be flicked down to close. Higher values make the sheet harder to close. | +| `dragCloseThreshold` | no | 0.6 | The portion of the sheet which must be dragged off-screen before it will close. | ## âš™ī¸ Methods and properties @@ -117,9 +149,9 @@ Imperative method that can be accessed via a ref for snapping to a snap point in import { Sheet, SheetRef } from 'react-modal-sheet'; import { useState, useRef } from 'react'; -const snapPoints = [1, 0.5, 0]; +const snapPoints = [0, 0.5, 1]; -function Example() { +function SnapExample() { const [isOpen, setOpen] = useState(false); const ref = useRef(null); const snapTo = (i: number) => ref.current?.snapTo(i); @@ -144,7 +176,6 @@ function Example() { - @@ -153,49 +184,43 @@ function Example() { } ``` -### Motion value `y` +### Motion values `y`, `yInverted` and `height` -The `y` value is an internal `MotionValue` that represents the distance to the top most position of the sheet when it is fully open. So for example the `y` value is zero when the sheet is completely open. +The `y` value is an internal [MotionValue](https://motion.dev/docs/react-motion-value) that represents the distance to the top most position of the sheet when it is fully open. So for example the `y` value is zero when the sheet is completely open. -Similarly to the `snapTo` method the `y` value can be accessed via a ref. +The `yInverted` value is the inverse of the `y` value and represents the distance from the bottom of the sheet. This can be useful for certain animations or calculations. -The `y` value can be useful for certain situtations, eg. when you want to combine snap points with scrollable sheet content and ensure that the content stays properly scrollable at any snap point. +The `height` value represents the current height of the sheet in pixels. -Below you can see a simplified example of this situation: +All these values can be accessed via a ref, similar to the `snapTo` method. + +Below you can see an example of how to use these values: ```tsx import { Sheet, SheetRef } from 'react-modal-sheet'; import { useState, useRef } from 'react'; -const snapPoints = [1, 0.5, 0]; - -function Example() { +function RefExample() { const [isOpen, setOpen] = useState(false); const ref = useRef(null); + function doSomething() { + console.log('> Current y value:', ref.current?.y.get()); + console.log('> Current yInverted value:', ref.current?.yInverted.get()); + console.log('> Current height value:', ref.current?.height); + } + return ( <> - setOpen(false)} - initialSnap={1} - snapPoints={snapPoints} - > + setOpen(false)}> - {/** - * Since `Sheet.Content` is a `motion.div` it can receive motion values - * in it's style prop which allows us to utilise the exposed `y` value. - * - * By syncing the padding bottom with the `y` motion value we introduce - * an offset that ensures that the sheet content can be scrolled all the - * way to the bottom in every snap point. - */} - - - {/* Some content here that makes the sheet content scrollable */} - + + + Use animated y value in some way + + {/* Your content here */} @@ -204,30 +229,21 @@ function Example() { } ``` -> [!NOTE] -> As this use case is quite a common one, the `Sheet.Scroller` component has a built-in prop called `autoPadding` which automatically keeps the bottom padding in sync with the animated `y` value. -> -> See [Automatic padding with snap points](#automatic-padding-with-snap-points) for more details. - ### Detents -By default the sheet will take the full height of the page minus top padding and safe area inset. If you want the sheet height to be based on it's content you can pass `detent="content-height"` prop to the `Sheet` component: +By default the sheet will take the full height of the page minus top padding and safe area inset. The `detent` prop controls the sheet's height behavior: -```tsx -function Example() { - const [isOpen, setOpen] = useState(false); +- `"default"` - Sheet takes full height minus safe areas (default behavior) +- `"content"` - Sheet height is based on its content +- `"full"` - Sheet takes the entire viewport height with no safe area insets +```tsx +function DetentExample() { return ( - setOpen(false)} - detent="content-height" - > + - -
Some content
-
+
Some content
@@ -235,9 +251,9 @@ function Example() { } ``` -If the sheet height changes dynamically the sheet will grow until it hits the maximum full height after which it becomes scrollable. +When using `detent="content"` and the sheet height changes dynamically, the sheet will grow until it hits the maximum default height, after which it becomes scrollable. -It is possible to use snap points with `detent="content-height"` **but** the snap points are restricted by the content height. For example if one of the snap points is 800px and the sheet height is only 700px then snapping to the 800px snap point would only snap to 700px since otherwise the sheet would become detached from the bottom. +It is possible to use snap points with `detent="content"` **but** the snap points are restricted by the content height. For example if one of the snap points is 800px and the sheet height is only 700px then snapping to the 800px snap point would only snap to 700px since otherwise the sheet would become detached from the bottom. > â„šī¸ If you are wondering where the term `detent` comes from it's from Apple's [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/components/presentation/sheets#ios-ipados). @@ -269,29 +285,17 @@ Sheet header acts as a drag target and has a dragging direction indicator. Rende ### `Sheet.Content` -Sheet content acts as a drag target and can be used in conjunction with `Sheet.Scroller` to make sure that content which doesn't fit inside the sheet becomes scrollable. +Sheet content acts as a drag target and handles scrollable content internally. It automatically manages scroll behavior and keyboard avoidance. -> đŸ–Ĩ Rendered element: `motion.div`. +> đŸ–Ĩ Rendered element: `motion.div` (with internal scroller). #### Content props -| Name | Required | Default | Description | -| ------------- | -------- | ------- | ----------------------------------- | -| `disableDrag` | no | false | Disable drag for the sheet content. | - -### `Sheet.Scroller` - -Sheet scroller can be used to make the whole sheet content or parts of it scrollable in a way that drag gestures are properly disabled and enabled based on the scroll state. See the [Scrolling on touch devices](#scrolling-on-touch-devices) section for more details. - -> đŸ–Ĩ Rendered element: `motion.div`. - -#### Scroller props - -| Name | Required | Default | Description | -| --------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `draggableAt` | no | `"top"` | Should the drag be enabled when the element is scrolled either to the top, bottom, or both. Available values: `top`, `bottom`, `both`. | -| `disableScroll` | no | false | Disable scrolling. This can be combined with snap points to make the sheet scroller only scrollable at a given snap point (usually the top position). | -| `autoPadding` | no | false | Automatically apply padding bottom to the scroller based on the dragged distance to ensure that content can be scrolled to the bottom at any snap point. | +| Name | Required | Default | Description | +| --------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `disableDrag` | no | false | Disable drag for the sheet content. Can be a boolean or a function that receives `{ scrollPosition, currentSnap }` and returns a boolean for dynamic control. | +| `disableScroll` | no | false | Disable scrolling. Can be a boolean or a function that receives `{ scrollPosition, currentSnap }` and returns a boolean. Useful for making content only scrollable at specific snap points. | +| `scrollRef` | no | | Optional ref to the internal scroll container for accessing scroll methods. | ### `Sheet.Backdrop` @@ -301,37 +305,48 @@ Sheet backdrop is a translucent overlay that helps to separate the sheet from it > đŸ–Ĩ Rendered element: `motion.div` or `motion.button`. -## ✨ Advanced usage +## ✨ Advanced behaviors ### âŒ¨ī¸ Virtual keyboard avoidance -A common problem with React Modal Sheet is that since the sheet is rendered as a fixed positioned element it doesn't move when the virtual keyboard is opened which can lead to the keyboard covering the input elements. - -Handling the virtual keyboard on mobile devices can be quite tricky. It doesn't help that the Web doesn't yet have widely supported APIs for working with the virtual keyboard. - -**âš ī¸ Note: This library does not provide any official built-in solution for keyboard avoidance!** - -This is because it is not possible for the library to know how your sheet content is structured and how the sheet should behave when the keyboard is opened. - -However, there are some strategies that you can use to handle the virtual keyboard: +React Modal Sheet v5 includes built-in virtual keyboard avoidance that works automatically on mobile devices. +When the `avoidKeyboard` prop is enabled (which is the default), the sheet will automatically add bottom padding to avoid the virtual keyboard covering input elements. -1. Design your sheet content in a way that the input elements are positioned at the top of the sheet so that they are not covered by the keyboard. This is the easiest solution and if you look at popular mobile apps you can see that they often have input elements at the top of the screen exactly for this reason. +```tsx +function KeyboardExample() { + return ( + + + + + +